mobile: items with feature offers (#1627)

* mobile: items with feature offers

* ios interactive contact/user preference change items

* android: interactive preference items

* add missing view

* revert change
This commit is contained in:
Evgeny Poberezkin
2022-12-22 21:01:29 +00:00
committed by GitHub
parent e6aad24e5f
commit a936c14cf2
15 changed files with 314 additions and 69 deletions
@@ -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}"
}
}
}
@@ -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),
@@ -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<Boolean>) -> 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 = { _, _ -> },
@@ -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
@@ -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,
@@ -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) }
}
@@ -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
// )
}
}
@@ -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 = { _, _ -> }
)
}
}
@@ -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)
@@ -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)
}
}
@@ -64,6 +64,10 @@ struct ChatItemContentView<Content: View>: 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)
+1 -1
View File
@@ -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"
}
}
@@ -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)
}
}
@@ -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 = "<group>"; };
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = "<group>"; };
5C6BA666289BD954009B8ECC /* DismissSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissSheets.swift; sourceTree = "<group>"; };
5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFeaturePreferenceView.swift; sourceTree = "<group>"; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
@@ -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;
};
+83 -16
View File
@@ -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<TimedMessagesPreference>(
enabled: FeatureEnabled(forUser: false, forContact: false),
userPreference: ContactUserPref<TimedMessagesPreference>.user(preference: TimedMessagesPreference(allow: .no)),
userPreference: ContactUserPref<TimedMessagesPreference>.user(preference: TimedMessagesPreference(allow: .yes)),
contactPreference: TimedMessagesPreference(allow: .no)
),
fullDelete: ContactUserPreference<SimplePreference>(
@@ -335,7 +351,9 @@ public enum ContactUserPref<P: Preference>: 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? {