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