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 514e04ce3c..778e79cf98 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 @@ -408,8 +408,7 @@ interface SomeChat { val sendMsgEnabled: Boolean val ntfsEnabled: Boolean val incognito: Boolean - val voiceMessageAllowed: Boolean - val fullDeletionAllowed: Boolean + fun featureEnabled(feature: ChatFeature): Boolean val timedMessagesTTL: Int? val createdAt: Instant val updatedAt: Instant @@ -472,8 +471,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val sendMsgEnabled get() = contact.sendMsgEnabled override val ntfsEnabled get() = contact.ntfsEnabled override val incognito get() = contact.incognito - override val voiceMessageAllowed get() = contact.voiceMessageAllowed - override val fullDeletionAllowed get() = contact.fullDeletionAllowed + override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL override val createdAt get() = contact.createdAt override val updatedAt get() = contact.updatedAt @@ -497,8 +495,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val sendMsgEnabled get() = groupInfo.sendMsgEnabled override val ntfsEnabled get() = groupInfo.ntfsEnabled override val incognito get() = groupInfo.incognito - override val voiceMessageAllowed get() = groupInfo.voiceMessageAllowed - override val fullDeletionAllowed get() = groupInfo.fullDeletionAllowed + override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature) override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL override val createdAt get() = groupInfo.createdAt override val updatedAt get() = groupInfo.updatedAt @@ -522,8 +519,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val sendMsgEnabled get() = contactRequest.sendMsgEnabled override val ntfsEnabled get() = contactRequest.ntfsEnabled override val incognito get() = contactRequest.incognito - override val voiceMessageAllowed get() = contactRequest.voiceMessageAllowed - override val fullDeletionAllowed get() = contactRequest.fullDeletionAllowed + override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL override val createdAt get() = contactRequest.createdAt override val updatedAt get() = contactRequest.updatedAt @@ -547,8 +543,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val sendMsgEnabled get() = contactConnection.sendMsgEnabled override val ntfsEnabled get() = contactConnection.incognito override val incognito get() = contactConnection.incognito - override val voiceMessageAllowed get() = contactConnection.voiceMessageAllowed - override val fullDeletionAllowed get() = contactConnection.fullDeletionAllowed + override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL override val createdAt get() = contactConnection.createdAt override val updatedAt get() = contactConnection.updatedAt @@ -585,8 +580,11 @@ data class Contact( override val sendMsgEnabled get() = true override val ntfsEnabled get() = chatSettings.enableNtfs override val incognito get() = contactConnIncognito - override val voiceMessageAllowed get() = mergedPreferences.voice.enabled.forUser - override val fullDeletionAllowed get() = mergedPreferences.fullDelete.enabled.forUser + override fun featureEnabled(feature: ChatFeature) = when (feature) { + ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser + ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser + ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser + } override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null } override val displayName get() = localAlias.ifEmpty { profile.displayName } override val fullName get() = profile.fullName @@ -600,6 +598,18 @@ data class Contact( val contactConnIncognito = activeConn.customUserProfileId != null + fun allowsFeature(feature: ChatFeature): Boolean = when (feature) { + ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO + ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO + ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO + } + + 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.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO + } + companion object { val sampleData = Contact( contactId = 1, @@ -720,8 +730,11 @@ data class GroupInfo ( override val sendMsgEnabled get() = membership.memberActive override val ntfsEnabled get() = chatSettings.enableNtfs override val incognito get() = membership.memberIncognito - override val voiceMessageAllowed get() = fullGroupPreferences.voice.on - override val fullDeletionAllowed get() = fullGroupPreferences.fullDelete.on + override fun featureEnabled(feature: ChatFeature) = when (feature) { + ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on + ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on + ChatFeature.Voice -> fullGroupPreferences.fullDelete.on + } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } override val displayName get() = groupProfile.displayName override val fullName get() = groupProfile.fullName @@ -968,8 +981,7 @@ class UserContactRequest ( override val sendMsgEnabled get() = false override val ntfsEnabled get() = false override val incognito get() = false - override val voiceMessageAllowed get() = false - override val fullDeletionAllowed get() = false + override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null override val displayName get() = profile.displayName override val fullName get() = profile.fullName @@ -1007,8 +1019,7 @@ class PendingContactConnection( override val sendMsgEnabled get() = false override val ntfsEnabled get() = false override val incognito get() = customUserProfileId != null - override val voiceMessageAllowed get() = false - override val fullDeletionAllowed get() = false + override fun featureEnabled(feature: ChatFeature) = false override val timedMessagesTTL: Int? get() = null override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId) override val displayName: String get() { @@ -1150,6 +1161,8 @@ data class ChatItem ( is CIContent.SndConnEventContent -> showNtfDir is CIContent.RcvChatFeature -> false is CIContent.SndChatFeature -> showNtfDir + is CIContent.RcvChatPreference -> false + is CIContent.SndChatPreference -> showNtfDir is CIContent.RcvGroupFeature -> false is CIContent.SndGroupFeature -> showNtfDir is CIContent.RcvChatFeatureRejected -> showNtfDir @@ -1385,6 +1398,8 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null } @@ -1406,6 +1421,8 @@ sealed class CIContent: ItemContent { is SndConnEventContent -> sndConnEvent.text is RcvChatFeature -> featureText(feature, enabled.text, param) is SndChatFeature -> featureText(feature, enabled.text, param) + is RcvChatPreference -> preferenceText(feature, allowed, param) + is SndChatPreference -> preferenceText(feature, allowed, param) is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param) is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param) is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}" @@ -1413,12 +1430,21 @@ sealed class CIContent: ItemContent { } companion object { - fun featureText(feature: Feature, value: String, param: Int?): String = + fun featureText(feature: Feature, enabled: String, param: Int?): String = if (feature.hasParam && param != null) { "${feature.text}: ${TimedMessagesPreference.ttlText(param)}" } else { - "${feature.text}: $value" + "${feature.text}: $enabled" } + + fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when { + allowed != FeatureAllowed.NO && feature.hasParam && param != null -> + "offered ${feature.text}: ${TimedMessagesPreference.ttlText(param)}" + allowed != FeatureAllowed.NO -> + "offered ${feature.text}" + else -> + "cancelled ${feature.text}" + } } } 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 1faab0370d..d537599df7 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 @@ -985,6 +985,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun allowFeatureToContact(contact: Contact, feature: ChatFeature) { + val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature) + val toContact = apiSetContactPrefs(contact.contactId, prefs) + if (toContact != null) { + chatModel.updateContact(toContact) + } + } + private fun networkErrorAlert(r: CR): Boolean { return when { r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent @@ -2037,6 +2045,13 @@ data class ChatPreferences( val fullDelete: SimpleChatPreference? = null, val voice: SimpleChatPreference? = null, ) { + fun setAllowed(feature: ChatFeature, allowed: FeatureAllowed = FeatureAllowed.YES): ChatPreferences = + when (feature) { + ChatFeature.TimedMessages -> this.copy(timedMessages = TimedMessagesPreference(allow = allowed, ttl = this.timedMessages?.ttl)) + ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed)) + ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed)) + } + companion object { val sampleData = ChatPreferences( timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO), 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 6edd148c30..96ee2059a1 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 @@ -226,6 +226,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { chatModel.callManager.acceptIncomingCall(invitation = invitation) } }, + acceptFeature = { contact, feature -> + withApi { + chatModel.controller.allowFeatureToContact(contact, feature) + } + }, addMembers = { groupInfo -> hideKeyboard(view) withApi { @@ -282,6 +287,7 @@ fun ChatLayout( joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit, + acceptFeature: (Contact, ChatFeature) -> Unit, addMembers: (GroupInfo) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, @@ -322,7 +328,7 @@ fun ChatLayout( ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, - receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed, + receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed, ) } } @@ -497,6 +503,7 @@ fun BoxWithConstraintsScope.ChatItemsList( receiveFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, + acceptFeature: (Contact, ChatFeature) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit, onComposed: () -> Unit, @@ -602,11 +609,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, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) } } else { Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) } } } else { // direct message @@ -617,7 +624,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem) } } @@ -1019,6 +1026,7 @@ fun PreviewChatLayout() { joinGroup = {}, startCall = {}, acceptCall = { _ -> }, + acceptFeature = { _, _ -> }, addMembers = { _ -> }, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, @@ -1077,6 +1085,7 @@ fun PreviewGroupChatLayout() { joinGroup = {}, startCall = {}, acceptCall = { _ -> }, + acceptFeature = { _, _ -> }, addMembers = { _ -> }, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index a7744ea9f0..7fa723e0f1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -516,12 +516,8 @@ fun ComposeView( fun allowVoiceToContact() { val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return - val prefs = contact.mergedPreferences.toPreferences().copy(voice = SimpleChatPreference(allow = FeatureAllowed.YES)) withApi { - val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs) - if (toContact != null) { - chatModel.updateContact(toContact) - } + chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice) } } @@ -674,7 +670,7 @@ fun ComposeView( .clip(CircleShape) ) } - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.voiceMessageAllowed } + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } LaunchedEffect(allowedVoiceByPrefs) { if (!allowedVoiceByPrefs && chosenAudio.value != null) { // Voice was disabled right when this user records it, just cancel it diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt index eda609b5e0..7f64869e68 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* @@ -14,14 +15,15 @@ import chat.simplex.app.model.* fun CIChatFeatureView( chatItem: ChatItem, feature: Feature, - iconColor: Color + iconColor: Color, + icon: ImageVector? = null ) { Row( Modifier.padding(horizontal = 6.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Icon(feature.iconFilled, feature.text, Modifier.size(15.dp), tint = iconColor) + Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(15.dp), tint = iconColor) Text( chatEventText(chatItem), Modifier, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt index 654674f701..24e15c8026 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt @@ -43,7 +43,7 @@ fun CIEventView(ci: ChatItem) { } } -private fun withChatEventStyle(builder: AnnotatedString.Builder, text: String) { +fun withChatEventStyle(builder: AnnotatedString.Builder, text: String) { return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt new file mode 100644 index 0000000000..a10ded313f --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt @@ -0,0 +1,54 @@ +package chat.simplex.app.views.chat.item + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleButton + +@Composable +fun CIFeaturePreferenceView( + chatItem: ChatItem, + chatInfo: ChatInfo, + feature: ChatFeature, + allowed: FeatureAllowed, + acceptFeature: (Contact, ChatFeature) -> Unit +) { + Row( + Modifier.padding(horizontal = 6.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(feature.icon, feature.text, Modifier.size(15.dp), tint = HighOrLowlight) + Text(chatItem.content.text, fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight) + if (chatInfo is ChatInfo.Direct && allowed != FeatureAllowed.NO) { + val ct = chatInfo.contact + if (ct.allowsFeature(feature) && !ct.userAllowsFeature(feature)) { + Text(stringResource(R.string.accept), modifier = Modifier.clickable { acceptFeature(ct, feature) }, + fontSize = 12.sp, color = MaterialTheme.colors.primary) + } + } + Text(chatItem.timestampText, fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight) +// buildAnnotatedString { +// withChatEventStyle(this, chatItem.content.text) +// append(" ") +// withChatEventStyle(this, chatItem.timestampText) +// }, +// Modifier, +// // this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp +// fontSize = 12.sp +// ) + } +} 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 c6cad053f0..b62c4f6d18 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.ComposeContextItem import chat.simplex.app.views.chat.ComposeState @@ -42,6 +43,7 @@ fun ChatItemView( joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, + acceptFeature: (Contact, ChatFeature) -> Unit ) { val context = LocalContext.current val uriHandler = LocalUriHandler.current @@ -49,7 +51,7 @@ fun ChatItemView( val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } val revealed = remember { mutableStateOf(false) } - val fullDeleteAllowed = remember(cInfo) { cInfo.fullDeletionAllowed } + val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file) val onLinkLongClick = { _: String -> showMenu.value = true } @@ -224,6 +226,8 @@ fun ChatItemView( 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 -> CIFeaturePreferenceView(cItem, cInfo, c.feature, c.allowed, acceptFeature) + is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, 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) @@ -319,6 +323,7 @@ fun PreviewChatItemView() { joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, + acceptFeature = { _, _ -> } ) } } @@ -338,6 +343,7 @@ fun PreviewChatItemViewDeletedContent() { joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, + acceptFeature = { _, _ -> } ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index 2033ec3c7e..a7bff2f421 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -12,12 +12,14 @@ import SimpleXChat struct CIChatFeatureView: View { var chatItem: ChatItem var feature: Feature + var icon: String? = nil var iconColor: Color var body: some View { HStack(alignment: .bottom, spacing: 4) { - Image(systemName: feature.iconFilled) + Image(systemName: icon ?? feature.iconFilled) .foregroundColor(iconColor) + .scaleEffect(feature.iconScale) chatEventText(chatItem) } .padding(.leading, 6) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift new file mode 100644 index 0000000000..7443a843ee --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -0,0 +1,72 @@ +// +// CIFeaturePreferenceView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 21/12/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct CIFeaturePreferenceView: View { + @EnvironmentObject var chat: Chat + var chatItem: ChatItem + var feature: ChatFeature + var allowed: FeatureAllowed + var param: Int? + + var body: some View { + HStack(alignment: .bottom, spacing: 4) { + Image(systemName: feature.icon) + .foregroundColor(.secondary) + .scaleEffect(feature.iconScale) + Text(CIContent.preferenceText(feature, allowed, param)) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + if let ct = chat.chatInfo.contact, + allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) { + Button("Accept") { allowFeatureToContact(ct, feature) } + .font(.caption) + } + chatItem.timestampText + .font(.caption) + .foregroundColor(Color.secondary) + .fontWeight(.light) + } + .padding(.leading, 6) + .padding(.bottom, 6) + .textSelection(.disabled) + } +} + +func allowFeatureToContact(_ contact: Contact, _ feature: ChatFeature) { + Task { + do { + let prefs = contactUserPreferencesToPreferences(contact.mergedPreferences).setAllowed(feature) + if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) { + await MainActor.run { + ChatModel.shared.updateContact(toContact) + } + } + } catch { + logger.error("allowFeatureToContact apiSetContactPrefs error: \(responseError(error))") + } + } +} + +struct CIFeaturePreferenceView_Previews: PreviewProvider { + static var previews: some View { + let content = CIContent.rcvChatPreference(feature: .timedMessages, allowed: .yes, param: 30) + let chatItem = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false), + content: content, + quotedItem: nil, + file: nil + ) + CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) + .environmentObject(Chat.sampleData) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 4d7d87b62e..1d9c3a6a6f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -64,6 +64,10 @@ struct ChatItemContentView: View { case .sndConnEvent: eventItemView() case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) + case let .rcvChatPreference(feature, allowed, param): + CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param) + case let .sndChatPreference(feature, _, _): + CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary) case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 63bdf2f490..c90da11094 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -595,7 +595,7 @@ struct ChatView: View { } private var broadcastDeleteButtonText: LocalizedStringKey { - chat.chatInfo.fullDeletionAllowed ? "Delete for everyone" : "Mark deleted for everyone" + chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3720916c24..f2191d5e15 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -222,15 +222,15 @@ struct ComposeView: View { }, sendLiveMessage: sendLiveMessage, updateLiveMessage: updateLiveMessage, - voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed, + voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, startVoiceMessageRecording: { Task { await startVoiceMessageRecording() } }, - finishVoiceMessageRecording: { finishVoiceMessageRecording() }, - allowVoiceMessagesToContact: { allowVoiceMessagesToContact() }, + finishVoiceMessageRecording: finishVoiceMessageRecording, + allowVoiceMessagesToContact: allowVoiceMessagesToContact, onImageAdded: { image in chosenImages = [image] }, keyboardVisible: $keyboardVisible ) @@ -344,7 +344,7 @@ struct ComposeView: View { startingRecording = false } } - .onChange(of: chat.chatInfo.voiceMessageAllowed) { vmAllowed in + .onChange(of: chat.chatInfo.featureEnabled(.voice)) { vmAllowed in if !vmAllowed && composeState.voicePreview, let fileName = composeState.voiceMessageRecordingFileName { cancelVoiceMessageRecording(fileName) @@ -639,19 +639,7 @@ struct ComposeView: View { private func allowVoiceMessagesToContact() { if case let .direct(contact) = chat.chatInfo { - Task { - do { - var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences) - prefs.voice = SimplePreference(allow: .yes) - if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) { - await MainActor.run { - chatModel.updateContact(toContact) - } - } - } catch { - logger.error("ComposeView allowVoiceMessagesToContact, apiSetContactPrefs error: \(responseError(error))") - } - } + allowFeatureToContact(contact, .voice) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 63836da9fc..3197106bdb 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BA666289BD954009B8ECC /* DismissSheets.swift */; }; + 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; @@ -260,6 +261,7 @@ 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; 5C6BA666289BD954009B8ECC /* DismissSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissSheets.swift; sourceTree = ""; }; + 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFeaturePreferenceView.swift; sourceTree = ""; }; 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; @@ -688,6 +690,7 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */, 6440C9FF288857A10062C672 /* CIEventView.swift */, 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */, + 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */, 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */, 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */, ); @@ -1037,6 +1040,7 @@ 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, + 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e8b3cc5f1b..4dd6250e52 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -153,6 +153,22 @@ public struct Preferences: Codable { self.voice = voice } + func copy(timedMessages: TimedMessagesPreference? = nil, fullDelete: SimplePreference? = nil, voice: SimplePreference? = nil) -> Preferences { + Preferences( + timedMessages: timedMessages ?? self.timedMessages, + fullDelete: fullDelete ?? self.fullDelete, + voice: voice ?? self.voice + ) + } + + public func setAllowed(_ feature: ChatFeature, allowed: FeatureAllowed = .yes) -> Preferences { + switch feature { + case .timedMessages: return copy(timedMessages: TimedMessagesPreference(allow: allowed, ttl: timedMessages?.ttl)) + case .fullDelete: return copy(fullDelete: SimplePreference(allow: allowed)) + case .voice: return copy(voice: SimplePreference(allow: allowed)) + } + } + public static let sampleData = Preferences( timedMessages: TimedMessagesPreference(allow: .no), fullDelete: SimplePreference(allow: .no), @@ -256,7 +272,7 @@ public struct ContactUserPreferences: Decodable { public static let sampleData = ContactUserPreferences( timedMessages: ContactUserPreference( enabled: FeatureEnabled(forUser: false, forContact: false), - userPreference: ContactUserPref.user(preference: TimedMessagesPreference(allow: .no)), + userPreference: ContactUserPref.user(preference: TimedMessagesPreference(allow: .yes)), contactPreference: TimedMessagesPreference(allow: .no) ), fullDelete: ContactUserPreference( @@ -335,7 +351,9 @@ public enum ContactUserPref: Decodable { } public protocol Feature { + var icon: String { get } var iconFilled: String { get } + var iconScale: CGFloat { get } var hasParam: Bool { get } var text: String { get } } @@ -373,7 +391,7 @@ public enum ChatFeature: String, Decodable, Feature { public var icon: String { switch self { - case .timedMessages: return "timer" + case .timedMessages: return "stopwatch" case .fullDelete: return "trash.slash" case .voice: return "mic" } @@ -381,12 +399,19 @@ public enum ChatFeature: String, Decodable, Feature { public var iconFilled: String { switch self { - case .timedMessages: return "timer" + case .timedMessages: return "stopwatch.fill" case .fullDelete: return "trash.slash.fill" case .voice: return "mic.fill" } } + public var iconScale: CGFloat { + switch self { + case .timedMessages: return 0.9 + default: return 1 + } + } + public func allowDescription(_ allowed: FeatureAllowed) -> LocalizedStringKey { switch self { case .timedMessages: @@ -484,6 +509,13 @@ public enum GroupFeature: String, Decodable, Feature { } } + public var iconScale: CGFloat { + switch self { + case .timedMessages: return 0.9 + default: return 1 + } + } + public func enableDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool) -> LocalizedStringKey { if canEdit { switch self { @@ -879,18 +911,23 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } - public var voiceMessageAllowed: Bool { + // this works for features that are common for contacts and groups + public func featureEnabled(_ feature: ChatFeature) -> Bool { switch self { - case let .direct(contact): return contact.mergedPreferences.voice.enabled.forUser - case let .group(groupInfo): return groupInfo.fullGroupPreferences.voice.on - default: return false - } - } - - public var fullDeletionAllowed: Bool { - switch self { - case let .direct(contact): return contact.mergedPreferences.fullDelete.enabled.forUser - case let .group(groupInfo): return groupInfo.fullGroupPreferences.fullDelete.on + case let .direct(contact): + let cups = contact.mergedPreferences + switch feature { + case .timedMessages: return cups.timedMessages.enabled.forUser + case .fullDelete: return cups.fullDelete.enabled.forUser + case .voice: return cups.voice.enabled.forUser + } + case let .group(groupInfo): + let prefs = groupInfo.fullGroupPreferences + switch feature { + case .timedMessages: return prefs.timedMessages.on + case .fullDelete: return prefs.fullDelete.on + case .voice: return prefs.voice.on + } default: return false } } @@ -1028,6 +1065,22 @@ public struct Contact: Identifiable, Decodable, NamedChat { activeConn.customUserProfileId != nil } + public func allowsFeature(_ feature: ChatFeature) -> Bool { + switch feature { + case .timedMessages: return mergedPreferences.timedMessages.contactPreference.allow != .no + case .fullDelete: return mergedPreferences.fullDelete.contactPreference.allow != .no + case .voice: return mergedPreferences.voice.contactPreference.allow != .no + } + } + + public func userAllowsFeature(_ feature: ChatFeature) -> Bool { + switch feature { + case .timedMessages: return mergedPreferences.timedMessages.userPreference.preference.allow != .no + case .fullDelete: return mergedPreferences.fullDelete.userPreference.preference.allow != .no + case .voice: return mergedPreferences.voice.userPreference.preference.allow != .no + } + } + public static let sampleData = Contact( contactId: 1, localDisplayName: "alice", @@ -1637,6 +1690,8 @@ public struct ChatItem: Identifiable, Decodable { case .sndConnEvent: return showNtfDir case .rcvChatFeature: return false case .sndChatFeature: return showNtfDir + case .rcvChatPreference: return false + case .sndChatPreference: return showNtfDir case .rcvGroupFeature: return false case .sndGroupFeature: return showNtfDir case .rcvChatFeatureRejected: return showNtfDir @@ -1892,6 +1947,8 @@ public enum CIContent: Decodable, ItemContent { case sndConnEvent(sndConnEvent: SndConnEvent) case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?) case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?) + case rcvChatPreference(feature: ChatFeature, allowed: FeatureAllowed, param: Int?) + case sndChatPreference(feature: ChatFeature, allowed: FeatureAllowed, param: Int?) case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) case rcvChatFeatureRejected(feature: ChatFeature) @@ -1915,6 +1972,8 @@ public enum CIContent: Decodable, ItemContent { case let .sndConnEvent(sndConnEvent): return sndConnEvent.text case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) + case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) case let .rcvGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) case let .sndGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) @@ -1923,10 +1982,18 @@ public enum CIContent: Decodable, ItemContent { } } - static func featureText(_ feature: Feature, _ value: String, _ param: Int?) -> String { + static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { feature.hasParam && param != nil ? "\(feature.text): \(TimedMessagesPreference.ttlText(param))" - : "\(feature.text): \(value)" + : "\(feature.text): \(enabled)" + } + + public static func preferenceText(_ feature: Feature, _ allowed: FeatureAllowed, _ param: Int?) -> String { + allowed != .no && feature.hasParam && param != nil + ? "offered \(feature.text): \(TimedMessagesPreference.ttlText(param))" + : allowed != .no + ? "offered \(feature.text)" + : "cancelled \(feature.text)" } public var msgContent: MsgContent? {