From a32fd5e665972f1388f4c1d5268755b9adad398e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 18 May 2023 11:43:44 +0200 Subject: [PATCH 1/4] android: message reactions (#2448) * android: message reactions * android: reactions UI * call api to add/remove reactions, UI for preferences * fix preferences * hide Reactions preferences, ios: always show React menu, update icons * fix reactions menu * improve voice message layout --- apps/android/README.md | 13 +- .../java/chat/simplex/app/model/ChatModel.kt | 131 ++++++ .../java/chat/simplex/app/model/SimpleXAPI.kt | 83 +++- .../chat/simplex/app/views/chat/ChatView.kt | 26 +- .../app/views/chat/ComposeVoiceView.kt | 4 +- .../app/views/chat/ContactPreferences.kt | 5 + .../app/views/chat/group/GroupPreferences.kt | 5 + .../simplex/app/views/chat/item/CIFileView.kt | 2 + .../simplex/app/views/chat/item/CIMetaView.kt | 2 +- .../app/views/chat/item/CIVoiceView.kt | 36 +- .../app/views/chat/item/ChatItemView.kt | 394 ++++++++++-------- .../chat/simplex/app/views/helpers/Section.kt | 4 +- .../app/views/usersettings/Preferences.kt | 5 + .../src/main/res/drawable/ic_add_reaction.xml | 9 + .../res/drawable/ic_add_reaction_filled.xml | 9 + .../app/src/main/res/values/strings.xml | 12 + apps/ios/Shared/Views/Chat/ChatView.swift | 3 +- .../Views/Chat/ContactPreferencesView.swift | 5 +- .../Chat/Group/GroupPreferencesView.swift | 5 +- .../Views/UserSettings/PreferencesView.swift | 5 +- apps/ios/SimpleXChat/ChatTypes.swift | 6 +- scripts/ios/prepare-x86_64.sh | 2 + scripts/ios/prepare.sh | 2 + 23 files changed, 558 insertions(+), 210 deletions(-) create mode 100644 apps/android/app/src/main/res/drawable/ic_add_reaction.xml create mode 100644 apps/android/app/src/main/res/drawable/ic_add_reaction_filled.xml diff --git a/apps/android/README.md b/apps/android/README.md index 7822847e56..e8b0e086c9 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -4,7 +4,6 @@ This readme is currently a stub and as such is in development. Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app. - ## Gotchas #### SHA Signature for verification for app links/deep links @@ -23,3 +22,15 @@ To find your SHA certificate fingerprint perform the following steps. More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`. Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing. + +## Adding icons + +1. Find a [Material symbol](https://fonts.google.com/icons?icon.style=Rounded) in Rounded category. + +2. Set weight to 400, grade to -25 and size to 48px. + +3. Click on the icon, choose Android and download XML file. + +4. Update the color to black (#FF000000) and the size to "24.dp", as in other icons. + +For example, this is [add reaction icon](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:add_reaction:FILL@0;wght@300;GRAD@-25;opsz@24&icon.style=Rounded). diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 65a2992917..9f8e76da32 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -238,6 +238,17 @@ class ChatModel(val controller: ChatController) { } } + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) { + if (chatId.value == cInfo.id) { + withContext(Dispatchers.Main) { + val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } + if (itemIndex >= 0) { + chatItems[itemIndex] = cItem + } + } + } + } + fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) { if (cItem.isRcvNew) { decreaseCounterInChat(cInfo.id) @@ -729,6 +740,7 @@ data class Contact( override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser + ChatFeature.Reactions -> mergedPreferences.reactions.enabled.forUser ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser } @@ -750,12 +762,14 @@ data class Contact( 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.Reactions -> mergedPreferences.reactions.contactPreference.allow != FeatureAllowed.NO ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO } fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO 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.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO } @@ -888,6 +902,7 @@ data class GroupInfo ( override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on + ChatFeature.Reactions -> fullGroupPreferences.reactions.on ChatFeature.Voice -> fullGroupPreferences.voice.on ChatFeature.Calls -> false } @@ -1257,6 +1272,20 @@ class AChatItem ( val chatItem: ChatItem ) +@Serializable +class ACIReaction( + val chatInfo: ChatInfo, + val chatReaction: CIReaction +) + +@Serializable +class CIReaction( + val chatDir: CIDirection, + val chatItem: ChatItem, + val sentAt: Instant, + val reaction: MsgReaction +) + @Serializable @Stable data class ChatItem ( val chatDir: CIDirection, @@ -1264,6 +1293,7 @@ data class ChatItem ( val content: CIContent, val formattedText: List? = null, val quotedItem: CIQuote? = null, + val reactions: List, val file: CIFile? = null ) { val id: Long get() = meta.itemId @@ -1280,6 +1310,11 @@ data class ChatItem ( val isRcvNew: Boolean get() = meta.isRcvNew + val allowAddReaction: Boolean get() = + meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3) + + private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID + val memberDisplayName: String? get() = if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName else null @@ -1369,6 +1404,7 @@ data class ChatItem ( meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemTimed, editable), content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), quotedItem = quotedItem, + reactions = listOf(), file = file ) @@ -1384,6 +1420,7 @@ data class ChatItem ( meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead()), content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)), quotedItem = null, + reactions = listOf(), file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus) ) @@ -1399,6 +1436,7 @@ data class ChatItem ( meta = CIMeta.getSample(id, ts, text, status), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), quotedItem = null, + reactions = listOf(), file = null ) @@ -1408,6 +1446,7 @@ data class ChatItem ( meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead()), content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin), quotedItem = null, + reactions = listOf(), file = null ) @@ -1417,6 +1456,7 @@ data class ChatItem ( meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead()), content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)), quotedItem = null, + reactions = listOf(), file = null ) @@ -1427,6 +1467,7 @@ data class ChatItem ( meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead()), content = content, quotedItem = null, + reactions = listOf(), file = null ) } @@ -1452,6 +1493,7 @@ data class ChatItem ( ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), quotedItem = null, + reactions = listOf(), file = null ) @@ -1472,6 +1514,7 @@ data class ChatItem ( ), content = CIContent.SndMsgContent(MsgContent.MCText("")), quotedItem = null, + reactions = listOf(), file = null ) @@ -1481,6 +1524,7 @@ data class ChatItem ( meta = meta ?: CIMeta.invalidJSON(), content = CIContent.InvalidJSON(json), quotedItem = null, + reactions = listOf(), file = null ) } @@ -1729,6 +1773,93 @@ class CIQuote ( } } +@Serializable +class CIReactionCount(val reaction: MsgReaction, val userReacted: Boolean, val totalReacted: Int) + +@Serializable(with = MsgReactionSerializer::class) +sealed class MsgReaction { + @Serializable(with = MsgReactionSerializer::class) class Emoji(val emoji: MREmojiChar): MsgReaction() + @Serializable(with = MsgReactionSerializer::class) class Unknown(val type: String? = null, val json: JsonElement): MsgReaction() + + val text: String get() = when (this) { + is Emoji -> when (emoji) { + MREmojiChar.Heart -> "❤️" + else -> emoji.value + } + is Unknown -> "" + } + + val cmdString: String get() = when(this) { + is Emoji -> emoji.cmdString + is Unknown -> "" + } + + companion object { + val values: List get() = MREmojiChar.values().map(::Emoji) + } +} + +object MsgReactionSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgReaction", PolymorphicKind.SEALED) { + element("Emoji", buildClassSerialDescriptor("Emoji") { + element("emoji") + }) + element("Unknown", buildClassSerialDescriptor("Unknown")) + } + + override fun deserialize(decoder: Decoder): MsgReaction { + require(decoder is JsonDecoder) + val json = decoder.decodeJsonElement() + return if (json is JsonObject && "type" in json) { + when(val t = json["type"]?.jsonPrimitive?.content ?: "") { + "emoji" -> { + val emoji = Json.decodeFromString(json["emoji"].toString()) + if (emoji == null) MsgReaction.Unknown(t, json) else MsgReaction.Emoji(emoji) + } + else -> MsgReaction.Unknown(t, json) + } + } else { + MsgReaction.Unknown("", json) + } + } + + override fun serialize(encoder: Encoder, value: MsgReaction) { + require(encoder is JsonEncoder) + val json = when (value) { + is MsgReaction.Emoji -> + buildJsonObject { + put("type", "emoji") + put("emoji", json.encodeToJsonElement(value.emoji)) + } + is MsgReaction.Unknown -> value.json + } + encoder.encodeJsonElement(json) + } +} + +@Serializable +enum class MREmojiChar(val value: String) { + @SerialName("👍") ThumbsUp("👍"), + @SerialName("👎") ThumbsDown("👎"), + @SerialName("😀") Smile("😀"), + @SerialName("🎉") Celebration("🎉"), + @SerialName("😕") Confused("😕"), + @SerialName("❤") Heart("❤"), + @SerialName("🚀") Launch("🚀"), + @SerialName("👀") Looking("👀"); + + val cmdString: String get() = when(this) { + ThumbsUp -> "+" + ThumbsDown -> "-" + Smile -> ")" + Celebration -> "!" + Confused -> "?" + Heart -> "*" + Launch -> "^" + Looking -> "%" + } +} + @Serializable class CIFile( val fileId: Long, diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index fd2157898e..e38e6d0b9b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -600,6 +600,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } + suspend fun apiChatItemReaction(type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { + val r = sendCmd(CC.ApiChatItemReaction(type, id, itemId, add, reaction)) + if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem + Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? { val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode)) if (r is CR.ChatItemDeleted) return r @@ -1378,6 +1385,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } is CR.ChatItemUpdated -> chatItemSimpleUpdate(r.user, r.chatItem) + is CR.ChatItemReaction -> { + if (active(r.user)) { + chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + } + } is CR.ChatItemDeleted -> { if (!active(r.user)) { if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) { @@ -1885,6 +1897,7 @@ sealed class CC { class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() + class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() @@ -1973,6 +1986,7 @@ sealed class CC { is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" + is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" @@ -2059,6 +2073,7 @@ sealed class CC { is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" + is ApiChatItemReaction -> "apiChatItemReaction" is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" @@ -2473,15 +2488,23 @@ data class ChatSettings( data class FullChatPreferences( val timedMessages: TimedMessagesPreference, val fullDelete: SimpleChatPreference, + val reactions: SimpleChatPreference, val voice: SimpleChatPreference, val calls: SimpleChatPreference, ) { - fun toPreferences(): ChatPreferences = ChatPreferences(timedMessages = timedMessages, fullDelete = fullDelete, voice = voice, calls = calls) + fun toPreferences(): ChatPreferences = ChatPreferences( + timedMessages = timedMessages, + fullDelete = fullDelete, + reactions = reactions, + voice = voice, + calls = calls + ) companion object { val sampleData = FullChatPreferences( timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO), fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO), + reactions = SimpleChatPreference(allow = FeatureAllowed.YES), voice = SimpleChatPreference(allow = FeatureAllowed.YES), calls = SimpleChatPreference(allow = FeatureAllowed.YES), ) @@ -2492,6 +2515,7 @@ data class FullChatPreferences( data class ChatPreferences( val timedMessages: TimedMessagesPreference?, val fullDelete: SimpleChatPreference?, + val reactions: SimpleChatPreference?, val voice: SimpleChatPreference?, val calls: SimpleChatPreference?, ) { @@ -2499,6 +2523,7 @@ data class ChatPreferences( when (feature) { ChatFeature.TimedMessages -> this.copy(timedMessages = TimedMessagesPreference(allow = allowed, ttl = param ?: this.timedMessages?.ttl)) 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.Calls -> this.copy(calls = SimpleChatPreference(allow = allowed)) } @@ -2507,6 +2532,7 @@ data class ChatPreferences( val sampleData = ChatPreferences( timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO), fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO), + reactions = SimpleChatPreference(allow = FeatureAllowed.YES), voice = SimpleChatPreference(allow = FeatureAllowed.YES), calls = SimpleChatPreference(allow = FeatureAllowed.YES), ) @@ -2579,12 +2605,14 @@ data class TimedMessagesPreference( data class ContactUserPreferences( val timedMessages: ContactUserPreferenceTimed, val fullDelete: ContactUserPreference, + val reactions: ContactUserPreference, val voice: ContactUserPreference, val calls: ContactUserPreference, ) { fun toPreferences(): ChatPreferences = ChatPreferences( timedMessages = timedMessages.userPreference.pref, fullDelete = fullDelete.userPreference.pref, + reactions = reactions.userPreference.pref, voice = voice.userPreference.pref, calls = calls.userPreference.pref ) @@ -2601,6 +2629,11 @@ data class ContactUserPreferences( userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.NO)), contactPreference = SimpleChatPreference(allow = FeatureAllowed.NO) ), + reactions = ContactUserPreference( + enabled = FeatureEnabled(forUser = true, forContact = true), + userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)), + contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES) + ), voice = ContactUserPreference( enabled = FeatureEnabled(forUser = true, forContact = true), userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)), @@ -2697,6 +2730,7 @@ interface Feature { enum class ChatFeature: Feature { @SerialName("timedMessages") TimedMessages, @SerialName("fullDelete") FullDelete, + @SerialName("reactions") Reactions, @SerialName("voice") Voice, @SerialName("calls") Calls; @@ -2714,6 +2748,7 @@ enum class ChatFeature: Feature { get() = when(this) { TimedMessages -> generalGetString(R.string.timed_messages) FullDelete -> generalGetString(R.string.full_deletion) + Reactions -> generalGetString(R.string.message_reactions) Voice -> generalGetString(R.string.voice_messages) Calls -> generalGetString(R.string.audio_video_calls) } @@ -2722,6 +2757,7 @@ enum class ChatFeature: Feature { @Composable get() = when(this) { TimedMessages -> painterResource(R.drawable.ic_timer) FullDelete -> painterResource(R.drawable.ic_delete_forever) + Reactions -> painterResource(R.drawable.ic_add_reaction) Voice -> painterResource(R.drawable.ic_keyboard_voice) Calls -> painterResource(R.drawable.ic_call) } @@ -2730,6 +2766,7 @@ enum class ChatFeature: Feature { override fun iconFilled(): Painter = when(this) { TimedMessages -> painterResource(R.drawable.ic_timer_filled) FullDelete -> painterResource(R.drawable.ic_delete_forever_filled) + Reactions -> painterResource(R.drawable.ic_add_reaction_filled) Voice -> painterResource(R.drawable.ic_keyboard_voice_filled) Calls -> painterResource(R.drawable.ic_call_filled) } @@ -2746,7 +2783,12 @@ enum class ChatFeature: Feature { FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if) FeatureAllowed.NO -> generalGetString(R.string.contacts_can_mark_messages_for_deletion) } - Voice -> when (allowed) { + Reactions -> when (allowed) { + FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_adding_message_reactions) + FeatureAllowed.YES -> generalGetString(R.string.allow_message_reactions_only_if) + FeatureAllowed.NO -> generalGetString(R.string.prohibit_message_reactions) + } + Voice -> when (allowed) { FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_voice_messages) FeatureAllowed.YES -> generalGetString(R.string.allow_voice_messages_only_if) FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_voice_messages) @@ -2772,6 +2814,12 @@ enum class ChatFeature: Feature { enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete) else -> generalGetString(R.string.message_deletion_prohibited) } + Reactions -> when { + enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_add_message_reactions) + enabled.forUser -> generalGetString(R.string.only_you_can_add_message_reactions) + enabled.forContact -> generalGetString(R.string.only_your_contact_can_add_message_reactions) + else -> generalGetString(R.string.message_reactions_prohibited_in_this_chat) + } Voice -> when { enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_voice) enabled.forUser -> generalGetString(R.string.only_you_can_send_voice) @@ -2792,6 +2840,7 @@ enum class GroupFeature: Feature { @SerialName("timedMessages") TimedMessages, @SerialName("directMessages") DirectMessages, @SerialName("fullDelete") FullDelete, + @SerialName("reactions") Reactions, @SerialName("voice") Voice; override val hasParam: Boolean get() = when(this) { @@ -2804,6 +2853,7 @@ enum class GroupFeature: Feature { TimedMessages -> generalGetString(R.string.timed_messages) DirectMessages -> generalGetString(R.string.direct_messages) FullDelete -> generalGetString(R.string.full_deletion) + Reactions -> generalGetString(R.string.message_reactions) Voice -> generalGetString(R.string.voice_messages) } @@ -2812,6 +2862,7 @@ enum class GroupFeature: Feature { TimedMessages -> painterResource(R.drawable.ic_timer) DirectMessages -> painterResource(R.drawable.ic_swap_horizontal_circle) FullDelete -> painterResource(R.drawable.ic_delete_forever) + Reactions -> painterResource(R.drawable.ic_add_reaction) Voice -> painterResource(R.drawable.ic_keyboard_voice) } @@ -2820,6 +2871,7 @@ enum class GroupFeature: Feature { TimedMessages -> painterResource(R.drawable.ic_timer_filled) DirectMessages -> painterResource(R.drawable.ic_swap_horizontal_circle_filled) FullDelete -> painterResource(R.drawable.ic_delete_forever_filled) + Reactions -> painterResource(R.drawable.ic_add_reaction_filled) Voice -> painterResource(R.drawable.ic_keyboard_voice_filled) } @@ -2838,6 +2890,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages) GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion) } + Reactions -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(R.string.allow_message_reactions) + GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_reactions_group) + } Voice -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice) GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice) @@ -2857,6 +2913,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete) GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat) } + Reactions -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_add_message_reactions) + GroupFeatureEnabled.OFF -> generalGetString(R.string.message_reactions_are_prohibited) + } Voice -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice) GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited) @@ -2897,6 +2957,7 @@ data class ContactFeaturesAllowed( val timedMessagesAllowed: Boolean, val timedMessagesTTL: Int?, val fullDelete: ContactFeatureAllowed, + val reactions: ContactFeatureAllowed, val voice: ContactFeatureAllowed, val calls: ContactFeatureAllowed, ) { @@ -2905,6 +2966,7 @@ data class ContactFeaturesAllowed( timedMessagesAllowed = false, timedMessagesTTL = null, fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO), + reactions = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES), voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES), calls = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES), ) @@ -2918,6 +2980,7 @@ fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPrefere timedMessagesAllowed = allow == FeatureAllowed.YES || allow == FeatureAllowed.ALWAYS, timedMessagesTTL = pref.pref.ttl, fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete), + reactions = contactUserPrefToFeatureAllowed(contactUserPreferences.reactions), voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice), calls = contactUserPrefToFeatureAllowed(contactUserPreferences.calls), ) @@ -2937,6 +3000,7 @@ fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed ChatPreferences( timedMessages = TimedMessagesPreference(if (contactFeaturesAllowed.timedMessagesAllowed) FeatureAllowed.YES else FeatureAllowed.NO, contactFeaturesAllowed.timedMessagesTTL), fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete), + reactions = contactFeatureAllowedToPref(contactFeaturesAllowed.reactions), voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice), calls = contactFeatureAllowedToPref(contactFeaturesAllowed.calls), ) @@ -2968,16 +3032,24 @@ data class FullGroupPreferences( val timedMessages: TimedMessagesGroupPreference, val directMessages: GroupPreference, val fullDelete: GroupPreference, + val reactions: GroupPreference, val voice: GroupPreference ) { fun toGroupPreferences(): GroupPreferences = - GroupPreferences(timedMessages = timedMessages, directMessages = directMessages, fullDelete = fullDelete, voice = voice) + GroupPreferences( + timedMessages = timedMessages, + directMessages = directMessages, + fullDelete = fullDelete, + reactions = reactions, + voice = voice + ) companion object { val sampleData = FullGroupPreferences( timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), + reactions = GroupPreference(GroupFeatureEnabled.ON), voice = GroupPreference(GroupFeatureEnabled.ON) ) } @@ -2988,6 +3060,7 @@ data class GroupPreferences( val timedMessages: TimedMessagesGroupPreference?, val directMessages: GroupPreference?, val fullDelete: GroupPreference?, + val reactions: GroupPreference?, val voice: GroupPreference? ) { companion object { @@ -2995,6 +3068,7 @@ data class GroupPreferences( timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), + reactions = GroupPreference(GroupFeatureEnabled.ON), voice = GroupPreference(GroupFeatureEnabled.ON) ) } @@ -3166,6 +3240,7 @@ sealed class CR { @Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: User, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() @Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List): CR() // group events @@ -3277,6 +3352,7 @@ sealed class CR { is NewChatItem -> "newChatItem" is ChatItemStatusUpdated -> "chatItemStatusUpdated" is ChatItemUpdated -> "chatItemUpdated" + is ChatItemReaction -> "chatItemReaction" is ChatItemDeleted -> "chatItemDeleted" is ContactsList -> "contactsList" is GroupCreated -> "groupCreated" @@ -3386,6 +3462,7 @@ sealed class CR { is NewChatItem -> withUser(user, json.encodeToString(chatItem)) is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem)) is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) + is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser") is ContactsList -> withUser(user, json.encodeToString(contacts)) is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 787d56416d..80472372c3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -258,6 +258,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { chatModel.controller.allowFeatureToContact(contact, feature, param) } }, + setReaction = { cInfo, cItem, add, reaction -> + withApi { + val updatedCI = chatModel.controller.apiChatItemReaction( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = cItem.id, + add = add, + reaction = reaction + ) + if (updatedCI != null) { + chatModel.updateChatItem(cInfo, updatedCI) + } + } + }, addMembers = { groupInfo -> hideKeyboard(view) withApi { @@ -316,6 +330,7 @@ fun ChatLayout( startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, addMembers: (GroupInfo) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, @@ -358,7 +373,7 @@ fun ChatLayout( ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, - receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed, + receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, setReaction, markRead, setFloatingButton, onComposed, ) } } @@ -531,6 +546,7 @@ fun BoxWithConstraintsScope.ChatItemsList( joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit, onComposed: () -> Unit, @@ -644,11 +660,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction) } } else { Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction) } } } else { // direct message @@ -659,7 +675,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction) } } @@ -1082,6 +1098,7 @@ fun PreviewChatLayout() { startCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, + setReaction = { _, _, _, _ -> }, addMembers = { _ -> }, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, @@ -1142,6 +1159,7 @@ fun PreviewGroupChatLayout() { startCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, + setReaction = { _, _, _, _ -> }, addMembers = { _ -> }, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt index 7244ce41aa..4013038a12 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt @@ -40,10 +40,10 @@ fun ComposeVoiceView( val audioPlaying = rememberSaveable { mutableStateOf(false) } Row( Modifier - .height(60.dp) + .height(57.dp) .fillMaxWidth() .background(sentColor) - .padding(top = 8.dp), + .padding(top = 3.dp), verticalAlignment = Alignment.CenterVertically ) { IconButton( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt index 006eb77151..0bd06c20d8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt @@ -95,6 +95,11 @@ private fun ContactPreferencesLayout( applyPrefs(featuresAllowed.copy(fullDelete = it)) } SectionDividerSpaced(true, maxBottomPadding = false) +// val allowReactions: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.reactions) } +// FeatureSection(ChatFeature.Reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, allowReactions) { +// applyPrefs(featuresAllowed.copy(reactions = it)) +// } +// SectionDividerSpaced(true, maxBottomPadding = false) val allowVoice: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) } FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) { applyPrefs(featuresAllowed.copy(voice = it)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt index 2b2d85e2a3..76a7978180 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt @@ -95,6 +95,11 @@ private fun GroupPreferencesLayout( applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it))) } SectionDividerSpaced(true, maxBottomPadding = false) +// val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) } +// FeatureSection(GroupFeature.Reactions, allowReactions, groupInfo, preferences, onTTLUpdated) { +// applyPrefs(preferences.copy(reactions = GroupPreference(enable = it))) +// } +// SectionDividerSpaced(true, maxBottomPadding = false) val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) } FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) { applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index 39ac18f08a..52a4a417f9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -211,6 +211,7 @@ class ChatItemProvider: PreviewParameterProvider { meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true), content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")), quotedItem = null, + reactions = listOf(), file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete) ) private val fileChatItemWtFile = ChatItem( @@ -218,6 +219,7 @@ class ChatItemProvider: PreviewParameterProvider { meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ), content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")), quotedItem = null, + reactions = listOf(), file = null ) override val values = listOf( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index 23ed293876..8f7e464e8d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -24,7 +24,7 @@ fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = Ma Text( chatItem.timestampText, color = metaColor, - fontSize = 14.sp, + fontSize = 12.sp, modifier = Modifier.padding(start = 3.dp) ) } else { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index fa94e00c74..00f96c60f7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -150,32 +150,32 @@ private fun VoiceLayout( } } sent -> { - Row { - Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { - Spacer(Modifier.height(56.dp)) - Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp)) - DurationText(text, PaddingValues(end = 12.dp)) - } - Column { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) - Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { - CIMetaView(ci, timedMessagesTTL) + Column(horizontalAlignment = Alignment.End) { + Row { + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { + Spacer(Modifier.height(56.dp)) + Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp)) + DurationText(text, PaddingValues(end = 12.dp)) } + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) + } + Box(Modifier.padding(top = 6.dp, end = 6.dp)) { + CIMetaView(ci, timedMessagesTTL) } } } else -> { - Row { - Column { + Column(horizontalAlignment = Alignment.Start) { + Row { VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) - Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { - CIMetaView(ci, timedMessagesTTL) + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { + DurationText(text, PaddingValues(start = 12.dp)) + Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) + Spacer(Modifier.height(56.dp)) } } - Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { - DurationText(text, PaddingValues(start = 12.dp)) - Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) - Spacer(Modifier.height(56.dp)) + Box(Modifier.padding(top = 6.dp)) { + CIMetaView(ci, timedMessagesTTL) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 4d68c41c76..531fb16851 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -2,7 +2,8 @@ package chat.simplex.app.views.chat.item import android.Manifest import android.os.Build -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -16,8 +17,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.* import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* @@ -45,7 +48,8 @@ fun ChatItemView( joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, - acceptFeature: (Contact, ChatFeature, Int?) -> Unit + acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, ) { val context = LocalContext.current val uriHandler = LocalUriHandler.current @@ -75,186 +79,246 @@ fun ChatItemView( else -> {} } } - Column( - Modifier - .clip(RoundedCornerShape(18.dp)) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick), - ) { - @Composable - fun framedItemView() { - FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem) - } - fun deleteMessageQuestionText(): String { - return if (fullDeleteAllowed) { - generalGetString(R.string.delete_message_cannot_be_undone_warning) - } else { - generalGetString(R.string.delete_message_mark_deleted_warning) - } - } - - fun moderateMessageQuestionText(): String { - return if (fullDeleteAllowed) { - generalGetString(R.string.moderate_message_will_be_deleted_warning) - } else { - generalGetString(R.string.moderate_message_will_be_marked_warning) - } - } - - @Composable - fun MsgContentItemDropdownMenu() { - DefaultDropdownMenu(showMenu) { - if (cItem.meta.itemDeleted == null && !live) { - ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } - showMenu.value = false - }) - } - ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = { - val filePath = getLoadedFilePath(SimplexApp.context, cItem.file) - when { - filePath != null -> shareFile(context, cItem.text, filePath) - else -> shareText(context, cItem.content.text) + @Composable + fun ChatItemReactions() { + Row { + cItem.reactions.forEach { r -> + var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) + if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) { + modifier = modifier.clickable { + setReaction(cInfo, cItem, !r.userReacted, r.reaction) } - showMenu.value = false - }) - ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = { - copyText(context, cItem.content.text) - showMenu.value = false - }) - if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) { - val filePath = getLoadedFilePath(context, cItem.file) - if (filePath != null) { - val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) - ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = { - when (cItem.content.msgContent) { - is MsgContent.MCImage -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) { - saveImage(context, cItem.file) - } else { - writePermissionState.launchPermissionRequest() - } - } - is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName) - else -> {} + } + Row(modifier.padding(2.dp)) { + Text(r.reaction.text, fontSize = 12.sp) + if (r.totalReacted > 1) { + Spacer(Modifier.width(4.dp)) + Text("${r.totalReacted}", + fontSize = 11.5.sp, + fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal, + color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + ) + } + } + } + } + } + + Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { + Column( + Modifier + .clip(RoundedCornerShape(18.dp)) + .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick), + ) { + @Composable + fun framedItemView() { + FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem) + } + + fun deleteMessageQuestionText(): String { + return if (fullDeleteAllowed) { + generalGetString(R.string.delete_message_cannot_be_undone_warning) + } else { + generalGetString(R.string.delete_message_mark_deleted_warning) + } + } + + fun moderateMessageQuestionText(): String { + return if (fullDeleteAllowed) { + generalGetString(R.string.moderate_message_will_be_deleted_warning) + } else { + generalGetString(R.string.moderate_message_will_be_marked_warning) + } + } + + @Composable + fun MsgReactionsMenu() { + val rs = MsgReaction.values.mapNotNull { r -> + if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { + r + } else { + null + } + } + if (rs.isNotEmpty()) { + Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState())) { + rs.forEach() { r -> + Box( + Modifier.size(36.dp).clickable { + setReaction(cInfo, cItem, true, r) + showMenu.value = false + }, + contentAlignment = Alignment.Center + ) { + Text(r.text) + } + } + } + } + } + + @Composable + fun MsgContentItemDropdownMenu() { + DefaultDropdownMenu(showMenu) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() + } + if (cItem.meta.itemDeleted == null && !live) { + ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } showMenu.value = false }) } - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = { + val filePath = getLoadedFilePath(SimplexApp.context, cItem.file) + when { + filePath != null -> shareFile(context, cItem.text, filePath) + else -> shareText(context, cItem.content.text) + } showMenu.value = false }) - } - if (cItem.meta.itemDeleted != null && revealed.value) { - ItemAction( - stringResource(R.string.hide_verb), - painterResource(R.drawable.ic_visibility_off), - onClick = { - revealed.value = false - showMenu.value = false + ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = { + copyText(context, cItem.content.text) + showMenu.value = false + }) + if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) { + val filePath = getLoadedFilePath(context, cItem.file) + if (filePath != null) { + val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) + ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = { + when (cItem.content.msgContent) { + is MsgContent.MCImage -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) { + saveImage(context, cItem.file) + } else { + writePermissionState.launchPermissionRequest() + } + } + is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName) + else -> {} + } + showMenu.value = false + }) } - ) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + showMenu.value = false + }) + } + if (cItem.meta.itemDeleted != null && revealed.value) { + ItemAction( + stringResource(R.string.hide_verb), + painterResource(R.drawable.ic_visibility_off), + onClick = { + revealed.value = false + showMenu.value = false + } + ) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + } + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } } - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - if (!(live && cItem.meta.isLive)) { + } + + @Composable + fun MarkedDeletedItemDropdownMenu() { + DefaultDropdownMenu(showMenu) { + if (!cItem.isDeletedContent) { + ItemAction( + stringResource(R.string.reveal_verb), + painterResource(R.drawable.ic_visibility), + onClick = { + revealed.value = true + showMenu.value = false + } + ) + } DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) - } } - } - @Composable - fun MarkedDeletedItemDropdownMenu() { - DefaultDropdownMenu(showMenu) { - if (!cItem.isDeletedContent) { - ItemAction( - stringResource(R.string.reveal_verb), - painterResource(R.drawable.ic_visibility), - onClick = { - revealed.value = true - showMenu.value = false - } - ) - } - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - } - - @Composable - fun ContentItem() { - val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && !revealed.value) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) - MarkedDeletedItemDropdownMenu() - } else if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL) - MsgContentItemDropdownMenu() - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile) - MsgContentItemDropdownMenu() + @Composable + fun ContentItem() { + val mc = cItem.content.msgContent + if (cItem.meta.itemDeleted != null && !revealed.value) { + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + MarkedDeletedItemDropdownMenu() } else { - framedItemView() + if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile) + } else { + framedItemView() + } + } else { + framedItemView() + } MsgContentItemDropdownMenu() } - } else { - framedItemView() - MsgContentItemDropdownMenu() + } + + @Composable fun DeletedItem() { + DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + DefaultDropdownMenu(showMenu) { + DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + } + } + + @Composable fun CallItem(status: CICallStatus, duration: Int) { + CICallItemView(cInfo, cItem, status, duration, acceptCall) + } + + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> DeletedItem() + is CIContent.RcvDeleted -> DeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.RcvGroupEventContent -> CIEventView(cItem) + is CIContent.SndGroupEventContent -> CIEventView(cItem) + is CIContent.RcvConnEventContent -> CIEventView(cItem) + is CIContent.SndConnEventContent -> CIEventView(cItem) + is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) + is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + } + is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,) + is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) + is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) + is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) + is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) + is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) } } - @Composable fun DeletedItem() { - DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - } - - @Composable fun CallItem(status: CICallStatus, duration: Int) { - CICallItemView(cInfo, cItem, status, duration, acceptCall) - } - - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> DeletedItem() - is CIContent.RcvDeleted -> DeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) - is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) - is CIContent.RcvGroupEventContent -> CIEventView(cItem) - is CIContent.SndGroupEventContent -> CIEventView(cItem) - is CIContent.RcvConnEventContent -> CIEventView(cItem) - is CIContent.SndConnEventContent -> CIEventView(cItem) - is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) - is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - } - is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,) - is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) - is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) - is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) + if (cItem.content.msgContent != null && cItem.meta.itemDeleted == null && cItem.reactions.isNotEmpty()) { + ChatItemReactions() } } } @@ -430,7 +494,8 @@ fun PreviewChatItemView() { joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, - acceptFeature = { _, _, _ -> } + acceptFeature = { _, _, _ -> }, + setReaction = { _, _, _, _ -> }, ) } } @@ -451,7 +516,8 @@ fun PreviewChatItemViewDeletedContent() { joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, - acceptFeature = { _, _, _ -> } + acceptFeature = { _, _, _ -> }, + setReaction = { _, _, _, _ -> }, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt index 38b00dadee..b61af759ea 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -202,9 +202,9 @@ fun SectionDividerSpaced(maxTopPadding: Boolean = false, maxBottomPadding: Boole Divider( Modifier.padding( start = DEFAULT_PADDING_HALF, - top = if (maxTopPadding) 40.dp else 30.dp, + top = if (maxTopPadding) 37.dp else 27.dp, end = DEFAULT_PADDING_HALF, - bottom = if (maxBottomPadding) 40.dp else 30.dp) + bottom = if (maxBottomPadding) 37.dp else 27.dp) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt index 702156a948..8e59df5f4f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt @@ -72,6 +72,11 @@ private fun PreferencesLayout( applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it))) } SectionDividerSpaced(true, maxBottomPadding = false) +// val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.allow) } +// FeatureSection(ChatFeature.Reactions, allowReactions) { +// applyPrefs(preferences.copy(reactions = SimpleChatPreference(allow = it))) +// } +// SectionDividerSpaced(true, maxBottomPadding = false) val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) } FeatureSection(ChatFeature.Voice, allowVoice) { applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it))) diff --git a/apps/android/app/src/main/res/drawable/ic_add_reaction.xml b/apps/android/app/src/main/res/drawable/ic_add_reaction.xml new file mode 100644 index 0000000000..093d8fcc44 --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_add_reaction.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/android/app/src/main/res/drawable/ic_add_reaction_filled.xml b/apps/android/app/src/main/res/drawable/ic_add_reaction_filled.xml new file mode 100644 index 0000000000..1ffc21bc28 --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_add_reaction_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 65be20b66a..67734ef17d 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -1250,6 +1250,7 @@ Disappearing messages Direct messages Delete for everyone + Message reactions Voice messages Audio/video calls \nAvailable in v5.1 @@ -1269,6 +1270,9 @@ Allow your contacts to send voice messages. Allow voice messages only if your contact allows them. Prohibit sending voice messages. + Allow your contacts adding message reactions. + Allow message reactions only if your contact allows them. + Prohibit message reactions. Allow your contacts to call you. Allow calls only if your contact allows them. Prohibit audio/video calls. @@ -1284,6 +1288,10 @@ Only you can send voice messages. Only your contact can send voice messages. Voice messages are prohibited in this chat. + Both you and your contact can add message reactions. + Only you can add message reactions. + Only your contact can add message reactions. + Message reactions are prohibited in this chat. Both you and your contact can make calls. Only you can make calls. Only your contact can make calls. @@ -1296,6 +1304,8 @@ Prohibit irreversible message deletion. Allow to send voice messages. Prohibit sending voice messages. + Allow message reactions. + Prohibit messages reactions. Group members can send disappearing messages. Disappearing messages are prohibited in this group. Group members can send direct messages. @@ -1304,6 +1314,8 @@ Irreversible message deletion is prohibited in this group. Group members can send voice messages. Voice messages are prohibited in this group. + Group members can add message reactions. + Message reactions are prohibited in this group. Delete after %d sec %ds diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4cbf2ed7a0..e837f88609 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -444,7 +444,6 @@ struct ChatView: View { private struct ChatItemWithMenu: View { @EnvironmentObject var chat: Chat @Environment(\.colorScheme) var colorScheme - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false var ci: ChatItem var showMember: Bool = false var maxWidth: CGFloat @@ -538,7 +537,7 @@ struct ChatView: View { private func menu(live: Bool) -> [UIMenuElement] { var menu: [UIMenuElement] = [] if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { - if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction && developerTools, + if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, let rm = reactionUIMenu() { menu.append(rm) } diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift index 839c80a6a9..9bee25f585 100644 --- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -12,7 +12,6 @@ import SimpleXChat struct ContactPreferencesView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @Binding var contact: Contact @State var featuresAllowed: ContactFeaturesAllowed @State var currentFeaturesAllowed: ContactFeaturesAllowed @@ -25,9 +24,7 @@ struct ContactPreferencesView: View { List { timedMessagesFeatureSection() featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete) - if developerTools { - featureSection(.reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, $featuresAllowed.reactions) - } + // featureSection(.reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, $featuresAllowed.reactions) featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice) featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index eedf08d869..92d3710c1d 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -12,7 +12,6 @@ import SimpleXChat struct GroupPreferencesView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @Binding var groupInfo: GroupInfo @State var preferences: FullGroupPreferences @State var currentPreferences: FullGroupPreferences @@ -26,9 +25,7 @@ struct GroupPreferencesView: View { featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.directMessages, $preferences.directMessages.enable) - if developerTools { - featureSection(.reactions, $preferences.reactions.enable) - } + // featureSection(.reactions, $preferences.reactions.enable) featureSection(.voice, $preferences.voice.enable) if groupInfo.canEdit { diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift index 22797d7d02..3be84718f5 100644 --- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -11,7 +11,6 @@ import SimpleXChat struct PreferencesView: View { @EnvironmentObject var chatModel: ChatModel - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State var profile: LocalProfile @State var preferences: FullPreferences @State var currentPreferences: FullPreferences @@ -21,9 +20,7 @@ struct PreferencesView: View { List { timedMessagesFeatureSection($preferences.timedMessages.allow) featureSection(.fullDelete, $preferences.fullDelete.allow) - if developerTools { - featureSection(.reactions, $preferences.reactions.allow) - } + // featureSection(.reactions, $preferences.reactions.allow) featureSection(.voice, $preferences.voice.allow) featureSection(.calls, $preferences.calls.allow) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f93d2a5f2f..be508fb012 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2474,7 +2474,11 @@ public enum MsgReaction: Hashable { public var text: String { switch self { - case let .emoji(emoji): return emoji.rawValue + case let .emoji(emoji): + switch emoji { + case .heart: return "❤️" + default: return emoji.rawValue + } case .unknown: return "?" } } diff --git a/scripts/ios/prepare-x86_64.sh b/scripts/ios/prepare-x86_64.sh index 534365cb59..de70685eb4 100755 --- a/scripts/ios/prepare-x86_64.sh +++ b/scripts/ios/prepare-x86_64.sh @@ -1,5 +1,7 @@ #!/bin/sh +set -e + # the binaries folders should be in ~/Downloads folder rm -rf ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim mkdir -p ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim diff --git a/scripts/ios/prepare.sh b/scripts/ios/prepare.sh index 1043af4bdb..6d6f1cfced 100755 --- a/scripts/ios/prepare.sh +++ b/scripts/ios/prepare.sh @@ -1,5 +1,7 @@ #!/bin/sh +set -e + # the binaries folder should be in ~/Downloads folder rm -rf ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim From 3a50da1b533ab2d6f0fbae380a14f13cee0682c3 Mon Sep 17 00:00:00 2001 From: M Sarmad Qadeer Date: Thu, 18 May 2023 15:03:35 +0500 Subject: [PATCH 2/4] website: fix guide urls related to blogs (#2431) --- website/.eleventy.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/.eleventy.js b/website/.eleventy.js index 07815958e5..bf0bf8131f 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -212,6 +212,9 @@ module.exports = function (ty) { if (parsed.scheme || parsed.host || !parsed.path.endsWith(".md")) { return link } + if (parsed.path.startsWith("../../blog")) { + parsed.path = parsed.path.replace("../../blog", "/blog") + } parsed.path = parsed.path.replace(/\.md$/, ".html") return uri.serialize(parsed) } From 01b3e983583282a67a11583d5f2432541a179d99 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 18 May 2023 17:52:58 +0200 Subject: [PATCH 3/4] core: update chat item details api (#2456) --- src/Simplex/Chat.hs | 28 ++++++++--------------- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Messages.hs | 8 ++----- src/Simplex/Chat/Store.hs | 42 +++++++++++++++++++--------------- src/Simplex/Chat/View.hs | 4 ++-- tests/ChatTests/Direct.hs | 12 +++++----- tests/ChatTests/Groups.hs | 12 +++++----- 7 files changed, 49 insertions(+), 59 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 817236bbf2..06e2642923 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -468,22 +468,10 @@ processChatCommand = \case APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user pagination search pure $ CRChatItems user chatItems - APIGetChatItemInfo itemId -> withUser $ \user -> do - (chatItem@(AChatItem _ _ _ ChatItem {meta}), itemVersions) <- withStore $ \db -> do - ci <- getAChatItem db user itemId - versions <- liftIO $ getChatItemVersions db itemId - pure (ci, versions) - let CIMeta {itemTs, createdAt, updatedAt, itemTimed} = meta - ciInfo = - ChatItemInfo - { chatItemId = itemId, - itemTs, - createdAt, - updatedAt, - deleteAt = itemTimed >>= timedDeleteAt', - itemVersions - } - pure $ CRChatItemInfo user chatItem ciInfo + APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do + (chatItem, itemVersions) <- withStore $ \db -> + (,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId) + pure $ CRChatItemInfo user chatItem ChatItemInfo {itemVersions} APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId @@ -1490,7 +1478,9 @@ processChatCommand = \case chatItems <- withStore $ \db -> getAllChatItems db user (CPLast $ index + 1) Nothing pure $ CRChatItemId user (fmap aChatItemId . listToMaybe $ chatItems) ShowChatItem (Just itemId) -> withUser $ \user -> do - chatItem <- withStore $ \db -> getAChatItem db user itemId + chatItem <- withStore $ \db -> do + chatRef <- getChatRefViaItemId db user itemId + getAChatItem db user chatRef itemId pure $ CRChatItems user ((: []) chatItem) ShowChatItem Nothing -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing @@ -1498,7 +1488,7 @@ processChatCommand = \case ShowChatItemInfo chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName itemId <- getChatItemIdByText user chatRef msg - processChatCommand $ APIGetChatItemInfo itemId + processChatCommand $ APIGetChatItemInfo chatRef itemId ShowLiveItems on -> withUser $ \_ -> asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do @@ -4756,7 +4746,7 @@ chatCommandP = "/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), - "/_get item info " *> (APIGetChatItemInfo <$> A.decimal), + "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index cfed01e0e1..5ec15ee6c5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -214,7 +214,7 @@ data ChatCommand | APIGetChats {userId :: UserId, pendingConnections :: Bool} | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) - | APIGetChatItemInfo ChatItemId + | APIGetChatItemInfo ChatRef ChatItemId | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessage :: ComposedMessage} | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b0d48bd7af..5ab4801010 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1501,12 +1501,7 @@ jsonCIDeleted = \case CIModerated m -> JCIDModerated m data ChatItemInfo = ChatItemInfo - { chatItemId :: ChatItemId, - itemTs :: UTCTime, - createdAt :: UTCTime, - updatedAt :: UTCTime, - deleteAt :: Maybe UTCTime, - itemVersions :: [ChatItemVersion] + { itemVersions :: [ChatItemVersion] } deriving (Eq, Show, Generic) @@ -1515,6 +1510,7 @@ instance ToJSON ChatItemInfo where toEncoding = J.genericToEncoding J.defaultOpt data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, msgContent :: MsgContent, + formattedText :: Maybe MarkdownList, itemVersionTs :: UTCTime, createdAt :: UTCTime } diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 47cac9d038..5d17563968 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -227,6 +227,7 @@ module Simplex.Chat.Store getGroupChat, getAllChatItems, getAChatItem, + getChatRefViaItemId, getChatItemVersions, getDirectCIReactions, getDirectReactions, @@ -4290,11 +4291,14 @@ getAllChatItems db user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of CPLast count -> liftIO $ getAllChatItemsLast_ count - CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem db user afterId - CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem db user beforeId - mapM (uncurry (getAChatItem_ db user) >=> liftIO . getACIReactions db) itemRefs + CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId + CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId + mapM (uncurry (getAChatItem db user) >=> liftIO . getACIReactions db) itemRefs where search = fromMaybe "" search_ + getAChatItem_ itemId = do + chatRef <- getChatRefViaItemId db user itemId + getAChatItem db user chatRef itemId getAllChatItemsLast_ count = reverse <$> DB.query @@ -4771,7 +4775,7 @@ getGroupChatItemIdByText' db User {userId} groupId msg = getChatItemByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO AChatItem getChatItemByFileId db user@User {userId} fileId = do - (itemId, chatRef) <- + (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ DB.query db @@ -4783,11 +4787,11 @@ getChatItemByFileId db user@User {userId} fileId = do LIMIT 1 |] (userId, fileId) - getAChatItem_ db user itemId chatRef + getAChatItem db user chatRef itemId getChatItemByGroupId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO AChatItem getChatItemByGroupId db user@User {userId} groupId = do - (itemId, chatRef) <- + (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByGroupId groupId) $ DB.query db @@ -4799,22 +4803,20 @@ getChatItemByGroupId db user@User {userId} groupId = do LIMIT 1 |] (userId, groupId) - getAChatItem_ db user itemId chatRef + getAChatItem db user chatRef itemId -getAChatItem :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO AChatItem -getAChatItem db user@User {userId} itemId = do - chatRef <- - ExceptT . firstRow' toChatRef (SEChatItemNotFound itemId) $ - DB.query db "SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId) - getAChatItem_ db user itemId chatRef +getChatRefViaItemId :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO ChatRef +getChatRefViaItemId db User {userId} itemId = do + ExceptT . firstRow' toChatRef (SEChatItemNotFound itemId) $ + DB.query db "SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId) where toChatRef = \case (Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId (_, _) -> Left $ SEBadChatItem itemId -getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem -getAChatItem_ db user itemId = \case +getAChatItem :: DB.Connection -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem +getAChatItem db user chatRef itemId = case chatRef of ChatRef CTDirect contactId -> do ct <- getContact db user contactId (CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId @@ -4839,7 +4841,9 @@ getChatItemVersions db itemId = do (Only itemId) where toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion - toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = ChatItemVersion {chatItemVersionId, msgContent, itemVersionTs, createdAt} + toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = + let formattedText = parseMaybeMarkdownList $ msgContentText msgContent + in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt} getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect) getDirectChatReactions_ db ct c@Chat {chatItems} = do @@ -4984,10 +4988,10 @@ updateDirectCIFileStatus db user fileId fileStatus = do pure $ AChatItem SCTDirect d cInfo $ updateFileStatus ci fileStatus _ -> pure aci -toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatItemId, ChatRef) +toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) toChatItemRef = \case - (itemId, Just contactId, Nothing) -> Right (itemId, ChatRef CTDirect contactId) - (itemId, Nothing, Just groupId) -> Right (itemId, ChatRef CTGroup groupId) + (itemId, Just contactId, Nothing) -> Right (ChatRef CTDirect contactId, itemId) + (itemId, Nothing, Just groupId) -> Right (ChatRef CTGroup groupId, itemId) (itemId, _, _) -> Left $ SEBadChatItem itemId updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO () diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2088894126..37efe90117 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -424,7 +424,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file} prohibited = styled (colored Red) ("[unexpected chat item created, please report to developers]" :: String) viewChatItemInfo :: AChatItem -> ChatItemInfo -> TimeZone -> [StyledString] -viewChatItemInfo (AChatItem _ msgDir _ _) ChatItemInfo {itemTs, createdAt, deleteAt, itemVersions} tz = +viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt}}) ChatItemInfo {itemVersions} tz = ["sent at: " <> ts itemTs] <> receivedAt <> toBeDeletedAt @@ -434,7 +434,7 @@ viewChatItemInfo (AChatItem _ msgDir _ _) ChatItemInfo {itemTs, createdAt, delet receivedAt = case msgDir of SMDRcv -> ["received at: " <> ts createdAt] SMDSnd -> [] - toBeDeletedAt = case deleteAt of + toBeDeletedAt = case itemTimed >>= timedDeleteAt' of Just d -> ["to be deleted at: " <> ts d] Nothing -> [] versions = diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 72ef1ad07e..f671d6cd50 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -283,9 +283,9 @@ testDirectMessageEditHistory = alice #> "@bob hello!" bob <# "alice> hello!" - alice ##> ("/_get item info " <> itemId 1) + alice ##> ("/_get item info @2 " <> itemId 1) alice <##. "sent at: " - bob ##> ("/_get item info " <> itemId 1) + bob ##> ("/_get item info @2 " <> itemId 1) bob <##. "sent at: " bob <##. "received at: " @@ -293,12 +293,12 @@ testDirectMessageEditHistory = alice <# "@bob [edited] hey 👋" bob <# "alice> [edited] hey 👋" - alice ##> ("/_get item info " <> itemId 1) + alice ##> ("/_get item info @2 " <> itemId 1) alice <##. "sent at: " alice <## "message history:" alice .<## ": hey 👋" alice .<## ": hello!" - bob ##> ("/_get item info " <> itemId 1) + bob ##> ("/_get item info @2 " <> itemId 1) bob <##. "sent at: " bob <##. "received at: " bob <## "message history:" @@ -434,12 +434,12 @@ testDirectLiveMessage = alice <# "@bob [LIVE] hello 2" bob <# "alice> [LIVE ended] hello 2" -- live message has edit history - alice ##> ("/_get item info " <> itemId 2) + alice ##> ("/_get item info @2 " <> itemId 2) alice <##. "sent at: " alice <## "message history:" alice .<## ": hello 2" alice .<## ":" - bob ##> ("/_get item info " <> itemId 2) + bob ##> ("/_get item info @2 " <> itemId 2) bob <##. "sent at: " bob <##. "received at: " bob <## "message history:" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 978a7d9fc5..1a2faf5c94 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -892,9 +892,9 @@ testGroupMessageEditHistory = aliceItemId <- lastItemId alice bobItemId <- lastItemId bob - alice ##> ("/_get item info " <> aliceItemId) + alice ##> ("/_get item info #1 " <> aliceItemId) alice <##. "sent at: " - bob ##> ("/_get item info " <> bobItemId) + bob ##> ("/_get item info #1 " <> bobItemId) bob <##. "sent at: " bob <##. "received at: " @@ -902,12 +902,12 @@ testGroupMessageEditHistory = alice <# "#team [edited] hey 👋" bob <# "#team alice> [edited] hey 👋" - alice ##> ("/_get item info " <> aliceItemId) + alice ##> ("/_get item info #1 " <> aliceItemId) alice <##. "sent at: " alice <## "message history:" alice .<## ": hey 👋" alice .<## ": hello!" - bob ##> ("/_get item info " <> bobItemId) + bob ##> ("/_get item info #1 " <> bobItemId) bob <##. "sent at: " bob <##. "received at: " bob <## "message history:" @@ -1059,13 +1059,13 @@ testGroupLiveMessage = bob <# "#team alice> [LIVE ended] hello 2" cath <# "#team alice> [LIVE ended] hello 2" -- live message has edit history - alice ##> ("/_get item info " <> msgItemId2) + alice ##> ("/_get item info #1 " <> msgItemId2) alice <##. "sent at: " alice <## "message history:" alice .<## ": hello 2" alice .<## ":" bobItemId <- lastItemId bob - bob ##> ("/_get item info " <> bobItemId) + bob ##> ("/_get item info #1 " <> bobItemId) bob <##. "sent at: " bob <##. "received at: " bob <## "message history:" From f155611d29d81968909a0df9d5a0f023c8a9d5c5 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 18 May 2023 20:00:16 +0400 Subject: [PATCH 4/4] android: allow to set disappearance interval when sending message (#2455) --- apps/android/app/build.gradle | 3 + .../java/chat/simplex/app/model/ChatModel.kt | 8 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 140 ++++++++---- .../chat/simplex/app/views/TerminalView.kt | 2 +- .../simplex/app/views/chat/ComposeView.kt | 31 +-- .../app/views/chat/ContactPreferences.kt | 4 +- .../simplex/app/views/chat/SendMsgView.kt | 199 ++++++++++++++---- .../app/views/chat/group/GroupPreferences.kt | 2 +- .../simplex/app/views/chat/item/CIMetaView.kt | 4 +- .../app/views/helpers/CustomTimePicker.kt | 193 +++++++++++++++++ .../app/src/main/res/drawable/ic_close.xml | 2 +- .../app/src/main/res/values/strings.xml | 15 ++ apps/android/settings.gradle | 1 + 13 files changed, 502 insertions(+), 102 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index fca43254fe..f0e3c66ec5 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -160,6 +160,9 @@ dependencies { // Video support implementation "com.google.android.exoplayer:exoplayer:2.17.1" + // Wheel picker + implementation 'com.github.zj565061763:compose-wheel-picker:1.0.0-alpha10' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 9f8e76da32..725377fcd8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -1715,18 +1715,18 @@ sealed class CIContent: ItemContent { companion object { fun featureText(feature: Feature, enabled: String, param: Int?): String = if (feature.hasParam) { - "${feature.text}: ${TimedMessagesPreference.ttlText(param)}" + "${feature.text}: ${timeText(param)}" } else { "${feature.text}: $enabled" } fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when { allowed != FeatureAllowed.NO && feature.hasParam && param != null -> - String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, TimedMessagesPreference.ttlText(param)) + String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, timeText(param)) allowed != FeatureAllowed.NO -> - String.format(generalGetString(R.string.feature_offered_item), feature.text, TimedMessagesPreference.ttlText(param)) + String.format(generalGetString(R.string.feature_offered_item), feature.text, timeText(param)) else -> - String.format(generalGetString(R.string.feature_cancelled_item), feature.text, TimedMessagesPreference.ttlText(param)) + String.format(generalGetString(R.string.feature_cancelled_item), feature.text, timeText(param)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index e38e6d0b9b..1a66d6d44b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -165,6 +165,7 @@ class AppPreferences(val context: Context) { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) + val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -289,6 +290,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_THEMES = "Themes" private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion" private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" + private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" } } @@ -579,8 +581,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false): AChatItem? { - val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live) + suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) val r = sendCmd(cmd) return when (r) { is CR.NewChatItem -> r.chatItem @@ -1893,7 +1895,7 @@ sealed class CC { class ApiStorageEncryption(val config: DBEncryptionConfig): CC() class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() - class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC() + class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() @@ -1982,7 +1984,10 @@ sealed class CC { is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") - is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" + is ApiSendMessage -> { + val ttlStr = if (ttl != null) "$ttl" else "default" + "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" + } is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" @@ -2555,52 +2560,103 @@ data class TimedMessagesPreference( ): ChatPreference { companion object { val ttlValues: List - get() = listOf(30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, null) + get() = listOf(3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, null) + } +} - fun ttlText(ttl: Int?): String { - ttl ?: return generalGetString(R.string.feature_off) - if (ttl == 0) return String.format(generalGetString(R.string.ttl_sec), 0) - val (m_, s) = divMod(ttl, 60) - val (h_, m) = divMod(m_, 60) - val (d_, h) = divMod(h_, 24) - val (mm, d) = divMod(d_, 30) - return maybe(mm, if (mm == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), mm)) + - maybe(d, if (d == 1) String.format(generalGetString(R.string.ttl_day), 1) else if (d == 7) String.format(generalGetString(R.string.ttl_week), 1) else if (d == 14) String.format(generalGetString(R.string.ttl_weeks), 2) else String.format(generalGetString(R.string.ttl_days), d)) + - maybe(h, if (h == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), h)) + - maybe(m, String.format(generalGetString(R.string.ttl_min), m)) + - maybe(s, String.format(generalGetString(R.string.ttl_sec), s)) +sealed class CustomTimeUnit { + object Second: CustomTimeUnit() + object Minute: CustomTimeUnit() + object Hour: CustomTimeUnit() + object Day: CustomTimeUnit() + object Week: CustomTimeUnit() + object Month: CustomTimeUnit() + + val toSeconds: Int + get() = + when (this) { + Second -> 1 + Minute -> 60 + Hour -> 3600 + Day -> 86400 + Week -> 7 * 86400 + Month -> 30 * 86400 + } + + val text: String + get() = + when (this) { + Second -> generalGetString(R.string.custom_time_unit_seconds) + Minute -> generalGetString(R.string.custom_time_unit_minutes) + Hour -> generalGetString(R.string.custom_time_unit_hours) + Day -> generalGetString(R.string.custom_time_unit_days) + Week -> generalGetString(R.string.custom_time_unit_weeks) + Month -> generalGetString(R.string.custom_time_unit_months) + } + + companion object { + fun toTimeUnit(seconds: Int): Pair { + val tryUnits = listOf(Month, Week, Day, Hour, Minute) + var selectedUnit: Pair? = null + for (unit in tryUnits) { + val (v, r) = divMod(seconds, unit.toSeconds) + if (r == 0) { + selectedUnit = Pair(unit, v) + break + } + } + return selectedUnit ?: Pair(Second, seconds) } - fun shortTtlText(ttl: Int?): String { - ttl ?: return generalGetString(R.string.feature_off) - val m = ttl / 60 - if (m == 0) { - return String.format(generalGetString(R.string.ttl_s), ttl) - } - val h = m / 60 - if (h == 0) { - return String.format(generalGetString(R.string.ttl_m), m) - } - val d = h / 24 - if (d == 0) { - return String.format(generalGetString(R.string.ttl_h), h) - } - val mm = d / 30 - if (mm > 0) { - return String.format(generalGetString(R.string.ttl_mth), mm) - } - val w = d / 7 - return if (w == 0 || d % 7 != 0) String.format(generalGetString(R.string.ttl_d), d) else String.format(generalGetString(R.string.ttl_w), w) - } - - fun divMod(n: Int, d: Int): Pair = + private fun divMod(n: Int, d: Int): Pair = n / d to n % d - fun maybe(n: Int, s: String): String = - if (n == 0) "" else s + fun toText(seconds: Int): String { + val (unit, value) = toTimeUnit(seconds) + return when (unit) { + Second -> String.format(generalGetString(R.string.ttl_sec), value) + Minute -> String.format(generalGetString(R.string.ttl_min), value) + Hour -> if (value == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), value) + Day -> if (value == 1) String.format(generalGetString(R.string.ttl_day), 1) else String.format(generalGetString(R.string.ttl_days), value) + Week -> if (value == 1) String.format(generalGetString(R.string.ttl_week), 1) else String.format(generalGetString(R.string.ttl_weeks), value) + Month -> if (value == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), value) + } + } + + fun toShortText(seconds: Int): String { + val (unit, value) = toTimeUnit(seconds) + return when (unit) { + Second -> String.format(generalGetString(R.string.ttl_s), value) + Minute -> String.format(generalGetString(R.string.ttl_m), value) + Hour -> String.format(generalGetString(R.string.ttl_h), value) + Day -> String.format(generalGetString(R.string.ttl_d), value) + Week -> String.format(generalGetString(R.string.ttl_w), value) + Month -> String.format(generalGetString(R.string.ttl_mth), value) + } + } } } +fun timeText(seconds: Int?): String { + if (seconds == null) { + return generalGetString(R.string.feature_off) + } + if (seconds == 0) { + String.format(generalGetString(R.string.ttl_sec), 0) + } + return CustomTimeUnit.toText(seconds) +} + +fun shortTimeText(seconds: Int?): String { + if (seconds == null) { + return generalGetString(R.string.feature_off) + } + if (seconds == 0) { + String.format(generalGetString(R.string.ttl_s), 0) + } + return CustomTimeUnit.toShortText(seconds) +} + @Serializable data class ContactUserPreferences( val timedMessages: ContactUserPreferenceTimed, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 15688db8be..cb00859314 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -86,7 +86,7 @@ fun TerminalLayout( userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, - sendMessage = sendCommand, + sendMessage = { sendCommand() }, sendLiveMessage = null, updateLiveMessage = null, onMessageChange = ::onMessageChange, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 8cc8c96772..3b770e55f6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -366,14 +366,15 @@ fun ComposeView( chatModel.filesToDelete.clear() } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? { + suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, file = file, quotedItemId = quoted, mc = mc, - live = live + live = live, + ttl = ttl ) if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) return aChatItem?.chatItem @@ -381,7 +382,7 @@ fun ComposeView( - suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? { + suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? { val cInfo = chat.chatInfo val cs = composeState.value var sent: ChatItem? @@ -495,7 +496,8 @@ fun ComposeView( msgs.forEachIndexed { index, content -> if (index > 0) delay(100) sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), - if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false + live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, + ttl = ttl ) } if (sent == null && @@ -503,16 +505,16 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live) + sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) return sent } - fun sendMessage() { + fun sendMessage(ttl: Int?) { withBGApi { - sendMessageAsync(null, false) + sendMessageAsync(null, false, ttl) } } @@ -588,7 +590,7 @@ fun ComposeView( val cs = composeState.value val typedMsg = cs.message if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) { - val ci = sendMessageAsync(typedMsg, live = true) + val ci = sendMessageAsync(typedMsg, live = true, ttl = null) if (ci != null) { composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) } @@ -609,7 +611,7 @@ fun ComposeView( if (liveMessage != null) { val sentMsg = liveMessageToSend(liveMessage, typedMsg) if (sentMsg != null) { - val ci = sendMessageAsync(sentMsg, live = true) + val ci = sendMessageAsync(sentMsg, live = true, ttl = null) if (ci != null) { composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) } @@ -763,7 +765,7 @@ fun ComposeView( if (orientation == activity.resources.configuration.orientation) { val cs = composeState.value if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage() + sendMessage(null) resetLinkPreview() clearCurrentDraft() deleteUnusedFiles() @@ -784,6 +786,9 @@ fun ComposeView( } } + // TODO in 5.2 - allow if ttl is not configured + // val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) && chat.chatInfo.timedMessagesTTL != null } SendMsgView( composeState, showVoiceRecordIcon = true, @@ -795,8 +800,10 @@ fun ComposeView( allowVoiceToContact = ::allowVoiceToContact, userIsObserver = userIsObserver.value, userCanSend = userCanSend.value, - sendMessage = { - sendMessage() + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + sendMessage = { ttl -> + sendMessage(ttl) resetLinkPreview() }, sendLiveMessage = ::sendLiveMessage, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt index 0bd06c20d8..a7799d262d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt @@ -188,7 +188,7 @@ private fun TimedMessagesFeatureSection( val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) } TimedMessagesTTLPicker(ttl, onTTLUpdated) } else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) { - InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl)) + InfoRow(generalGetString(R.string.delete_after), timeText(pref.contactPreference.ttl)) } } SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled)) @@ -212,7 +212,7 @@ fun TimedMessagesTTLPicker(selection: MutableState, onSelected: (Int?) -> val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value) ExposedDropDownSettingRow( generalGetString(R.string.delete_after), - values.map { it to TimedMessagesPreference.ttlText(it) }, + values.map { it to timeText(it) }, selection, onSelected = onSelected ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 831badf7fa..94087754f1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -18,7 +18,7 @@ import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* @@ -29,14 +29,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.* import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat @@ -44,8 +44,7 @@ import androidx.core.widget.* import chat.simplex.app.R import chat.simplex.app.SimplexApp import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.CurrentColors -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -64,13 +63,25 @@ fun SendMsgView( userIsObserver: Boolean, userCanSend: Boolean, allowVoiceToContact: () -> Unit, - sendMessage: () -> Unit, + timedMessageAllowed: Boolean = false, + customDisappearingMessageTimePref: SharedPreference? = null, + sendMessage: (Int?) -> Unit, sendLiveMessage: (suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null, onMessageChange: (String) -> Unit, textStyle: MutableState ) { + val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } + + if (showCustomDisappearingMessageDialog.value) { + CustomDisappearingMessageDialog( + sendMessage = sendMessage, + setShowDialog = { showCustomDisappearingMessageDialog.value = it }, + customDisappearingMessageTimePref = customDisappearingMessageTimePref + ) + } + Box(Modifier.padding(vertical = 8.dp)) { val cs = composeState.value val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview) @@ -80,14 +91,15 @@ fun SendMsgView( NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) // Disable clicks on text field if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) { - Box(Modifier - .matchParentSize() - .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { - AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.observer_cant_send_message_title), - text = generalGetString(R.string.observer_cant_send_message_desc) - ) - }) + Box( + Modifier + .matchParentSize() + .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.observer_cant_send_message_title), + text = generalGetString(R.string.observer_cant_send_message_desc) + ) + }) ) } if (showDeleteTextButton.value) { @@ -124,10 +136,11 @@ fun SendMsgView( else -> RecordVoiceView(recState, stopRecOnNextClick) } - if (sendLiveMessage != null - && updateLiveMessage != null - && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) - && cs.contextItem is ComposeContextItem.NoContextItem) { + if (sendLiveMessage != null + && updateLiveMessage != null + && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) + && cs.contextItem is ComposeContextItem.NoContextItem + ) { Spacer(Modifier.width(10.dp)) StartLiveMessageButton(userCanSend) { if (composeState.value.preview is ComposePreview.NoPreview) { @@ -146,27 +159,43 @@ fun SendMsgView( val cs = composeState.value val icon = if (cs.editing || cs.liveMessage != null) painterResource(R.drawable.ic_check_filled) else painterResource(R.drawable.ic_arrow_upward) val disabled = !cs.sendEnabled() || - (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || - cs.endLiveDisabled - if (cs.liveMessage == null && - cs.preview !is ComposePreview.VoicePreview && !cs.editing && - cs.contextItem is ComposeContextItem.NoContextItem && - sendLiveMessage != null && updateLiveMessage != null - ) { + (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || + cs.endLiveDisabled + val showSendLiveMessageMenuButton = + cs.liveMessage == null && !cs.editing && + cs.preview !is ComposePreview.VoicePreview && + cs.contextItem is ComposeContextItem.NoContextItem && + sendLiveMessage != null && updateLiveMessage != null + val showSendDisappearingMessageMenuButton = + cs.liveMessage == null && !cs.editing && + timedMessageAllowed + + if (showSendLiveMessageMenuButton || showSendDisappearingMessageMenuButton) { val showDropdown = rememberSaveable { mutableStateOf(false) } SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true } - DefaultDropdownMenu( showDropdown, ) { - ItemAction( - generalGetString(R.string.send_live_message), - BoltFilled, - onClick = { - startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) - showDropdown.value = false - } - ) + if (showSendLiveMessageMenuButton && sendLiveMessage != null && updateLiveMessage != null) { + ItemAction( + generalGetString(R.string.send_live_message), + BoltFilled, + onClick = { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + showDropdown.value = false + } + ) + } + if (showSendDisappearingMessageMenuButton) { + ItemAction( + generalGetString(R.string.disappearing_message), + painterResource(R.drawable.ic_timer), + onClick = { + showCustomDisappearingMessageDialog.value = true + showDropdown.value = false + } + ) + } } } else { SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) @@ -177,6 +206,99 @@ fun SendMsgView( } } +@Composable +private fun CustomDisappearingMessageDialog( + sendMessage: (Int?) -> Unit, + setShowDialog: (Boolean) -> Unit, + customDisappearingMessageTimePref: SharedPreference? +) { + val showCustomTimePicker = remember { mutableStateOf(false) } + + if (showCustomTimePicker.value) { + val selectedDisappearingMessageTime = remember { + mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300) + } + CustomTimePickerDialog( + selectedDisappearingMessageTime, + title = generalGetString(R.string.delete_after), + confirmButtonText = generalGetString(R.string.send_disappearing_message_send), + confirmButtonAction = { ttl -> + sendMessage(ttl) + customDisappearingMessageTimePref?.set?.invoke(ttl) + setShowDialog(false) + }, + cancel = { setShowDialog(false) } + ) + } else { + @Composable + fun ChoiceButton( + text: String, + onClick: () -> Unit + ) { + TextButton(onClick) { + Text( + text, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + } + + Dialog(onDismissRequest = { setShowDialog(false) }) { + Surface( + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(" ") // centers title + Text( + generalGetString(R.string.send_disappearing_message), + fontSize = 16.sp, + color = MaterialTheme.colors.secondary + ) + Icon( + painterResource(R.drawable.ic_close), + generalGetString(R.string.icon_descr_close_button), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(25.dp) + .clickable { setShowDialog(false) } + ) + } + + ChoiceButton(generalGetString(R.string.send_disappearing_message_30_seconds)) { + sendMessage(30) + setShowDialog(false) + } + ChoiceButton(generalGetString(R.string.send_disappearing_message_1_minute)) { + sendMessage(60) + setShowDialog(false) + } + ChoiceButton(generalGetString(R.string.send_disappearing_message_5_minutes)) { + sendMessage(300) + setShowDialog(false) + } + ChoiceButton(generalGetString(R.string.send_disappearing_message_custom_time)) { + showCustomTimePicker.value = true + } + } + } + } + } + } +} + @Composable private fun NativeKeyboard( composeState: MutableState, @@ -455,14 +577,14 @@ private fun SendMsgButton( sizeDp: Animatable, alpha: Animatable, enabled: Boolean, - sendMessage: () -> Unit, + sendMessage: (Int?) -> Unit, onLongClick: (() -> Unit)? = null ) { val interactionSource = remember { MutableInteractionSource() } Box( modifier = Modifier.requiredSize(36.dp) .combinedClickable( - onClick = sendMessage, + onClick = { sendMessage(null) }, onLongClick = onLongClick, enabled = enabled, role = Role.Button, @@ -607,6 +729,7 @@ fun PreviewSendMsgView() { userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, + timedMessageAllowed = false, sendMessage = {}, onMessageChange = { _ -> }, textStyle = textStyle @@ -637,6 +760,7 @@ fun PreviewSendMsgViewEditing() { userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, + timedMessageAllowed = false, sendMessage = {}, onMessageChange = { _ -> }, textStyle = textStyle @@ -667,6 +791,7 @@ fun PreviewSendMsgViewInProgress() { userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, + timedMessageAllowed = false, sendMessage = {}, onMessageChange = { _ -> }, textStyle = textStyle diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt index 76a7978180..e99a0883ad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt @@ -151,7 +151,7 @@ private fun FeatureSection( iconTint = iconTint, ) if (timedOn) { - InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl)) + InfoRow(generalGetString(R.string.delete_after), timeText(preferences.timedMessages.ttl)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index 8f7e464e8d..c9e460d03d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -44,7 +44,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) { StatusIconText(painterResource(R.drawable.ic_timer), color) val ttl = meta.itemTimed?.ttl if (ttl != chatTTL) { - Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp) + Text(shortTimeText(ttl), color = color, fontSize = 13.sp) } Spacer(Modifier.width(4.dp)) } @@ -69,7 +69,7 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String { res += iconSpace val ttl = meta.itemTimed.ttl if (ttl != chatTTL) { - res += TimedMessagesPreference.shortTtlText(ttl) + res += shortTimeText(ttl) } } if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt new file mode 100644 index 0000000000..7fa045dd15 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt @@ -0,0 +1,193 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import chat.simplex.app.R +import chat.simplex.app.model.CustomTimeUnit +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import com.sd.lib.compose.wheel_picker.* + +@Composable +fun CustomTimePicker( + selection: MutableState, + timeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits +) { + fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List { + val unitLimits = timeUnitsLimits.firstOrNull { it.timeUnit == unit } ?: TimeUnitLimits.defaultUnitLimits(unit) + val unitValues = (unitLimits.minValue..unitLimits.maxValue).toList() + return unitValues + if (unitValues.contains(selectedValue)) emptyList() else listOf(selectedValue) + } + + val (unit, duration) = CustomTimeUnit.toTimeUnit(selection.value) + val selectedUnit: MutableState = remember { mutableStateOf(unit) } + val selectedDuration = remember { mutableStateOf(duration) } + val selectedUnitValues = remember { mutableStateOf(getUnitValues(selectedUnit.value, selectedDuration.value)) } + + LaunchedEffect(selectedUnit.value) { + val maxValue = timeUnitsLimits.firstOrNull { it.timeUnit == selectedUnit.value }?.maxValue + if (maxValue != null && selectedDuration.value > maxValue) { + selectedDuration.value = maxValue + selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) + } else { + selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) + selection.value = selectedUnit.value.toSeconds * selectedDuration.value + } + } + + LaunchedEffect(selectedDuration.value) { + selection.value = selectedUnit.value.toSeconds * selectedDuration.value + } + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + Column(Modifier.weight(1f)) { + val durationPickerState = rememberFWheelPickerState(selectedUnitValues.value.indexOf(selectedDuration.value)) + FVerticalWheelPicker( + count = selectedUnitValues.value.count(), + state = durationPickerState, + unfocusedCount = 2, + focus = { + FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) + } + ) { index -> + Text( + selectedUnitValues.value[index].toString(), + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + LaunchedEffect(durationPickerState) { + snapshotFlow { durationPickerState.currentIndex } + .collect { + selectedDuration.value = selectedUnitValues.value[it] + } + } + } + Column(Modifier.weight(1f)) { + val unitPickerState = rememberFWheelPickerState(timeUnitsLimits.indexOfFirst { it.timeUnit == selectedUnit.value }) + FVerticalWheelPicker( + count = timeUnitsLimits.count(), + state = unitPickerState, + unfocusedCount = 2, + focus = { + FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) + } + ) { index -> + Text( + timeUnitsLimits[index].timeUnit.text, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + LaunchedEffect(unitPickerState) { + snapshotFlow { unitPickerState.currentIndex } + .collect { + selectedUnit.value = timeUnitsLimits[it].timeUnit + } + } + } + } +} + +data class TimeUnitLimits( + val timeUnit: CustomTimeUnit, + val minValue: Int = 1, + val maxValue: Int +) { + companion object { + fun defaultUnitLimits(unit: CustomTimeUnit): TimeUnitLimits { + return when (unit) { + CustomTimeUnit.Second -> TimeUnitLimits(CustomTimeUnit.Second, maxValue = 120) + CustomTimeUnit.Minute -> TimeUnitLimits(CustomTimeUnit.Minute, maxValue = 120) + CustomTimeUnit.Hour -> TimeUnitLimits(CustomTimeUnit.Hour, maxValue = 72) + CustomTimeUnit.Day -> TimeUnitLimits(CustomTimeUnit.Day, maxValue = 60) + CustomTimeUnit.Week -> TimeUnitLimits(CustomTimeUnit.Week, maxValue = 12) // TODO in 5.2 - 54 + CustomTimeUnit.Month -> TimeUnitLimits(CustomTimeUnit.Month, maxValue = 3) // TODO in 5.2 - 12 + } + } + + val defaultUnitsLimits: List + get() = listOf( + defaultUnitLimits(CustomTimeUnit.Second), + defaultUnitLimits(CustomTimeUnit.Minute), + defaultUnitLimits(CustomTimeUnit.Hour), + defaultUnitLimits(CustomTimeUnit.Day), + defaultUnitLimits(CustomTimeUnit.Week), + defaultUnitLimits(CustomTimeUnit.Month) + ) + } +} + +@Composable +fun CustomTimePickerDialog( + selection: MutableState, + timeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits, + title: String, + confirmButtonText: String, + confirmButtonAction: (Int) -> Unit, + cancel: () -> Unit +) { + Dialog(onDismissRequest = cancel) { + Surface( + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(" ") // centers title + Text( + title, + fontSize = 16.sp, + color = MaterialTheme.colors.secondary + ) + Icon( + painterResource(R.drawable.ic_close), + generalGetString(R.string.icon_descr_close_button), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(25.dp) + .clickable { cancel() } + ) + } + + CustomTimePicker( + selection, + timeUnitsLimits + ) + + TextButton(onClick = { confirmButtonAction(selection.value) }) { + Text( + confirmButtonText, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + } + } + } + } +} diff --git a/apps/android/app/src/main/res/drawable/ic_close.xml b/apps/android/app/src/main/res/drawable/ic_close.xml index fc0f4d3fd6..5acbc7cfcc 100644 --- a/apps/android/app/src/main/res/drawable/ic_close.xml +++ b/apps/android/app/src/main/res/drawable/ic_close.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M480,520.5L271,729.5Q262,738.5 250.75,738.5Q239.5,738.5 230.5,729.5Q221.5,720.5 221.5,709.25Q221.5,698 230.5,689L440,479.5L230.5,270Q221.5,261.5 221.5,250.25Q221.5,239 230.5,230Q239.5,221 250.75,221Q262,221 271,230L480,439.5L689,230.5Q698,221.5 709.25,221.5Q720.5,221.5 729.5,230.5Q738.5,239.5 738.5,250.75Q738.5,262 729.5,271L520.5,480L730,689.5Q738.5,698.5 738.5,709.75Q738.5,721 730,729.5Q721,738.5 709.75,738.5Q698.5,738.5 689.5,729.5L480,520.5Z"/> diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 67734ef17d..11df772d51 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -338,6 +338,13 @@ Please ask your contact to enable sending voice messages. Only group owners can enable voice messages. Send live message + Disappearing message + Send disappearing message + 30 seconds + 1 minute + 5 minutes + Custom time + Send Live message! Send a live message - it will update for the recipient(s) as you type it Send @@ -1392,4 +1399,12 @@ Set it instead of system authentication. Polish interface Thanks to the users – contribute via Weblate! + + + seconds + minutes + hours + days + weeks + months diff --git a/apps/android/settings.gradle b/apps/android/settings.gradle index 64c6620acf..3621283ff6 100644 --- a/apps/android/settings.gradle +++ b/apps/android/settings.gradle @@ -10,6 +10,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } } rootProject.name = "SimpleX"