From 0169589e7ce3edf5ac4e7402d759612585b8f599 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 30 Aug 2022 14:17:28 +0300 Subject: [PATCH] Incognito mode (#974) * Incognito mode * Incognito icon color and state applying * Added a spacer under username * Local contact aliases support * Simplified incognito * update help title * ChatInfo layout * corrections * color * icon Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 104 +++++++++++++---- .../java/chat/simplex/app/model/SimpleXAPI.kt | 32 ++++- .../java/chat/simplex/app/ui/theme/Color.kt | 1 + .../simplex/app/views/chat/ChatInfoView.kt | 80 ++++++++++++- .../chat/simplex/app/views/chat/ChatView.kt | 28 +++-- .../views/chat/group/AddGroupMembersView.kt | 59 ++++++++-- .../app/views/chat/group/GroupChatInfoView.kt | 47 ++++++-- .../views/chat/group/GroupMemberInfoView.kt | 8 +- .../views/chat/item/CIGroupInvitationView.kt | 19 ++- .../app/views/chat/item/ChatItemView.kt | 7 +- .../app/views/chatlist/ChatListNavLinkView.kt | 45 ++++--- .../app/views/chatlist/ChatListView.kt | 27 +++-- .../app/views/chatlist/ChatPreviewView.kt | 22 +++- .../app/views/chatlist/ContactRequestView.kt | 7 +- .../simplex/app/views/helpers/AlertManager.kt | 6 +- .../app/views/helpers/ChatInfoImage.kt | 14 ++- .../views/helpers/DefaultBasicTextField.kt | 110 ++++++++++++++++++ .../app/views/newchat/AddContactView.kt | 75 ++++++++++-- .../simplex/app/views/newchat/AddGroupView.kt | 18 ++- .../app/views/newchat/PasteToConnect.kt | 15 ++- .../app/views/newchat/ScanToConnectView.kt | 18 +-- .../app/views/usersettings/CallSettings.kt | 38 ++++++ .../app/views/usersettings/IncognitoView.kt | 47 ++++++++ .../app/views/usersettings/SettingsView.kt | 76 +++++++++++- .../app/views/usersettings/UserProfileView.kt | 9 +- .../app/src/main/res/values-ru/strings.xml | 34 +++++- .../app/src/main/res/values/strings.xml | 34 +++++- 27 files changed, 833 insertions(+), 147 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt 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 e83f365cdd..918a6a30e4 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 @@ -47,6 +47,7 @@ class ChatModel(val controller: ChatController) { val runServiceInBackground = mutableStateOf(true) val performLA = mutableStateOf(false) val showAdvertiseLAUnavailableAlert = mutableStateOf(false) + var incognito = mutableStateOf(false) // current WebRTC call val callManager = CallManager(this) @@ -57,7 +58,7 @@ class ChatModel(val controller: ChatController) { val showCallView = mutableStateOf(false) val switchingCall = mutableStateOf(false) - fun updateUserProfile(profile: Profile) { + fun updateUserProfile(profile: LocalProfile) { val user = currentUser.value if (user != null) { currentUser.value = user.copy(profile = profile) @@ -290,19 +291,20 @@ data class User( val userId: Long, val userContactId: Long, val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, val activeUser: Boolean ): NamedChat { override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName override val image: String? get() = profile.image + override val localAlias: String = "" companion object { val sampleData = User( userId = 1, userContactId = 1, localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, activeUser = true ) } @@ -314,8 +316,9 @@ interface NamedChat { val displayName: String val fullName: String val image: String? + val localAlias: String val chatViewName: String - get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") + get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } } interface SomeChat { @@ -376,6 +379,8 @@ data class Chat ( @Serializable sealed class ChatInfo: SomeChat, NamedChat { + abstract val incognito: Boolean + @Serializable @SerialName("direct") class Direct(val contact: Contact): ChatInfo() { override val chatType get() = ChatType.Direct @@ -385,11 +390,13 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contact.ready override val sendMsgEnabled get() = contact.sendMsgEnabled override val ntfsEnabled get() = contact.chatSettings.enableNtfs + override val incognito get() = contact.contactConnIncognito override val createdAt get() = contact.createdAt override val updatedAt get() = contact.updatedAt override val displayName get() = contact.displayName override val fullName get() = contact.fullName override val image get() = contact.image + override val localAlias: String get() = contact.localAlias companion object { val sampleData = Direct(Contact.sampleData) @@ -405,11 +412,13 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = groupInfo.ready override val sendMsgEnabled get() = groupInfo.sendMsgEnabled override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs + override val incognito get() = groupInfo.membership.memberIncognito override val createdAt get() = groupInfo.createdAt override val updatedAt get() = groupInfo.updatedAt override val displayName get() = groupInfo.displayName override val fullName get() = groupInfo.fullName override val image get() = groupInfo.image + override val localAlias get() = groupInfo.localAlias companion object { val sampleData = Group(GroupInfo.sampleData) @@ -425,11 +434,13 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactRequest.ready override val sendMsgEnabled get() = contactRequest.sendMsgEnabled override val ntfsEnabled get() = false + override val incognito get() = false override val createdAt get() = contactRequest.createdAt override val updatedAt get() = contactRequest.updatedAt override val displayName get() = contactRequest.displayName override val fullName get() = contactRequest.fullName override val image get() = contactRequest.image + override val localAlias get() = contactRequest.localAlias companion object { val sampleData = ContactRequest(UserContactRequest.sampleData) @@ -445,11 +456,13 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactConnection.ready override val sendMsgEnabled get() = contactConnection.sendMsgEnabled override val ntfsEnabled get() = false + override val incognito get() = contactConnection.incognito override val createdAt get() = contactConnection.createdAt override val updatedAt get() = contactConnection.updatedAt override val displayName get() = contactConnection.displayName override val fullName get() = contactConnection.fullName override val image get() = contactConnection.image + override val localAlias get() = contactConnection.localAlias companion object { fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection = @@ -462,7 +475,7 @@ sealed class ChatInfo: SomeChat, NamedChat { data class Contact( val contactId: Long, override val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, val activeConn: Connection, val viaGroup: Long? = null, val chatSettings: ChatSettings, @@ -475,18 +488,22 @@ data class Contact( override val ready get() = activeConn.connStatus == ConnStatus.Ready override val sendMsgEnabled get() = true override val ntfsEnabled get() = chatSettings.enableNtfs - override val displayName get() = profile.displayName + override val displayName get() = localAlias.ifEmpty { profile.displayName } override val fullName get() = profile.fullName override val image get() = profile.image + override val localAlias get() = profile.localAlias val isIndirectContact: Boolean get() = activeConn.connLevel > 0 || viaGroup != null + val contactConnIncognito = + activeConn.customUserProfileId != null + companion object { val sampleData = Contact( contactId = 1, localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, activeConn = Connection.sampleData, chatSettings = ChatSettings(true), createdAt = Clock.System.now(), @@ -510,10 +527,10 @@ class ContactSubStatus( ) @Serializable -class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int) { +class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val customUserProfileId: Long? = null) { val id: ChatId get() = ":$connId" companion object { - val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0) + val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, customUserProfileId = null) } } @@ -521,13 +538,16 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In class Profile( override val displayName: String, override val fullName: String, - override val image: String? = null + override val image: String? = null, + override val localAlias : String = "" ): NamedChat { val profileViewName: String get() { return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } + fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias) + companion object { val sampleData = Profile( displayName = "alice", @@ -536,6 +556,28 @@ class Profile( } } +@Serializable +class LocalProfile( + val profileId: Long, + override val displayName: String, + override val fullName: String, + override val image: String? = null, + override val localAlias: String, +): NamedChat { + val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } + + fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias) + + companion object { + val sampleData = LocalProfile( + profileId = 1L, + displayName = "alice", + fullName = "Alice", + localAlias = "" + ) + } +} + @Serializable class Group ( val groupInfo: GroupInfo, @@ -548,6 +590,7 @@ data class GroupInfo ( override val localDisplayName: String, val groupProfile: GroupProfile, val membership: GroupMember, + val hostConnCustomUserProfileId: Long? = null, val chatSettings: ChatSettings, override val createdAt: Instant, override val updatedAt: Instant @@ -561,6 +604,7 @@ data class GroupInfo ( override val displayName get() = groupProfile.displayName override val fullName get() = groupProfile.fullName override val image get() = groupProfile.image + override val localAlias get() = "" val canEdit: Boolean get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent @@ -577,6 +621,7 @@ data class GroupInfo ( localDisplayName = "team", groupProfile = GroupProfile.sampleData, membership = GroupMember.sampleData, + hostConnCustomUserProfileId = null, chatSettings = ChatSettings(true), createdAt = Clock.System.now(), updatedAt = Clock.System.now() @@ -588,7 +633,8 @@ data class GroupInfo ( class GroupProfile ( override val displayName: String, override val fullName: String, - override val image: String? = null + override val image: String? = null, + override val localAlias: String = "", ): NamedChat { companion object { val sampleData = GroupProfile( @@ -608,17 +654,18 @@ class GroupMember ( var memberStatus: GroupMemberStatus, var invitedBy: InvitedBy, val localDisplayName: String, - val memberProfile: Profile, + val memberProfile: LocalProfile, val memberContactId: Long? = null, + val memberContactProfileId: Long, var activeConn: Connection? = null ) { val id: String get() = "#$groupId @$groupMemberId" - val displayName: String get() = memberProfile.displayName + val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName } val fullName: String get() = memberProfile.fullName val image: String? get() = memberProfile.image val chatViewName: String - get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") + get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } val memberActive: Boolean get() = when (this.memberStatus) { GroupMemberStatus.MemRemoved -> false @@ -654,6 +701,8 @@ class GroupMember ( && userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent } + val memberIncognito = memberProfile.profileId != memberContactProfileId + companion object { val sampleData = GroupMember( groupMemberId = 1, @@ -664,8 +713,9 @@ class GroupMember ( memberStatus = GroupMemberStatus.MemComplete, invitedBy = InvitedBy.IBUser(), localDisplayName = "alice", - memberProfile = Profile.sampleData, + memberProfile = LocalProfile.sampleData, memberContactId = 1, + memberContactProfileId = 1L, activeConn = Connection.sampleData ) } @@ -783,6 +833,7 @@ class UserContactRequest ( override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image + override val localAlias get() = "" companion object { val sampleData = UserContactRequest( @@ -801,6 +852,7 @@ class PendingContactConnection( val pccAgentConnId: String, val pccConnStatus: ConnStatus, val viaContactUri: Boolean, + val customUserProfileId: Long? = null, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -825,14 +877,21 @@ class PendingContactConnection( } override val fullName get() = "" override val image get() = null + override val localAlias get() = "" + val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri + val incognito = customUserProfileId != null + val description: String get() { val initiated = pccConnStatus.initiated return if (initiated == null) "" else generalGetString( - if (initiated && !viaContactUri) R.string.description_you_shared_one_time_link - else if (viaContactUri ) R.string.description_via_contact_address_link - else R.string.description_via_one_time_link + if (initiated && !viaContactUri) + if (incognito) R.string.description_you_shared_one_time_link_incognito else R.string.description_you_shared_one_time_link + else if (viaContactUri ) + if (incognito) R.string.description_via_contact_address_link_incognito else R.string.description_via_contact_address_link + else + if (incognito) R.string.description_via_one_time_link_incognito else R.string.description_via_one_time_link ) } @@ -843,6 +902,7 @@ class PendingContactConnection( pccAgentConnId = "abcd", pccConnStatus = status, viaContactUri = viaContactUri, + customUserProfileId = null, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -897,7 +957,7 @@ data class ChatItem ( val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew val memberDisplayName: String? get() = - if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName + if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName else null val isDeletedContent: Boolean get() = @@ -1190,9 +1250,11 @@ class CIGroupInvitation ( val groupMemberId: Long, val localDisplayName: String, val groupProfile: GroupProfile, - val status: CIGroupInvitationStatus + val status: CIGroupInvitationStatus, ) { - val text: String get() = String.format(generalGetString(R.string.group_invitation_item_description), groupProfile.displayName) + val text: String get() = String.format( + generalGetString(R.string.group_invitation_item_description), + groupProfile.displayName) companion object { fun getSample( 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 44f4e7f7c6..95da5ff799 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 @@ -95,6 +95,7 @@ class AppPreferences(val context: Context) { val networkTCPKeepIdle = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_IDLE, KeepAliveOpts.defaults.keepIdle) val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl) val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt) + val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false) val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name) val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb()) @@ -165,6 +166,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_NETWORK_TCP_KEEP_IDLE = "NetworkTCPKeepIdle" private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl" private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt" + private const val SHARED_PREFS_INCOGNITO = "Incognito" private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme" private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor" } @@ -179,6 +181,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager init { chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get() chatModel.performLA.value = appPrefs.performLA.get() + chatModel.incognito.value = appPrefs.incognito.get() } suspend fun startChat(user: User) { @@ -189,6 +192,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager val justStarted = apiStartChat() if (justStarted) { apiSetFilesFolder(getAppFilesDirectory(appContext)) + apiSetIncognito(chatModel.incognito.value) chatModel.userAddress.value = apiGetUserAddress() chatModel.userSMPServers.value = getUserSMPServers() val chats = apiGetChats() @@ -297,6 +301,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager throw Error("failed to set files folder: ${r.responseType} ${r.details}") } + suspend fun apiSetIncognito(incognito: Boolean) { + val r = sendCmd(CC.SetIncognito(incognito)) + if (r is CR.CmdOk) return + throw Exception("failed to set incognito: ${r.responseType} ${r.details}") + } + suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -405,9 +415,9 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } - suspend fun apiContactInfo(contactId: Long): ConnectionStats? { + suspend fun apiContactInfo(contactId: Long): Pair? { val r = sendCmd(CC.APIContactInfo(contactId)) - if (r is CR.ContactInfo) return r.connectionStats + if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") return null } @@ -518,6 +528,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager 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 + Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiCreateUserAddress(): String? { val r = sendCmd(CC.CreateMyAddress()) if (r is CR.UserContactLinkCreated) return r.connReqContact @@ -1133,6 +1150,7 @@ sealed class CC { class StartChat: CC() class ApiStopChat: CC() class SetFilesFolder(val filesFolder: String): CC() + class SetIncognito(val incognito: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -1163,6 +1181,7 @@ sealed class CC { class ListContacts: CC() class ApiUpdateProfile(val profile: Profile): CC() class ApiParseMarkdown(val text: String): CC() + class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() @@ -1185,6 +1204,7 @@ sealed class CC { is StartChat -> "/_start" is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" + is SetIncognito -> "/incognito ${if (incognito) "on" else "off"}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -1214,6 +1234,7 @@ sealed class CC { is ListContacts -> "/contacts" is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" is ApiParseMarkdown -> "/_parse $text" + is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" is CreateMyAddress -> "/address" is DeleteMyAddress -> "/delete_address" is ShowMyAddress -> "/show_address" @@ -1237,6 +1258,7 @@ sealed class CC { is StartChat -> "startChat" is ApiStopChat -> "apiStopChat" is SetFilesFolder -> "setFilesFolder" + is SetIncognito -> "setIncognito" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" @@ -1266,6 +1288,7 @@ sealed class CC { is ListContacts -> "listContacts" is ApiUpdateProfile -> "updateProfile" is ApiParseMarkdown -> "apiParseMarkdown" + is ApiSetContactAlias -> "apiSetContactAlias" is CreateMyAddress -> "createMyAddress" is DeleteMyAddress -> "deleteMyAddress" is ShowMyAddress -> "showMyAddress" @@ -1412,7 +1435,7 @@ sealed class CR { @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() - @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR() @@ -1422,6 +1445,7 @@ sealed class CR { @Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() + @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR() @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() @@ -1508,6 +1532,7 @@ sealed class CR { is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" + is ContactAliasUpdated -> "contactAliasUpdated" is ParsedMarkdown -> "apiParsedMarkdown" is UserContactLink -> "userContactLink" is UserContactLinkCreated -> "userContactLinkCreated" @@ -1592,6 +1617,7 @@ sealed class CR { is ChatCleared -> json.encodeToString(chatInfo) is UserProfileNoChange -> noDetails() is UserProfileUpdated -> json.encodeToString(toProfile) + is ContactAliasUpdated -> json.encodeToString(toContact) is ParsedMarkdown -> json.encodeToString(formattedText) is UserContactLink -> connReqContact is UserContactLinkCreated -> connReqContact diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index fde270bb38..5b3e0b0ea6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -7,6 +7,7 @@ val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) val Gray = Color(0x22222222) +val Indigo = Color(0xff330099) val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files val SimplexGreen = Color(77, 218, 103, 255) val SecretColor = Color(0x40808080) 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 1734a3f1b5..130fbaf864 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 @@ -10,6 +10,7 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -22,7 +23,9 @@ import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -33,15 +36,29 @@ import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @Composable -fun ChatInfoView(chatModel: ChatModel, connStats: ConnectionStats?, close: () -> Unit) { +fun ChatInfoView( + chatModel: ChatModel, + contact: Contact, + connStats: ConnectionStats?, + customUserProfile: Profile?, + localAlias: String, + close: () -> Unit, + onChatUpdated: (Chat) -> Unit, +) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null) { ChatInfoLayout( chat, + contact, connStats, + customUserProfile, + localAlias, developerTools, + onLocalAliasChanged = { + setContactAlias(chat.chatInfo.apiId, it, chatModel, onChatUpdated) + }, deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, changeNtfsState = { enabled -> @@ -120,8 +137,12 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit @Composable fun ChatInfoLayout( chat: Chat, + contact: Contact, connStats: ConnectionStats?, + customUserProfile: Profile?, + localAlias: String, developerTools: Boolean, + onLocalAliasChanged: (String) -> Unit, deleteContact: () -> Unit, clearChat: () -> Unit, changeNtfsState: (Boolean) -> Unit, @@ -136,8 +157,18 @@ fun ChatInfoLayout( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - ChatInfoHeader(chat.chatInfo) + ChatInfoHeader(chat.chatInfo, contact) } + + LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged) + + if (customUserProfile != null) { + SectionSpacer() + SectionView(generalGetString(R.string.incognito).uppercase()) { + InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName) + } + } + SectionSpacer() if (connStats != null) { @@ -193,19 +224,20 @@ fun ChatInfoLayout( } @Composable -fun ChatInfoHeader(cInfo: ChatInfo) { +fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { Column( Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) Text( - cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 8.dp) ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) { Text( cInfo.fullName, style = MaterialTheme.typography.h2, color = MaterialTheme.colors.onBackground, @@ -216,6 +248,31 @@ fun ChatInfoHeader(cInfo: ChatInfo) { } } +@Composable +private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) { + var value by remember { mutableStateOf(initialValue) } + DefaultBasicTextField( + Modifier.fillMaxWidth().padding(horizontal = 10.dp), + initialValue, + { + Text( + generalGetString(R.string.text_field_set_contact_placeholder), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = HighOrLowlight + ) + }, + color = HighOrLowlight, + textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center), + keyboardActions = KeyboardActions(onDone = { updateValue(value) }) + ) { + value = it + } + DisposableEffect(Unit) { + onDispose { updateValue(value) } + } +} + @Composable fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { Row( @@ -348,6 +405,13 @@ fun DeleteContactButton(deleteContact: () -> Unit) { } } +private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi { + chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let { + chatModel.updateContact(it) + onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi) + } +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -358,9 +422,13 @@ fun PreviewChatInfoLayout() { chatItems = arrayListOf(), serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) ), + Contact.sampleData, + localAlias = "", changeNtfsState = {}, developerTools = false, connStats = null, + onLocalAliasChanged = {}, + customUserProfile = null, deleteContact = {}, clearChat = {} ) } 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 f56e5e90f5..26b0e86636 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 @@ -105,18 +105,21 @@ fun ChatView(chatModel: ChatModel) { chatModel.chatItems, searchText, useLinkPreviews = useLinkPreviews, + chatModelIncognito = chatModel.incognito.value, back = { chatModel.chatId.value = null }, info = { withApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { - val connStats = chatModel.controller.apiContactInfo(cInfo.apiId) + val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId) ModalManager.shared.showCustomModal { close -> ModalView( close = close, modifier = Modifier, background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { - ChatInfoView(chatModel, connStats, close) + ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) { + activeChat = it + } } } } else if (cInfo is ChatInfo.Group) { @@ -191,7 +194,7 @@ fun ChatView(chatModel: ChatModel) { close = close, modifier = Modifier, background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { - AddGroupMembersView(groupInfo, chatModel, close) + AddGroupMembersView(groupInfo, chatModel, true, close) } } } @@ -232,6 +235,7 @@ fun ChatLayout( chatItems: List, searchValue: State, useLinkPreviews: Boolean, + chatModelIncognito: Boolean, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, @@ -277,7 +281,7 @@ fun ChatLayout( BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) { ChatItemsList( user, chat, unreadCount, composeState, chatItems, searchValue, - useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage, + useLinkPreviews, chatModelIncognito, openDirectChat, loadPrevMessages, deleteMessage, receiveFile, joinGroup, acceptCall, markRead, setFloatingButton ) } @@ -331,7 +335,7 @@ fun ChatInfoToolbar( startCall(CallMediaType.Video) }) } - } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) { + } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) { barButtons.add { IconButton({ showMenu = false @@ -375,6 +379,9 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { + if (cInfo.incognito) { + IncognitoImage(size = 36.dp, Indigo) + } ChatInfoImage(cInfo, size = imageSize, iconColor) Column( Modifier.padding(start = 8.dp), @@ -384,7 +391,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo cInfo.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) { Text( cInfo.fullName, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -415,6 +422,7 @@ fun BoxWithConstraintsScope.ChatItemsList( chatItems: List, searchValue: State, useLinkPreviews: Boolean, + chatModelIncognito: Boolean, openDirectChat: (Long) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -514,11 +522,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } } else { // direct message @@ -529,7 +537,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) } } @@ -815,6 +823,7 @@ fun PreviewChatLayout() { chatItems = chatItems, searchValue, useLinkPreviews = true, + chatModelIncognito = false, back = {}, info = {}, openDirectChat = {}, @@ -871,6 +880,7 @@ fun PreviewGroupChatLayout() { chatItems = chatItems, searchValue, useLinkPreviews = true, + chatModelIncognito = false, back = {}, info = {}, openDirectChat = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt index 6730da6fb3..28cbd1c48d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt @@ -11,11 +11,14 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList 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.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -28,7 +31,7 @@ import chat.simplex.app.views.chat.ChatInfoToolbarTitle import chat.simplex.app.views.helpers.* @Composable -fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { +fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, showFooterCounter: Boolean = true, close: () -> Unit) { val selectedContacts = remember { mutableStateListOf() } val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) } @@ -38,6 +41,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> contactsToAdd = getContactsToAdd(chatModel), selectedContacts = selectedContacts, selectedRole = selectedRole, + showFooterCounter = showFooterCounter, inviteMembers = { withApi { selectedContacts.forEach { @@ -75,6 +79,7 @@ fun AddGroupMembersLayout( contactsToAdd: List, selectedContacts: SnapshotStateList, selectedRole: MutableState, + showFooterCounter: Boolean, inviteMembers: () -> Unit, clearSelection: () -> Unit, addContact: (Long) -> Unit, @@ -119,13 +124,15 @@ fun AddGroupMembersLayout( InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty()) } } - SectionCustomFooter { - InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection) + if (showFooterCounter) { + SectionCustomFooter { + InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection) + } } SectionSpacer() SectionView { - ContactList(contacts = contactsToAdd, selectedContacts, addContact, removeContact) + ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact) } SectionSpacer() } @@ -255,6 +262,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) fun ContactList( contacts: List, selectedContacts: SnapshotStateList, + groupInfo: GroupInfo, addContact: (Long) -> Unit, removeContact: (Long) -> Unit ) { @@ -262,7 +270,7 @@ fun ContactList( contacts.forEachIndexed { index, contact -> SectionItemView { ContactCheckRow( - contact, addContact, removeContact, + contact, groupInfo, addContact, removeContact, checked = selectedContacts.contains(contact.apiId) ) } @@ -276,14 +284,35 @@ fun ContactList( @Composable fun ContactCheckRow( contact: Contact, + groupInfo: GroupInfo, addContact: (Long) -> Unit, removeContact: (Long) -> Unit, checked: Boolean ) { + val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito + val icon: ImageVector + val iconColor: Color + if (prohibitedToInviteIncognito) { + icon = Icons.Filled.TheaterComedy + iconColor = HighOrLowlight + } else if (checked) { + icon = Icons.Filled.CheckCircle + iconColor = MaterialTheme.colors.primary + } else { + icon = Icons.Outlined.Circle + iconColor = HighOrLowlight + } Row( Modifier .fillMaxSize() - .clickable { if (!checked) addContact(contact.apiId) else removeContact(contact.apiId) }, + .clickable { + if (prohibitedToInviteIncognito) { + showProhibitedToInviteIncognitoAlertDialog() + } else if (!checked) + addContact(contact.apiId) + else + removeContact(contact.apiId) + }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -292,16 +321,27 @@ fun ContactCheckRow( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { ProfileImage(size = 36.dp, contact.image) - Text(contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified + ) } Icon( - if (checked) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + icon, contentDescription = stringResource(R.string.icon_descr_contact_checked), - tint = if (checked) MaterialTheme.colors.primary else HighOrLowlight + tint = iconColor ) } } +fun showProhibitedToInviteIncognitoAlertDialog() { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.invite_prohibited), + text = generalGetString(R.string.invite_prohibited_description), + confirmText = generalGetString(R.string.ok), + ) +} + @Preview @Composable fun PreviewAddGroupMembersLayout() { @@ -311,6 +351,7 @@ fun PreviewAddGroupMembersLayout() { contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData), selectedContacts = remember { mutableStateListOf() }, selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }, + showFooterCounter = true, inviteMembers = {}, clearSelection = {}, addContact = {}, 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 3c827752c5..0d1208f191 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 @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -24,6 +25,7 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* 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.* @@ -49,20 +51,20 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { close = close, modifier = Modifier, background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { - AddGroupMembersView(groupInfo, chatModel, close) + AddGroupMembersView(groupInfo, chatModel, true, close) } } } }, showMemberInfo = { member -> withApi { - val connStats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) ModalManager.shared.showCustomModal { close -> ModalView( close = close, modifier = Modifier, background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { - GroupMemberInfoView(groupInfo, member, connStats, chatModel, close) + GroupMemberInfoView(groupInfo, member, stats, chatModel, close) } } } @@ -137,14 +139,16 @@ fun GroupChatInfoLayout( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - ChatInfoHeader(chat.chatInfo) + GroupChatInfoHeader(chat.chatInfo) } SectionSpacer() SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) { if (groupInfo.canAddMembers) { SectionItemView { - AddMembersButton(addMembers) + val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary + val onClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers + AddMembersButton(tint, onClick) } SectionDivider() } @@ -206,7 +210,31 @@ fun GroupChatInfoLayout( } @Composable -fun AddMembersButton(addMembers: () -> Unit) { +fun GroupChatInfoHeader(cInfo: ChatInfo) { + Column( + Modifier.padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) + Text( + cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + Text( + cInfo.fullName, style = MaterialTheme.typography.h2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, addMembers: () -> Unit) { Row( Modifier .fillMaxSize() @@ -216,10 +244,10 @@ fun AddMembersButton(addMembers: () -> Unit) { Icon( Icons.Outlined.Add, stringResource(R.string.button_add_members), - tint = MaterialTheme.colors.primary + tint = tint ) Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.button_add_members), color = MaterialTheme.colors.primary) + Text(stringResource(R.string.button_add_members), color = tint) } } @@ -251,7 +279,8 @@ fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = nu ) { ProfileImage(size = 46.dp, member.image) Column { - Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified) val s = member.memberStatus.shortText val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s Text( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index ba0a39bd3f..5aa06861d7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -27,7 +27,13 @@ import chat.simplex.app.views.chat.SimplexServers import chat.simplex.app.views.helpers.* @Composable -fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: ConnectionStats?, chatModel: ChatModel, close: () -> Unit) { +fun GroupMemberInfoView( + groupInfo: GroupInfo, + member: GroupMember, + connStats: ConnectionStats?, + chatModel: ChatModel, + close: () -> Unit +) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt index 73e7608d2b..3bdd453b41 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R -import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -28,6 +27,7 @@ fun CIGroupInvitationView( ci: ChatItem, groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole, + chatIncognito: Boolean = false, joinGroup: (Long) -> Unit ) { val sent = ci.chatDir.sent @@ -37,7 +37,7 @@ fun CIGroupInvitationView( fun groupInfoView() { val p = groupInvitation.groupProfile val iconColor = - if (action) MaterialTheme.colors.primary + if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary else if (isInDarkTheme()) FileDark else FileLight Row( @@ -46,7 +46,7 @@ fun CIGroupInvitationView( .padding(vertical = 4.dp) .padding(end = 2.dp) ) { - ProfileImage(size = 60.dp, icon = Icons.Filled.SupervisedUserCircle, color = iconColor) + ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = Icons.Filled.SupervisedUserCircle, color = iconColor) Spacer(Modifier.padding(horizontal = 3.dp)) Column( Modifier.defaultMinSize(minHeight = 60.dp), @@ -71,13 +71,10 @@ fun CIGroupInvitationView( } } - fun acceptInvitation() { - Log.d(TAG, "CIGroupInvitationView acceptInvitation") - joinGroup(groupInvitation.groupId) - } - Surface( - modifier = if (action) Modifier.clickable(onClick = ::acceptInvitation) else Modifier, + modifier = if (action) Modifier.clickable(onClick = { + joinGroup(groupInvitation.groupId) + }) else Modifier, shape = RoundedCornerShape(18.dp), color = if (sent) SentColorLight else ReceivedColorLight, ) { @@ -99,7 +96,9 @@ fun CIGroupInvitationView( Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) if (action) { groupInvitationText() - Text(stringResource(R.string.group_invitation_tap_to_join), color = MaterialTheme.colors.primary) + Text(stringResource( + if (chatIncognito) R.string.group_invitation_tap_to_join_incognito else R.string.group_invitation_tap_to_join), + color = if (chatIncognito) Indigo else MaterialTheme.colors.primary) } else { Box(Modifier.padding(end = 48.dp)) { groupInvitationText() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 9c8f05238a..a8a806ecbf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -35,6 +35,7 @@ fun ChatItemView( cxt: Context, uriHandler: UriHandler? = null, showMember: Boolean = false, + chatModelIncognito: Boolean, useLinkPreviews: Boolean, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, @@ -147,8 +148,8 @@ fun ChatItemView( is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember) - is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) - is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) + is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.RcvGroupEventContent -> CIGroupEventView(cItem) is CIContent.SndGroupEventContent -> CIGroupEventView(cItem) } @@ -213,6 +214,7 @@ fun PreviewChatItemView() { useLinkPreviews = true, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, cxt = LocalContext.current, + chatModelIncognito = false, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, @@ -232,6 +234,7 @@ fun PreviewChatItemViewDeletedContent() { useLinkPreviews = true, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, cxt = LocalContext.current, + chatModelIncognito = false, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index fb9faf5457..b8f4d8b1e4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -15,8 +16,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.ui.theme.WarningOrange +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.* import chat.simplex.app.views.chat.group.deleteGroupDialog import chat.simplex.app.views.chat.group.leaveGroupDialog @@ -38,7 +38,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { when (chat.chatInfo) { is ChatInfo.Direct -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, stopped) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -46,7 +46,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ) is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, stopped) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) }, click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, @@ -54,7 +54,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( - chatLinkPreview = { ContactRequestView(chat.chatInfo) }, + chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) }, click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, showMenu, @@ -128,7 +128,7 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - JoinGroupAction(groupInfo, chatModel, showMenu) + JoinGroupAction(chat, groupInfo, chatModel, showMenu) if (groupInfo.canDelete) { DeleteGroupAction(chat, chatModel, showMenu) } @@ -214,12 +214,14 @@ fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { +fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { + val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } } ItemAction( - stringResource(R.string.join_group_button), - Icons.Outlined.Login, + if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button), + if (chat.chatInfo.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.Login, + color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground, onClick = { - withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } + joinGroup() showMenu.value = false } ) @@ -241,8 +243,9 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab @Composable fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { ItemAction( - stringResource(R.string.accept_contact_button), - Icons.Outlined.Check, + if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button), + if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check, + color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground, onClick = { acceptContactRequest(chatInfo, chatModel) showMenu.value = false @@ -291,7 +294,7 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel AlertManager.shared.showAlertDialog( title = generalGetString(R.string.accept_connection_request__question), text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified), - confirmText = generalGetString(R.string.accept_contact_button), + confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button), onConfirm = { acceptContactRequest(contactRequest, chatModel) }, dismissText = generalGetString(R.string.reject_contact_button), onDismiss = { rejectContactRequest(contactRequest, chatModel) } @@ -388,13 +391,21 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) AlertManager.shared.showAlertDialog( title = generalGetString(R.string.join_group_question), text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members), - confirmText = generalGetString(R.string.join_group_button), + confirmText = if (groupInfo.membership.memberIncognito) generalGetString(R.string.join_group_incognito_button) else generalGetString(R.string.join_group_button), onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }, dismissText = generalGetString(R.string.delete_verb), onDismiss = { deleteGroup(groupInfo, chatModel) } ) } +fun cantInviteIncognitoAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.alert_title_cant_invite_contacts), + text = generalGetString(R.string.alert_title_cant_invite_contacts_descr), + confirmText = generalGetString(R.string.ok), + ) +} + fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) { withApi { val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId) @@ -473,6 +484,8 @@ fun PreviewChatListNavLinkDirect() { ), chatStats = Chat.ChatStats() ), + false, + null, stopped = false ) }, @@ -508,6 +521,8 @@ fun PreviewChatListNavLinkGroup() { ), chatStats = Chat.ChatStats() ), + false, + null, stopped = false ) }, @@ -530,7 +545,7 @@ fun PreviewChatListNavLinkContactRequest() { SimpleXTheme { ChatListNavLinkLayout( chatLinkPreview = { - ContactRequestView(ChatInfo.ContactRequest.sampleData) + ContactRequestView(false, ChatInfo.ContactRequest.sampleData) }, click = {}, dropdownMenuItems = null, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index a6f1029f34..1698cd2ca6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -8,8 +8,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.material.icons.outlined.* 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 @@ -17,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.Indigo import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.onboarding.MakeConnection @@ -68,7 +71,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse() } BottomSheetScaffold( - topBar = { ChatListToolbar(scaffoldCtrl, stopped) }, + topBar = { ChatListToolbar(chatModel, scaffoldCtrl, stopped) }, scaffoldState = scaffoldCtrl.state, drawerContent = { SettingsView(chatModel, setPerformLA) }, sheetPeekHeight = 0.dp, @@ -100,15 +103,25 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: } @Composable -fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) { +fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stopped: Boolean) { DefaultTopAppBar( navigationButton = { NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } }, title = { - Text( - stringResource(R.string.your_chats), - color = MaterialTheme.colors.onBackground, - fontWeight = FontWeight.SemiBold, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.your_chats), + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, + ) + if (chatModel.incognito.value) { + Icon( + Icons.Filled.TheaterComedy, + stringResource(R.string.incognito), + tint = Indigo, + modifier = Modifier.padding(10.dp).size(26.dp) + ) + } + } }, onTitleClick = null, showSearch = false, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index dc71336cef..accbffb2e1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -26,7 +26,7 @@ import chat.simplex.app.views.chat.item.MarkdownText import chat.simplex.app.views.helpers.* @Composable -fun ChatPreviewView(chat: Chat, stopped: Boolean) { +fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) { val cInfo = chat.chatInfo @Composable @@ -70,7 +70,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight) is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> chatPreviewTitleText(MaterialTheme.colors.primary) + GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary) GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight) else -> chatPreviewTitleText() } @@ -79,7 +79,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { } @Composable - fun chatPreviewText() { + fun chatPreviewText(chatModelIncognito: Boolean) { val ci = chat.chatItems.lastOrNull() if (ci != null) { MarkdownText( @@ -97,7 +97,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> Text(stringResource(R.string.group_preview_you_are_invited)) + GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo)) GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight) else -> {} } @@ -119,7 +119,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { .weight(1F) ) { chatPreviewTitle() - chatPreviewText() + chatPreviewText(chatModelIncognito) } val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt) @@ -178,6 +178,16 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { } } +@Composable +private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { + return if (groupInfo.membership.memberIncognito) + String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName) + else if (chatModelIncognito) + String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "") + else + stringResource(R.string.group_preview_you_are_invited) +} + @Composable fun unreadCountStr(n: Int): String { return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation) @@ -215,6 +225,6 @@ fun ChatStatusImage(chat: Chat) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, stopped = false) + ChatPreviewView(Chat.sampleData, false, "", stopped = false) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 6ec6263f26..70b91e634e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -10,13 +10,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.ChatInfo -import chat.simplex.app.model.getTimestampText +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.ChatInfoImage @Composable -fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { +fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) { Row { ChatInfoImage(contactRequest, size = 72.dp) Column( @@ -30,7 +29,7 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.primary + color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary ) Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index f4a6595798..5ae9e32341 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -5,6 +5,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import chat.simplex.app.R import chat.simplex.app.TAG @@ -45,7 +46,8 @@ class AlertManager { confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null, dismissText: String = generalGetString(R.string.cancel_verb), - onDismiss: (() -> Unit)? = null + onDismiss: (() -> Unit)? = null, + destructive: Boolean = false ) { val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) } showAlert { @@ -57,7 +59,7 @@ class AlertManager { TextButton(onClick = { onConfirm?.invoke() hideAlert() - }) { Text(confirmText) } + }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } }, dismissButton = { TextButton(onClick = { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 5a596cd5ec..f1d6fa9caa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -6,8 +6,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.SupervisedUserCircle +import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -31,6 +30,17 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme ProfileImage(size, chatInfo.image, icon, iconColor) } +@Composable +fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) { + Box(Modifier.size(size)) { + Icon( + Icons.Filled.TheaterComedy, stringResource(R.string.incognito), + modifier = Modifier.size(size).padding(size / 12), + iconColor + ) + } +} + @Composable fun ProfileImage( size: Dp, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt new file mode 100644 index 0000000000..31de2f5212 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt @@ -0,0 +1,110 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.foundation.text.* +import androidx.compose.material.* +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DefaultBasicTextField( + modifier: Modifier, + initialValue: String, + placeholder: (@Composable () -> Unit)? = null, + focus: Boolean = false, + color: Color = MaterialTheme.colors.onBackground, + textStyle: TextStyle = TextStyle.Default, + selectTextOnFocus: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions: KeyboardActions = KeyboardActions(), + onValueChange: (String) -> Unit, +) { + val state = remember { + mutableStateOf(TextFieldValue(initialValue)) + } + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + if (!focus) return@LaunchedEffect + focusRequester.requestFocus() + delay(200) + keyboard?.show() + } + val enabled = true + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = state.value, + modifier = modifier + .background(colors.backgroundColor(enabled).value, shape) + .indicatorLine(enabled, false, interactionSource, colors) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused && selectTextOnFocus) { + val text = state.value.text + state.value = state.value.copy( + selection = TextRange(0, text.length) + ) + } + } + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = { + state.value = it + onValueChange(it.text) + }, + cursorBrush = SolidColor(colors.cursorColor(false).value), + visualTransformation = VisualTransformation.None, + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions(onDone = { + keyboard?.hide() + keyboardActions.onDone?.invoke(this) + }), + singleLine = true, + textStyle = textStyle.copy( + color = color, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = state.value.text, + innerTextField = innerTextField, + placeholder = placeholder, + singleLine = true, + enabled = enabled, + interactionSource = interactionSource, + contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp), + visualTransformation = VisualTransformation.None, + colors = colors + ) + } + ) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index 0340693610..b2a5bb8bbd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -2,9 +2,10 @@ package chat.simplex.app.views.newchat import android.content.res.Configuration import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TheaterComedy +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -18,8 +19,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.SimpleButton -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.shareText @Composable @@ -28,6 +29,7 @@ fun AddContactView(chatModel: ChatModel) { if (connReq != null) { val cxt = LocalContext.current AddContactLayout( + chatModelIncognito = chatModel.incognito.value, connReq = connReq, share = { shareText(cxt, connReq) } ) @@ -35,22 +37,32 @@ fun AddContactView(chatModel: ChatModel) { } @Composable -fun AddContactLayout(connReq: String, share: () -> Unit) { +fun AddContactLayout(chatModelIncognito: Boolean, connReq: String, share: () -> Unit) { BoxWithConstraints { val screenHeight = maxHeight Column( - horizontalAlignment = Alignment.CenterHorizontally, + Modifier.padding(bottom = 16.dp), verticalArrangement = Arrangement.SpaceBetween, ) { Text( stringResource(R.string.add_contact), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + modifier = Modifier + .padding(vertical = 5.dp) + .padding(horizontal = 8.dp) ) Text( stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), - style = MaterialTheme.typography.h3, - textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp) ) + Row(Modifier.padding(horizontal = 8.dp)) { + InfoAboutIncognito( + chatModelIncognito, + true, + generalGetString(R.string.incognito_random_profile_description), + generalGetString(R.string.your_profile_will_be_sent) + ) + } QRCode( connReq, Modifier .weight(1f, fill = false) @@ -59,14 +71,52 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { ) Text( stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), - textAlign = TextAlign.Center, lineHeight = 22.sp, modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp) ) - SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) - Spacer(Modifier.height(10.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) + } + } + } +} + +@Composable +fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String) { + if (chatModelIncognito) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (supportedIncognito) Icons.Filled.TheaterComedy else Icons.Outlined.Info, + stringResource(R.string.incognito), + tint = if (supportedIncognito) Indigo else WarningOrange, + modifier = Modifier.padding(end = 10.dp).size(20.dp) + ) + Text(onText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2) + } + } else { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Info, + stringResource(R.string.incognito), + tint = HighOrLowlight, + modifier = Modifier.padding(end = 10.dp).size(20.dp) + ) + Text(offText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2) } } } @@ -81,6 +131,7 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( + chatModelIncognito = false, connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", share = {} ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt index d4d03bfbc3..3f1ce2ef7f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -34,6 +35,7 @@ import kotlinx.coroutines.launch @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { AddGroupLayout( + chatModel.incognito.value, createGroup = { groupProfile -> withApi { val groupInfo = chatModel.controller.apiNewGroup(groupProfile) @@ -48,7 +50,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { close = close, modifier = Modifier, background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { - AddGroupMembersView(groupInfo, chatModel, close) + AddGroupMembersView(groupInfo, chatModel, false, close) } } } @@ -59,7 +61,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { } @Composable -fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { +fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> Unit, close: () -> Unit) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = remember { mutableStateOf("") } @@ -92,11 +94,16 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { ) { Text( stringResource(R.string.create_secret_group_title), - style = MaterialTheme.typography.h4, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), modifier = Modifier.padding(vertical = 5.dp) ) - ReadableText(R.string.group_is_decentralized) - Spacer(Modifier.height(10.dp)) + Text(stringResource(R.string.group_is_decentralized)) + InfoAboutIncognito( + chatModelIncognito, + false, + generalGetString(R.string.group_unsupported_incognito_main_profile_sent), + generalGetString(R.string.group_main_profile_sent) + ) Box( Modifier .fillMaxWidth() @@ -170,6 +177,7 @@ fun CreateGroupButton(color: Color, modifier: Modifier) { fun PreviewAddGroupLayout() { SimpleXTheme { AddGroupLayout( + chatModelIncognito = false, createGroup = {}, close = {} ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt index 6e9372dcc2..bfb62fd89b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt @@ -31,6 +31,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { val clipboard = getSystemService(context, ClipboardManager::class.java) BackHandler(onBack = close) PasteToConnectLayout( + chatModel.incognito.value, connectionLink = connectionLink, pasteFromClipboard = { connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String @@ -55,6 +56,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @Composable fun PasteToConnectLayout( + chatModelIncognito: Boolean, connectionLink: MutableState, pasteFromClipboard: () -> Unit, connectViaLink: (String) -> Unit, @@ -62,16 +64,22 @@ fun PasteToConnectLayout( ) { ModalView(close) { Column( - horizontalAlignment = Alignment.CenterHorizontally, + Modifier.padding(bottom = 16.dp), verticalArrangement = Arrangement.SpaceBetween, ) { Text( stringResource(R.string.connect_via_link), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(vertical = 5.dp) ) Text(stringResource(R.string.paste_connection_link_below_to_connect)) - Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link)) + + InfoAboutIncognito( + chatModelIncognito, + true, + generalGetString(R.string.incognito_random_profile_from_contact_description), + generalGetString(R.string.profile_will_be_sent_to_contact_sending_link) + ) Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) { TextEditor(Modifier.height(180.dp), text = connectionLink) @@ -111,6 +119,7 @@ fun PasteToConnectLayout( fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( + chatModelIncognito = false, connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, connectViaLink = { link -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt index 11c67a409a..465591c544 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -22,6 +21,7 @@ import chat.simplex.app.views.helpers.* fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { BackHandler(onBack = close) ConnectContactLayout( + chatModelIncognito = chatModel.incognito.value, qrCodeScanner = { QRCodeScanner { connReqUri -> try { @@ -67,21 +67,22 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) { } @Composable -fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) { +fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit, close: () -> Unit) { ModalView(close) { Column( - horizontalAlignment = Alignment.CenterHorizontally, + Modifier.padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( generalGetString(R.string.scan_QR_code), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + modifier = Modifier.padding(vertical = 5.dp) ) - Text( - generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact), - style = MaterialTheme.typography.h3, - textAlign = TextAlign.Center, - modifier = Modifier.padding(bottom = 4.dp) + InfoAboutIncognito( + chatModelIncognito, + true, + generalGetString(R.string.incognito_random_profile_description), + generalGetString(R.string.your_profile_will_be_sent) ) Box( Modifier @@ -106,6 +107,7 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( + chatModelIncognito = false, qrCodeScanner = { Surface {} }, close = {}, ) 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 95478fd9d9..7535138ee7 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 @@ -3,11 +3,15 @@ package chat.simplex.app.views.usersettings import SectionDivider import SectionItemView import SectionView +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -82,6 +86,40 @@ fun SharedPreferenceToggle( } } +@Composable +fun SharedPreferenceToggleWithIcon( + text: String, + icon: ImageVector, + stopped: Boolean = false, + onClickInfo: () -> Unit, + preference: Preference, + preferenceState: MutableState? = null +) { + val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(text, Modifier.padding(end = 4.dp)) + Icon( + icon, + null, + Modifier.clickable(onClick = onClickInfo), + tint = MaterialTheme.colors.primary + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Switch( + checked = prefState.value, + onCheckedChange = { + preference.set(it) + prefState.value = it + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + enabled = !stopped + ) + } +} + @Composable fun SharedPreferenceRadioButton(text: String, prefState: MutableState, preference: Preference, value: T) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt new file mode 100644 index 0000000000..002dedf43a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt @@ -0,0 +1,47 @@ +package chat.simplex.app.views.usersettings + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.views.helpers.generalGetString + +@Composable +fun IncognitoView() { + IncognitoLayout() +} + +@Composable +fun IncognitoLayout() { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + stringResource(R.string.settings_section_title_incognito), + Modifier.padding(start = 8.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 8.dp) + ) { + Column( + Modifier.padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text(generalGetString(R.string.incognito_info_protects)) + Text(generalGetString(R.string.incognito_info_allows)) + Text(generalGetString(R.string.incognito_info_share)) + Text(generalGetString(R.string.incognito_info_find)) + } + } + } +} 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 9061369acc..1e0a24beff 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 @@ -9,7 +9,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -37,6 +37,8 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false + MaintainIncognitoState(chatModel) + fun setRunServiceInBackground(on: Boolean) { chatModel.controller.appPrefs.runServiceInBackground.set(on) if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { @@ -51,6 +53,8 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { SettingsLayout( profile = user.profile, stopped, + chatModel.incognito, + chatModel.controller.appPrefs.incognito, runServiceInBackground = chatModel.runServiceInBackground, developerTools = chatModel.controller.appPrefs.developerTools, setRunServiceInBackground = ::setRunServiceInBackground, @@ -84,8 +88,10 @@ val simplexTeamUri = @Composable fun SettingsLayout( - profile: Profile, + profile: LocalProfile, stopped: Boolean, + incognito: MutableState, + incognitoPref: Preference, runServiceInBackground: MutableState, developerTools: Preference, setRunServiceInBackground: (Boolean) -> Unit, @@ -116,6 +122,8 @@ fun SettingsLayout( ProfilePreview(profile, stopped = stopped) } SectionDivider() + SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { onClickIncognitoInfo(showModal) } + SectionDivider() SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped) SectionDivider() DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) @@ -163,6 +171,47 @@ fun SettingsLayout( } } +@Composable +fun SettingsIncognitoActionItem( + incognitoPref: Preference, + incognito: MutableState, + stopped: Boolean, + onClickInfo: () -> Unit, +) { + SettingsPreferenceItemWithInfo( + if (incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.TheaterComedy, + if (incognito.value) Indigo else HighOrLowlight, + stringResource(R.string.incognito), + stopped, + onClickInfo, + incognitoPref, + incognito + ) +} + +private val onClickIncognitoInfo: ((@Composable (ChatModel) -> Unit) -> (() -> Unit)) -> Unit = { showModal -> + showModal { IncognitoView() }() +} + +@Composable +fun MaintainIncognitoState(chatModel: ChatModel) { + // Cache previous value and once it changes in background, update it via API + var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) } + LaunchedEffect(chatModel.incognito.value) { + // Don't do anything if nothing changed + if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect + try { + chatModel.controller.apiSetIncognito(chatModel.incognito.value) + } catch (e: Exception) { + // Rollback the state + chatModel.controller.appPrefs.incognito.set(cachedIncognito) + // Crash the app + throw e + } + cachedIncognito = chatModel.incognito.value + } +} + @Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemView(openDatabaseView) { Row( @@ -322,6 +371,25 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference Unit, + pref: Preference, + prefState: MutableState? = null +) { + SectionItemView() { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onClickInfo() }) { + Icon(icon, text, tint = if (stopped) HighOrLowlight else iconTint) + Spacer(Modifier.padding(horizontal = 4.dp)) + SharedPreferenceToggleWithIcon(text, Icons.Outlined.Info, stopped, onClickInfo, pref, prefState) + } + } +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -332,8 +400,10 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference Unit) { val user = chatModel.currentUser.value if (user != null) { val editProfile = remember { mutableStateOf(false) } - var profile by remember { mutableStateOf(user.profile) } + var profile by remember { mutableStateOf(user.profile.toProfile()) } UserProfileLayout( close = close, editProfile = editProfile, @@ -47,7 +46,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val p = Profile(displayName, fullName, image) val newProfile = chatModel.controller.apiUpdateProfile(p) if (newProfile != null) { - chatModel.updateUserProfile(newProfile) + chatModel.currentUser.value?.profile?.profileId?.let { + chatModel.updateUserProfile(newProfile.toLocalProfile(it)) + } profile = newProfile } editProfile.value = false 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 994672296a..023e067f1c 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -31,8 +31,11 @@ приглашение соединиться соединяется… вы создали одноразовую ссылку + вы создали одноразовую ссылку инкогнито через ссылку-контакт + инкогнито через ссылку-контакт через одноразовую ссылку + инкогнито через одноразовую ссылку Ошибка при сохранении SMP серверов @@ -114,6 +117,7 @@ Ваши чаты соединяется… вы приглашены в группу + вступить как %s соединяется… @@ -148,6 +152,7 @@ Удалить контакт? Контакт и все сообщения будут удалены - это действие нельзя отменить! Удалить контакт + Имя контакта… Соединение с сервером установлено Соединение с сервером не установлено Ошибка соединения с сервером @@ -160,7 +165,7 @@ Назад Отменить Подтвердить - + OK нет описания Добавить контакт Скопировано в буфер обмена @@ -199,6 +204,7 @@ Принять запрос на соединение? Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение. Принять + Принять инкогнито Отклонить @@ -251,13 +257,15 @@ Запрос на соединение послан! Соединение будет установлено когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! Соединение будет установлено когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! - Покажите QR код вашему контакту, чтобы сосканировать его из приложения + Покажите QR код вашему контакту, чтобы сосканировать его из приложения. Если вы не можете встретиться лично, вы можете показать QR код во время видеозвонка или отправить ссылку через любой другой канал связи. Ваш профиль будет отправлен\nвашему контакту Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку. Поделиться ссылкой Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. - + Ваш профиль будет отправлен вашему контакту + + Настройки Ваш SimpleX адрес @@ -469,6 +477,7 @@ SOCKS ПРОКСИ ИКОНКА ТЕМЫ + Режим Инкогнито Данные чата @@ -519,6 +528,7 @@ Вступить в группу? Вы приглашены в группу. Вступите, чтобы соединиться с членами группы. Вступить + Вступить инкогнито Вступление в группу Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы. Выйти @@ -531,11 +541,14 @@ Группа не найдена! Эта группа больше не существует. Ошибка приглашения + Нельзя пригласить контакты! + Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено Вы отправили приглашение в группу Вы приглашены в группу Нажмите, чтобы вступить + Нажмите, чтобы вступить инкогнито Вы вступили в эту группу Вы отклонили приглашение в группу Приглашение в группу истекло @@ -581,6 +594,8 @@ Очистить Выбрано контактов: %1$s Контакты не выбраны + Нельзя пригласить контакт! + Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль Пригласить членов группы @@ -618,6 +633,8 @@ Группа полностью децентрализована — она видна только членам. Имя группы: Полное имя: + Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы + Ваш профиль чата будет отправлен членам группы Профиль группы хранится на устройствах членов, а не на серверах. @@ -637,6 +654,17 @@ Обновление настроек приведет к переподключению клиента ко всем серверам. Обновить + + Инкогнито + Случайный профиль + Вашему контакту будет отправлен случайный профиль + Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль + + Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль. + Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя. + Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом. + Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата. + Системная Светлая diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 8019e862ab..cf21338f7a 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -31,8 +31,11 @@ invited to connect connecting… you shared one-time link + you shared one-time link incognito via contact address link + incognito via contact address link via one-time link + incognito via one-time link Error saving SMP servers @@ -114,6 +117,7 @@ Your chats connecting… you are invited to group + join as %s connecting… @@ -148,6 +152,7 @@ Delete contact? Contact and all messages will be deleted - this cannot be undone! Delete contact + Set contact name… Connected Disconnected Error @@ -160,7 +165,7 @@ Back Cancel Confirm - Ok + OK no details Add contact Copied to clipboard @@ -199,6 +204,7 @@ Accept connection request? If you choose to reject sender will NOT be notified. Accept + Accept incognito Reject @@ -251,12 +257,13 @@ Connection request sent! You will be connected when your connection request is accepted, please wait or check later! You will be connected when your contact\'s device is online, please wait or check later! - Show QR code for your contact\nto scan from the app - If you cannot meet in person, you can show QR code in the video call, or you can share the invitation link via any other channel. + Show QR code for your contact to scan from the app. + If you can\'t meet in person, you can show QR code in the video call, or you can share the invitation link via any other channel. Your chat profile will be sent\nto your contact If you cannot meet in person, you can scan QR code in the video call, or your contact can share an invitation link. Share invitation link Paste the link you received into the box below to connect with your contact. + Your chat profile will be sent to your contact Connect via link @@ -471,6 +478,7 @@ SOCKS PROXY APP ICON THEMES + Incognito mode Your chat database @@ -521,6 +529,7 @@ Join group? You are invited to group. Join to connect with group members. Join + Join incognito Joining group You joined this group. Connecting to inviting group member. Leave @@ -533,11 +542,14 @@ Group not found! This group no longer exists. Error joining group + Can\'t invite contacts! + You\'re using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed You sent group invitation You are invited to group Tap to join + Tap to join incognito You joined this group You rejected group invitation Group invitation expired @@ -583,6 +595,8 @@ Clear %1$s contact(s) selected No contacts selected + Can\'t invite contact! + You\'re trying to invite contact with whom you\'ve shared an incognito profile to the group in which you\'re using your main profile Invite members @@ -620,6 +634,9 @@ The group is fully decentralized – it is visible only to the members. Group display name: Group full name: + Incognito mode is not supported here - your main profile will be sent to group members + Your chat profile will be sent to group members + Group profile is stored on members\' devices, not on the servers. @@ -639,6 +656,17 @@ Updating settings will re-connect the client to all servers. Update + + Incognito + Your random profile + A random profile will be sent to your contact + A random profile will be sent to the contact that you received this link from + + Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. + It allows having many anonymous connections without any shared data between them in a single chat profile. + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + To find the profile used for an incognito connection, tap the contact or group name on top of the chat. + System Light