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 6dc1263462..4d37ab2eb2 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 @@ -352,6 +352,7 @@ data class User( val userContactId: Long, val localDisplayName: String, val profile: LocalProfile, + val fullPreferences: FullChatPreferences, val activeUser: Boolean ): NamedChat { override val displayName: String get() = profile.displayName @@ -365,6 +366,7 @@ data class User( userContactId = 1, localDisplayName = "alice", profile = LocalProfile.sampleData, + fullPreferences = FullChatPreferences.sampleData, activeUser = true ) } @@ -539,8 +541,8 @@ data class Contact( val activeConn: Connection, val viaGroup: Long? = null, val chatSettings: ChatSettings, - // User applies his preferences for the contact here. Named user_preferences on the contact in DB val userPreferences: ChatPreferences, + val mergedPreferences: ContactUserPreferences, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -571,7 +573,8 @@ data class Contact( profile = LocalProfile.sampleData, activeConn = Connection.sampleData, chatSettings = ChatSettings(true), - userPreferences = ChatPreferences(), + userPreferences = ChatPreferences.sampleData, + mergedPreferences = ContactUserPreferences.sampleData, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -601,12 +604,11 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In } @Serializable -class Profile( +data class Profile( override val displayName: String, override val fullName: String, override val image: String? = null, override val localAlias : String = "", - // Contact applies his preferences here val preferences: ChatPreferences? = null ): NamedChat { val profileViewName: String @@ -614,7 +616,7 @@ class Profile( return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias) + fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, preferences) companion object { val sampleData = Profile( @@ -631,18 +633,18 @@ class LocalProfile( override val fullName: String, override val image: String? = null, override val localAlias: String, - // Contact applies his preferences here val preferences: ChatPreferences? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias) + fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, preferences) companion object { val sampleData = LocalProfile( profileId = 1L, displayName = "alice", fullName = "Alice", + preferences = ChatPreferences.sampleData, localAlias = "" ) } @@ -659,10 +661,10 @@ data class GroupInfo ( val groupId: Long, override val localDisplayName: String, val groupProfile: GroupProfile, + val fullGroupPreferences: FullGroupPreferences, val membership: GroupMember, val hostConnCustomUserProfileId: Long? = null, val chatSettings: ChatSettings, -// val groupPreferences: GroupPreferences? = null, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -691,6 +693,7 @@ data class GroupInfo ( groupId = 1, localDisplayName = "team", groupProfile = GroupProfile.sampleData, + fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, hostConnCustomUserProfileId = null, chatSettings = ChatSettings(true), @@ -701,11 +704,12 @@ data class GroupInfo ( } @Serializable -class GroupProfile ( +data class GroupProfile ( override val displayName: String, override val fullName: String, override val image: String? = null, override val localAlias: String = "", + val groupPreferences: GroupPreferences? = null ): NamedChat { companion object { val sampleData = GroupProfile( @@ -1568,7 +1572,7 @@ enum class FormatColor(val color: String) { red -> Color.Red green -> SimplexGreen blue -> SimplexBlue - yellow -> Color.Yellow + yellow -> WarningYellow cyan -> Color.Cyan magenta -> Color.Magenta black -> MaterialTheme.colors.onBackground 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 83507d19b5..eac8a9a38d 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 @@ -12,7 +12,9 @@ import android.util.Log import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.KeyboardVoice +import androidx.compose.material.icons.outlined.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource @@ -84,7 +86,7 @@ class AppPreferences(val context: Context) { val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true) private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name) - val callOnLockScreen: Preference = Preference( + val callOnLockScreen: SharedPreference = SharedPreference( get = fun(): CallOnLockScreen { val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default return try { @@ -141,33 +143,33 @@ class AppPreferences(val context: Context) { val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb()) private fun mkIntPreference(prefName: String, default: Int) = - Preference( + SharedPreference( get = fun() = sharedPreferences.getInt(prefName, default), set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply() ) private fun mkLongPreference(prefName: String, default: Long) = - Preference( + SharedPreference( get = fun() = sharedPreferences.getLong(prefName, default), set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply() ) - private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): Preference { + private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference { val d = if (networkUseSocksProxy.get()) proxyDefault else default - return Preference( + return SharedPreference( get = fun() = sharedPreferences.getLong(prefName, d), set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply() ) } private fun mkBoolPreference(prefName: String, default: Boolean) = - Preference( + SharedPreference( get = fun() = sharedPreferences.getBoolean(prefName, default), set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply() ) - private fun mkStrPreference(prefName: String, default: String?): Preference = - Preference( + private fun mkStrPreference(prefName: String, default: String?): SharedPreference = + SharedPreference( get = fun() = sharedPreferences.getString(prefName, default), set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply() ) @@ -176,8 +178,8 @@ class AppPreferences(val context: Context) { * Provide `[commit] = true` to save preferences right now, not after some unknown period of time. * So in case of a crash this value will be saved 100% * */ - private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): Preference = - Preference( + private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): SharedPreference = + SharedPreference( get = { val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString()) pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) } @@ -658,6 +660,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } + suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? { + val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs)) + if (r is CR.ContactPrefsUpdated) return r.toContact + Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? { val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias)) if (r is CR.ContactAliasUpdated) return r.toContact @@ -672,13 +681,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } - suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? { - val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs)) - if (r is CR.ContactPrefsUpdated) return r.toContact - Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiCreateUserAddress(): String? { val r = sendCmd(CC.CreateMyAddress()) return when (r) { @@ -1469,7 +1471,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } -class Preference(val get: () -> T, val set: (T) -> Unit) +class SharedPreference(val get: () -> T, val set: (T) -> Unit) // ChatCommand sealed class CC { @@ -1518,10 +1520,10 @@ sealed class CC { class ApiClearChat(val type: ChatType, val id: Long): CC() class ListContacts: CC() class ApiUpdateProfile(val profile: Profile): CC() + class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC() class ApiParseMarkdown(val text: String): CC() class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() - class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC() class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() @@ -1585,10 +1587,10 @@ sealed class CC { is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ListContacts -> "/contacts" is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" + is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" is ApiParseMarkdown -> "/_parse $text" is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" - is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" is CreateMyAddress -> "/address" is DeleteMyAddress -> "/delete_address" is ShowMyAddress -> "/show_address" @@ -1653,10 +1655,10 @@ sealed class CC { is ApiClearChat -> "apiClearChat" is ListContacts -> "listContacts" is ApiUpdateProfile -> "updateProfile" + is ApiSetContactPrefs -> "apiSetContactPrefs" is ApiParseMarkdown -> "apiParseMarkdown" is ApiSetContactAlias -> "apiSetContactAlias" is ApiSetConnectionAlias -> "apiSetConnectionAlias" - is ApiSetContactPrefs -> "apiSetContactPrefs" is CreateMyAddress -> "createMyAddress" is DeleteMyAddress -> "deleteMyAddress" is ShowMyAddress -> "showMyAddress" @@ -1946,29 +1948,280 @@ data class ChatSettings( ) @Serializable -data class ChatPreferences( - val voice: ChatPreference? = null +data class FullChatPreferences( + val fullDelete: ChatPreference, + val voice: ChatPreference, ) { + + fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice) + companion object { - val default = ChatPreferences( - voice = ChatPreference(allow = PrefAllowed.NO) - ) - val empty = ChatPreferences( - voice = null - ) + val sampleData = FullChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES)) + } +} + +@Serializable +data class ChatPreferences( + val fullDelete: ChatPreference? = null, + val voice: ChatPreference? = null, +) { + + companion object { + val sampleData = ChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES)) } } @Serializable data class ChatPreference( - val allow: PrefAllowed + val allow: FeatureAllowed ) @Serializable -enum class PrefAllowed { - @SerialName("always") ALWAYS, +data class ContactUserPreferences( + val fullDelete: ContactUserPreference, + val voice: ContactUserPreference, +) { + companion object { + val sampleData = ContactUserPreferences( + fullDelete = ContactUserPreference( + enabled = FeatureEnabled(forUser = false, forContact = false), + userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)), + contactPreference = ChatPreference(allow = FeatureAllowed.NO) + ), + voice = ContactUserPreference( + enabled = FeatureEnabled(forUser = true, forContact = true), + userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.YES)), + contactPreference = ChatPreference(allow = FeatureAllowed.YES) + ) + ) + } +} + +@Serializable +data class ContactUserPreference( + val enabled: FeatureEnabled, + val userPreference: ContactUserPref, + val contactPreference: ChatPreference, +) + +@Serializable +data class FeatureEnabled( + val forUser: Boolean, + val forContact: Boolean +) { + companion object { + fun enabled(user: ChatPreference, contact: ChatPreference): FeatureEnabled = + when { + user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = true) + user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = true, forContact = false) + contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false) + user.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false) + else -> FeatureEnabled(forUser = true, forContact = true) + } + } +} + +@Serializable +sealed class ContactUserPref { + @Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() // contact override is set + @Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() // global user default is used +} + +@Serializable +enum class Feature { + @SerialName("fullDelete") FullDelete, + @SerialName("voice") Voice; + + fun text() = + when(this) { + FullDelete -> generalGetString(R.string.full_deletion) + Voice -> generalGetString(R.string.voice_messages) + } + + fun icon(filled: Boolean) = + when(this) { + FullDelete -> if (filled) Icons.Filled.DeleteForever else Icons.Outlined.DeleteForever + Voice -> if (filled) Icons.Filled.KeyboardVoice else Icons.Outlined.KeyboardVoice + } + + fun allowDescription(allowed: FeatureAllowed): String = + when (this) { + FullDelete -> when (allowed) { + FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_irreversibly_delete) + 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) { + 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) + } + } + + fun enabledDescription(enabled: FeatureEnabled): String = + when (this) { + FullDelete -> when { + enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contacts_can_delete) + enabled.forUser -> generalGetString(R.string.only_you_can_delete_messages) + enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete) + else -> generalGetString(R.string.message_deletion_prohibited) + } + 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) + enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_voice) + else -> generalGetString(R.string.voice_prohibited_in_this_chat) + } + } + +fun enableGroupPrefDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String = + if (canEdit) { + when(this) { + FullDelete -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages) + GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion) + } + Voice -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice) + GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice) + } + } + } else { + when(this) { + FullDelete -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete) + GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat) + } + Voice -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice) + GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited) + } + } + } +} + +@Serializable +sealed class ContactFeatureAllowed { + @Serializable @SerialName("userDefault") data class UserDefault(val default: FeatureAllowed): ContactFeatureAllowed() + @Serializable @SerialName("always") object Always: ContactFeatureAllowed() + @Serializable @SerialName("yes") object Yes: ContactFeatureAllowed() + @Serializable @SerialName("no") object No: ContactFeatureAllowed() + + companion object { + fun values(def: FeatureAllowed): List = listOf(UserDefault(def), Always, Yes, No) + } + + val allowed: FeatureAllowed + get() = when (this) { + is UserDefault -> this.default + is Always -> FeatureAllowed.ALWAYS + is Yes -> FeatureAllowed.YES + is No -> FeatureAllowed.NO + } + val text: String + get() = when (this) { + is UserDefault -> String.format(generalGetString(R.string.chat_preferences_default), default.text) + is Always -> generalGetString(R.string.chat_preferences_always) + is Yes -> generalGetString(R.string.chat_preferences_yes) + is No -> generalGetString(R.string.chat_preferences_no) + } +} + +data class ContactFeaturesAllowed( + val fullDelete: ContactFeatureAllowed, + val voice: ContactFeatureAllowed +) { + companion object { + val sampleData = ContactFeaturesAllowed( + fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO), + voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES) + ) + } +} + +fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed = + ContactFeaturesAllowed( + fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete), + voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice) + ) + +fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed = + when (val pref = contactUserPreference.userPreference) { + is ContactUserPref.User -> ContactFeatureAllowed.UserDefault(pref.preference.allow) + is ContactUserPref.Contact -> when (pref.preference.allow) { + FeatureAllowed.ALWAYS -> ContactFeatureAllowed.Always + FeatureAllowed.YES -> ContactFeatureAllowed.Yes + FeatureAllowed.NO -> ContactFeatureAllowed.No + } + } + +fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences = + ChatPreferences( + fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete), + voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice) + ) + +fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): ChatPreference? = + when(contactFeatureAllowed) { + is ContactFeatureAllowed.UserDefault -> null + is ContactFeatureAllowed.Always -> ChatPreference(allow = FeatureAllowed.ALWAYS) + is ContactFeatureAllowed.Yes -> ChatPreference(allow = FeatureAllowed.YES) + is ContactFeatureAllowed.No -> ChatPreference(allow = FeatureAllowed.NO) + } + +@Serializable +enum class FeatureAllowed { @SerialName("yes") YES, - @SerialName("no") NO + @SerialName("no") NO, + @SerialName("always") ALWAYS; + + val text: String + get() = when(this) { + ALWAYS -> generalGetString(R.string.chat_preferences_always) + YES -> generalGetString(R.string.chat_preferences_yes) + NO -> generalGetString(R.string.chat_preferences_no) + } +} + +@Serializable +data class FullGroupPreferences( + val fullDelete: GroupPreference, + val voice: GroupPreference +) { + fun toGroupPreferences(): GroupPreferences = + GroupPreferences(fullDelete = fullDelete, voice = voice) + + companion object { + val sampleData = FullGroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON)) + } +} + +@Serializable +data class GroupPreferences( + val fullDelete: GroupPreference?, + val voice: GroupPreference? +) { + companion object { + val sampleData = GroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON)) + } +} + +@Serializable +data class GroupPreference( + val enable: GroupFeatureEnabled +) + +@Serializable +enum class GroupFeatureEnabled { + @SerialName("on") ON, + @SerialName("off") OFF; + + val text: String + get() = when (this) { + ON -> generalGetString(R.string.chat_preferences_on) + OFF -> generalGetString(R.string.chat_preferences_off) + } + } val json = Json { @@ -2024,7 +2277,7 @@ sealed class CR { @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR() @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val toConnection: PendingContactConnection): CR() - @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val toContact: Contact): CR() + @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val fromContact: Contact, val toContact: Contact): CR() @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val contactLink: UserContactLinkRec): CR() @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val contactLink: UserContactLinkRec): CR() @@ -2218,7 +2471,7 @@ sealed class CR { is UserProfileUpdated -> json.encodeToString(toProfile) is ContactAliasUpdated -> json.encodeToString(toContact) is ConnectionAliasUpdated -> json.encodeToString(toConnection) - is ContactPrefsUpdated -> json.encodeToString(toContact) + is ContactPrefsUpdated -> "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}" is ParsedMarkdown -> json.encodeToString(formattedText) is UserContactLink -> contactLink.responseDetails is UserContactLinkUpdated -> contactLink.responseDetails diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 7e6c0cf4d1..045568b75c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -35,7 +35,7 @@ import chat.simplex.app.SimplexApp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.app.views.usersettings.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -62,6 +62,11 @@ fun ChatInfoView( onLocalAliasChanged = { setContactAlias(chat.chatInfo.apiId, it, chatModel) }, + openPreferences = { + ModalManager.shared.showModal(true) { + ContactPreferencesView(chatModel, chatModel.currentUser.value ?: return@showModal, contact) + } + }, deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, switchContactAddress = { @@ -117,6 +122,7 @@ fun ChatInfoLayout( localAlias: String, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, + openPreferences: () -> Unit, deleteContact: () -> Unit, clearChat: () -> Unit, switchContactAddress: () -> Unit, @@ -143,6 +149,11 @@ fun ChatInfoLayout( } } + SectionSpacer() + SectionView { + ContactPreferencesButton(openPreferences) + } + SectionSpacer() SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) { @@ -327,6 +338,15 @@ fun SwitchAddressButton(onClick: () -> Unit) { } } +@Composable +private fun ContactPreferencesButton(onClick: () -> Unit) { + SettingsActionItem( + Icons.Outlined.ToggleOn, + stringResource(R.string.contact_preferences), + click = onClick + ) +} + @Composable fun ClearChatButton(onClick: () -> Unit) { SettingsActionItem( @@ -386,6 +406,7 @@ fun PreviewChatInfoLayout() { connStats = null, onLocalAliasChanged = {}, customUserProfile = null, + openPreferences = {}, deleteContact = {}, clearChat = {}, switchContactAddress = {}, 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 62e5f2f985..7f96c4076d 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 @@ -128,14 +128,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { info = { hideKeyboard(view) withApi { - val cInfo = chat.chatInfo - if (cInfo is ChatInfo.Direct) { - val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId) + if (chat.chatInfo is ChatInfo.Direct) { + val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) ModalManager.shared.showModalCloseable(true) { close -> - ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) + val contact = remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } } + contact.value?.let { ct -> + ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) + } } - } else if (cInfo is ChatInfo.Group) { - setGroupMembers(cInfo.groupInfo, chatModel) + } else if (chat.chatInfo is ChatInfo.Group) { + setGroupMembers(chat.chatInfo.groupInfo, chatModel) ModalManager.shared.showModalCloseable(true) { close -> GroupChatInfoView(chatModel, close) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt index b88c5eab0b..12eee95941 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -4,6 +4,7 @@ import InfoRow import SectionDivider import SectionItemView import SectionSpacer +import SectionTextFooter import SectionView import androidx.activity.compose.BackHandler import androidx.compose.foundation.* @@ -28,6 +29,7 @@ import chat.simplex.app.views.chat.* import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert import chat.simplex.app.views.chatlist.setGroupMembers import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.* @Composable fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { @@ -62,6 +64,14 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { editGroupProfile = { ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) } }, + openPreferences = { + ModalManager.shared.showModal(true) { + GroupPreferencesView( + chatModel, + groupInfo + ) + } + }, deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, @@ -120,6 +130,7 @@ fun GroupChatInfoLayout( addMembers: () -> Unit, showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, + openPreferences: () -> Unit, deleteGroup: () -> Unit, clearChat: () -> Unit, leaveGroup: () -> Unit, @@ -139,6 +150,16 @@ fun GroupChatInfoLayout( } SectionSpacer() + SectionView { + if (groupInfo.canEdit) { + SectionItemView(editGroupProfile) { EditGroupProfileButton() } + SectionDivider() + } + GroupPreferencesButton(openPreferences) + } + SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs)) + SectionSpacer() + SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) { if (groupInfo.canAddMembers) { SectionItemView(manageGroupLink) { GroupLinkButton() } @@ -160,10 +181,6 @@ fun GroupChatInfoLayout( } SectionSpacer() SectionView { - if (groupInfo.canEdit) { - SectionItemView(editGroupProfile) { EditGroupProfileButton() } - SectionDivider() - } ClearChatButton(clearChat) if (groupInfo.canDelete) { SectionDivider() @@ -211,6 +228,15 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) { } } +@Composable +private fun GroupPreferencesButton(onClick: () -> Unit) { + SettingsActionItem( + Icons.Outlined.ToggleOn, + stringResource(R.string.group_preferences), + click = onClick + ) +} + @Composable fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) { Row( @@ -286,10 +312,10 @@ fun GroupLinkButton() { Icon( Icons.Outlined.Link, stringResource(R.string.group_link), - tint = MaterialTheme.colors.primary + tint = HighOrLowlight ) Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary) + Text(stringResource(R.string.group_link)) } } @@ -303,10 +329,10 @@ fun EditGroupProfileButton() { Icon( Icons.Outlined.Edit, stringResource(R.string.button_edit_group_profile), - tint = MaterialTheme.colors.primary + tint = HighOrLowlight ) Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary) + Text(stringResource(R.string.button_edit_group_profile)) } } @@ -355,7 +381,7 @@ fun PreviewGroupChatInfoLayout() { groupInfo = GroupInfo.sampleData, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, + addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt index 75799c6818..2a7fe31c6d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt @@ -24,7 +24,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat Modifier .padding(horizontal = 4.dp) .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { - @Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green) + @Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen) when (status) { CICallStatus.Pending -> if (sent) { Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index f5566b6f19..77588cc149 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -15,6 +15,7 @@ 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.WarningYellow import kotlinx.datetime.Clock @Composable @@ -51,7 +52,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) { Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) } is CIStatus.SndError -> { - Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) + Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow) } is CIStatus.RcvNew -> { Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index b4cb0edf89..ed7dd74a52 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -126,7 +126,7 @@ fun DatabaseLayout( chatDbChanged: Boolean, useKeyChain: Boolean, chatDbEncrypted: Boolean?, - initialRandomDBPassphrase: Preference, + initialRandomDBPassphrase: SharedPreference, importArchiveLauncher: ManagedActivityResultLauncher, chatArchiveName: MutableState, chatArchiveTime: MutableState, @@ -683,7 +683,7 @@ fun PreviewDatabaseLayout() { chatDbChanged = false, useKeyChain = false, chatDbEncrypted = false, - initialRandomDBPassphrase = Preference({ true }, {}), + initialRandomDBPassphrase = SharedPreference({ true }, {}), importArchiveLauncher = rememberGetContentLauncher {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt index 89ba9669ea..138c78e2ad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt @@ -10,9 +10,12 @@ 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.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.app.R +import chat.simplex.app.ui.theme.DEFAULT_PADDING import chat.simplex.app.ui.theme.HighOrLowlight @Composable @@ -27,7 +30,7 @@ fun ExposedDropDownSettingRow( onSelected: (T) -> Unit ) { Row( - Modifier.fillMaxWidth(), + Modifier.fillMaxWidth().padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { var expanded by remember { mutableStateOf(false) } @@ -40,9 +43,7 @@ fun ExposedDropDownSettingRow( tint = iconTint ) } - Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight) - - Spacer(Modifier.fillMaxWidth().weight(1f)) + Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight) ExposedDropdownMenuBox( expanded = expanded, @@ -55,8 +56,10 @@ fun ExposedDropDownSettingRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { + val maxWidth = with(LocalDensity.current){ 180.sp.toDp() } Text( values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""), + Modifier.widthIn(max = maxWidth), maxLines = 1, overflow = TextOverflow.Ellipsis, color = HighOrLowlight diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt index 056d6b5872..752bab6dc5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -42,7 +42,7 @@ fun SectionView( content: (@Composable ColumnScope.() -> Unit) ) { Column { - val iconSize = with(LocalDensity.current) { 15.sp.toDp() } + val iconSize = with(LocalDensity.current) { 21.sp.toDp() } Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint) Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index 085824c787..20b941ec34 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -30,8 +30,8 @@ fun CallSettingsView(m: ChatModel, @Composable fun CallSettingsLayout( - webrtcPolicyRelay: Preference, - callOnLockScreen: Preference, + webrtcPolicyRelay: SharedPreference, + callOnLockScreen: SharedPreference, editIceServers: () -> Unit, ) { Column( @@ -79,7 +79,7 @@ private fun LockscreenOpts(lockscreenOpts: State, enabled: Sta @Composable fun SharedPreferenceToggle( text: String, - preference: Preference, + preference: SharedPreference, preferenceState: MutableState? = null ) { val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) } @@ -106,7 +106,7 @@ fun SharedPreferenceToggleWithIcon( icon: ImageVector, stopped: Boolean = false, onClickInfo: () -> Unit, - preference: Preference, + preference: SharedPreference, preferenceState: MutableState? = null ) { val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) } @@ -135,7 +135,7 @@ fun SharedPreferenceToggleWithIcon( } @Composable -fun SharedPreferenceRadioButton(text: String, prefState: MutableState, preference: Preference, value: T) { +fun SharedPreferenceRadioButton(text: String, prefState: MutableState, preference: SharedPreference, value: T) { Row(verticalAlignment = Alignment.CenterVertically) { Text(text) val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ContactPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ContactPreferences.kt new file mode 100644 index 0000000000..530c62c812 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ContactPreferences.kt @@ -0,0 +1,144 @@ +package chat.simplex.app.views.usersettings + +import SectionDivider +import SectionItemView +import SectionItemWithValue +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* + +@Composable +fun ContactPreferencesView( + m: ChatModel, + user: User, + contact: Contact, +) { + var featuresAllowed by remember { mutableStateOf(contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)) } + var currentFeaturesAllowed by remember { mutableStateOf(featuresAllowed) } + ContactPreferencesLayout( + featuresAllowed, + currentFeaturesAllowed, + user, + contact, + applyPrefs = { prefs -> + featuresAllowed = prefs + }, + reset = { + featuresAllowed = currentFeaturesAllowed + }, + savePrefs = { + withApi { + val prefs = contactFeaturesAllowedToPrefs(featuresAllowed) + val toContact = m.controller.apiSetContactPrefs(contact.contactId, prefs) + if (toContact != null) { + m.updateContact(toContact) + currentFeaturesAllowed = featuresAllowed + } + } + }, + ) +} + +@Composable +private fun ContactPreferencesLayout( + featuresAllowed: ContactFeaturesAllowed, + currentFeaturesAllowed: ContactFeaturesAllowed, + user: User, + contact: Contact, + applyPrefs: (ContactFeaturesAllowed) -> Unit, + reset: () -> Unit, + savePrefs: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = DEFAULT_PADDING), + horizontalAlignment = Alignment.Start, + ) { + AppBarTitle(stringResource(R.string.contact_preferences)) + val allowFullDeletion: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) } + FeatureSection(Feature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) { + applyPrefs(featuresAllowed.copy(fullDelete = it)) + } + + SectionSpacer() + val allowVoice: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) } + FeatureSection(Feature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) { + applyPrefs(featuresAllowed.copy(voice = it)) + } + + SectionSpacer() + ResetSaveButtons( + reset = reset, + save = savePrefs, + disabled = featuresAllowed == currentFeaturesAllowed + ) + } +} + +@Composable +private fun FeatureSection( + feature: Feature, + userDefault: FeatureAllowed, + pref: ContactUserPreference, + allowFeature: State, + onSelected: (ContactFeatureAllowed) -> Unit +) { + val enabled = FeatureEnabled.enabled( + user = ChatPreference(allow = allowFeature.value.allowed), + contact = pref.contactPreference + ) + + SectionView( + feature.text().uppercase(), + icon = feature.icon(true), + iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red, + leadingIcon = true, + ) { + SectionItemView { + ExposedDropDownSettingRow( + generalGetString(R.string.chat_preferences_you_allow), + ContactFeatureAllowed.values(userDefault).map { it to it.text }, + allowFeature, + icon = null, + onSelected = onSelected + ) + } + SectionDivider() + SectionItemWithValue( + generalGetString(R.string.chat_preferences_contact_allows), + remember { mutableStateOf(pref.contactPreference.allow) }, + listOf(ValueTitleDesc(pref.contactPreference.allow, pref.contactPreference.allow.text, "")), + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = {} + ) + } + SectionTextFooter(feature.enabledDescription(enabled)) +} + +@Composable +private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { + SectionView { + SectionItemView(reset) { + Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary) + } + SectionItemView(save) { + Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/GroupPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/GroupPreferences.kt new file mode 100644 index 0000000000..90f530a093 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/GroupPreferences.kt @@ -0,0 +1,119 @@ +package chat.simplex.app.views.usersettings + +import SectionItemView +import SectionItemWithValue +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.views.helpers.* + +@Composable +fun GroupPreferencesView(m: ChatModel, groupInfo: GroupInfo) { + var preferences by remember { mutableStateOf(groupInfo.fullGroupPreferences) } + var currentPreferences by remember { mutableStateOf(preferences) } + GroupPreferencesLayout( + preferences, + currentPreferences, + groupInfo, + applyPrefs = { prefs -> + preferences = prefs + }, + reset = { + preferences = currentPreferences + }, + savePrefs = { + withApi { + val gp = groupInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) + val gInfo = m.controller.apiUpdateGroup(groupInfo.groupId, gp) + if (gInfo != null) { + m.updateGroup(gInfo) + currentPreferences = preferences + } + } + }, + ) +} + +@Composable +private fun GroupPreferencesLayout( + preferences: FullGroupPreferences, + currentPreferences: FullGroupPreferences, + groupInfo: GroupInfo, + applyPrefs: (FullGroupPreferences) -> Unit, + reset: () -> Unit, + savePrefs: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + ) { + AppBarTitle(stringResource(R.string.group_preferences)) + val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) } + FeatureSection(Feature.FullDelete, allowFullDeletion, groupInfo) { + applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it))) + } + + SectionSpacer() + val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) } + FeatureSection(Feature.Voice, allowVoice, groupInfo) { + applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) + } + + SectionSpacer() + ResetSaveButtons( + reset = reset, + save = savePrefs, + disabled = preferences == currentPreferences + ) + } +} + +@Composable +private fun FeatureSection(feature: Feature, enableFeature: State, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) { + SectionView { + if (groupInfo.canEdit) { + SectionItemView { + ExposedDropDownSettingRow( + feature.text(), + GroupFeatureEnabled.values().map { it to it.text }, + enableFeature, + icon = feature.icon(false), + onSelected = onSelected + ) + } + } else { + SectionItemWithValue( + feature.text(), + remember { mutableStateOf(enableFeature.value) }, + listOf(ValueTitleDesc(enableFeature.value, enableFeature.value.text, "")), + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = {} + ) + } + } + SectionTextFooter(feature.enableGroupPrefDescription(enableFeature.value, groupInfo.canEdit)) +} + +@Composable +private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { + SectionView { + SectionItemView(reset) { + Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary) + } + SectionItemView(save) { + Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt new file mode 100644 index 0000000000..d786e047a2 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt @@ -0,0 +1,109 @@ +package chat.simplex.app.views.usersettings + +import SectionItemView +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* + +@Composable +fun PreferencesView(m: ChatModel, user: User) { + var preferences by remember { mutableStateOf(user.fullPreferences) } + var currentPreferences by remember { mutableStateOf(preferences) } + PreferencesLayout( + preferences, + currentPreferences, + applyPrefs = { prefs -> + preferences = prefs + }, + reset = { + preferences = currentPreferences + }, + savePrefs = { + withApi { + val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences()) + val updatedProfile = m.controller.apiUpdateProfile(newProfile) + if (updatedProfile != null) { + val updatedUser = user.copy( + profile = updatedProfile.toLocalProfile(user.profile.profileId), + fullPreferences = preferences + ) + currentPreferences = preferences + m.currentUser.value = updatedUser + } + } + }, + ) +} + +@Composable +private fun PreferencesLayout( + preferences: FullChatPreferences, + currentPreferences: FullChatPreferences, + applyPrefs: (FullChatPreferences) -> Unit, + reset: () -> Unit, + savePrefs: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + ) { + AppBarTitle(stringResource(R.string.your_preferences)) + val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) } + FeatureSection(Feature.FullDelete, allowFullDeletion) { + applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it))) + } + + SectionSpacer() + val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) } + FeatureSection(Feature.Voice, allowVoice) { + applyPrefs(preferences.copy(voice = ChatPreference(allow = it))) + } + + SectionSpacer() + ResetSaveButtons( + reset = reset, + save = savePrefs, + disabled = preferences == currentPreferences + ) + } +} + +@Composable +private fun FeatureSection(feature: Feature, allowFeature: State, onSelected: (FeatureAllowed) -> Unit) { + SectionView { + SectionItemView { + ExposedDropDownSettingRow( + feature.text(), + FeatureAllowed.values().map { it to it.text }, + allowFeature, + icon = feature.icon(false), + onSelected = onSelected + ) + } + } + SectionTextFooter(feature.allowDescription(allowFeature.value)) +} + +@Composable +private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { + SectionView { + SectionItemView(reset) { + Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary) + } + SectionItemView(save) { + Text(stringResource(R.string.save_and_notify_contacts), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt index a2276626a7..acb4d5a029 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt @@ -15,7 +15,10 @@ import chat.simplex.app.model.ChatModel import chat.simplex.app.views.helpers.AppBarTitle @Composable -fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { +fun PrivacySettingsView( + chatModel: ChatModel, + setPerformLA: (Boolean) -> Unit +) { Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start @@ -34,6 +37,7 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { SectionDivider() } SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) + SectionDivider() } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServerView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServerView.kt index 170cbb9c76..88418f7a90 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServerView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServerView.kt @@ -182,7 +182,7 @@ private fun UseServerSection( @Composable fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = when (server.tested) { - true -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Green) + true -> Icon(Icons.Outlined.Check, null, modifier, tint = SimplexGreen) false -> Icon(Icons.Outlined.Clear, null, modifier, tint = MaterialTheme.colors.error) else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 06b5bf3eb9..a92f1fa387 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -80,8 +80,8 @@ fun SettingsLayout( stopped: Boolean, encrypted: Boolean, incognito: MutableState, - incognitoPref: Preference, - developerTools: Preference, + incognitoPref: SharedPreference, + developerTools: SharedPreference, userDisplayName: String, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), @@ -116,20 +116,22 @@ fun SettingsLayout( SectionDivider() SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped) SectionDivider() - DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + ChatPreferencesItem(showSettingsModal) } SectionSpacer() SectionView(stringResource(R.string.settings_section_title_settings)) { SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) SectionDivider() + SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped) + SectionDivider() SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) SectionDivider() SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped) SectionDivider() SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped) SectionDivider() - SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped) + DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } SectionSpacer() @@ -175,7 +177,7 @@ fun SettingsLayout( @Composable fun SettingsIncognitoActionItem( - incognitoPref: Preference, + incognitoPref: SharedPreference, incognito: MutableState, stopped: Boolean, onClickInfo: () -> Unit, @@ -237,6 +239,20 @@ fun MaintainIncognitoState(chatModel: ChatModel) { } } +@Composable fun ChatPreferencesItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + SettingsActionItem( + Icons.Outlined.ToggleOn, + stringResource(R.string.chat_preferences), + click = { + withApi { + showSettingsModal { + PreferencesView(it, it.currentUser.value ?: return@showSettingsModal) + }() + } + } + ) +} + @Composable fun ChatLockItem(performLA: MutableState, setPerformLA: (Boolean) -> Unit) { SectionItemView() { Row(verticalAlignment = Alignment.CenterVertically) { @@ -365,8 +381,8 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n } @Composable -fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference, prefState: MutableState? = null) { - SectionItemView() { +fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: SharedPreference, prefState: MutableState? = null) { + SectionItemView { Row(verticalAlignment = Alignment.CenterVertically) { Icon(icon, text, tint = HighOrLowlight) Spacer(Modifier.padding(horizontal = 4.dp)) @@ -382,7 +398,7 @@ fun SettingsPreferenceItemWithInfo( text: String, stopped: Boolean, onClickInfo: () -> Unit, - pref: Preference, + pref: SharedPreference, prefState: MutableState? = null ) { SectionItemView(onClickInfo) { @@ -417,18 +433,20 @@ fun PreferenceToggle( @Composable fun PreferenceToggleWithIcon( text: String, - icon: ImageVector, - iconColor: Color = HighOrLowlight, + icon: ImageVector? = null, + iconColor: Color? = HighOrLowlight, checked: Boolean, onChange: (Boolean) -> Unit = {}, ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - icon, - null, - tint = iconColor - ) - Spacer(Modifier.padding(horizontal = 4.dp)) + if (icon != null) { + Icon( + icon, + null, + tint = iconColor ?: HighOrLowlight + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + } Text(text) Spacer(Modifier.fillMaxWidth().weight(1f)) Switch( @@ -458,8 +476,8 @@ fun PreviewSettingsLayout() { stopped = false, encrypted = false, incognito = remember { mutableStateOf(false) }, - incognitoPref = Preference({ false }, {}), - developerTools = Preference({ false }, {}), + incognitoPref = SharedPreference({ false }, {}), + developerTools = SharedPreference({ false }, {}), userDisplayName = "Alice", setPerformLA = {}, showModal = { {} }, diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 4fdcf391f4..3f1c12d998 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -460,7 +460,9 @@ Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\n\nSimpleX-Server können Ihr Profil nicht sehen. Bild bearbeiten Bild löschen + ***Save (and notify contacts) Speichern (und Kontakte benachrichtigen) + ***Save (and notify group members) Sie haben volle Kontrolle über Ihren Chat! @@ -837,6 +839,7 @@ Alle Gruppenmitglieder bleiben verbunden. Fehler beim Erzeugen des Gruppen-Links Fehler beim Löschen des Gruppen-Links + ***Only group owners can change group preferences. FÜR KONSOLE @@ -918,4 +921,43 @@ Farbe speichern Farben zurücksetzen Akzent + + + ***You allow + ***Contact allows + ***default (%s) + ***yes + ***no + ***always + ***on + ***off + ***Chat preferences + ***Contact preferences + ***Group preferences + ***Your preferences + ***Full deletion + ***Voice messages + ***Allow your contacts to irreversibly delete sent messages. + ***Allow irreversible message deletion only if your contact allows it to you. + ***Contacts can mark messages for deletion; you will be able to view them. + ***Allow your contacts to send voice messages. + ***Allow voice messages only if your contact allows them. + ***Prohibit sending voice messages. + ***Both you and your contact can irreversibly delete sent messages. + ***Only you can irreversibly delete messages (your contact can mark them for deletion). + ***Only your contact can irreversibly delete messages (you can mark them for deletion). + ***Irreversible message deletion is prohibited in this chat. + ***Both you and your contact can send voice messages. + ***Only you can send voice messages. + ***Only your contact can send voice messages. + ***Voice messages are prohibited in this chat. + ***Allow to irreversibly delete sent messages. + ***Prohibit irreversible message deletion. + ***Allow to send voice messages. + ***Prohibit sending voice messages. + ***Group members can irreversibly delete sent messages. + ***Irreversible message deletion is prohibited in this chat. + ***Group members can send voice messages. + ***Voice messages are prohibited in this chat. + diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 12e72d5198..0314b4c641 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -457,7 +457,9 @@ Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\nSimpleX серверы не могут получить доступ к вашему профилю. Поменять аватар Удалить аватар + Сохранить (и уведомить контакт) Сохранить (и послать обновление контактам) + Сохранить (и уведомить членов группы) Вы котролируете ваш чат! @@ -837,6 +839,7 @@ Все члены группы, которые соединились через эту ссылку, останутся в группе. Ошибка при создании ссылки группы Ошибка при удалении ссылки группы + Только владельцы группы могут изменять предпочтения группы. ДЛЯ КОНСОЛИ @@ -917,4 +920,43 @@ Сохранить цвет Сбросить цвета Акцент + + + Вы разрешаете + Контакт разрешает + по умолчанию (%s) + да + нет + всегда + да + нет + Предпочтения + Предпочтения контакта + Предпочтения группы + Ваши предпочтения + Полное удаление + Голосовые сообщения + Разрешить вашим контактам необратимо удалять отправленные сообщения. + Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам. + Контакты могут помечать сообщения для удаления; вы сможете просмотреть их. + Разрешить вашим контактам отправлять голосовые сообщения. + Разрешить голосовые сообщения, только если их разрешает ваш контакт. + Запретить отправлять голосовые сообщений. + Вы и ваш контакт можете необратимо удалять отправленные сообщения. + Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление). + Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление). + Необратимое удаление сообщений запрещено в этом чате. + Вы и ваш контакт можете отправлять голосовые сообщения. + Только вы можете отправлять голосовые сообщения. + Только ваш контакт может отправлять голосовые сообщения. + Голосовые сообщения запрещены в этом чате. + Разрешить необратимо удалять отправленные сообщения. + Запретить необратимое удаление сообщений. + Разрешить отправлять голосовые сообщения. + Запретить отправлять голосовые сообщений. + Члены группы могут необратимо удалять отправленные сообщения. + Необратимое удаление сообщений запрещено в этом чате. + Члены группы могут отправлять голосовые сообщения. + Голосовые сообщения запрещены в этом чате. + diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 0a4700a5d0..372983c2dc 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -460,7 +460,9 @@ Your profile is stored on your device and shared only with your contacts.\n\nSimpleX servers cannot see your profile. Edit image Delete image + Save (and notify contact) Save (and notify contacts) + Save (and notify group members) You control your chat! @@ -837,6 +839,7 @@ All group members will remain connected. Error creating group link Error deleting group link + Only group owners can change group preferences. FOR CONSOLE @@ -918,4 +921,43 @@ Save color Reset colors Accent + + + You allow + Contact allows + default (%s) + yes + no + always + on + off + Chat preferences + Contact preferences + Group preferences + Your preferences + Full deletion + Voice messages + Allow your contacts to irreversibly delete sent messages. + Allow irreversible message deletion only if your contact allows it to you. + Contacts can mark messages for deletion; you will be able to view them. + Allow your contacts to send voice messages. + Allow voice messages only if your contact allows them. + Prohibit sending voice messages. + Both you and your contact can irreversibly delete sent messages. + Only you can irreversibly delete messages (your contact can mark them for deletion). + Only your contact can irreversibly delete messages (you can mark them for deletion). + Irreversible message deletion is prohibited in this chat. + Both you and your contact can send voice messages. + Only you can send voice messages. + Only your contact can send voice messages. + Voice messages are prohibited in this chat. + Allow to irreversibly delete sent messages. + Prohibit irreversible message deletion. + Allow to send voice messages. + Prohibit sending voice messages. + Group members can irreversibly delete sent messages. + Irreversible message deletion is prohibited in this chat. + Group members can send voice messages. + Voice messages are prohibited in this chat. +