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
This commit is contained in:
Evgeny Poberezkin 2023-05-18 11:43:44 +02:00 committed by GitHub
parent b6a4f5f518
commit a32fd5e665
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 558 additions and 210 deletions

View file

@ -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).

View file

@ -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<FormattedText>? = null,
val quotedItem: CIQuote? = null,
val reactions: List<CIReactionCount>,
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<MsgReaction> get() = MREmojiChar.values().map(::Emoji)
}
}
object MsgReactionSerializer : KSerializer<MsgReaction> {
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgReaction", PolymorphicKind.SEALED) {
element("Emoji", buildClassSerialDescriptor("Emoji") {
element<String>("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<MREmojiChar>(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,

View file

@ -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<Contact>): 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))

View file

@ -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<Boolean>) -> 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 = { _, _ -> },

View file

@ -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(

View file

@ -95,6 +95,11 @@ private fun ContactPreferencesLayout(
applyPrefs(featuresAllowed.copy(fullDelete = it))
}
SectionDividerSpaced(true, maxBottomPadding = false)
// val allowReactions: MutableState<ContactFeatureAllowed> = 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<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))

View file

@ -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)))

View file

@ -211,6 +211,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
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<ChatItem> {
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(

View file

@ -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 {

View file

@ -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)
}
}
}

View file

@ -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 = { _, _, _, _ -> },
)
}
}

View file

@ -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)
)
}

View file

@ -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)))

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.dp"
android:height="24.dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M480.33,875Q398.63,875 326.7,843.74Q254.78,812.47 201.14,758.74Q147.5,705 116.25,633.21Q85,561.42 85,479.8Q85,398.09 116.36,325.93Q147.73,253.78 201.13,200.39Q254.54,147 326.79,116Q399.04,85 479.92,85Q526.76,85 570.69,95.51Q614.62,106.03 654,125Q650.5,133.5 649.25,142.08Q648,150.67 648,160Q648,167.9 648.75,174.95Q649.5,182 652,189Q614,166.5 570.93,154.5Q527.86,142.5 479.9,142.5Q339.69,142.5 241.09,240.75Q142.5,339 142.5,479.51Q142.5,620.03 241.24,718.76Q339.97,817.5 480.49,817.5Q621,817.5 719.25,718.91Q817.5,620.31 817.5,480Q817.5,441.47 809.25,404.99Q801,368.5 786,336Q796.96,343.76 810.93,347.88Q824.9,352 840,352Q843.36,352 846.75,352Q850.14,352 853.5,351.5Q864,382 869.5,413.93Q875,445.87 875,479.82Q875,560.91 843.99,633.3Q812.97,705.68 759.49,758.99Q706,812.3 633.98,843.65Q561.95,875 480.33,875ZM624.45,426.5Q647.4,426.5 662.45,411.5Q677.5,396.49 677.5,373.55Q677.5,350.6 662.5,335.55Q647.49,320.5 624.55,320.5Q601.6,320.5 586.55,335.5Q571.5,350.51 571.5,373.45Q571.5,396.4 586.5,411.45Q601.51,426.5 624.45,426.5ZM335.45,426.5Q358.4,426.5 373.45,411.5Q388.5,396.49 388.5,373.55Q388.5,350.6 373.5,335.55Q358.49,320.5 335.55,320.5Q312.6,320.5 297.55,335.5Q282.5,350.51 282.5,373.45Q282.5,396.4 297.5,411.45Q312.51,426.5 335.45,426.5ZM480,696Q544.5,696 599.25,661.5Q654,627 679,566.5L281,566.5Q307,627 361.25,661.5Q415.5,696 480,696ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM811.5,188.5L760,188.5Q747.75,188.5 739.63,180.33Q731.5,172.15 731.5,159.82Q731.5,147.5 739.63,139.25Q747.75,131 760,131L811.5,131L811.5,80Q811.5,67.75 819.67,59.38Q827.85,51 840.17,51Q852.5,51 860.75,59.38Q869,67.75 869,80L869,131L920,131Q932.25,131 940.63,139.43Q949,147.85 949,160.17Q949,172.5 940.63,180.5Q932.25,188.5 920,188.5L869,188.5L869,240Q869,252.25 860.58,260.38Q852.15,268.5 839.83,268.5Q827.5,268.5 819.5,260.38Q811.5,252.25 811.5,240L811.5,188.5Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.dp"
android:height="24.dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M811.5,188.5L760,188.5Q748.5,188.5 740,180.33Q731.5,172.15 731.5,159.82Q731.5,147.5 739.63,139.25Q747.75,131 760,131L811.5,131L811.5,80Q811.5,67.75 819.67,59.38Q827.85,51 840.17,51Q852.5,51 860.75,59.38Q869,67.75 869,80L869,131L920,131Q932.25,131 940.63,139.43Q949,147.85 949,160.17Q949,172.5 940.63,180.5Q932.25,188.5 920,188.5L869,188.5L869,240Q869,251.5 860.58,260Q852.15,268.5 839.83,268.5Q827.5,268.5 819.5,260.38Q811.5,252.25 811.5,240L811.5,188.5ZM480.33,875Q398.63,875 326.7,843.74Q254.78,812.47 201.14,758.74Q147.5,705 116.25,633.21Q85,561.42 85,479.8Q85,398.09 116.36,325.93Q147.73,253.78 201.13,200.39Q254.54,147 326.79,116Q399.04,85 479.92,85Q526.76,85 570.69,95.51Q614.62,106.03 654,125Q650.5,133.5 649.25,142.08Q648,150.67 648,159.57Q648,198 671.44,228.46Q694.88,258.93 732.5,267.5Q741.57,305.17 772.03,328.58Q802.48,352 840.28,352Q843.36,352 846.75,352Q850.14,352 853.5,351.5Q864,382 869.5,413.93Q875,445.87 875,479.82Q875,560.91 843.99,633.3Q812.97,705.68 759.49,758.99Q706,812.3 633.98,843.65Q561.95,875 480.33,875ZM624.45,426.5Q647.4,426.5 662.45,411.5Q677.5,396.49 677.5,373.55Q677.5,350.6 662.5,335.55Q647.49,320.5 624.55,320.5Q601.6,320.5 586.55,335.5Q571.5,350.51 571.5,373.45Q571.5,396.4 586.5,411.45Q601.51,426.5 624.45,426.5ZM335.45,426.5Q358.4,426.5 373.45,411.5Q388.5,396.49 388.5,373.55Q388.5,350.6 373.5,335.55Q358.49,320.5 335.55,320.5Q312.6,320.5 297.55,335.5Q282.5,350.51 282.5,373.45Q282.5,396.4 297.5,411.45Q312.51,426.5 335.45,426.5ZM480,696Q544.5,696 599.25,661.5Q654,627 679,566.5L281,566.5Q307,627 361.25,661.5Q415.5,696 480,696Z"/>
</vector>

View file

@ -1250,6 +1250,7 @@
<string name="timed_messages">Disappearing messages</string>
<string name="direct_messages">Direct messages</string>
<string name="full_deletion">Delete for everyone</string>
<string name="message_reactions">Message reactions</string>
<string name="voice_messages">Voice messages</string>
<string name="audio_video_calls">Audio/video calls</string>
<string name="available_in_v51">\nAvailable in v5.1</string>
@ -1269,6 +1270,9 @@
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
<string name="allow_your_contacts_adding_message_reactions">Allow your contacts adding message reactions.</string>
<string name="allow_message_reactions_only_if">Allow message reactions only if your contact allows them.</string>
<string name="prohibit_message_reactions">Prohibit message reactions.</string>
<string name="allow_your_contacts_to_call">Allow your contacts to call you.</string>
<string name="allow_calls_only_if">Allow calls only if your contact allows them.</string>
<string name="prohibit_calls">Prohibit audio/video calls.</string>
@ -1284,6 +1288,10 @@
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_add_message_reactions">Both you and your contact can add message reactions.</string>
<string name="only_you_can_add_message_reactions">Only you can add message reactions.</string>
<string name="only_your_contact_can_add_message_reactions">Only your contact can add message reactions.</string>
<string name="message_reactions_prohibited_in_this_chat">Message reactions are prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_make_calls">Both you and your contact can make calls.</string>
<string name="only_you_can_make_calls">Only you can make calls.</string>
<string name="only_your_contact_can_make_calls">Only your contact can make calls.</string>
@ -1296,6 +1304,8 @@
<string name="prohibit_message_deletion">Prohibit irreversible message deletion.</string>
<string name="allow_to_send_voice">Allow to send voice messages.</string>
<string name="prohibit_sending_voice">Prohibit sending voice messages.</string>
<string name="allow_message_reactions">Allow message reactions.</string>
<string name="prohibit_message_reactions_group">Prohibit messages reactions.</string>
<string name="group_members_can_send_disappearing">Group members can send disappearing messages.</string>
<string name="disappearing_messages_are_prohibited">Disappearing messages are prohibited in this group.</string>
<string name="group_members_can_send_dms">Group members can send direct messages.</string>
@ -1304,6 +1314,8 @@
<string name="message_deletion_prohibited_in_chat">Irreversible message deletion is prohibited in this group.</string>
<string name="group_members_can_send_voice">Group members can send voice messages.</string>
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this group.</string>
<string name="group_members_can_add_message_reactions">Group members can add message reactions.</string>
<string name="message_reactions_are_prohibited">Message reactions are prohibited in this group.</string>
<string name="delete_after">Delete after</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_s">%ds</string>

View file

@ -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)
}

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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 "?"
}
}

View file

@ -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

View file

@ -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