Compare commits

...

29 Commits

Author SHA1 Message Date
JRoberts 74b11d1c5d 3.2.1 (#992) 2022-08-31 16:13:40 +04:00
JRoberts a1562bf0e7 android: restore footer counter (#990) 2022-08-31 12:26:41 +04:00
JRoberts 73447ce22b android: version 3.2 (51) 2022-08-31 09:55:21 +04:00
JRoberts 956cf6b203 android: version 3.2 (50) 2022-08-31 09:45:18 +04:00
Stanislav Dmitrenko 378118b82e Options when using .onion hosts (#989)
* Options when using .onion hosts

* Confirmation alert before applying network settings

* Useless new line was removed

* Different ordering of options in enum
2022-08-30 22:24:33 +01:00
Stanislav Dmitrenko 92abdde69e android: start direct chat button inside MemberInfo (#987)
* Start direct chat button inside GroupInfo

* More code for better understanding
2022-08-30 18:37:44 +01:00
Evgeny Poberezkin 69758971af update simplexmq (to fix network-transport at 0.5.4) 2022-08-30 16:17:48 +01:00
JRoberts 5fa2d7f7ce ios: disable incognito toggle when chat is stopped (#983) 2022-08-30 15:33:36 +04:00
Stanislav Dmitrenko 0169589e7c 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>
2022-08-30 15:17:28 +04:00
Stanislav Dmitrenko 2d4348c50d Regex for Emoji (#982) 2022-08-30 08:50:44 +01:00
JRoberts 14f6e5c16f ios: version 3.2 (70) 2022-08-29 20:10:27 +04:00
Evgeny Poberezkin 51a2fa8c28 ios: programmatic navigation between list/chat (#980)
* ios: programmatic navigation between list/chat

* prevent chat info sheet from showing when switching conversation

* add direct chat with member to model

* set status to connected

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-29 17:08:46 +04:00
JRoberts 7343e4a51a ios: simplify incognito feature (#979) 2022-08-29 14:47:29 +04:00
Evgeny Poberezkin b4d7afb4c1 ios: dark/light mode toggle (#975) 2022-08-28 09:14:55 +01:00
JRoberts 2fc6873c42 core: simplify incognito feature - remove host/invitee incognito profiles communication; remove incognito mode group creation and join; use same incognito profile known to host when joining (#978) 2022-08-27 19:56:03 +04:00
JRoberts 7683254de2 ios: group member navigation (#973) 2022-08-26 17:27:38 +04:00
JRoberts 3a077d927d ios: contact aliases (#970)
* ios: contact aliases

* wip

* wip

* wip

* move onTapGesture

* revert test

* improve search

* corrections

* font size

* remove parameter

* clear button

* button style

* remove clear button

* ternary

* refactor search

* rename

* ios: contact aliases translations (#972)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-25 17:36:26 +04:00
Stanislav Dmitrenko a5e74ea2f0 android: choosing theme and accent color (#967)
* Theme selector
- ability to select from three default choices: system theme, light, dark
- ability to choose color accent (primary color)

* Removed unused code and made small changes to colors and buttons
2022-08-24 21:15:26 +01:00
JRoberts 53a71cf28c core: contact aliases (#968) 2022-08-24 19:03:43 +04:00
JRoberts e6551abc68 ios: incognito mode (#945)
* ios: incognito types

* wip

* wip

* wip

* wip

* wip

* cleaner interface

* CIGroupInvitationView logic

* masks not filled

* ui improvements

* wip

* wip

* incognito may be compromised alerts

* help

* remove modifier

* Update apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* contact request

* texts

* ;

* prepare for merge

* restore help

* wip

* update help

* wip

* update incognito help

* the

* Update apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* wording

* translations

* secondary color

* translations

* translations

* fix Your Chats title

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-23 18:18:12 +04:00
Evgeny Poberezkin bd7aa81625 core: DB json encoding for group events (platform independent) (#966)
* core: DB json encoding for group events (platform independent)

* comment, migration

* shorter constructors

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-08-23 16:24:43 +04:00
Stanislav Dmitrenko 04592f52de Ability to disable notifications per chat (#964)
* Ability to disable notifications per chat

* All Buttons in AlertDialog replaced with TextButtons

* update icon

* update strings

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-08-22 21:16:01 +01:00
JRoberts a06499d710 core: add host_conn_custom_user_profile_id to groups to replace join with connections causing duplicates (avoids complex subqueries) (#965) 2022-08-22 23:12:09 +04:00
Stanislav Dmitrenko 6d1414af71 UI tweaks (#963)
* UI tweaks

* correction
2022-08-22 17:36:39 +01:00
Stanislav Dmitrenko 9cd7a7fdb0 Re-apply new chat instance on chatId changes (#962)
* Re-apply new chat instance on chatId changes

* Fixed incorrectly calculated counters on floating buttons

* Show chat at the bottom of a view instead of at the top
2022-08-22 14:02:46 +01:00
Stanislav Dmitrenko 3c2f5d14f5 Made scrolling faster (#961) 2022-08-22 12:14:46 +01:00
JRoberts 985f3dffd3 core: read host's connection custom_user_profile_id into group info (#960) 2022-08-22 11:04:34 +04:00
Evgeny Poberezkin 0a048eb286 ios: fix typo, add information to settings (#958) 2022-08-20 23:03:56 +01:00
Evgeny Poberezkin 2cffe91e0b ios: choose accent color (#956) 2022-08-20 21:55:06 +01:00
99 changed files with 3776 additions and 1160 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 49
versionCode 51
versionName "3.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -94,6 +94,7 @@ dependencies {
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
implementation 'androidx.webkit:webkit:1.4.0'
implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2"
def work_version = "2.7.1"
implementation "androidx.work:work-runtime-ktx:$work_version"
@@ -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)
@@ -66,6 +67,7 @@ class ChatModel(val controller: ChatController) {
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
@@ -290,19 +292,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 +317,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 {
@@ -325,6 +329,7 @@ interface SomeChat {
val apiId: Long
val ready: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val createdAt: Instant
val updatedAt: Instant
}
@@ -375,6 +380,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
@@ -383,11 +390,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contact.apiId
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)
@@ -402,11 +412,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = groupInfo.apiId
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)
@@ -421,11 +434,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactRequest.apiId
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)
@@ -440,11 +456,14 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId
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 =
@@ -454,13 +473,13 @@ sealed class ChatInfo: SomeChat, NamedChat {
}
@Serializable
class Contact(
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,
val chatSettings: ChatSettings,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -469,19 +488,25 @@ class Contact(
override val apiId get() = contactId
override val ready get() = activeConn.connStatus == ConnStatus.Ready
override val sendMsgEnabled get() = true
override val displayName get() = profile.displayName
override val ntfsEnabled get() = chatSettings.enableNtfs
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(),
updatedAt = Clock.System.now()
)
@@ -503,10 +528,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)
}
}
@@ -514,13 +539,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",
@@ -529,6 +557,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,
@@ -536,12 +586,13 @@ class Group (
)
@Serializable
class GroupInfo (
data class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
val membership: GroupMember,
// val chatSettings: ChatSettings,
val hostConnCustomUserProfileId: Long? = null,
val chatSettings: ChatSettings,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -550,9 +601,11 @@ class GroupInfo (
override val apiId get() = groupId
override val ready get() = true
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs
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
@@ -569,6 +622,8 @@ class GroupInfo (
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
membership = GroupMember.sampleData,
hostConnCustomUserProfileId = null,
chatSettings = ChatSettings(true),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -579,7 +634,8 @@ 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(
@@ -599,17 +655,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
@@ -645,6 +702,8 @@ class GroupMember (
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent
}
val memberIncognito = memberProfile.profileId != memberContactProfileId
companion object {
val sampleData = GroupMember(
groupMemberId = 1,
@@ -655,8 +714,9 @@ class GroupMember (
memberStatus = GroupMemberStatus.MemComplete,
invitedBy = InvitedBy.IBUser(),
localDisplayName = "alice",
memberProfile = Profile.sampleData,
memberProfile = LocalProfile.sampleData,
memberContactId = 1,
memberContactProfileId = 1L,
activeConn = Connection.sampleData
)
}
@@ -770,9 +830,11 @@ class UserContactRequest (
override val apiId get() = contactRequestId
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
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(
@@ -791,6 +853,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 {
@@ -799,6 +862,7 @@ class PendingContactConnection(
override val apiId get() = pccConnId
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
override val displayName: String get() {
val initiated = pccConnStatus.initiated
@@ -814,14 +878,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
)
}
@@ -832,6 +903,7 @@ class PendingContactConnection(
pccAgentConnId = "abcd",
pccConnStatus = status,
viaContactUri = viaContactUri,
customUserProfileId = null,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -886,7 +958,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() =
@@ -1179,9 +1251,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(
@@ -64,6 +64,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
@@ -5,7 +5,6 @@ import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.app.Application
import android.content.*
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
@@ -16,12 +15,14 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
@@ -87,6 +88,8 @@ class AppPreferences(val context: Context) {
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name)
val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false)
val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout)
val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout)
val networkSMPPingInterval = mkLongPreference(SHARED_PREFS_NETWORK_SMP_PING_INTERVAL, NetCfg.defaults.smpPingInterval)
@@ -94,6 +97,10 @@ 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())
private fun mkIntPreference(prefName: String, default: Int) =
Preference(
@@ -154,6 +161,8 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode"
private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode"
private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout"
private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout"
private const val SHARED_PREFS_NETWORK_SMP_PING_INTERVAL = "NetworkSMPPingInterval"
@@ -161,6 +170,9 @@ 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"
}
}
@@ -173,6 +185,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) {
@@ -183,6 +196,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()
@@ -291,6 +305,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
@@ -388,9 +408,20 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
suspend fun apiContactInfo(contactId: Long): ConnectionStats? {
suspend fun apiSetSettings(type: ChatType,id: Long, settings: ChatSettings): Boolean {
val r = sendCmd(CC.APISetChatSettings(type, id, settings))
return when (r) {
is CR.CmdOk -> true
else -> {
Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}")
false
}
}
}
suspend fun apiContactInfo(contactId: Long): Pair<ConnectionStats, Profile?>? {
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
}
@@ -501,6 +532,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
@@ -942,7 +980,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
},
confirmButton = {
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
}
@@ -973,7 +1011,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
},
confirmButton = {
Button(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) }
TextButton(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) }
}
)
}
@@ -999,7 +1037,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
},
confirmButton = {
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
}
@@ -1069,6 +1107,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
fun getNetCfg(): NetCfg {
val useSocksProxy = appPrefs.networkUseSocksProxy.get()
val socksProxy = if (useSocksProxy) ":9050" else null
val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!)
val requiredHostMode = appPrefs.networkRequiredHostMode.get()
val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get()
val tcpTimeout = appPrefs.networkTCPTimeout.get()
val smpPingInterval = appPrefs.networkSMPPingInterval.get()
@@ -1083,6 +1123,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
return NetCfg(
socksProxy = socksProxy,
hostMode = hostMode,
requiredHostMode = requiredHostMode,
tcpConnectTimeout = tcpConnectTimeout,
tcpTimeout = tcpTimeout,
tcpKeepAlive = tcpKeepAlive,
@@ -1092,6 +1134,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
fun setNetCfg(cfg: NetCfg) {
appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy)
appPrefs.networkHostMode.set(cfg.hostMode.name)
appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode)
appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout)
appPrefs.networkTCPTimeout.set(cfg.tcpTimeout)
appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval)
@@ -1116,6 +1160,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()
@@ -1146,6 +1191,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()
@@ -1168,6 +1214,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"
@@ -1197,6 +1244,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"
@@ -1220,6 +1268,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"
@@ -1249,6 +1298,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"
@@ -1330,6 +1380,26 @@ data class NetCfg(
smpPingInterval = 600_000_000
)
}
val onionHosts: OnionHosts get() = when {
hostMode == HostMode.Public && requiredHostMode -> OnionHosts.NEVER
hostMode == HostMode.OnionViaSocks && !requiredHostMode -> OnionHosts.PREFER
hostMode == HostMode.OnionViaSocks && requiredHostMode -> OnionHosts.REQUIRED
else -> OnionHosts.PREFER
}
fun withOnionHosts(mode: OnionHosts): NetCfg = when (mode) {
OnionHosts.NEVER ->
this.copy(hostMode = HostMode.Public, requiredHostMode = true)
OnionHosts.PREFER ->
this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = false)
OnionHosts.REQUIRED ->
this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = true)
}
}
enum class OnionHosts {
NEVER, PREFER, REQUIRED
}
@Serializable
@@ -1395,7 +1465,7 @@ sealed class CR {
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): 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()
@@ -1405,6 +1475,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<FormattedText>? = null): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
@@ -1491,6 +1562,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"
@@ -1575,6 +1647,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
@@ -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)
@@ -2,10 +2,15 @@ package chat.simplex.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.MutableStateFlow
private val DarkColorPalette = darkColors(
enum class DefaultTheme {
SYSTEM, DARK, LIGHT
}
val DarkColorPalette = darkColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexGreen,
secondary = DarkGray,
@@ -18,7 +23,7 @@ private val DarkColorPalette = darkColors(
onSurface = Color(0xFFFFFBFA),
// onError: Color = Color.Black,
)
private val LightColorPalette = lightColors(
val LightColorPalette = lightColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexGreen,
secondary = LightGray,
@@ -30,16 +35,28 @@ private val LightColorPalette = lightColors(
// onSurface = Color.Black,
)
@Composable
fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(true))
@Composable
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
LaunchedEffect(darkTheme) {
// For preview
if (darkTheme != null)
CurrentColors.value = ThemeManager.currentColors(darkTheme)
}
val systemDark = isSystemInDarkTheme()
LaunchedEffect(systemDark) {
if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
}
}
val theme by CurrentColors.collectAsState()
MaterialTheme(
colors = colors,
colors = theme.first,
typography = Typography,
shapes = Shapes,
content = content
@@ -0,0 +1,64 @@
package chat.simplex.app.ui.theme
import androidx.compose.material.Colors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.views.helpers.generalGetString
object ThemeManager {
private val appPrefs: AppPreferences by lazy {
AppPreferences(SimplexApp.context)
}
fun currentColors(darkForSystemTheme: Boolean): Pair<Colors, DefaultTheme> {
val theme = appPrefs.currentTheme.get()!!
val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
val res = when (theme) {
DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
}
return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
}
// colors, default theme enum, localized name of theme
fun allThemes(darkForSystemTheme: Boolean): List<Triple<Colors, DefaultTheme, String>> {
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
allThemes.add(
Triple(
if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
DefaultTheme.SYSTEM,
generalGetString(R.string.theme_system)
)
)
allThemes.add(
Triple(
LightColorPalette,
DefaultTheme.LIGHT,
generalGetString(R.string.theme_light)
)
)
allThemes.add(
Triple(
DarkColorPalette,
DefaultTheme.DARK,
generalGetString(R.string.theme_dark)
)
)
return allThemes
}
fun applyTheme(name: String, darkForSystemTheme: Boolean) {
appPrefs.currentTheme.set(name)
CurrentColors.value = currentColors(darkForSystemTheme)
}
fun saveAndApplyPrimaryColor(color: Color) {
appPrefs.primaryColor.set(color.toArgb())
CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
}
}
@@ -45,7 +45,7 @@ fun IncomingCallAlertLayout(
ignoreCall: () -> Unit,
acceptCall: () -> Unit
) {
val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
IncomingCallInfo(invitation)
Spacer(Modifier.height(8.dp))
@@ -1,47 +1,102 @@
package chat.simplex.app.views.chat
import InfoRow
import InfoRowEllipsis
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
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.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
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) }
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
changeNtfsState = { enabled ->
changeNtfsState(enabled, chat, chatModel)
},
)
}
}
fun changeNtfsState(enabled: Boolean, chat: Chat, chatModel: ChatModel) {
val newChatInfo = when(chat.chatInfo) {
is ChatInfo.Direct -> with (chat.chatInfo) {
ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
}
is ChatInfo.Group -> with(chat.chatInfo) {
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
}
else -> null
}
withApi {
val res = when (newChatInfo) {
is ChatInfo.Direct -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
}
is ChatInfo.Group -> with(newChatInfo) {
chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
}
else -> false
}
if (res && newChatInfo != null) {
chatModel.updateChatInfo(newChatInfo)
if (!enabled) {
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
}
}
}
}
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_contact_question),
@@ -82,10 +137,15 @@ 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
clearChat: () -> Unit,
changeNtfsState: (Boolean) -> Unit,
) {
Column(
Modifier
@@ -97,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) {
@@ -120,6 +190,17 @@ fun ChatInfoLayout(
SectionSpacer()
}
var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
SectionView(title = stringResource(R.string.settings_section_title_settings)) {
SectionItemView {
NtfsSwitch(ntfsEnabled) {
ntfsEnabled = !ntfsEnabled
changeNtfsState(ntfsEnabled)
}
}
}
SectionSpacer()
SectionView {
SectionItemView {
ClearChatButton(clearChat)
@@ -143,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 (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
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,
@@ -166,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(
@@ -223,7 +330,43 @@ fun ServerImage(networkStatus: Chat.NetworkStatus) {
@Composable
fun SimplexServers(text: String, servers: List<String>) {
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
InfoRow(text, info)
val clipboardManager: ClipboardManager = LocalClipboardManager.current
InfoRowEllipsis(text, info) {
clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show()
}
}
@Composable
fun NtfsSwitch(
ntfsEnabled: Boolean,
toggleNtfs: (Boolean) -> Unit
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Outlined.Notifications,
stringResource(R.string.notifications),
tint = HighOrLowlight
)
Text(stringResource(R.string.notifications))
}
Switch(
checked = ntfsEnabled,
onCheckedChange = toggleNtfs,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
}
@Composable
@@ -262,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() {
@@ -272,8 +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 = {}
)
}
@@ -32,8 +32,7 @@ import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.group.AddGroupMembersView
import chat.simplex.app.views.chat.group.GroupChatInfoView
import chat.simplex.app.views.chat.group.*
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chatlist.*
@@ -56,6 +55,22 @@ fun ChatView(chatModel: ChatModel) {
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
activeChat = if (chatModel.chatId.value == null) {
null
} else {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
chatModel.getChat(chatModel.chatId.value!!)
}
}
}
if (activeChat == null || user == null) {
chatModel.chatId.value = null
} else {
@@ -89,18 +104,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 (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
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) {
@@ -108,7 +126,7 @@ fun ChatView(chatModel: ChatModel) {
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupChatInfoView(chatModel, close)
}
@@ -116,15 +134,16 @@ fun ChatView(chatModel: ChatModel) {
}
}
},
openDirectChat = { contactId ->
val c = chatModel.chats.firstOrNull {
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
}
if (c != null) {
withApi {
openChat(c.chatInfo, chatModel)
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
activeChat = c
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
withApi {
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, stats, chatModel, close, close)
}
}
}
},
@@ -177,7 +196,7 @@ fun ChatView(chatModel: ChatModel) {
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
@@ -220,9 +239,10 @@ fun ChatLayout(
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
chatModelIncognito: Boolean,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
@@ -252,24 +272,22 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
val setFloatingButton = { button: @Composable () -> Unit ->
floatingButton.value = button
}
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = floatingButton.value,
floatingActionButton = { floatingButton.value() },
) { contentPadding ->
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
) {
BoxWithConstraints(Modifier.padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, floatingButton
)
}
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
)
}
}
}
@@ -321,7 +339,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
@@ -365,6 +383,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),
@@ -374,7 +395,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
@@ -405,14 +426,15 @@ fun BoxWithConstraintsScope.ChatItemsList(
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
openDirectChat: (Long) -> Unit,
chatModelIncognito: Boolean,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
floatingButton: MutableState<@Composable () -> Unit>
setFloatingButton: (@Composable () -> Unit) -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
@@ -449,86 +471,90 @@ fun BoxWithConstraintsScope.ChatItemsList(
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
val directions = setOf(DismissDirection.EndToStart)
val swipeableModifier = SwipeToDismissModifier(
state = dismissState,
directions = directions,
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
)
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
LaunchedEffect(Unit) {
scope.launch {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
}
}
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
MemberImage(member)
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
) {
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
val directions = setOf(DismissDirection.EndToStart)
val swipeableModifier = SwipeToDismissModifier(
state = dismissState,
directions = directions,
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
)
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
LaunchedEffect(Unit) {
scope.launch {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
Box(
Modifier
.clip(CircleShape)
.clickable {
openDirectChat(contactId)
// Scroll to first unread message when direct chat will be loaded
shouldAutoScroll = true
}
) {
MemberImage(member)
}
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
Spacer(Modifier.size(4.dp))
} 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)
}
} 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)
}
}
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier.padding(
start = if (sent) 76.dp else 12.dp,
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)
}
}
if (cItem.isRcvNew) {
LaunchedEffect(cItem.id) {
scope.launch {
delay(750)
markRead(CC.ItemRange(cItem.id, cItem.id), null)
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
MemberImage(member)
} else {
Box(
Modifier
.clip(CircleShape)
.clickable {
showMemberInfo(chat.chatInfo.groupInfo, member)
}
) {
MemberImage(member)
}
}
Spacer(Modifier.size(4.dp))
} else {
Spacer(Modifier.size(42.dp))
}
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, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
}
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier.padding(
start = if (sent) 76.dp else 12.dp,
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
}
}
if (cItem.isRcvNew) {
LaunchedEffect(cItem.id) {
scope.launch {
delay(750)
markRead(CC.ItemRange(cItem.id, cItem.id), null)
}
}
}
}
}
}
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, floatingButton, listState)
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
}
@Composable
@@ -538,7 +564,7 @@ fun BoxWithConstraintsScope.FloatingButtons(
minUnreadItemId: Long,
searchValue: State<String>,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
floatingButton: MutableState<@Composable () -> Unit>,
setFloatingButton: (@Composable () -> Unit) -> Unit,
listState: LazyListState
) {
val scope = rememberCoroutineScope()
@@ -554,6 +580,11 @@ fun BoxWithConstraintsScope.FloatingButtons(
firstVisibleIndex = it
firstItemIsVisible = firstVisibleIndex == 0
}
}
LaunchedEffect(listState) {
// When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
// so separate them into two LaunchedEffects
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.collect {
@@ -577,17 +608,18 @@ fun BoxWithConstraintsScope.FloatingButtons(
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
floatingButton.value = bottomEndFloatingButton(
bottomUnreadCount,
showButtonWithCounter,
showButtonWithArrow,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
}
)
setFloatingButton(
bottomEndFloatingButton(
bottomUnreadCount,
showButtonWithCounter,
showButtonWithArrow,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
}
))
}
// Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return
@@ -735,7 +767,7 @@ private fun bottomEndFloatingButton(
}
}
private fun ViewConfiguration.bigTouchSlop(slop: Float = 80f) = object: ViewConfiguration {
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
override val longPressTimeoutMillis
get() =
this@bigTouchSlop.longPressTimeoutMillis
@@ -793,9 +825,10 @@ fun PreviewChatLayout() {
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
chatModelIncognito = false,
back = {},
info = {},
openDirectChat = {},
showMemberInfo = {_, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
@@ -849,9 +882,10 @@ fun PreviewGroupChatLayout() {
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
chatModelIncognito = false,
back = {},
info = {},
openDirectChat = {},
showMemberInfo = {_, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
@@ -1,5 +1,4 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -31,7 +30,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (isSystemInDarkTheme()) FileDark else FileLight
tint = if (isInDarkTheme()) FileDark else FileLight
)
Text(fileName)
Spacer(Modifier.weight(1f))
@@ -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
@@ -93,7 +96,7 @@ fun AddGroupMembersLayout(
ChatInfoToolbarTitle(
ChatInfo.Group(groupInfo),
imageSize = 60.dp,
iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight
iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight
)
}
SectionSpacer()
@@ -125,7 +128,7 @@ fun AddGroupMembersLayout(
SectionSpacer()
SectionView {
ContactList(contacts = contactsToAdd, selectedContacts, addContact, removeContact)
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
}
SectionSpacer()
}
@@ -255,6 +258,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit)
fun ContactList(
contacts: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
groupInfo: GroupInfo,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit
) {
@@ -262,7 +266,7 @@ fun ContactList(
contacts.forEachIndexed { index, contact ->
SectionItemView {
ContactCheckRow(
contact, addContact, removeContact,
contact, groupInfo, addContact, removeContact,
checked = selectedContacts.contains(contact.apiId)
)
}
@@ -276,14 +280,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 +317,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() {
@@ -11,11 +11,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import 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.*
@@ -47,7 +49,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, close)
}
@@ -56,13 +58,13 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
},
showMemberInfo = { member ->
withApi {
val connStats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showCustomModal { close ->
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
ModalManager.shared.showCustomModal { closeCurrent ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
close = closeCurrent, modifier = Modifier,
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
GroupMemberInfoView(groupInfo, member, connStats, chatModel, close)
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
}
}
}
@@ -72,7 +74,10 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
changeNtfsState = { enabled ->
changeNtfsState(enabled, chat, chatModel)
},
)
}
}
@@ -122,6 +127,7 @@ fun GroupChatInfoLayout(
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
changeNtfsState: (Boolean) -> Unit,
) {
Column(
Modifier
@@ -133,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()
}
@@ -154,6 +162,17 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
SectionView(title = stringResource(R.string.settings_section_title_settings)) {
SectionItemView {
NtfsSwitch(ntfsEnabled) {
ntfsEnabled = !ntfsEnabled
changeNtfsState(ntfsEnabled)
}
}
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView {
@@ -191,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()
@@ -201,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)
}
}
@@ -236,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(
@@ -322,7 +366,8 @@ fun PreviewGroupChatInfoLayout() {
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {},
changeNtfsState = {},
)
}
}
@@ -24,10 +24,18 @@ import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.SimplexServers
import chat.simplex.app.views.chatlist.openChat
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,
closeAll: () -> Unit, // Close all open windows up to ChatView
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
@@ -37,6 +45,22 @@ fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: Co
member,
connStats,
developerTools,
openDirectChat = {
withApi {
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
if (oldChat != null) {
openChat(oldChat.chatInfo, chatModel)
} else {
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
chatModel.addChat(newChat)
chatModel.chatItems.clear()
chatModel.chatId.value = newChat.id
}
closeAll()
}
},
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }
)
}
@@ -65,6 +89,7 @@ fun GroupMemberInfoLayout(
member: GroupMember,
connStats: ConnectionStats?,
developerTools: Boolean,
openDirectChat: () -> Unit,
removeMember: () -> Unit,
) {
Column(
@@ -81,6 +106,13 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
SectionView {
SectionItemView {
OpenChatButton(openDirectChat)
}
}
SectionSpacer()
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
val conn = member.activeConn
@@ -139,7 +171,7 @@ fun GroupMemberInfoHeader(member: GroupMember) {
Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileImage(size = 192.dp, member.image, color = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight)
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
Text(
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
@@ -175,6 +207,25 @@ fun RemoveMemberButton(removeMember: () -> Unit) {
}
}
@Composable
fun OpenChatButton(onClick: () -> Unit) {
Row(
Modifier
.fillMaxSize()
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Message,
stringResource(R.string.button_send_direct_message),
Modifier.padding(top = 5.dp),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_send_direct_message), color = MaterialTheme.colors.primary)
}
}
@Preview
@Composable
fun PreviewGroupMemberInfoLayout() {
@@ -184,6 +235,7 @@ fun PreviewGroupMemberInfoLayout() {
member = GroupMember.sampleData,
connStats = null,
developerTools = false,
openDirectChat = {},
removeMember = {}
)
}
@@ -2,7 +2,6 @@ package chat.simplex.app.views.chat.item
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -39,7 +38,7 @@ fun CIFileView(
@Composable
fun fileIcon(
innerIcon: ImageVector? = null,
color: Color = if (isSystemInDarkTheme()) FileDark else FileLight
color: Color = if (isInDarkTheme()) FileDark else FileLight
) {
Box(
contentAlignment = Alignment.Center
@@ -105,7 +104,7 @@ fun CIFileView(
fun progressIndicator() {
CircularProgressIndicator(
Modifier.size(32.dp),
color = if (isSystemInDarkTheme()) FileDark else FileLight,
color = if (isInDarkTheme()) FileDark else FileLight,
strokeWidth = 4.dp
)
}
@@ -3,7 +3,6 @@ package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -19,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.*
@@ -29,6 +27,7 @@ fun CIGroupInvitationView(
ci: ChatItem,
groupInvitation: CIGroupInvitation,
memberRole: GroupMemberRole,
chatIncognito: Boolean = false,
joinGroup: (Long) -> Unit
) {
val sent = ci.chatDir.sent
@@ -38,8 +37,8 @@ fun CIGroupInvitationView(
fun groupInfoView() {
val p = groupInvitation.groupProfile
val iconColor =
if (action) MaterialTheme.colors.primary
else if (isSystemInDarkTheme()) FileDark else FileLight
if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary
else if (isInDarkTheme()) FileDark else FileLight
Row(
Modifier
@@ -47,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),
@@ -72,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,
) {
@@ -100,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()
@@ -1,8 +1,7 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
@@ -16,7 +15,6 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimplexBlue
import kotlinx.datetime.Clock
@Composable
@@ -56,7 +54,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
}
is CIStatus.RcvNew -> {
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue)
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
}
else -> {}
}
@@ -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)
}
@@ -184,13 +185,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = {
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(stringResource(R.string.for_me_only)) }
if (chatItem.meta.editable) {
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = {
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
AlertManager.shared.hideAlert()
}) { Text(stringResource(R.string.for_everybody)) }
@@ -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 = {},
@@ -31,12 +31,19 @@ fun EmojiText(text: String) {
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
}
private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C
// https://stackoverflow.com/a/46279500
private const val emojiStr = "^(" +
"(?:[\\u2700-\\u27bf]|" +
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?" +
"(?:\\u200d(?:[^\\ud800-\\udfff]|" +
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?)*|" +
"[\\u0023-\\u0039]\\ufe0f?\\u20e3|\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|[\\ud83c\\udd70-\\ud83c\\udd71]|[\\ud83c\\udd7e-\\ud83c\\udd7f]|\\ud83c\\udd8e|[\\ud83c\\udd91-\\ud83c\\udd9a]|[\\ud83c\\udde6-\\ud83c\\uddff]|[\\ud83c\\ude01-\\ud83c\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|[\\ud83c\\ude32-\\ud83c\\ude3a]|[\\ud83c\\ude50-\\ud83c\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff]" +
")+$" // Multiple matches with emojis where one follows another without interruptions from other characters
private val emojiRegex = Regex(emojiStr)
fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c)
// TODO count perceived emojis, possibly using icu4j
fun isShortEmoji(str: String): Boolean {
val s = str.trim()
return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji)
return s.codePoints().count() in 1..5 && emojiRegex.matches(str)
}
@@ -87,7 +87,7 @@ fun FramedItemView(
Modifier
.padding(top = 6.dp, end = 4.dp)
.size(22.dp),
tint = if (isSystemInDarkTheme()) FileDark else FileLight
tint = if (isInDarkTheme()) FileDark else FileLight
)
}
else -> ciQuotedMsgView(qi)
@@ -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,
@@ -119,6 +119,7 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
}
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
DeleteContactAction(chat, chatModel, showMenu)
}
@@ -127,7 +128,7 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> {
JoinGroupAction(groupInfo, chatModel, showMenu)
JoinGroupAction(chat, groupInfo, chatModel, showMenu)
if (groupInfo.canDelete) {
DeleteGroupAction(chat, chatModel, showMenu)
}
@@ -136,6 +137,7 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
}
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
if (groupInfo.membership.memberCurrent) {
LeaveGroupAction(groupInfo, chatModel, showMenu)
@@ -160,6 +162,18 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
)
}
@Composable
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
onClick = {
changeNtfsState(!ntfsEnabled, chat, chatModel)
showMenu.value = false
}
)
}
@Composable
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -200,12 +214,14 @@ fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<B
}
@Composable
fun JoinGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
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
}
)
@@ -227,8 +243,9 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
@Composable
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
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
@@ -277,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) }
@@ -318,14 +335,14 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = {
TextButton(onClick = {
AlertManager.shared.hideAlert()
deleteContactConnectionAlert(connection, chatModel)
}) {
Text(stringResource(R.string.delete_verb))
}
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = { AlertManager.shared.hideAlert() }) {
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(R.string.ok))
}
}
@@ -374,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)
@@ -459,6 +484,8 @@ fun PreviewChatListNavLinkDirect() {
),
chatStats = Chat.ChatStats()
),
false,
null,
stopped = false
)
},
@@ -494,6 +521,8 @@ fun PreviewChatListNavLinkGroup() {
),
chatStats = Chat.ChatStats()
),
false,
null,
stopped = false
)
},
@@ -516,7 +545,7 @@ fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chatLinkPreview = {
ContactRequestView(ChatInfo.ContactRequest.sampleData)
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
},
click = {},
dropdownMenuItems = null,
@@ -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,
@@ -2,13 +2,13 @@ package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -23,11 +23,10 @@ import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
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
@@ -71,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()
}
@@ -80,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(
@@ -88,7 +87,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
metaText = ci.timestampText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
)
} else {
when (cInfo) {
@@ -98,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 -> {}
}
@@ -120,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)
@@ -134,6 +133,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
modifier = Modifier.padding(bottom = 5.dp)
)
val n = chat.chatStats.unreadCount
val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
if (n > 0) {
Box(
Modifier.padding(top = 24.dp),
@@ -144,12 +144,27 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
.background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
.background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
}
} else if (showNtfsIcon) {
Box(
Modifier.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.NotificationsOff,
contentDescription = generalGetString(R.string.notifications),
tint = HighOrLowlight,
modifier = Modifier
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.size(17.dp)
)
}
}
if (cInfo is ChatInfo.Direct) {
Box(
@@ -163,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)
@@ -200,6 +225,6 @@ fun ChatStatusImage(chat: Chat) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, stopped = false)
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
}
}
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -37,7 +36,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
fontWeight = FontWeight.Bold,
color = HighOrLowlight
)
Text(contactConnection.description, maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
Text(contactConnection.description, maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
}
val ts = getTimestampText(contactConnection.updatedAt)
Column(
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -11,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(
@@ -31,9 +29,9 @@ 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 (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
}
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
Column(
@@ -2,8 +2,10 @@ package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
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
@@ -44,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 {
@@ -53,13 +56,13 @@ class AlertManager {
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
},
dismissButton = {
Button(onClick = {
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
}) { Text(dismissText) }
@@ -79,7 +82,7 @@ class AlertManager {
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
@@ -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,
@@ -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
)
}
)
}
@@ -13,8 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.ToolbarDark
import chat.simplex.app.ui.theme.ToolbarLight
import chat.simplex.app.ui.theme.*
@Composable
fun DefaultTopAppBar(
@@ -39,7 +38,7 @@ fun DefaultTopAppBar(
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
}
},
backgroundColor = if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight,
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
navigationIcon = navigationButton,
buttons = if (!showSearch) buttons else emptyList(),
centered = !showSearch
@@ -1,13 +1,13 @@
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.GroupDark
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.*
@Composable
fun SectionView(title: String? = null, content: (@Composable () -> Unit)) {
@@ -18,7 +18,7 @@ fun SectionView(title: String? = null, content: (@Composable () -> Unit)) {
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
)
}
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() }
}
}
@@ -38,6 +38,27 @@ fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: B
}
}
@Composable
fun SectionItemViewSpaceBetween(
click: (() -> Unit)? = null,
height: Dp = 46.dp,
padding: PaddingValues = PaddingValues(horizontal = 8.dp),
disabled: Boolean = false,
content: (@Composable () -> Unit)
) {
val modifier = Modifier
.padding(padding)
.fillMaxWidth()
.height(height)
Row(
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
@Composable
fun SectionTextFooter(text: String) {
Text(
@@ -49,9 +70,9 @@ fun SectionTextFooter(text: String) {
}
@Composable
fun SectionCustomFooter(content: (@Composable () -> Unit)) {
fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = 16.dp, end = 16.dp, top = 5.dp), content: (@Composable () -> Unit)) {
Row(
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp)
Modifier.padding(padding)
) {
content()
}
@@ -80,3 +101,26 @@ fun InfoRow(title: String, value: String) {
}
}
}
@Composable
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
SectionItemView {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val configuration = LocalConfiguration.current
Text(title)
Text(value,
Modifier
.padding(start = 10.dp)
.widthIn(max = (configuration.screenWidthDp / 2).dp)
.clickable(onClick = onClick),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
}
}
}
@@ -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 = {}
)
@@ -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)
@@ -46,7 +48,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AddGroupMembersView(groupInfo, chatModel, 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 = {}
)
@@ -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<String>,
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 ->
@@ -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 = {},
)
@@ -3,7 +3,6 @@ package chat.simplex.app.views.onboarding
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -77,7 +76,7 @@ fun SimpleXInfoLayout(
@Composable
fun SimpleXLogo() {
Image(
painter = painterResource(if (isSystemInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
contentDescription = stringResource(R.string.image_descr_simplex_logo),
modifier = Modifier
.padding(vertical = 20.dp)
@@ -1,5 +1,8 @@
package chat.simplex.app.views.usersettings
import SectionCustomFooter
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionView
import android.content.ComponentName
import android.content.pm.PackageManager
@@ -10,11 +13,13 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -24,7 +29,10 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import com.godaddy.android.colorpicker.*
enum class AppIcon(val resId: Int) {
DEFAULT(R.mipmap.icon),
@@ -32,7 +40,9 @@ enum class AppIcon(val resId: Int) {
}
@Composable
fun AppearanceView() {
fun AppearanceView(
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
) {
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
fun setAppIcon(newIcon: AppIcon) {
@@ -54,18 +64,33 @@ fun AppearanceView() {
AppearanceLayout(
appIcon,
changeIcon = ::setAppIcon
changeIcon = ::setAppIcon,
showThemeSelector = showCustomModal { _, close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight
) { ThemeSelectorView() }
},
editPrimaryColor = { primary ->
showCustomModal { _, close ->
ModalView(
close = close, modifier = Modifier,
background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight
) { ColorEditor(primary, close) }
}()
},
)
}
@Composable fun AppearanceLayout(
icon: MutableState<AppIcon>,
changeIcon: (AppIcon) -> Unit
changeIcon: (AppIcon) -> Unit,
showThemeSelector: () -> Unit,
editPrimaryColor: (Color) -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.appearance_settings),
@@ -73,10 +98,7 @@ fun AppearanceView() {
style = MaterialTheme.typography.h1
)
SectionView(stringResource(R.string.settings_section_title_icon)) {
LazyRow(
Modifier
.padding(horizontal = 8.dp)
) {
LazyRow {
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
val item = AppIcon.values()[index]
val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!!
@@ -97,9 +119,82 @@ fun AppearanceView() {
}
}
}
SectionSpacer()
val currentTheme by CurrentColors.collectAsState()
SectionView(stringResource(R.string.settings_section_title_themes)) {
Column(
Modifier.padding(horizontal = 8.dp)
) {
SectionItemViewSpaceBetween(showThemeSelector, padding = PaddingValues()) {
Text(generalGetString(R.string.theme))
}
Spacer(Modifier.padding(horizontal = 4.dp))
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }, padding = PaddingValues()) {
val title = generalGetString(R.string.color_primary)
Text(title)
Icon(Icons.Filled.Circle, title, tint = colors.primary)
}
}
}
if (currentTheme.first.primary != LightColorPalette.primary) {
SectionCustomFooter(PaddingValues(start = 7.dp, end = 7.dp, top = 5.dp)) {
TextButton(
onClick = {
ThemeManager.saveAndApplyPrimaryColor(LightColorPalette.primary)
},
) {
Text(generalGetString(R.string.reset_color))
}
}
}
}
}
@Composable
fun ColorEditor(
initialColor: Color,
close: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
) {
var currentColor by remember { mutableStateOf(initialColor) }
ColorPicker(initialColor) {
currentColor = it
}
SectionSpacer()
TextButton(
onClick = {
ThemeManager.saveAndApplyPrimaryColor(currentColor)
close()
},
Modifier.align(Alignment.CenterHorizontally),
colors = ButtonDefaults.textButtonColors(contentColor = currentColor)
) {
Text(generalGetString(R.string.save_color))
}
}
}
@Composable
fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) {
ClassicColorPicker(
color = initialColor,
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
showAlphaBar = false,
onColorChanged = { color: HsvColor ->
onColorChanged(color.toColor())
}
)
}
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
SimplexApp.context.packageManager.getComponentEnabledSetting(
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
@@ -112,7 +207,9 @@ fun PreviewAppearanceSettings() {
SimpleXTheme {
AppearanceLayout(
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
changeIcon = {}
changeIcon = {},
showThemeSelector = {},
editPrimaryColor = {},
)
}
}
@@ -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<Boolean>,
preferenceState: MutableState<Boolean>? = 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 <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -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))
}
}
}
}
@@ -10,12 +10,14 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NetCfg
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@@ -25,16 +27,19 @@ fun NetworkAndServersView(
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val netCfg: MutableState<NetCfg> = remember { mutableStateOf(chatModel.controller.getNetCfg()) }
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.value.useSocksProxy) }
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
val netCfg = remember { chatModel.controller.getNetCfg() }
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
NetworkAndServersLayout(
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = onionHosts,
showModal = showModal,
showSettingsModal = showSettingsModal,
toggleSocksProxy = { enable ->
toggleSocksProxy = { enable ->
if (enable) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.network_enable_socks),
@@ -45,6 +50,7 @@ fun NetworkAndServersView(
chatModel.controller.apiSetNetworkConfig(NetCfg.proxyDefaults)
chatModel.controller.setNetCfg(NetCfg.proxyDefaults)
networkUseSocksProxy.value = true
onionHosts.value = NetCfg.proxyDefaults.onionHosts
}
}
)
@@ -58,10 +64,29 @@ fun NetworkAndServersView(
chatModel.controller.apiSetNetworkConfig(NetCfg.defaults)
chatModel.controller.setNetCfg(NetCfg.defaults)
networkUseSocksProxy.value = false
onionHosts.value = NetCfg.defaults.onionHosts
}
}
)
}
},
useOnion = {
val prevValue = onionHosts.value
onionHosts.value = it
updateNetworkSettingsDialog(onDismiss = {
onionHosts.value = prevValue
}) {
withApi {
val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it)
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
if (res) {
chatModel.controller.setNetCfg(newCfg)
onionHosts.value = it
} else {
onionHosts.value = prevValue
}
}
}
}
)
}
@@ -69,9 +94,11 @@ fun NetworkAndServersView(
@Composable fun NetworkAndServersLayout(
developerTools: Boolean,
networkUseSocksProxy: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
toggleSocksProxy: (Boolean) -> Unit
toggleSocksProxy: (Boolean) -> Unit,
useOnion: (OnionHosts) -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
@@ -89,6 +116,10 @@ fun NetworkAndServersView(
SectionItemView {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
}
SectionDivider()
SectionItemView {
UseOnionHosts(onionHosts, networkUseSocksProxy, useOnion)
}
if (developerTools) {
SectionDivider()
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
@@ -129,6 +160,116 @@ fun UseSocksProxySwitch(
}
}
@Composable
private fun UseOnionHosts(onionHosts: MutableState<OnionHosts>, enabled: State<Boolean>, useOnion: (OnionHosts) -> Unit) {
val values = remember {
OnionHosts.values().map {
when (it) {
OnionHosts.NEVER -> OnionHosts.NEVER to generalGetString(R.string.network_use_onion_hosts_no)
OnionHosts.PREFER -> OnionHosts.PREFER to generalGetString(R.string.network_use_onion_hosts_prefer)
OnionHosts.REQUIRED -> OnionHosts.REQUIRED to generalGetString(R.string.network_use_onion_hosts_required)
}
}
}
ExposedDropDownSettingRow(
generalGetString(R.string.network_use_onion_hosts),
values,
onionHosts,
icon = Icons.Outlined.Security,
enabled = enabled,
onSelected = useOnion
)
}
@Composable
fun <T> ExposedDropDownSettingRow(
title: String,
values: List<Pair<T, String>>,
selection: State<T>,
label: String? = null,
icon: ImageVector? = null,
iconTint: Color = HighOrLowlight,
enabled: State<Boolean> = mutableStateOf(true),
onSelected: (T) -> Unit
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
var expanded by remember { mutableStateOf(false) }
if (icon != null) {
Icon(
icon,
"",
Modifier.padding(end = 8.dp),
tint = iconTint
)
}
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Spacer(Modifier.fillMaxWidth().weight(1f))
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded && enabled.value
}
) {
Row(
Modifier.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
)
Spacer(Modifier.size(12.dp))
Icon(
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
generalGetString(R.string.icon_descr_more_button),
tint = HighOrLowlight
)
}
ExposedDropdownMenu(
modifier = Modifier.widthIn(min = 200.dp),
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
values.forEach { selectionOption ->
DropdownMenuItem(
onClick = {
onSelected(selectionOption.first)
expanded = false
}
) {
Text(
selectionOption.second + (if (label != null) " $label" else ""),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
private fun updateNetworkSettingsDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.update_network_settings_question),
text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
confirmText = generalGetString(R.string.update_network_settings_confirmation),
onDismiss = onDismiss,
onConfirm = onConfirm,
)
}
@Preview(showBackground = true)
@Composable
fun PreviewNetworkAndServersLayout() {
@@ -138,7 +279,9 @@ fun PreviewNetworkAndServersLayout() {
networkUseSocksProxy = remember { mutableStateOf(true) },
showModal = { {} },
showSettingsModal = { {} },
toggleSocksProxy = {}
toggleSocksProxy = {},
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
useOnion = {},
)
}
}
@@ -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,
@@ -58,24 +62,12 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
ModalView(close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
modalView(chatModel)
}
} } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
showAppearance = {
withApi {
ModalManager.shared.showCustomModal { close ->
ModalView(
close = close, modifier = Modifier,
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
) {
AppearanceView()
}
}
}
}
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
)
}
@@ -88,7 +80,7 @@ val simplexTeamUri =
//fun showSectionedModal(chatModel: ChatModel, modalView: (@Composable (ChatModel) -> Unit)) {
// ModalManager.shared.showCustomModal { close ->
// ModalView(close = close, modifier = Modifier,
// background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
// background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
// modalView(chatModel)
// }
// }
@@ -96,8 +88,10 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: Profile,
profile: LocalProfile,
stopped: Boolean,
incognito: MutableState<Boolean>,
incognitoPref: Preference<Boolean>,
runServiceInBackground: MutableState<Boolean>,
developerTools: Preference<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
@@ -106,7 +100,6 @@ fun SettingsLayout(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
showAppearance: () -> Unit
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
@@ -114,7 +107,7 @@ fun SettingsLayout(
Column(
Modifier
.fillMaxSize()
.background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
.background(if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
.padding(top = 16.dp)
) {
Text(
@@ -129,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)
@@ -142,7 +137,7 @@ fun SettingsLayout(
SectionDivider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showAppearance, disabled = stopped)
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView(showCustomModal) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
}
@@ -176,6 +171,47 @@ fun SettingsLayout(
}
}
@Composable
fun SettingsIncognitoActionItem(
incognitoPref: Preference<Boolean>,
incognito: MutableState<Boolean>,
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(
@@ -288,7 +324,7 @@ fun SettingsLayout(
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
Text(generalGetString(R.string.install_simplex_chat_for_terminal), color = MaterialTheme.colors.primary)
}
}
@@ -335,6 +371,25 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boo
}
}
@Composable
fun SettingsPreferenceItemWithInfo(
icon: ImageVector,
iconTint: Color,
text: String,
stopped: Boolean,
onClickInfo: () -> Unit,
pref: Preference<Boolean>,
prefState: MutableState<Boolean>? = 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,
@@ -345,8 +400,10 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boo
fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
profile = LocalProfile.sampleData,
stopped = false,
incognito = remember { mutableStateOf(false) },
incognitoPref = Preference({ false}, {}),
runServiceInBackground = remember { mutableStateOf(true) },
developerTools = Preference({ false }, {}),
setRunServiceInBackground = {},
@@ -355,7 +412,6 @@ fun PreviewSettingsLayout() {
showSettingsModal = { {} },
showCustomModal = { {} },
showTerminal = {},
showAppearance = {},
// showVideoChatPrototype = {}
)
}
@@ -0,0 +1,69 @@
package chat.simplex.app.views.usersettings
import SectionItemViewSpaceBetween
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
@Composable
fun ThemeSelectorView() {
val darkTheme = isSystemInDarkTheme()
val allThemes by remember { mutableStateOf(ThemeManager.allThemes(darkTheme)) }
ThemeSelectorLayout(
allThemes,
onSelectTheme = {
ThemeManager.applyTheme(it, darkTheme)
},
)
}
@Composable fun ThemeSelectorLayout(
allThemes: List<Triple<Colors, DefaultTheme, String>>,
onSelectTheme: (String) -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.settings_section_title_themes).lowercase().capitalize(Locale.current),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
val currentTheme by CurrentColors.collectAsState()
SectionView(null) {
LazyColumn(
Modifier.padding(horizontal = 8.dp)
) {
items(allThemes.size) { index ->
val item = allThemes[index]
val onClick = {
onSelectTheme(item.second.name)
}
SectionItemViewSpaceBetween(onClick, padding = PaddingValues()) {
Text(item.third)
if (currentTheme.second == item.second) {
Icon(Icons.Outlined.Check, item.third, tint = HighOrLowlight)
}
}
Spacer(Modifier.padding(horizontal = 4.dp))
}
}
}
}
}
@@ -22,8 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview
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.model.Profile
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
@@ -37,7 +36,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> 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
@@ -31,8 +31,11 @@
<string name="display_name_invited_to_connect">приглашение соединиться</string>
<string name="display_name_connecting">соединяется…</string>
<string name="description_you_shared_one_time_link">вы создали одноразовую ссылку</string>
<string name="description_you_shared_one_time_link_incognito">вы создали одноразовую ссылку инкогнито</string>
<string name="description_via_contact_address_link">через ссылку-контакт</string>
<string name="description_via_contact_address_link_incognito">инкогнито через ссылку-контакт</string>
<string name="description_via_one_time_link">через одноразовую ссылку</string>
<string name="description_via_one_time_link_incognito">инкогнито через одноразовую ссылку</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
@@ -114,6 +117,7 @@
<string name="your_chats">Ваши чаты</string>
<string name="contact_connection_pending">соединяется…</string>
<string name="group_preview_you_are_invited">вы приглашены в группу</string>
<string name="group_preview_join_as">вступить как %s</string>
<string name="group_connection_pending">соединяется…</string>
<!-- ComposeView.kt, helpers -->
@@ -141,10 +145,14 @@
<string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Уведомления</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact_question">Удалить контакт?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="button_delete_contact">Удалить контакт</string>
<string name="text_field_set_contact_placeholder">Имя контакта…</string>
<string name="icon_descr_server_status_connected">Соединение с сервером установлено</string>
<string name="icon_descr_server_status_disconnected">Соединение с сервером не установлено</string>
<string name="icon_descr_server_status_error">Ошибка соединения с сервером</string>
@@ -157,9 +165,10 @@
<string name="back">Назад</string>
<string name="cancel_verb">Отменить</string>
<string name="confirm_verb">Подтвердить</string>
<string name="ok">Oк</string>
<string name="ok">OK</string>
<string name="no_details">нет описания</string>
<string name="add_contact">Добавить контакт</string>
<string name="copied">Скопировано в буфер обмена</string>
<!-- NewChatSheet -->
<string name="add_contact_or_create_group">Начать новый разговор</string>
@@ -195,6 +204,7 @@
<string name="accept_connection_request__question">Принять запрос на соединение?</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение.</string>
<string name="accept_contact_button">Принять</string>
<string name="accept_contact_incognito_button">Принять инкогнито</string>
<string name="reject_contact_button">Отклонить</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
@@ -204,6 +214,10 @@
<string name="clear_chat_button">Очистить чат</string>
<string name="mark_read">Прочитано</string>
<!-- Actions - ChatListNavLinkView.kt -->
<string name="mute_chat">Без звука</string>
<string name="unmute_chat">Уведомлять</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
@@ -243,13 +257,15 @@
<string name="connection_request_sent">Запрос на соединение послан!</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Соединение будет установлено когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Соединение будет установлено когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Покажите QR код вашему контакту, чтобы сосканировать его из приложения</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Покажите QR код вашему контакту, чтобы сосканировать его из приложения.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Если вы не можете встретиться лично, вы можете <b>показать QR код во время видеозвонка</b> или отправить ссылку через любой другой канал связи.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Ваш профиль будет отправлен\nвашему контакту</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если вы не можете встретиться лично, вы можете <b>сосканировать QR код во время видеозвонка</b>, или ваш контакт может отправить вам ссылку.</string>
<string name="share_invitation_link">Поделиться ссылкой</string>
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
<string name="your_profile_will_be_sent">Ваш профиль будет отправлен вашему контакту</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Настройки</string>
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
@@ -264,7 +280,7 @@
<string name="chat_lock">Блокировка SimpleX</string>
<string name="chat_console">Консоль</string>
<string name="smp_servers">SMP серверы</string>
<string name="install_simplex_chat_for_terminal"><font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</font></string>
<string name="install_simplex_chat_for_terminal"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</string>
<string name="use_simplex_chat_servers__question">Использовать серверы предосталенные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>?</string>
<string name="saved_SMP_servers_will_br_removed">Сохраненные SMP серверы будут удалены.</string>
<string name="your_SMP_servers">Ваши SMP серверы</string>
@@ -273,7 +289,7 @@
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
<string name="how_to">Инфо</string>
<string name="save_servers_button">Сохранить</string>
<string name="network_and_servers">Сеть &amp; серверы</string>
<string name="network_and_servers">Сеть и серверы</string>
<string name="network_settings">Настройки сети</string>
<string name="network_settings_title">Настройки сети</string>
<string name="network_socks_toggle">Использовать SOCKS прокси (порт 9050)</string>
@@ -281,6 +297,10 @@
<string name="network_enable_socks_info">Соединяться с серверами через SOCKS прокси через порт 9050? Прокси должен быть запущен до включения этой опции.</string>
<string name="network_disable_socks">Использовать прямое соединение с Интернет?</string>
<string name="network_disable_socks_info">Если вы подтвердите, серверы смогут видеть ваш IP адрес, а провайдер - с какими серверами вы соединяетесь.</string>
<string name="network_use_onion_hosts">Использовать .onion хосты</string>
<string name="network_use_onion_hosts_prefer">Когда возможно</string>
<string name="network_use_onion_hosts_no">Нет</string>
<string name="network_use_onion_hosts_required">Обязательно</string>
<string name="appearance_settings">Интерфейс</string>
<!-- Address Items - UserAddressView.kt -->
@@ -460,6 +480,8 @@
<string name="settings_experimental_features">Экспериментальные функции</string>
<string name="settings_section_title_socks">SOCKS ПРОКСИ</string>
<string name="settings_section_title_icon">ИКОНКА</string>
<string name="settings_section_title_themes">ТЕМЫ</string>
<string name="settings_section_title_incognito">Режим Инкогнито</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Данные чата</string>
@@ -510,6 +532,7 @@
<string name="join_group_question">Вступить в группу?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Вы приглашены в группу. Вступите, чтобы соединиться с членами группы.</string>
<string name="join_group_button">Вступить</string>
<string name="join_group_incognito_button">Вступить инкогнито</string>
<string name="joining_group">Вступление в группу</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы.</string>
<string name="leave_group_button">Выйти</string>
@@ -522,11 +545,14 @@
<string name="alert_title_no_group">Группа не найдена!</string>
<string name="alert_message_no_group">Эта группа больше не существует.</string>
<string name="alert_title_join_group_error">Ошибка приглашения</string>
<string name="alert_title_cant_invite_contacts">Нельзя пригласить контакты!</string>
<string name="alert_title_cant_invite_contacts_descr">Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
<string name="group_invitation_tap_to_join">Нажмите, чтобы вступить</string>
<string name="group_invitation_tap_to_join_incognito">Нажмите, чтобы вступить инкогнито</string>
<string name="you_joined_this_group">Вы вступили в эту группу</string>
<string name="you_rejected_group_invitation">Вы отклонили приглашение в группу</string>
<string name="group_invitation_expired">Приглашение в группу истекло</string>
@@ -572,6 +598,8 @@
<string name="clear_contacts_selection_button">Очистить</string>
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
<string name="no_contacts_selected">Контакты не выбраны</string>
<string name="invite_prohibited">Нельзя пригласить контакт!</string>
<string name="invite_prohibited_description">Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Пригласить членов группы</string>
@@ -590,6 +618,7 @@
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Удалить члена группы</string>
<string name="button_send_direct_message">Отправить сообщение</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Член группы будет удален - это действие нельзя отменить!</string>
<string name="remove_member_confirmation">Удалить</string>
<string name="member_info_section_title_member">ЧЛЕН ГРУППЫ</string>
@@ -609,6 +638,8 @@
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только членам.</string>
<string name="group_display_name_field">Имя группы:</string>
<string name="group_full_name_field">Полное имя:</string>
<string name="group_unsupported_incognito_main_profile_sent">Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы</string>
<string name="group_main_profile_sent">Ваш профиль чата будет отправлен членам группы</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах членов, а не на серверах.</string>
@@ -627,4 +658,26 @@
<string name="update_network_settings_question">Обновить настройки сети?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">Обновление настроек приведет к переподключению клиента ко всем серверам.</string>
<string name="update_network_settings_confirmation">Обновить</string>
<!-- Incognito mode -->
<string name="incognito">Инкогнито</string>
<string name="incognito_random_profile">Случайный профиль</string>
<string name="incognito_random_profile_description">Вашему контакту будет отправлен случайный профиль</string>
<string name="incognito_random_profile_from_contact_description">Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль</string>
<string name="incognito_info_protects">Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</string>
<string name="incognito_info_allows">Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</string>
<string name="incognito_info_share">Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</string>
<string name="incognito_info_find">Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.</string>
<!-- Default themes -->
<string name="theme_system">Системная</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Темная</string>
<!-- Appearance.kt -->
<string name="theme">Тема</string>
<string name="save_color">Сохранить цвет</string>
<string name="reset_color">Сбросить цвета</string>
<string name="color_primary">Акцент</string>
</resources>
@@ -31,8 +31,11 @@
<string name="display_name_invited_to_connect">invited to connect</string>
<string name="display_name_connecting">connecting…</string>
<string name="description_you_shared_one_time_link">you shared one-time link</string>
<string name="description_you_shared_one_time_link_incognito">you shared one-time link incognito</string>
<string name="description_via_contact_address_link">via contact address link</string>
<string name="description_via_contact_address_link_incognito">incognito via contact address link</string>
<string name="description_via_one_time_link">via one-time link</string>
<string name="description_via_one_time_link_incognito">incognito via one-time link</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Error saving SMP servers</string>
@@ -114,6 +117,7 @@
<string name="your_chats">Your chats</string>
<string name="contact_connection_pending">connecting…</string>
<string name="group_preview_you_are_invited">you are invited to group</string>
<string name="group_preview_join_as">join as %s</string>
<string name="group_connection_pending">connecting…</string>
<!-- ComposeView.kt, helpers -->
@@ -141,10 +145,14 @@
<string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Notifications</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="button_delete_contact">Delete contact</string>
<string name="text_field_set_contact_placeholder">Set contact name…</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
<string name="icon_descr_server_status_error">Error</string>
@@ -157,9 +165,10 @@
<string name="back">Back</string>
<string name="cancel_verb">Cancel</string>
<string name="confirm_verb">Confirm</string>
<string name="ok">Ok</string>
<string name="ok">OK</string>
<string name="no_details">no details</string>
<string name="add_contact">Add contact</string>
<string name="copied">Copied to clipboard</string>
<!-- NewChatSheet -->
<string name="add_contact_or_create_group">Start new chat</string>
@@ -195,6 +204,7 @@
<string name="accept_connection_request__question">Accept connection request?</string>
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">If you choose to reject sender will NOT be notified.</string>
<string name="accept_contact_button">Accept</string>
<string name="accept_contact_incognito_button">Accept incognito</string>
<string name="reject_contact_button">Reject</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
@@ -204,6 +214,10 @@
<string name="clear_chat_button">Clear chat</string>
<string name="mark_read">Mark read</string>
<!-- Actions - ChatListNavLinkView.kt -->
<string name="mute_chat">Mute</string>
<string name="unmute_chat">Unmute</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">You invited your contact</string>
<string name="you_accepted_connection">You accepted connection</string>
@@ -243,12 +257,13 @@
<string name="connection_request_sent">Connection request sent!</string>
<string name="you_will_be_connected_when_your_connection_request_is_accepted">You will be connected when your connection request is accepted, please wait or check later!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">You will be connected when your contact\'s device is online, please wait or check later!</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Show QR code for your contact\nto scan from the app</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">If you cannot meet in person, you can <b>show QR code in the video call</b>, or you can share the invitation link via any other channel.</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Show QR code for your contact to scan from the app.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">If you can\'t meet in person, you can <b>show QR code in the video call</b>, or you can share the invitation link via any other channel.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Your chat profile will be sent\nto your contact</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">If you cannot meet in person, you can <b>scan QR code in the video call</b>, or your contact can share an invitation link.</string>
<string name="share_invitation_link">Share invitation link</string>
<string name="paste_connection_link_below_to_connect">Paste the link you received into the box below to connect with your contact.</string>
<string name="your_profile_will_be_sent">Your chat profile will be sent to your contact</string>
<!-- PasteToConnect.kt -->
<string name="connect_via_link">Connect via link</string>
@@ -269,7 +284,7 @@
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="smp_servers">SMP servers</string>
<string name="install_simplex_chat_for_terminal">Install <font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</font></string>
<string name="install_simplex_chat_for_terminal">Install <xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</string>
<string name="use_simplex_chat_servers__question">Use <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers?</string>
<string name="saved_SMP_servers_will_br_removed">Saved SMP servers will be removed.</string>
<string name="your_SMP_servers">Your SMP servers</string>
@@ -286,6 +301,10 @@
<string name="network_enable_socks_info">Access the servers via SOCKS proxy on port 9050? Proxy must be started before enabling this option.</string>
<string name="network_disable_socks">Use direct Internet connection?</string>
<string name="network_disable_socks_info">If you confirm, the messaging servers will be able to see your IP address, and your provider - which servers you are connecting to.</string>
<string name="network_use_onion_hosts">Use .onion hosts</string>
<string name="network_use_onion_hosts_prefer">When available</string>
<string name="network_use_onion_hosts_no">No</string>
<string name="network_use_onion_hosts_required">Required</string>
<string name="appearance_settings">Appearance</string>
<!-- Address Items - UserAddressView.kt -->
@@ -462,6 +481,8 @@
<string name="settings_experimental_features">Experimental features</string>
<string name="settings_section_title_socks">SOCKS PROXY</string>
<string name="settings_section_title_icon">APP ICON</string>
<string name="settings_section_title_themes">THEMES</string>
<string name="settings_section_title_incognito">Incognito mode</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Your chat database</string>
@@ -512,6 +533,7 @@
<string name="join_group_question">Join group?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">You are invited to group. Join to connect with group members.</string>
<string name="join_group_button">Join</string>
<string name="join_group_incognito_button">Join incognito</string>
<string name="joining_group">Joining group</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">You joined this group. Connecting to inviting group member.</string>
<string name="leave_group_button">Leave</string>
@@ -524,11 +546,14 @@
<string name="alert_title_no_group">Group not found!</string>
<string name="alert_message_no_group">This group no longer exists.</string>
<string name="alert_title_join_group_error">Error joining group</string>
<string name="alert_title_cant_invite_contacts">Can\'t invite contacts!</string>
<string name="alert_title_cant_invite_contacts_descr">You\'re using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">You sent group invitation</string>
<string name="you_are_invited_to_group">You are invited to group</string>
<string name="group_invitation_tap_to_join">Tap to join</string>
<string name="group_invitation_tap_to_join_incognito">Tap to join incognito</string>
<string name="you_joined_this_group">You joined this group</string>
<string name="you_rejected_group_invitation">You rejected group invitation</string>
<string name="group_invitation_expired">Group invitation expired</string>
@@ -574,6 +599,8 @@
<string name="clear_contacts_selection_button">Clear</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
<string name="no_contacts_selected">No contacts selected</string>
<string name="invite_prohibited">Can\'t invite contact!</string>
<string name="invite_prohibited_description">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</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Invite members</string>
@@ -592,6 +619,7 @@
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Remove member</string>
<string name="button_send_direct_message">Send direct message</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="remove_member_confirmation">Remove</string>
<string name="member_info_section_title_member">MEMBER</string>
@@ -611,6 +639,9 @@
<string name="group_is_decentralized">The group is fully decentralized it is visible only to the members.</string>
<string name="group_display_name_field">Group display name:</string>
<string name="group_full_name_field">Group full name:</string>
<string name="group_unsupported_incognito_main_profile_sent">Incognito mode is not supported here - your main profile will be sent to group members</string>
<string name="group_main_profile_sent">Your chat profile will be sent to group members</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Group profile is stored on members\' devices, not on the servers.</string>
@@ -629,4 +660,26 @@
<string name="update_network_settings_question">Update network settings?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">Updating settings will re-connect the client to all servers.</string>
<string name="update_network_settings_confirmation">Update</string>
<!-- Incognito mode -->
<string name="incognito">Incognito</string>
<string name="incognito_random_profile">Your random profile</string>
<string name="incognito_random_profile_description">A random profile will be sent to your contact</string>
<string name="incognito_random_profile_from_contact_description">A random profile will be sent to the contact that you received this link from</string>
<string name="incognito_info_protects">Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.</string>
<string name="incognito_info_allows">It allows having many anonymous connections without any shared data between them in a single chat profile.</string>
<string name="incognito_info_share">When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</string>
<string name="incognito_info_find">To find the profile used for an incognito connection, tap the contact or group name on top of the chat.</string>
<!-- Default themes -->
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<!-- Appearance.kt -->
<string name="theme">Theme</string>
<string name="save_color">Save color</string>
<string name="reset_color">Reset colors</string>
<string name="color_primary">Accent</string>
</resources>
+21
View File
@@ -80,6 +80,16 @@ class AppDelegate: NSObject, UIApplicationDelegate {
terminateChat()
}
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
let complete = BGManager.shared.completionHandler {
logger.debug("AppDelegate: completed BGManager.receiveMessages")
@@ -89,3 +99,14 @@ class AppDelegate: NSObject, UIApplicationDelegate {
BGManager.shared.receiveMessages(complete)
}
}
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = windowScene.keyWindow
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
}
}
+12 -1
View File
@@ -36,6 +36,7 @@ final class ChatModel: ObservableObject {
@Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off
@Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get()
@Published var incognito: Bool = incognitoGroupDefault.get()
// pending notification actions
@Published var ntfContactRequest: ChatId?
@Published var ntfCallInvitationAction: (ChatId, NtfCallAction)?
@@ -58,6 +59,16 @@ final class ChatModel: ObservableObject {
chats.first(where: { $0.id == id })
}
func getContactChat(_ contactId: Int64) -> Chat? {
chats.first { chat in
if case let .direct(contact) = chat.chatInfo {
return contact.contactId == contactId
} else {
return false
}
}
}
private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id })
}
@@ -79,7 +90,7 @@ final class ChatModel: ObservableObject {
}
func updateContact(_ contact: Contact) {
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact())
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact)
}
func updateGroup(_ groupInfo: GroupInfo) {
+19 -5
View File
@@ -167,6 +167,12 @@ func apiSetFilesFolder(filesFolder: String) throws {
throw r
}
func apiSetIncognito(incognito: Bool) throws {
let r = chatSendCmdSync(.setIncognito(incognito: incognito))
if case .cmdOk = r { return }
throw r
}
func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config))
}
@@ -200,8 +206,9 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
func loadChat(chat: Chat, search: String = "") {
do {
let cInfo = chat.chatInfo
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
let m = ChatModel.shared
m.reversedChatItems = []
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
m.updateChatInfo(chat.chatInfo)
m.reversedChatItems = chat.chatItems.reversed()
} catch let error {
@@ -312,15 +319,15 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
func apiContactInfo(contactId: Int64) async throws -> ConnectionStats? {
func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, connStats) = r { return connStats }
if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
throw r
}
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> ConnectionStats? {
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) {
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, connStats_) = r { return connStats_ }
if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) }
throw r
}
@@ -429,6 +436,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
}
}
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
if case let .contactAliasUpdated(toContact) = r { return toContact }
throw r
}
func apiCreateUserAddress() async throws -> String {
let r = await chatSendCmd(.createMyAddress)
if case let .userContactLinkCreated(connReq) = r { return connReq }
@@ -646,6 +659,7 @@ func initializeChat(start: Bool) throws {
do {
let m = ChatModel.shared
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try apiSetIncognito(incognito: incognitoGroupDefault.get())
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
m.onboardingStage = .step1_SimpleXInfo
@@ -19,6 +19,10 @@ struct ChatInfoToolbar: View {
var body: some View {
let cInfo = chat.chatInfo
return HStack {
if (cInfo.incognito) {
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
Spacer().frame(width: 16)
}
ChatInfoImage(
chat: chat,
color: colorScheme == .dark
+53 -3
View File
@@ -46,7 +46,11 @@ struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
var contact: Contact
var connectionStats: ConnectionStats?
var customUserProfile: Profile?
@State var localAlias: String
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -63,6 +67,20 @@ struct ChatInfoView: View {
List {
contactInfoHeader()
.listRowBackground(Color.clear)
.contentShape(Rectangle())
.onTapGesture {
aliasTextFieldFocused = false
}
localAliasTextEdit()
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if let customUserProfile = customUserProfile {
Section("Incognito") {
infoRow("Your random profile", customUserProfile.chatViewName)
}
}
if let connStats = connectionStats {
Section("Servers") {
@@ -106,11 +124,11 @@ struct ChatInfoView: View {
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(cInfo.displayName)
Text(contact.profile.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
Text(cInfo.fullName)
.font(.title2)
.lineLimit(2)
@@ -119,6 +137,37 @@ struct ChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
.focused($aliasTextFieldFocused)
.submitLabel(.done)
.onChange(of: aliasTextFieldFocused) { focused in
if !focused {
setContactAlias()
}
}
.onSubmit {
setContactAlias()
}
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
private func setContactAlias() {
Task {
do {
if let contact = try await apiSetContactAlias(contactId: chat.chatInfo.apiId, localAlias: localAlias) {
await MainActor.run {
chatModel.updateContact(contact)
}
}
} catch {
logger.error("setContactAlias error: \(responseError(error))")
}
}
}
func networkStatusRow() -> some View {
HStack {
Text("Network status")
@@ -167,6 +216,7 @@ struct ChatInfoView: View {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
await MainActor.run {
chatModel.removeChat(chat.chatInfo.id)
chatModel.chatId = nil
dismiss()
}
} catch let error {
@@ -202,6 +252,6 @@ struct ChatInfoView: View {
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), contact: Contact.sampleData, localAlias: "")
}
}
@@ -10,10 +10,12 @@ import SwiftUI
import SimpleXChat
struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
var memberRole: GroupMemberRole
var chatIncognito: Bool = false
@State private var frameWidth: CGFloat = 0
var body: some View {
@@ -29,10 +31,13 @@ struct CIGroupInvitationView: View {
Divider().frame(width: frameWidth)
if action {
groupInvitationText().overlay(DetermineWidth())
Text("Tap to join")
.foregroundColor(.accentColor)
groupInvitationText()
.overlay(DetermineWidth())
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(chatIncognito ? .indigo : .accentColor)
.font(.callout)
.padding(.trailing, 60)
.overlay(DetermineWidth())
} else {
groupInvitationText()
.padding(.trailing, 60)
@@ -52,17 +57,26 @@ struct CIGroupInvitationView: View {
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
if action {
v.onTapGesture { acceptInvitation() }
v.onTapGesture {
joinGroup(groupInvitation.groupId)
}
} else {
v
}
}
private func groupInfoView(_ action: Bool) -> some View {
HStack(alignment: .top) {
var color: Color
if action {
color = chatIncognito ? .indigo : .accentColor
} else {
color = Color(uiColor: .tertiaryLabel)
}
return HStack(alignment: .top) {
ProfileImage(
imageStr: groupInvitation.groupProfile.image,
iconName: "person.2.circle.fill",
color: action ? .accentColor : Color(uiColor: .tertiaryLabel)
color: color
)
.frame(width: 44, height: 44)
.padding(.trailing, 4)
@@ -94,13 +108,6 @@ struct CIGroupInvitationView: View {
}
}
}
private func acceptInvitation() {
Task {
logger.debug("acceptInvitation")
await joinGroup(groupInvitation.groupId)
}
}
}
struct CIGroupInvitationView_Previews: PreviewProvider {
@@ -48,7 +48,7 @@ struct ChatItemView: View {
}
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole)
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito)
}
private func groupEventItemView() -> some View {
+64 -21
View File
@@ -22,6 +22,7 @@ struct ChatView: View {
@FocusState private var keyboardVisible: Bool
@State private var showDeleteMessage = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var tableView: UITableView?
@State private var loadingItems = false
@State private var firstPage = false
@@ -30,6 +31,9 @@ struct ChatView: View {
@State private var searchMode = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GroupMember? = nil
@State private var memberConnectionStats: ConnectionStats?
var body: some View {
let cInfo = chat.chatInfo
@@ -68,22 +72,38 @@ struct ChatView: View {
}
}
} label: {
Image(systemName: "chevron.backward")
HStack(spacing: 0) {
Image(systemName: "chevron.backward")
Text("Chats")
}
}
}
ToolbarItem(placement: .principal) {
Button {
if case .direct = cInfo {
if case let .direct(contact) = cInfo {
Button {
Task {
do {
let stats = try await apiContactInfo(contactId: chat.chatInfo.apiId)
await MainActor.run { connectionStats = stats }
let (stats, profile) = try await apiContactInfo(contactId: chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
}
} catch let error {
logger.error("apiContactInfo error: \(responseError(error))")
}
await MainActor.run { showChatInfoSheet = true }
}
} else if case let .group(groupInfo) = cInfo {
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
}) {
ChatInfoView(chat: chat, contact: contact, connectionStats: connectionStats, customUserProfile: customUserProfile, localAlias: chat.chatInfo.localAlias)
}
} else if case let .group(groupInfo) = cInfo {
Button {
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
@@ -91,18 +111,11 @@ struct ChatView: View {
showChatInfoSheet = true
}
}
} label: {
ChatInfoToolbar(chat: chat)
}
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatInfoSheet) {
switch cInfo {
case .direct:
ChatInfoView(chat: chat, connectionStats: connectionStats)
case let .group(groupInfo):
.sheet(isPresented: $showChatInfoSheet) {
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
default:
EmptyView()
}
}
}
@@ -126,10 +139,16 @@ struct ChatView: View {
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
addMembersButton()
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
if (chat.chatInfo.incognito) {
Image(systemName: "person.crop.circle.badge.plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
} else {
addMembersButton()
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
}
Menu {
searchButton()
@@ -219,6 +238,15 @@ struct ChatView: View {
.onChange(of: searchText) { _ in
loadChat(chat: chat, search: searchText)
}
.onChange(of: chatModel.chatId) { _ in
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
showChatInfoSheet = false
loadChat(chat: chat)
DispatchQueue.main.async {
scrollToBottom(proxy)
}
}
}
}
}
.scaleEffect(x: 1, y: -1, anchor: .center)
@@ -338,13 +366,28 @@ struct ChatView: View {
}
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
if case let .groupRcv(member) = ci.chatDir {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let prevItem = chatModel.getPrevChatItem(ci)
HStack(alignment: .top, spacing: 0) {
let showMember = prevItem == nil || showMemberImage(member, prevItem)
if showMember {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture {
Task {
do {
let stats = try await apiGroupMemberInfo(member.groupId, member.groupMemberId)
await MainActor.run { memberConnectionStats = stats }
} catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))")
}
await MainActor.run { selectedMember = member }
}
}
.sheet(item: $selectedMember, onDismiss: { memberConnectionStats = nil }) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: memberConnectionStats)
}
} else {
Rectangle().fill(.clear)
.frame(width: memberImageSize, height: memberImageSize)
@@ -15,9 +15,23 @@ struct AddGroupMembersView: View {
var chat: Chat
var groupInfo: GroupInfo
var showSkip: Bool = false
var showFooterCounter: Bool = true
var addedMembersCb: ((Set<Int64>) -> Void)? = nil
@State private var selectedContacts = Set<Int64>()
@State private var selectedRole: GroupMemberRole = .admin
@State private var alert: AddGroupMembersAlert?
private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
case .prohibitedToInviteIncognito: return "prohibitedToInviteIncognito"
case let .error(title, _): return "error \(title)"
}
}
}
var body: some View {
NavigationView {
@@ -42,15 +56,17 @@ struct AddGroupMembersView: View {
inviteMembersButton()
.disabled(count < 1)
} footer: {
if (count >= 1) {
HStack {
Button { selectedContacts.removeAll() } label: { Text("Clear") }
Spacer()
Text("\(count) contact(s) selected")
if showFooterCounter {
if (count >= 1) {
HStack {
Button { selectedContacts.removeAll() } label: { Text("Clear") }
Spacer()
Text("\(count) contact(s) selected")
}
} else {
Text("No contacts selected")
.frame(maxWidth: .infinity, alignment: .trailing)
}
} else {
Text("No contacts selected")
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
@@ -77,27 +93,22 @@ struct AddGroupMembersView: View {
}
}
.frame(maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alert in
switch alert {
case .prohibitedToInviteIncognito:
return Alert(
title: Text("Can't invite contact!"),
message: Text("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")
)
case let .error(title, error):
return Alert(title: Text(title), message: Text("\(error)"))
}
}
}
func inviteMembersButton() -> some View {
private func inviteMembersButton() -> some View {
Button {
Task {
do {
for contactId in selectedContacts {
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) }
}
await MainActor.run { dismiss() }
if let cb = addedMembersCb { cb(selectedContacts) }
} catch {
AlertManager.shared.showAlert(
Alert(
title: Text("Error adding member(s)"),
message: Text(responseError(error))
)
)
}
}
inviteMembers()
} label: {
HStack {
Text("Invite to group")
@@ -107,7 +118,22 @@ struct AddGroupMembersView: View {
.frame(maxWidth: .infinity, alignment: .trailing)
}
func rolePicker() -> some View {
private func inviteMembers() {
Task {
do {
for contactId in selectedContacts {
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) }
}
await MainActor.run { dismiss() }
if let cb = addedMembersCb { cb(selectedContacts) }
} catch {
alert = .error(title: "Error adding member(s)", error: responseError(error))
}
}
}
private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.allCases) { role in
if role <= groupInfo.membership.memberRole {
@@ -117,13 +143,32 @@ struct AddGroupMembersView: View {
}
}
func contactCheckView(_ contact: Contact) -> some View {
private func contactCheckView(_ contact: Contact) -> some View {
let checked = selectedContacts.contains(contact.apiId)
return Button {
let prohibitedToInviteIncognito = !chat.chatInfo.incognito && contact.contactConnIncognito
var icon: String
var iconColor: Color
if prohibitedToInviteIncognito {
icon = "theatermasks.circle.fill"
iconColor = Color(uiColor: .tertiaryLabel)
} else {
if checked {
selectedContacts.remove(contact.apiId)
icon = "checkmark.circle.fill"
iconColor = .accentColor
} else {
selectedContacts.insert(contact.apiId)
icon = "circle"
iconColor = Color(uiColor: .tertiaryLabel)
}
}
return Button {
if prohibitedToInviteIncognito {
alert = .prohibitedToInviteIncognito
} else {
if checked {
selectedContacts.remove(contact.apiId)
} else {
selectedContacts.insert(contact.apiId)
}
}
} label: {
HStack{
@@ -131,11 +176,11 @@ struct AddGroupMembersView: View {
.frame(width: 30, height: 30)
.padding(.trailing, 2)
Text(ChatInfo.direct(contact: contact).chatViewName)
.foregroundColor(.primary)
.foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
.lineLimit(1)
Spacer()
Image(systemName: checked ? "checkmark.circle.fill": "circle")
.foregroundColor(checked ? .accentColor : Color(uiColor: .tertiaryLabel))
Image(systemName: icon)
.foregroundColor(iconColor)
}
}
}
@@ -26,6 +26,7 @@ struct GroupChatInfoView: View {
case deleteGroupAlert
case clearChatAlert
case leaveGroupAlert
case cantInviteIncognitoAlert
var id: GroupChatInfoViewAlert { get { self } }
}
@@ -42,7 +43,13 @@ struct GroupChatInfoView: View {
Section("\(members.count + 1) members") {
if groupInfo.canAddMembers {
addMembersButton()
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
.onTapGesture { alert = .cantInviteIncognitoAlert }
} else {
addMembersButton()
}
}
memberView(groupInfo.membership, user: true)
ForEach(members) { member in
@@ -97,6 +104,8 @@ struct GroupChatInfoView: View {
case .deleteGroupAlert: return deleteGroupAlert()
case .clearChatAlert: return clearChatAlert()
case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
}
}
}
@@ -150,7 +159,7 @@ struct GroupChatInfoView: View {
VStack(alignment: .leading) {
Text(member.chatViewName)
.lineLimit(1)
.foregroundColor(.primary)
.foregroundColor(member.memberIncognito ? .indigo : .primary)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
@@ -212,6 +221,7 @@ struct GroupChatInfoView: View {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
await MainActor.run {
chatModel.removeChat(chat.chatInfo.id)
chatModel.chatId = nil
dismiss()
}
} catch let error {
@@ -252,6 +262,13 @@ struct GroupChatInfoView: View {
}
}
func cantInviteIncognitoAlert() -> Alert {
Alert(
title: Text("Can't invite contacts!"),
message: Text("You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed")
)
}
struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
@@ -30,6 +30,12 @@ struct GroupMemberInfoView: View {
groupMemberInfoHeader()
.listRowBackground(Color.clear)
if let contactId = member.memberContactId {
Section {
openDirectChatButton(contactId)
}
}
Section("Member") {
infoRow("Group", groupInfo.displayName)
// TODO change role
@@ -72,6 +78,30 @@ struct GroupMemberInfoView: View {
}
}
func openDirectChatButton(_ contactId: Int64) -> some View {
Button {
var chat = chatModel.getContactChat(contactId)
if chat == nil {
do {
chat = try apiGetChat(type: .direct, id: contactId)
if let chat = chat {
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
chat.serverInfo = Chat.ServerInfo(networkStatus: .connected)
chatModel.addChat(chat)
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
}
if let chat = chat {
dismissAllSheets(animated: true)
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
private func groupMemberInfoHeader() -> some View {
VStack {
ProfileImage(imageStr: member.image, color: Color(uiColor: .tertiarySystemFill))
@@ -28,16 +28,10 @@ struct ChatListNavLink: View {
}
}
private func chatView() -> some View {
ChatView(chat: chat)
.onAppear { loadChat(chat: chat) }
}
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
let v = NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
destination: { chatView() },
label: { ChatPreviewView(chat: chat) },
disabled: !contact.ready
)
@@ -75,14 +69,16 @@ struct ChatListNavLink: View {
ChatPreviewView(chat: chat)
.frame(height: 80)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
joinGroupButton(groupInfo.hostConnCustomUserProfileId)
if groupInfo.canDelete {
deleteGroupChatButton(groupInfo)
}
}
.onTapGesture { showJoinGroupDialog = true }
.confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) {
Button("Join group") { Task { await joinGroup(groupInfo.groupId) } }
Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") {
joinGroup(groupInfo.groupId)
}
Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } }
}
case .memAccepted:
@@ -95,7 +91,6 @@ struct ChatListNavLink: View {
NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
destination: { chatView() },
label: { ChatPreviewView(chat: chat) },
disabled: !groupInfo.ready
)
@@ -113,7 +108,7 @@ struct ChatListNavLink: View {
} label: {
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
}
.tint(Color.indigo)
.tint(Color.yellow)
}
}
.swipeActions(edge: .trailing) {
@@ -124,13 +119,13 @@ struct ChatListNavLink: View {
}
}
private func joinGroupButton() -> some View {
private func joinGroupButton(_ hostConnCustomUserProfileId: Int64?) -> some View {
Button {
Task { await joinGroup(chat.chatInfo.apiId) }
joinGroup(chat.chatInfo.apiId)
} label: {
Label("Join", systemImage: "ipad.and.arrow.forward")
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
}
.tint(Color.accentColor)
.tint(chat.chatInfo.incognito ? .indigo : .accentColor)
}
private func markReadButton() -> some View {
@@ -162,9 +157,10 @@ struct ChatListNavLink: View {
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest, chat: chat)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Task { await acceptContactRequest(contactRequest) } }
label: { Label("Accept", systemImage: "checkmark") }
.tint(Color.accentColor)
Button {
Task { await acceptContactRequest(contactRequest) }
} label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") }
.tint(chatModel.incognito ? .indigo : .accentColor)
Button(role: .destructive) {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: {
@@ -174,7 +170,7 @@ struct ChatListNavLink: View {
.frame(height: 80)
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept contact") { Task { await acceptContactRequest(contactRequest) } }
Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } }
Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
}
}
@@ -325,32 +321,35 @@ struct ChatListNavLink: View {
}
}
func joinGroup(_ groupId: Int64) async {
do {
let r = try await apiJoinGroup(groupId)
switch r {
case let .joined(groupInfo):
await MainActor.run { ChatModel.shared.updateGroup(groupInfo) }
case .invitationRemoved:
AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.")
await deleteGroup()
case .groupNotFound:
AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
await deleteGroup()
}
} catch let error {
let err = responseError(error)
AlertManager.shared.showAlert(Alert(title: Text("Error joining group"), message: Text(err)))
logger.error("apiJoinGroup error: \(err)")
}
func deleteGroup() async {
func joinGroup(_ groupId: Int64) {
Task {
logger.debug("joinGroup")
do {
// TODO this API should update chat item with the invitation as well
try await apiDeleteChat(type: .group, id: groupId)
await MainActor.run { ChatModel.shared.removeChat("#\(groupId)") }
} catch {
logger.error("apiDeleteChat error: \(responseError(error))")
let r = try await apiJoinGroup(groupId)
switch r {
case let .joined(groupInfo):
await MainActor.run { ChatModel.shared.updateGroup(groupInfo) }
case .invitationRemoved:
AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.")
await deleteGroup()
case .groupNotFound:
AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
await deleteGroup()
}
} catch let error {
let err = responseError(error)
AlertManager.shared.showAlert(Alert(title: Text("Error joining group"), message: Text(err)))
logger.error("apiJoinGroup error: \(err)")
}
func deleteGroup() async {
do {
// TODO this API should update chat item with the invitation as well
try await apiDeleteChat(type: .group, id: groupId)
await MainActor.run { ChatModel.shared.removeChat("#\(groupId)") }
} catch {
logger.error("apiDeleteChat error: \(responseError(error))")
}
}
}
}
@@ -14,6 +14,7 @@ struct ChatListView: View {
// not really used in this view
@State private var showSettings = false
@State private var searchText = ""
@State private var selectedChat: ChatId?
var body: some View {
let v = NavigationView {
@@ -25,6 +26,7 @@ struct ChatListView: View {
}
}
.onChange(of: chatModel.chatId) { _ in
selectedChat = chatModel.chatId
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
chatModel.chatToTop = nil
chatModel.popChat(chatId)
@@ -44,6 +46,17 @@ struct ChatListView: View {
ToolbarItem(placement: .navigationBarLeading) {
SettingsButton()
}
ToolbarItem(placement: .principal) {
if (chatModel.incognito) {
HStack {
if (chatModel.chats.count > 8) {
Text("Your chats").font(.headline)
Spacer().frame(width: 16)
}
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning {
case .some(true): NewChatButton()
@@ -52,6 +65,15 @@ struct ChatListView: View {
}
}
}
.background(
NavigationLink(
destination: chatView(selectedChat),
isActive: Binding(
get: { selectedChat != nil },
set: { _, _ in selectedChat = nil }
)
) { EmptyView() }
)
}
.navigationViewStyle(.stack)
@@ -62,13 +84,28 @@ struct ChatListView: View {
}
}
@ViewBuilder private func chatView(_ chatId: ChatId?) -> some View {
if let chatId = chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
}
}
private func filteredChats() -> [Chat] {
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
return s == ""
? chatModel.chats
: chatModel.chats.filter {
$0.chatInfo.chatType != .contactConnection &&
$0.chatInfo.chatViewName.localizedLowercase.contains(s)
: chatModel.chats.filter { chat in
let contains = chat.chatInfo.chatViewName.localizedLowercase.contains(s)
switch chat.chatInfo {
case let .direct(contact):
return contains
|| contact.profile.displayName.localizedLowercase.contains(s)
|| contact.fullName.localizedLowercase.contains(s)
case .contactConnection: return false
default: return contains
}
}
}
}
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat
@Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@@ -23,7 +24,6 @@ struct ChatPreviewView: View {
.frame(width: 63, height: 63)
chatPreviewImageOverlayIcon()
.padding([.bottom, .trailing], 1)
}
.padding(.leading, 4)
@@ -89,7 +89,7 @@ struct ChatPreviewView: View {
case .group(groupInfo: let groupInfo):
switch (groupInfo.membership.memberStatus) {
case .memInvited:
v.foregroundColor(.accentColor)
chat.chatInfo.incognito ? v.foregroundColor(.indigo) : v.foregroundColor(.accentColor)
case .memAccepted:
v.foregroundColor(.secondary)
default: v
@@ -127,7 +127,7 @@ struct ChatPreviewView: View {
}
case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) {
case .memInvited: chatPreviewInfoText("you are invited to group")
case .memInvited: groupInvitationPreviewText(groupInfo)
case .memAccepted: chatPreviewInfoText("connecting…")
default: EmptyView()
}
@@ -136,6 +136,15 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
: (chatModel.incognito
? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")")
: chatPreviewInfoText("you are invited to group")
)
}
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
Text(text)
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct ContactRequestView: View {
@EnvironmentObject var chatModel: ChatModel
var contactRequest: UserContactRequest
@ObservedObject var chat: Chat
@@ -17,12 +18,13 @@ struct ContactRequestView: View {
return HStack(spacing: 8) {
ChatInfoImage(chat: chat)
.frame(width: 63, height: 63)
.padding(.leading, 4)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top) {
Text(contactRequest.chatViewName)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.blue)
.foregroundColor(chatModel.incognito ? .indigo : .accentColor)
.padding(.leading, 8)
.padding(.top, 4)
.frame(maxHeight: .infinity, alignment: .topLeading)
@@ -8,10 +8,9 @@
import SwiftUI
struct NavLinkPlain<V: Hashable, Destination: View, Label: View>: View {
struct NavLinkPlain<V: Hashable, Label: View>: View {
@State var tag: V
@Binding var selection: V?
@ViewBuilder var destination: () -> Destination
@ViewBuilder var label: () -> Label
var disabled = false
@@ -21,10 +20,6 @@ struct NavLinkPlain<V: Hashable, Destination: View, Label: View>: View {
.disabled(disabled)
label()
}
.background {
NavigationLink("", tag: tag, selection: $selection, destination: destination)
.hidden()
}
}
}
@@ -10,6 +10,7 @@ import SwiftUI
import CoreImage.CIFilterBuiltins
struct AddContactView: View {
@EnvironmentObject var chatModel: ChatModel
var connReqInvitation: String
var body: some View {
ScrollView {
@@ -17,8 +18,23 @@ struct AddContactView: View {
Text("One-time invitation link")
.font(.title)
.padding(.vertical)
Text("Your contact can scan it from the app")
Text("Your contact can scan it from the app.")
.padding(.bottom, 4)
if (chatModel.incognito) {
HStack {
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
Spacer().frame(width: 8)
Text("A random profile will be sent to your contact").font(.footnote)
}
.padding(.bottom)
} else {
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your chat profile will be sent to your contact").font(.footnote)
}
.padding(.bottom)
}
QRCode(uri: connReqInvitation)
.padding(.bottom)
Text("If you can't meet in person, **show QR code in the video call**, or share the link.")
@@ -24,9 +24,12 @@ struct AddGroupView: View {
var body: some View {
if let chat = chat, let groupInfo = groupInfo {
AddGroupMembersView(chat: chat,
groupInfo: groupInfo,
showSkip: true) { _ in
AddGroupMembersView(
chat: chat,
groupInfo: groupInfo,
showSkip: true,
showFooterCounter: false
) { _ in
dismiss()
DispatchQueue.main.async {
m.chatId = groupInfo.id
@@ -44,7 +47,21 @@ struct AddGroupView: View {
.padding(.vertical, 4)
Text("The group is fully decentralized it is visible only to the members.")
.padding(.bottom, 4)
if (m.incognito) {
HStack {
Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote)
Spacer().frame(width: 8)
Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote)
}
.padding(.bottom)
} else {
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your chat profile will be sent to group members").font(.footnote)
}
.padding(.bottom)
}
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
@@ -9,6 +9,7 @@
import SwiftUI
struct PasteToConnectView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@State private var connectionLink: String = ""
@@ -18,8 +19,22 @@ struct PasteToConnectView: View {
.font(.title)
.padding(.vertical)
Text("Paste the link you received into the box below to connect with your contact.")
Text("Your profile will be sent to the contact that you received this link from")
.padding(.bottom, 4)
if (chatModel.incognito) {
HStack {
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
Spacer().frame(width: 8)
Text("A random profile will be sent to the contact that you received this link from").font(.footnote)
}
.padding(.bottom)
} else {
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your profile will be sent to the contact that you received this link from").font(.footnote)
}
.padding(.bottom)
}
TextEditor(text: $connectionLink)
.onSubmit(connect)
.textInputAutocapitalization(.never)
@@ -55,7 +70,7 @@ struct PasteToConnectView: View {
.frame(height: 48)
.padding(.bottom)
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button")
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
@@ -10,6 +10,7 @@ import SwiftUI
import CodeScanner
struct ScanToConnectView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var body: some View {
@@ -17,8 +18,21 @@ struct ScanToConnectView: View {
Text("Scan QR code")
.font(.title)
.padding(.vertical)
Text("Your chat profile will be sent to your contact")
if (chatModel.incognito) {
HStack {
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
Spacer().frame(width: 8)
Text("A random profile will be sent to your contact").font(.footnote)
}
.padding(.bottom)
} else {
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your chat profile will be sent to your contact").font(.footnote)
}
.padding(.bottom)
}
ZStack {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
@@ -8,9 +8,18 @@
import SwiftUI
let defaultAccentColor = CGColor.init(red: 0, green: 0.533, blue: 1, alpha: 1)
let interfaceStyles: [UIUserInterfaceStyle] = [.unspecified, .light, .dark]
let interfaceStyleNames: [LocalizedStringKey] = ["System", "Light", "Dark"]
struct AppearanceSettings: View {
@EnvironmentObject var sceneDelegate: SceneDelegate
@State private var iconLightTapped = false
@State private var iconDarkTapped = false
@State private var userInterfaceStyle = getUserInterfaceStyleDefault()
@State private var uiTintColor = getUIAccentColorDefault()
var body: some View {
VStack{
@@ -22,6 +31,32 @@ struct AppearanceSettings: View {
updateAppIcon(image: "icon-dark", icon: "DarkAppIcon", tapped: $iconDarkTapped)
}
}
Section {
Picker("Theme", selection: $userInterfaceStyle) {
ForEach(interfaceStyles, id: \.self) { style in
Text(interfaceStyleNames[interfaceStyles.firstIndex(of: style) ?? 0])
}
}
ColorPicker("Accent color", selection: $uiTintColor, supportsOpacity: false)
} header: {
Text("Colors")
} footer: {
Button {
uiTintColor = defaultAccentColor
setUIAccentColorDefault(defaultAccentColor)
} label: {
Text("Reset colors").font(.callout)
}
}
.onChange(of: userInterfaceStyle) { _ in
sceneDelegate.window?.overrideUserInterfaceStyle = userInterfaceStyle
setUserInterfaceStyleDefault(userInterfaceStyle)
}
.onChange(of: uiTintColor) { _ in
sceneDelegate.window?.tintColor = UIColor(cgColor: uiTintColor)
setUIAccentColorDefault(uiTintColor)
}
}
}
}
@@ -44,6 +79,44 @@ struct AppearanceSettings: View {
}
}
func getUIAccentColorDefault() -> CGColor {
let defs = UserDefaults.standard
return CGColor(
red: defs.double(forKey: DEFAULT_ACCENT_COLOR_RED),
green: defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN),
blue: defs.double(forKey: DEFAULT_ACCENT_COLOR_BLUE),
alpha: 1
)
}
func setUIAccentColorDefault(_ color: CGColor) {
if let cs = color.components {
let defs = UserDefaults.standard
defs.set(cs[0], forKey: DEFAULT_ACCENT_COLOR_RED)
defs.set(cs[1], forKey: DEFAULT_ACCENT_COLOR_GREEN)
defs.set(cs[2], forKey: DEFAULT_ACCENT_COLOR_BLUE)
}
}
func getUserInterfaceStyleDefault() -> UIUserInterfaceStyle {
switch UserDefaults.standard.integer(forKey: DEFAULT_USER_INTERFACE_STYLE) {
case 1: return .light
case 2: return .dark
default: return .unspecified
}
}
func setUserInterfaceStyleDefault(_ style: UIUserInterfaceStyle) {
var v: Int
switch style {
case .unspecified: v = 0
case .light: v = 1
case .dark: v = 2
default: v = 0
}
UserDefaults.standard.set(v, forKey: DEFAULT_USER_INTERFACE_STYLE)
}
struct AppearanceSettings_Previews: PreviewProvider {
static var previews: some View {
AppearanceSettings()
@@ -14,8 +14,16 @@ struct CallSettings: View {
var body: some View {
VStack {
List {
Section("Settings") {
Section {
Toggle("Connect via relay", isOn: $webrtcPolicyRelay)
} header: {
Text("Settings")
} footer: {
if webrtcPolicyRelay {
Text("Relay server protects your IP address, but it can observe the duration of the call.")
} else {
Text("Relay server is only used if necessary. Another party can observe your IP address.")
}
}
Section("Limitations") {
@@ -31,12 +39,12 @@ struct CallSettings: View {
}
}
}
}
private func textListItem(_ n: String, _ text: LocalizedStringKey) -> some View {
ZStack(alignment: .topLeading) {
Text(n)
Text(text).frame(maxWidth: .infinity, alignment: .leading).padding(.leading, 20)
}
func textListItem(_ n: String, _ text: LocalizedStringKey) -> some View {
ZStack(alignment: .topLeading) {
Text(n)
Text(text).frame(maxWidth: .infinity, alignment: .leading).padding(.leading, 20)
}
}
@@ -0,0 +1,38 @@
//
// IncognitoHelp.swift
// SimpleX (iOS)
//
// Created by JRoberts on 22.08.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct IncognitoHelp: View {
var body: some View {
VStack(alignment: .leading) {
Text("Incognito mode")
.font(.largeTitle)
.padding(.vertical)
ScrollView {
VStack(alignment: .leading) {
Group {
Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.")
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
Text("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.")
}
.padding(.bottom)
}
}
}
.frame(maxWidth: .infinity)
.padding()
}
}
struct IncognitoHelp_Previews: PreviewProvider {
static var previews: some View {
IncognitoHelp()
}
}
@@ -32,7 +32,7 @@ struct NetworkAndServers: View {
var body: some View {
VStack {
List {
Section("") {
Section {
NavigationLink {
SMPServers()
.navigationTitle("Your SMP servers")
@@ -52,6 +52,10 @@ struct NetworkAndServers: View {
settingsRow("app.connected.to.app.below.fill") { Text("Advanced network settings") }
}
}
} header: {
Text("")
} footer: {
Text("Using .onion hosts requires compatible VPN provider.")
}
}
}
@@ -26,6 +26,10 @@ let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
let DEFAULT_CHAT_V3_DB_MIGRATION = "chatV3DBMigration"
let DEFAULT_DEVELOPER_TOOLS = "developerTools"
let DEFAULT_ACCENT_COLOR_RED = "accentColorRed"
let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen"
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue"
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle"
let appDefaults: [String: Any] = [
DEFAULT_SHOW_LA_NOTICE: false,
@@ -36,7 +40,11 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
DEFAULT_EXPERIMENTAL_CALLS: false,
DEFAULT_CHAT_V3_DB_MIGRATION: "offer",
DEFAULT_DEVELOPER_TOOLS: false
DEFAULT_DEVELOPER_TOOLS: false,
DEFAULT_ACCENT_COLOR_RED: 0.000,
DEFAULT_ACCENT_COLOR_GREEN: 0.533,
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
DEFAULT_USER_INTERFACE_STYLE: 0
]
private var indent: CGFloat = 36
@@ -52,6 +60,7 @@ struct SettingsView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var settingsSheet: SettingsSheet?
var body: some View {
let user: User = chatModel.currentUser!
@@ -68,6 +77,9 @@ struct SettingsView: View {
}
.disabled(chatModel.chatRunning != true)
incognitoRow()
.disabled(chatModel.chatRunning != true)
NavigationLink {
UserAddress()
.navigationTitle("Your chat address")
@@ -196,9 +208,49 @@ struct SettingsView: View {
}
.navigationTitle("Your settings")
}
.sheet(item: $settingsSheet) { sheet in
switch sheet {
case .incognitoInfo: IncognitoHelp()
}
}
}
enum NotificationAlert {
@ViewBuilder private func incognitoRow() -> some View {
ZStack(alignment: .leading) {
Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks")
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.foregroundColor(chatModel.incognito ? Color.indigo : .secondary)
Toggle(isOn: $chatModel.incognito) {
HStack {
Text("Incognito")
Spacer().frame(width: 4)
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.font(.system(size: 14))
}
.onTapGesture {
settingsSheet = .incognitoInfo
}
}
.onChange(of: chatModel.incognito) { incognito in
incognitoGroupDefault.set(incognito)
do {
try apiSetIncognito(incognito: incognito)
} catch {
logger.error("apiSetIncognito: cannot set incognito \(responseError(error))")
}
}
.padding(.leading, indent)
}
}
private enum SettingsSheet: Identifiable {
case incognitoInfo
var id: SettingsSheet { get { self } }
}
private enum NotificationAlert {
case enable
case error(LocalizedStringKey, String)
}
@@ -77,7 +77,7 @@ struct UserProfile: View {
profileNameView("Display name:", user.profile.displayName)
profileNameView("Full name:", user.profile.fullName)
Button("Edit") {
profile = user.profile
profile = fromLocalProfile(user.profile)
editProfile = true
}
}
@@ -131,7 +131,7 @@ struct UserProfile: View {
}
func startEditingImage(_ user: User) {
profile = user.profile
profile = fromLocalProfile(user.profile)
editProfile = true
showChooseSource = true
}
@@ -141,7 +141,9 @@ struct UserProfile: View {
do {
if let newProfile = try await apiUpdateProfile(profile: profile) {
DispatchQueue.main.async {
chatModel.currentUser?.profile = newProfile
if let profileId = chatModel.currentUser?.profile.profileId {
chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "")
}
profile = newProfile
}
}
@@ -5,6 +5,13 @@
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
</header>
<body>
<trans-unit id="&#10;" xml:space="preserve">
<source>
</source>
<target>
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=" " xml:space="preserve">
<source> </source>
<target> </target>
@@ -165,6 +172,16 @@
<target>A new contact</target>
<note>notification title</note>
</trans-unit>
<trans-unit id="A random profile will be sent to the contact that you received this link from" xml:space="preserve">
<source>A random profile will be sent to the contact that you received this link from</source>
<target>A random profile will be sent to the contact that you received this link from</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="A random profile will be sent to your contact" xml:space="preserve">
<source>A random profile will be sent to your contact</source>
<target>A random profile will be sent to your contact</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="About SimpleX" xml:space="preserve">
<source>About SimpleX</source>
<target>About SimpleX</target>
@@ -175,6 +192,11 @@
<target>About SimpleX Chat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accent color" xml:space="preserve">
<source>Accent color</source>
<target>Accent color</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accept" xml:space="preserve">
<source>Accept</source>
<target>Accept</target>
@@ -191,6 +213,11 @@
<target>Accept contact request from %@?</target>
<note>notification body</note>
</trans-unit>
<trans-unit id="Accept incognito" xml:space="preserve">
<source>Accept incognito</source>
<target>Accept incognito</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add contact to start a new chat" xml:space="preserve">
<source>Add contact to start a new chat</source>
<target>Add contact to start a new chat</target>
@@ -261,6 +288,16 @@
<target>Call already ended!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't invite contact!" xml:space="preserve">
<source>Can't invite contact!</source>
<target>Can't invite contact!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't invite contacts!" xml:space="preserve">
<source>Can't invite contacts!</source>
<target>Can't invite contacts!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<target>Cancel</target>
@@ -309,7 +346,7 @@
<trans-unit id="Chats" xml:space="preserve">
<source>Chats</source>
<target>Chats</target>
<note>back button to return to chats list</note>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Choose file" xml:space="preserve">
<source>Choose file</source>
@@ -336,6 +373,11 @@
<target>Clear conversation?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Colors" xml:space="preserve">
<source>Colors</source>
<target>Colors</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Configure SMP servers" xml:space="preserve">
<source>Configure SMP servers</source>
<target>Configure SMP servers</target>
@@ -449,7 +491,7 @@
<trans-unit id="Copy" xml:space="preserve">
<source>Copy</source>
<target>Copy</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Create" xml:space="preserve">
<source>Create</source>
@@ -514,7 +556,7 @@
<trans-unit id="Delete" xml:space="preserve">
<source>Delete</source>
<target>Delete</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete Contact" xml:space="preserve">
<source>Delete Contact</source>
@@ -659,7 +701,7 @@
<trans-unit id="Edit" xml:space="preserve">
<source>Edit</source>
<target>Edit</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Edit group profile" xml:space="preserve">
<source>Edit group profile</source>
@@ -701,6 +743,11 @@
<target>Error accessing database file</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error adding member(s)" xml:space="preserve">
<source>Error adding member(s)</source>
<target>Error adding member(s)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating group" xml:space="preserve">
<source>Error creating group</source>
<target>Error creating group</target>
@@ -961,6 +1008,26 @@
<target>In person or via a video call the most secure way to connect.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito" xml:space="preserve">
<source>Incognito</source>
<target>Incognito</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito mode" xml:space="preserve">
<source>Incognito mode</source>
<target>Incognito mode</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito mode is not supported here - your main profile will be sent to group members" xml:space="preserve">
<source>Incognito mode is not supported here - your main profile will be sent to group members</source>
<target>Incognito mode is not supported here - your main profile will be sent to group members</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created." xml:space="preserve">
<source>Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.</source>
<target>Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incoming audio call" xml:space="preserve">
<source>Incoming audio call</source>
<target>Incoming audio call</target>
@@ -1006,6 +1073,11 @@
<target>Invite to group</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>It allows having many anonymous connections without any shared data between them in a single chat profile.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It can happen when:&#10;1. The messages expire on the server if they were not received for 30 days,&#10;2. The server you use to receive the messages from this contact was updated and restarted.&#10;3. The connection is compromised.&#10;Please connect to the developers via Settings to receive the updates about the servers.&#10;We will be adding server redundancy to prevent lost messages." xml:space="preserve">
<source>It can happen when:
1. The messages expire on the server if they were not received for 30 days,
@@ -1041,6 +1113,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Join group</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Join incognito" xml:space="preserve">
<source>Join incognito</source>
<target>Join incognito</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Joining group" xml:space="preserve">
<source>Joining group</source>
<target>Joining group</target>
@@ -1091,6 +1168,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark read" xml:space="preserve">
<source>Mark read</source>
<target>Mark read</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Markdown in messages" xml:space="preserve">
<source>Markdown in messages</source>
<target>Markdown in messages</target>
@@ -1141,6 +1223,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Most likely this contact has deleted the connection with you.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mute" xml:space="preserve">
<source>Mute</source>
<target>Mute</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Network &amp; servers" xml:space="preserve">
<source>Network &amp; servers</source>
<target>Network &amp; servers</target>
@@ -1181,6 +1268,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>New message</target>
<note>notification</note>
</trans-unit>
<trans-unit id="No" xml:space="preserve">
<source>No</source>
<target>No</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No contacts selected" xml:space="preserve">
<source>No contacts selected</source>
<target>No contacts selected</target>
@@ -1236,6 +1328,21 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>One-time invitation link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Onion hosts will be required for connection. Requires enabling VPN." xml:space="preserve">
<source>Onion hosts will be required for connection. Requires enabling VPN.</source>
<target>Onion hosts will be required for connection. Requires enabling VPN.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Onion hosts will be used when available. Requires enabling VPN." xml:space="preserve">
<source>Onion hosts will be used when available. Requires enabling VPN.</source>
<target>Onion hosts will be used when available. Requires enabling VPN.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Onion hosts will not be used." xml:space="preserve">
<source>Onion hosts will not be used.</source>
<target>Onion hosts will not be used.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
<target>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</target>
@@ -1376,6 +1483,16 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Reject contact request</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Relay server is only used if necessary. Another party can observe your IP address." xml:space="preserve">
<source>Relay server is only used if necessary. Another party can observe your IP address.</source>
<target>Relay server is only used if necessary. Another party can observe your IP address.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Relay server protects your IP address, but it can observe the duration of the call." xml:space="preserve">
<source>Relay server protects your IP address, but it can observe the duration of the call.</source>
<target>Relay server protects your IP address, but it can observe the duration of the call.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove" xml:space="preserve">
<source>Remove</source>
<target>Remove</target>
@@ -1394,6 +1511,16 @@ We will be adding server redundancy to prevent lost messages.</target>
<trans-unit id="Reply" xml:space="preserve">
<source>Reply</source>
<target>Reply</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Required" xml:space="preserve">
<source>Required</source>
<target>Required</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reset colors" xml:space="preserve">
<source>Reset colors</source>
<target>Reset colors</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reset to defaults" xml:space="preserve">
@@ -1434,7 +1561,7 @@ We will be adding server redundancy to prevent lost messages.</target>
<trans-unit id="Save" xml:space="preserve">
<source>Save</source>
<target>Save</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source>
@@ -1466,6 +1593,16 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Scan contact's QR code</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Search" xml:space="preserve">
<source>Search</source>
<target>Search</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send direct message" xml:space="preserve">
<source>Send direct message</source>
<target>Send direct message</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
<source>Send link previews</source>
<target>Send link previews</target>
@@ -1496,6 +1633,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Servers</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set contact name…" xml:space="preserve">
<source>Set contact name…</source>
<target>Set contact name…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set timeouts for proxy/VPN" xml:space="preserve">
<source>Set timeouts for proxy/VPN</source>
<target>Set timeouts for proxy/VPN</target>
@@ -1509,7 +1651,7 @@ We will be adding server redundancy to prevent lost messages.</target>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<target>Share</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Share invitation link" xml:space="preserve">
<source>Share invitation link</source>
@@ -1621,6 +1763,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Tap to join</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Tap to join incognito" xml:space="preserve">
<source>Tap to join incognito</source>
<target>Tap to join incognito</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Thank you for installing SimpleX Chat!" xml:space="preserve">
<source>Thank you for installing SimpleX Chat!</source>
<target>Thank you for installing SimpleX Chat!</target>
@@ -1701,6 +1848,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>To ask any questions and to receive updates:</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="To find the profile used for an incognito connection, tap the contact or group name on top of the chat." xml:space="preserve">
<source>To find the profile used for an incognito connection, tap the contact or group name on top of the chat.</source>
<target>To find the profile used for an incognito connection, tap the contact or group name on top of the chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="To make a new connection" xml:space="preserve">
<source>To make a new connection</source>
<target>To make a new connection</target>
@@ -1780,6 +1932,16 @@ To connect, please ask your contact to create another connection link and check
<target>Unlock</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Unmute" xml:space="preserve">
<source>Unmute</source>
<target>Unmute</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update .onion hosts setting?" xml:space="preserve">
<source>Update .onion hosts setting?</source>
<target>Update .onion hosts setting?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update network settings?" xml:space="preserve">
<source>Update network settings?</source>
<target>Update network settings?</target>
@@ -1790,6 +1952,16 @@ To connect, please ask your contact to create another connection link and check
<target>Updating settings will re-connect the client to all servers.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Updating this setting will re-connect the client to all servers." xml:space="preserve">
<source>Updating this setting will re-connect the client to all servers.</source>
<target>Updating this setting will re-connect the client to all servers.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Use .onion hosts" xml:space="preserve">
<source>Use .onion hosts</source>
<target>Use .onion hosts</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Use SimpleX Chat servers?" xml:space="preserve">
<source>Use SimpleX Chat servers?</source>
<target>Use SimpleX Chat servers?</target>
@@ -1800,11 +1972,21 @@ To connect, please ask your contact to create another connection link and check
<target>Use chat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Using .onion hosts requires compatible VPN provider." xml:space="preserve">
<source>Using .onion hosts requires compatible VPN provider.</source>
<target>Using .onion hosts requires compatible VPN provider.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Using SimpleX Chat servers." xml:space="preserve">
<source>Using SimpleX Chat servers.</source>
<target>Using SimpleX Chat servers.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Video call" xml:space="preserve">
<source>Video call</source>
<target>Video call</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Waiting for file" xml:space="preserve">
<source>Waiting for file</source>
<target>Waiting for file</target>
@@ -1820,6 +2002,16 @@ To connect, please ask your contact to create another connection link and check
<target>Welcome %@!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="When available" xml:space="preserve">
<source>When available</source>
<target>When available</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." xml:space="preserve">
<source>When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</source>
<target>When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You" xml:space="preserve">
<source>You</source>
<target>You</target>
@@ -1845,9 +2037,9 @@ To connect, please ask your contact to create another connection link and check
<target>You are invited to group</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" xml:space="preserve">
<source>You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button</source>
<target>You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button</target>
<trans-unit id="You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." xml:space="preserve">
<source>You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.</source>
<target>You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
@@ -1935,6 +2127,16 @@ To connect, please ask your contact to create another connection link and check
<target>You will stop receiving messages from this group. Chat history will be preserved.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="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" xml:space="preserve">
<source>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</source>
<target>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</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" xml:space="preserve">
<source>You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed</source>
<target>You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your SMP servers" xml:space="preserve">
<source>Your SMP servers</source>
<target>Your SMP servers</target>
@@ -1965,6 +2167,11 @@ To connect, please ask your contact to create another connection link and check
<target>Your chat profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat profile will be sent to group members" xml:space="preserve">
<source>Your chat profile will be sent to group members</source>
<target>Your chat profile will be sent to group members</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat profile will be sent to your contact" xml:space="preserve">
<source>Your chat profile will be sent to your contact</source>
<target>Your chat profile will be sent to your contact</target>
@@ -1975,9 +2182,9 @@ To connect, please ask your contact to create another connection link and check
<target>Your chats</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact can scan it from the app" xml:space="preserve">
<source>Your contact can scan it from the app</source>
<target>Your contact can scan it from the app</target>
<trans-unit id="Your contact can scan it from the app." xml:space="preserve">
<source>Your contact can scan it from the app.</source>
<target>Your contact can scan it from the app.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact needs to be online for the connection to complete.&#10;You can cancel this connection and remove the contact (and try later with a new link)." xml:space="preserve">
@@ -2019,6 +2226,11 @@ SimpleX servers cannot see your profile.</target>
<target>Your profile, contacts and delivered messages are stored on your device.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your random profile" xml:space="preserve">
<source>Your random profile</source>
<target>Your random profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your settings" xml:space="preserve">
<source>Your settings</source>
<target>Your settings</target>
@@ -2219,6 +2431,16 @@ SimpleX servers cannot see your profile.</target>
<target>group profile updated</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="incognito via contact address link" xml:space="preserve">
<source>incognito via contact address link</source>
<target>incognito via contact address link</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="incognito via one-time link" xml:space="preserve">
<source>incognito via one-time link</source>
<target>incognito via one-time link</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="indirect (%d)" xml:space="preserve">
<source>indirect (%d)</source>
<target>indirect (%d)</target>
@@ -2249,6 +2471,11 @@ SimpleX servers cannot see your profile.</target>
<target>italic</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="join as %@" xml:space="preserve">
<source>join as %@</source>
<target>join as %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="left" xml:space="preserve">
<source>left</source>
<target>left</target>
@@ -2424,6 +2651,11 @@ SimpleX servers cannot see your profile.</target>
<target>you shared one-time link</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="you shared one-time link incognito" xml:space="preserve">
<source>you shared one-time link incognito</source>
<target>you shared one-time link incognito</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="you: " xml:space="preserve">
<source>you: </source>
<target>you: </target>
@@ -5,6 +5,13 @@
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
</header>
<body>
<trans-unit id="&#10;" xml:space="preserve">
<source>
</source>
<target>
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=" " xml:space="preserve">
<source> </source>
<target> </target>
@@ -165,6 +172,16 @@
<target>Новый контакт</target>
<note>notification title</note>
</trans-unit>
<trans-unit id="A random profile will be sent to the contact that you received this link from" xml:space="preserve">
<source>A random profile will be sent to the contact that you received this link from</source>
<target>Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="A random profile will be sent to your contact" xml:space="preserve">
<source>A random profile will be sent to your contact</source>
<target>Вашему контакту будет отправлен случайный профиль</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="About SimpleX" xml:space="preserve">
<source>About SimpleX</source>
<target>О SimpleX</target>
@@ -175,6 +192,11 @@
<target>Информация о SimpleX Chat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accent color" xml:space="preserve">
<source>Accent color</source>
<target>Основной цвет</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accept" xml:space="preserve">
<source>Accept</source>
<target>Принять</target>
@@ -191,6 +213,11 @@
<target>Принять запрос на соединение от %@?</target>
<note>notification body</note>
</trans-unit>
<trans-unit id="Accept incognito" xml:space="preserve">
<source>Accept incognito</source>
<target>Принять инкогнито</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add contact to start a new chat" xml:space="preserve">
<source>Add contact to start a new chat</source>
<target>Добавьте контакт, чтобы начать разговор</target>
@@ -261,6 +288,16 @@
<target>Звонок уже завершен!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't invite contact!" xml:space="preserve">
<source>Can't invite contact!</source>
<target>Нельзя пригласить контакт!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't invite contacts!" xml:space="preserve">
<source>Can't invite contacts!</source>
<target>Нельзя пригласить контакты!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<target>Отменить</target>
@@ -309,7 +346,7 @@
<trans-unit id="Chats" xml:space="preserve">
<source>Chats</source>
<target>Чаты</target>
<note>back button to return to chats list</note>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Choose file" xml:space="preserve">
<source>Choose file</source>
@@ -336,6 +373,11 @@
<target>Очистить разговор?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Colors" xml:space="preserve">
<source>Colors</source>
<target>Цвета</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Configure SMP servers" xml:space="preserve">
<source>Configure SMP servers</source>
<target>Настройка SMP серверов</target>
@@ -449,7 +491,7 @@
<trans-unit id="Copy" xml:space="preserve">
<source>Copy</source>
<target>Скопировать</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Create" xml:space="preserve">
<source>Create</source>
@@ -514,7 +556,7 @@
<trans-unit id="Delete" xml:space="preserve">
<source>Delete</source>
<target>Удалить</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete Contact" xml:space="preserve">
<source>Delete Contact</source>
@@ -659,7 +701,7 @@
<trans-unit id="Edit" xml:space="preserve">
<source>Edit</source>
<target>Редактировать</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Edit group profile" xml:space="preserve">
<source>Edit group profile</source>
@@ -701,6 +743,11 @@
<target>Ошибка при доступе к данным чата</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error adding member(s)" xml:space="preserve">
<source>Error adding member(s)</source>
<target>Ошибка при добавлении членов группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating group" xml:space="preserve">
<source>Error creating group</source>
<target>Ошибка при создании группы</target>
@@ -961,6 +1008,26 @@
<target>При встрече или в видеозвонке – самый безопасный способ установить соединение</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito" xml:space="preserve">
<source>Incognito</source>
<target>Инкогнито</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito mode" xml:space="preserve">
<source>Incognito mode</source>
<target>Режим Инкогнито</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito mode is not supported here - your main profile will be sent to group members" xml:space="preserve">
<source>Incognito mode is not supported here - your main profile will be sent to group members</source>
<target>Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created." xml:space="preserve">
<source>Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.</source>
<target>Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incoming audio call" xml:space="preserve">
<source>Incoming audio call</source>
<target>Входящий аудиозвонок</target>
@@ -1006,6 +1073,11 @@
<target>Пригласить в группу</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It can happen when:&#10;1. The messages expire on the server if they were not received for 30 days,&#10;2. The server you use to receive the messages from this contact was updated and restarted.&#10;3. The connection is compromised.&#10;Please connect to the developers via Settings to receive the updates about the servers.&#10;We will be adding server redundancy to prevent lost messages." xml:space="preserve">
<source>It can happen when:
1. The messages expire on the server if they were not received for 30 days,
@@ -1041,6 +1113,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Вступить в группу</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Join incognito" xml:space="preserve">
<source>Join incognito</source>
<target>Вступить инкогнито</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Joining group" xml:space="preserve">
<source>Joining group</source>
<target>Вступление в группу</target>
@@ -1091,6 +1168,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?*</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark read" xml:space="preserve">
<source>Mark read</source>
<target>Прочитано</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Markdown in messages" xml:space="preserve">
<source>Markdown in messages</source>
<target>Форматирование сообщений</target>
@@ -1141,6 +1223,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Скорее всего, этот контакт удалил соединение с вами.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mute" xml:space="preserve">
<source>Mute</source>
<target>Без звука</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Network &amp; servers" xml:space="preserve">
<source>Network &amp; servers</source>
<target>Сеть &amp; серверы</target>
@@ -1181,6 +1268,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Новое сообщение</target>
<note>notification</note>
</trans-unit>
<trans-unit id="No" xml:space="preserve">
<source>No</source>
<target>Нет</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No contacts selected" xml:space="preserve">
<source>No contacts selected</source>
<target>Контакты не выбраны</target>
@@ -1236,6 +1328,21 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Одноразовая ссылка</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Onion hosts will be required for connection. Requires enabling VPN." xml:space="preserve">
<source>Onion hosts will be required for connection. Requires enabling VPN.</source>
<target>Подключаться только к onion хостам. Требуется включенный VPN.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Onion hosts will be used when available. Requires enabling VPN." xml:space="preserve">
<source>Onion hosts will be used when available. Requires enabling VPN.</source>
<target>Onion хосты используются, если возможно. Требуется включенный VPN.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Onion hosts will not be used." xml:space="preserve">
<source>Onion hosts will not be used.</source>
<target>Onion хосты не используются.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
<target>Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**</target>
@@ -1376,6 +1483,16 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Отклонить запрос</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Relay server is only used if necessary. Another party can observe your IP address." xml:space="preserve">
<source>Relay server is only used if necessary. Another party can observe your IP address.</source>
<target>Relay сервер используется только при необходимости. Другая сторона может видеть ваш IP адрес.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Relay server protects your IP address, but it can observe the duration of the call." xml:space="preserve">
<source>Relay server protects your IP address, but it can observe the duration of the call.</source>
<target>Relay сервер защищает ваш IP адрес, но может отслеживать продолжительность звонка.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove" xml:space="preserve">
<source>Remove</source>
<target>Удалить</target>
@@ -1394,6 +1511,16 @@ We will be adding server redundancy to prevent lost messages.</source>
<trans-unit id="Reply" xml:space="preserve">
<source>Reply</source>
<target>Ответить</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Required" xml:space="preserve">
<source>Required</source>
<target>Обязательно</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reset colors" xml:space="preserve">
<source>Reset colors</source>
<target>Сбросить цвета</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reset to defaults" xml:space="preserve">
@@ -1434,7 +1561,7 @@ We will be adding server redundancy to prevent lost messages.</source>
<trans-unit id="Save" xml:space="preserve">
<source>Save</source>
<target>Сохранить</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source>
@@ -1466,6 +1593,16 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Сосканировать QR код контакта</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Search" xml:space="preserve">
<source>Search</source>
<target>Поиск</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send direct message" xml:space="preserve">
<source>Send direct message</source>
<target>Отправить сообщение</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send link previews" xml:space="preserve">
<source>Send link previews</source>
<target>Отправлять картинки ссылок</target>
@@ -1496,6 +1633,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Серверы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set contact name…" xml:space="preserve">
<source>Set contact name…</source>
<target>Имя контакта…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set timeouts for proxy/VPN" xml:space="preserve">
<source>Set timeouts for proxy/VPN</source>
<target>Установить таймауты для прокси/VPN</target>
@@ -1509,7 +1651,7 @@ We will be adding server redundancy to prevent lost messages.</source>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<target>Поделиться</target>
<note>No comment provided by engineer.</note>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Share invitation link" xml:space="preserve">
<source>Share invitation link</source>
@@ -1621,6 +1763,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Нажмите, чтобы вступить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Tap to join incognito" xml:space="preserve">
<source>Tap to join incognito</source>
<target>Нажмите, чтобы вступить инкогнито</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Thank you for installing SimpleX Chat!" xml:space="preserve">
<source>Thank you for installing SimpleX Chat!</source>
<target>Спасибо, что установили SimpleX Chat!</target>
@@ -1701,6 +1848,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Чтобы задать вопросы и получать уведомления о новых версиях,</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="To find the profile used for an incognito connection, tap the contact or group name on top of the chat." xml:space="preserve">
<source>To find the profile used for an incognito connection, tap the contact or group name on top of the chat.</source>
<target>Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="To make a new connection" xml:space="preserve">
<source>To make a new connection</source>
<target>Чтобы соединиться</target>
@@ -1780,6 +1932,16 @@ To connect, please ask your contact to create another connection link and check
<target>Разблокировать</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Unmute" xml:space="preserve">
<source>Unmute</source>
<target>Уведомлять</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update .onion hosts setting?" xml:space="preserve">
<source>Update .onion hosts setting?</source>
<target>Обновить настройки .onion хостов?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update network settings?" xml:space="preserve">
<source>Update network settings?</source>
<target>Обновить настройки сети?</target>
@@ -1787,7 +1949,17 @@ To connect, please ask your contact to create another connection link and check
</trans-unit>
<trans-unit id="Updating settings will re-connect the client to all servers." xml:space="preserve">
<source>Updating settings will re-connect the client to all servers.</source>
<target>Обновление настроек приведет к переподключению клиента ко всем серверам.</target>
<target>Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Updating this setting will re-connect the client to all servers." xml:space="preserve">
<source>Updating this setting will re-connect the client to all servers.</source>
<target>Обновление этих настроек приведет к сбросу и установке нового соединения со всеми серверами.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Use .onion hosts" xml:space="preserve">
<source>Use .onion hosts</source>
<target>Использовать .onion хосты</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Use SimpleX Chat servers?" xml:space="preserve">
@@ -1800,11 +1972,21 @@ To connect, please ask your contact to create another connection link and check
<target>Использовать чат</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Using .onion hosts requires compatible VPN provider." xml:space="preserve">
<source>Using .onion hosts requires compatible VPN provider.</source>
<target>Для использования .onion хостов требуется совместимый VPN провайдер.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Using SimpleX Chat servers." xml:space="preserve">
<source>Using SimpleX Chat servers.</source>
<target>Используются серверы, предоставленные SimpleX Chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Video call" xml:space="preserve">
<source>Video call</source>
<target>Видеозвонок</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Waiting for file" xml:space="preserve">
<source>Waiting for file</source>
<target>Ожидается прием файла</target>
@@ -1820,6 +2002,16 @@ To connect, please ask your contact to create another connection link and check
<target>Здравствуйте %@!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="When available" xml:space="preserve">
<source>When available</source>
<target>Когда возможно</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." xml:space="preserve">
<source>When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</source>
<target>Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You" xml:space="preserve">
<source>You</source>
<target>Вы</target>
@@ -1845,8 +2037,8 @@ To connect, please ask your contact to create another connection link and check
<target>Вы приглашены в группу</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" xml:space="preserve">
<source>You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button</source>
<trans-unit id="You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." xml:space="preserve">
<source>You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.</source>
<target>Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -1935,6 +2127,16 @@ To connect, please ask your contact to create another connection link and check
<target>Вы перестанете получать сообщения от этой группы. История чата будет сохранена.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="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" xml:space="preserve">
<source>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</source>
<target>Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" xml:space="preserve">
<source>You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed</source>
<target>Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your SMP servers" xml:space="preserve">
<source>Your SMP servers</source>
<target>Ваши SMP серверы</target>
@@ -1965,6 +2167,11 @@ To connect, please ask your contact to create another connection link and check
<target>Ваш профиль</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat profile will be sent to group members" xml:space="preserve">
<source>Your chat profile will be sent to group members</source>
<target>Ваш профиль чата будет отправлен членам группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat profile will be sent to your contact" xml:space="preserve">
<source>Your chat profile will be sent to your contact</source>
<target>Ваш профиль будет отправлен вашему контакту</target>
@@ -1975,9 +2182,9 @@ To connect, please ask your contact to create another connection link and check
<target>Ваши чаты</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact can scan it from the app" xml:space="preserve">
<source>Your contact can scan it from the app</source>
<target>Ваш контакт может сосканировать QR в приложении</target>
<trans-unit id="Your contact can scan it from the app." xml:space="preserve">
<source>Your contact can scan it from the app.</source>
<target>Ваш контакт может сосканировать QR код в приложении</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact needs to be online for the connection to complete.&#10;You can cancel this connection and remove the contact (and try later with a new link)." xml:space="preserve">
@@ -2019,6 +2226,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your random profile" xml:space="preserve">
<source>Your random profile</source>
<target>Ваш случайный профиль</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your settings" xml:space="preserve">
<source>Your settings</source>
<target>Настройки</target>
@@ -2219,6 +2431,16 @@ SimpleX серверы не могут получить доступ к ваше
<target>профиль группы обновлен</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="incognito via contact address link" xml:space="preserve">
<source>incognito via contact address link</source>
<target>инкогнито через ссылку-контакт</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="incognito via one-time link" xml:space="preserve">
<source>incognito via one-time link</source>
<target>инкогнито через одноразовую ссылку</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="indirect (%d)" xml:space="preserve">
<source>indirect (%d)</source>
<target>непрямое (%d)</target>
@@ -2249,6 +2471,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>курсив</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="join as %@" xml:space="preserve">
<source>join as %@</source>
<target>вступить как %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="left" xml:space="preserve">
<source>left</source>
<target>покинул(а) группу</target>
@@ -2424,6 +2651,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>вы создали ссылку</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="you shared one-time link incognito" xml:space="preserve">
<source>you shared one-time link incognito</source>
<target>вы создали ссылку инкогнито</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="you: " xml:space="preserve">
<source>you: </source>
<target>вы: </target>
@@ -160,6 +160,7 @@ func startChat() -> User? {
let justStarted = try apiStartChat()
if justStarted {
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try apiSetIncognito(incognito: incognitoGroupDefault.get())
chatLastStartGroupDefault.set(Date.now)
Task { await receiveMessages() }
}
@@ -248,6 +249,12 @@ func apiSetFilesFolder(filesFolder: String) throws {
throw r
}
func apiSetIncognito(incognito: Bool) throws {
let r = sendSimpleXCmd(.setIncognito(incognito: incognito))
if case .cmdOk = r { return }
throw r
}
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo))
if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r {
+30 -26
View File
@@ -13,11 +13,6 @@
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; };
5C00165628B02AF40094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165128B02AF30094D739 /* libffi.a */; };
5C00165728B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165228B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a */; };
5C00165828B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165328B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a */; };
5C00165928B02AF40094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165428B02AF30094D739 /* libgmp.a */; };
5C00165A28B02AF40094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00165528B02AF30094D739 /* libgmpxx.a */; };
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
@@ -131,6 +126,12 @@
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
64F1CC4128B3A99A00CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64F1CC3C28B3A99900CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5.a */; };
64F1CC4228B3A99A00CD1FB1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64F1CC3D28B3A99900CD1FB1 /* libgmpxx.a */; };
64F1CC4328B3A99A00CD1FB1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64F1CC3E28B3A99900CD1FB1 /* libgmp.a */; };
64F1CC4428B3A99A00CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64F1CC3F28B3A99A00CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5-ghc8.10.7.a */; };
64F1CC4528B3A99A00CD1FB1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64F1CC4028B3A99A00CD1FB1 /* libffi.a */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -196,11 +197,6 @@
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = "<group>"; };
5C00165128B02AF30094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C00165228B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a"; sourceTree = "<group>"; };
5C00165328B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a"; sourceTree = "<group>"; };
5C00165428B02AF30094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C00165528B02AF30094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
@@ -317,6 +313,12 @@
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
64F1CC3C28B3A99900CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5.a"; sourceTree = "<group>"; };
64F1CC3D28B3A99900CD1FB1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64F1CC3E28B3A99900CD1FB1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64F1CC3F28B3A99A00CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5-ghc8.10.7.a"; sourceTree = "<group>"; };
64F1CC4028B3A99A00CD1FB1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -349,13 +351,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
64F1CC4428B3A99A00CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5-ghc8.10.7.a in Frameworks */,
64F1CC4228B3A99A00CD1FB1 /* libgmpxx.a in Frameworks */,
64F1CC4328B3A99A00CD1FB1 /* libgmp.a in Frameworks */,
64F1CC4128B3A99A00CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5.a in Frameworks */,
64F1CC4528B3A99A00CD1FB1 /* libffi.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C00165728B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a in Frameworks */,
5C00165828B02AF40094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a in Frameworks */,
5C00165628B02AF40094D739 /* libffi.a in Frameworks */,
5C00165928B02AF40094D739 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C00165A28B02AF40094D739 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -410,11 +412,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C00165128B02AF30094D739 /* libffi.a */,
5C00165428B02AF30094D739 /* libgmp.a */,
5C00165528B02AF30094D739 /* libgmpxx.a */,
5C00165228B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J-ghc8.10.7.a */,
5C00165328B02AF30094D739 /* libHSsimplex-chat-3.1.0-KA4pfwpgEHbFrTKfOobU7J.a */,
64F1CC4028B3A99A00CD1FB1 /* libffi.a */,
64F1CC3E28B3A99900CD1FB1 /* libgmp.a */,
64F1CC3D28B3A99900CD1FB1 /* libgmpxx.a */,
64F1CC3F28B3A99A00CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5-ghc8.10.7.a */,
64F1CC3C28B3A99900CD1FB1 /* libHSsimplex-chat-3.2.0-6p2ah0FJ9icAh1HFBZcXP5.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -553,6 +555,7 @@
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
640F50E227CF991C001E05C2 /* SMPServers.swift */,
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -869,6 +872,7 @@
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */,
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */,
5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */,
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
@@ -1160,7 +1164,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1202,7 +1206,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1281,7 +1285,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1311,7 +1315,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1342,7 +1346,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 70;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1388,7 +1392,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 70;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
+13 -4
View File
@@ -20,6 +20,7 @@ public enum ChatCommand {
case apiActivateChat
case apiSuspendChat(timeoutMicroseconds: Int)
case setFilesFolder(filesFolder: String)
case setIncognito(incognito: Bool)
case apiExportArchive(config: ArchiveConfig)
case apiImportArchive(config: ArchiveConfig)
case apiDeleteStorage
@@ -54,6 +55,7 @@ public enum ChatCommand {
case apiClearChat(type: ChatType, id: Int64)
case listContacts
case apiUpdateProfile(profile: Profile)
case apiSetContactAlias(contactId: Int64, localAlias: String)
case createMyAddress
case deleteMyAddress
case showMyAddress
@@ -82,6 +84,7 @@ public enum ChatCommand {
case .apiActivateChat: return "/_app activate"
case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)"
case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)"
case let .setIncognito(incognito): return "/incognito \(incognito ? "on" : "off")"
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
case .apiDeleteStorage: return "/_db delete"
@@ -118,6 +121,7 @@ public enum ChatCommand {
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case .listContacts: return "/contacts"
case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
case .createMyAddress: return "/address"
case .deleteMyAddress: return "/delete_address"
case .showMyAddress: return "/show_address"
@@ -148,6 +152,7 @@ public enum ChatCommand {
case .apiActivateChat: return "apiActivateChat"
case .apiSuspendChat: return "apiSuspendChat"
case .setFilesFolder: return "setFilesFolder"
case .setIncognito: return "setIncognito"
case .apiExportArchive: return "apiExportArchive"
case .apiImportArchive: return "apiImportArchive"
case .apiDeleteStorage: return "apiDeleteStorage"
@@ -181,6 +186,7 @@ public enum ChatCommand {
case .apiClearChat: return "apiClearChat"
case .listContacts: return "listContacts"
case .apiUpdateProfile: return "apiUpdateProfile"
case .apiSetContactAlias: return "apiSetContactAlias"
case .createMyAddress: return "createMyAddress"
case .deleteMyAddress: return "deleteMyAddress"
case .showMyAddress: return "showMyAddress"
@@ -225,7 +231,7 @@ public enum ChatResponse: Decodable, Error {
case apiChat(chat: ChatData)
case userSMPServers(smpServers: [String])
case networkConfig(networkConfig: NetCfg)
case contactInfo(contact: Contact, connectionStats: ConnectionStats)
case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?)
case invitation(connReqInvitation: String)
case sentConfirmation
@@ -235,6 +241,7 @@ public enum ChatResponse: Decodable, Error {
case chatCleared(chatInfo: ChatInfo)
case userProfileNoChange
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
case contactAliasUpdated(toContact: Contact)
case userContactLink(connReqContact: String)
case userContactLinkCreated(connReqContact: String)
case userContactLinkDeleted
@@ -326,6 +333,7 @@ public enum ChatResponse: Decodable, Error {
case .chatCleared: return "chatCleared"
case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileUpdated"
case .contactAliasUpdated: return "contactAliasUpdated"
case .userContactLink: return "userContactLink"
case .userContactLinkCreated: return "userContactLinkCreated"
case .userContactLinkDeleted: return "userContactLinkDeleted"
@@ -407,8 +415,8 @@ public enum ChatResponse: Decodable, Error {
case let .apiChat(chat): return String(describing: chat)
case let .userSMPServers(smpServers): return String(describing: smpServers)
case let .networkConfig(networkConfig): return String(describing: networkConfig)
case let .contactInfo(contact, connectionStats): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))"
case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\\nconnectionStats_: \(String(describing: connectionStats_))"
case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))"
case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))"
case let .invitation(connReqInvitation): return connReqInvitation
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
@@ -417,6 +425,7 @@ public enum ChatResponse: Decodable, Error {
case let .chatCleared(chatInfo): return String(describing: chatInfo)
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
case let .contactAliasUpdated(toContact): return String(describing: toContact)
case let .userContactLink(connReq): return connReq
case let .userContactLinkCreated(connReq): return connReq
case .userContactLinkDeleted: return noDetails
@@ -561,7 +570,7 @@ public enum OnionHosts: String, Identifiable {
switch self {
case .no: return "No"
case .prefer: return "When available"
case .require: return "Requred"
case .require: return "Required"
}
}
+5 -1
View File
@@ -23,6 +23,7 @@ let GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE = "networkEnableKeepAlive"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE = "networkTCPKeepIdle"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt"
let GROUP_DEFAULT_INCOGNITO = "incognito"
let APP_GROUP_NAME = "group.chat.simplex.app"
@@ -37,7 +38,8 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive,
GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle,
GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl,
GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt
GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt,
GROUP_DEFAULT_INCOGNITO: false
])
}
@@ -82,6 +84,8 @@ public let ntfPreviewModeGroupDefault = EnumDefault<NotificationPreviewMode>(
withDefault: .message
)
public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO)
public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES)
public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT)
+129 -19
View File
@@ -13,18 +13,19 @@ public struct User: Decodable, NamedChat {
var userId: Int64
var userContactId: Int64
var localDisplayName: ContactName
public var profile: Profile
public var profile: LocalProfile
var activeUser: Bool
public var displayName: String { get { profile.displayName } }
public var fullName: String { get { profile.fullName } }
public var image: String? { get { profile.image } }
public var localAlias: String { get { "" } }
public static let sampleData = User(
userId: 1,
userContactId: 1,
localDisplayName: "alice",
profile: Profile.sampleData,
profile: LocalProfile.sampleData,
activeUser: true
)
}
@@ -43,6 +44,7 @@ public struct Profile: Codable, NamedChat {
public var displayName: String
public var fullName: String
public var image: String?
public var localAlias: String { get { "" } }
var profileViewName: String {
(fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))"
@@ -54,6 +56,43 @@ public struct Profile: Codable, NamedChat {
)
}
public struct LocalProfile: Codable, NamedChat {
public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, localAlias: String) {
self.profileId = profileId
self.displayName = displayName
self.fullName = fullName
self.image = image
self.localAlias = localAlias
}
public var profileId: Int64
public var displayName: String
public var fullName: String
public var image: String?
public var localAlias: String
var profileViewName: String {
localAlias == ""
? (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))"
: localAlias
}
static let sampleData = LocalProfile(
profileId: 1,
displayName: "alice",
fullName: "Alice",
localAlias: ""
)
}
public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile {
LocalProfile(profileId: profileId, displayName: profile.displayName, fullName: profile.fullName, image: profile.image, localAlias: localAlias)
}
public func fromLocalProfile (_ profile: LocalProfile) -> Profile {
Profile(displayName: profile.displayName, fullName: profile.fullName, image: profile.image)
}
public enum ChatType: String {
case direct = "@"
case group = "#"
@@ -65,11 +104,14 @@ public protocol NamedChat {
var displayName: String { get }
var fullName: String { get }
var image: String? { get }
var localAlias: String { get }
}
extension NamedChat {
public var chatViewName: String {
get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") }
localAlias == ""
? displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)")
: localAlias
}
}
@@ -125,6 +167,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var localAlias: String {
get {
switch self {
case let .direct(contact): return contact.localAlias
case let .group(groupInfo): return groupInfo.localAlias
case let .contactRequest(contactRequest): return contactRequest.localAlias
case let .contactConnection(contactConnection): return contactConnection.localAlias
}
}
}
public var id: ChatId {
get {
switch self {
@@ -180,6 +233,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var incognito: Bool {
get {
switch self {
case let .direct(contact): return contact.contactConnIncognito
case let .group(groupInfo): return groupInfo.membership.memberIncognito
case .contactRequest: return false
case let .contactConnection(contactConnection): return contactConnection.incognito
}
}
}
public var contact: Contact? {
get {
switch self {
@@ -247,9 +311,9 @@ public struct ChatStats: Decodable {
}
public struct Contact: Identifiable, Decodable, NamedChat {
var contactId: Int64
public var contactId: Int64
var localDisplayName: ContactName
public var profile: Profile
public var profile: LocalProfile
public var activeConn: Connection
public var viaGroup: Int64?
public var chatSettings: ChatSettings
@@ -260,18 +324,23 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var apiId: Int64 { get { contactId } }
public var ready: Bool { get { activeConn.connStatus == .ready } }
public var sendMsgEnabled: Bool { get { true } }
public var displayName: String { get { profile.displayName } }
public var displayName: String { localAlias == "" ? profile.displayName : localAlias }
public var fullName: String { get { profile.fullName } }
public var image: String? { get { profile.image } }
public var localAlias: String { profile.localAlias }
public func isIndirectContact() -> Bool {
return activeConn.connLevel > 0 || viaGroup != nil
public var isIndirectContact: Bool {
activeConn.connLevel > 0 || viaGroup != nil
}
public var contactConnIncognito: Bool {
activeConn.customUserProfileId != nil
}
public static let sampleData = Contact(
contactId: 1,
localDisplayName: "alice",
profile: Profile.sampleData,
profile: LocalProfile.sampleData,
activeConn: Connection.sampleData,
chatSettings: ChatSettings.defaults,
createdAt: .now,
@@ -295,6 +364,7 @@ public struct Connection: Decodable {
var connId: Int64
var connStatus: ConnStatus
public var connLevel: Int
public var customUserProfileId: Int64?
public var id: ChatId { get { ":\(connId)" } }
@@ -336,6 +406,7 @@ public struct UserContactRequest: Decodable, NamedChat {
public var displayName: String { get { profile.displayName } }
public var fullName: String { get { profile.fullName } }
public var image: String? { get { profile.image } }
public var localAlias: String { "" }
public static let sampleData = UserContactRequest(
contactRequestId: 1,
@@ -352,6 +423,7 @@ public struct PendingContactConnection: Decodable, NamedChat {
var pccAgentConnId: String
var pccConnStatus: ConnStatus
public var viaContactUri: Bool
public var customUserProfileId: Int64?
var createdAt: Date
public var updatedAt: Date
@@ -376,16 +448,37 @@ public struct PendingContactConnection: Decodable, NamedChat {
}
public var fullName: String { get { "" } }
public var image: String? { get { nil } }
public var localAlias: String { "" }
public var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } }
public var incognito: Bool {
customUserProfileId != nil
}
public var description: String {
get {
if let initiated = pccConnStatus.initiated {
return initiated && !viaContactUri
? NSLocalizedString("you shared one-time link", comment: "chat list item description")
: viaContactUri
? NSLocalizedString("via contact address link", comment: "chat list item description")
: NSLocalizedString("via one-time link", comment: "chat list item description")
var desc: String
if initiated && !viaContactUri {
if incognito {
desc = NSLocalizedString("you shared one-time link incognito", comment: "chat list item description")
} else {
desc = NSLocalizedString("you shared one-time link", comment: "chat list item description")
}
} else if viaContactUri {
if incognito {
desc = NSLocalizedString("incognito via contact address link", comment: "chat list item description")
} else {
desc = NSLocalizedString("via contact address link", comment: "chat list item description")
}
} else {
if incognito {
desc = NSLocalizedString("incognito via one-time link", comment: "chat list item description")
} else {
desc = NSLocalizedString("via one-time link", comment: "chat list item description")
}
}
return desc
} else {
return ""
}
@@ -443,6 +536,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
var localDisplayName: GroupName
public var groupProfile: GroupProfile
public var membership: GroupMember
public var hostConnCustomUserProfileId: Int64?
public var chatSettings: ChatSettings
var createdAt: Date
var updatedAt: Date
@@ -454,6 +548,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
public var displayName: String { get { groupProfile.displayName } }
public var fullName: String { get { groupProfile.fullName } }
public var image: String? { get { groupProfile.image } }
public var localAlias: String { "" }
public var canEdit: Bool {
return membership.memberRole == .owner && membership.memberCurrent
@@ -472,6 +567,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
localDisplayName: "team",
groupProfile: GroupProfile.sampleData,
membership: GroupMember.sampleData,
hostConnCustomUserProfileId: nil,
chatSettings: ChatSettings.defaults,
createdAt: .now,
updatedAt: .now
@@ -488,6 +584,7 @@ public struct GroupProfile: Codable, NamedChat {
public var displayName: String
public var fullName: String
public var image: String?
public var localAlias: String { "" }
public static let sampleData = GroupProfile(
displayName: "team",
@@ -504,12 +601,18 @@ public struct GroupMember: Identifiable, Decodable {
public var memberStatus: GroupMemberStatus
public var invitedBy: InvitedBy
public var localDisplayName: ContactName
public var memberProfile: Profile
public var memberProfile: LocalProfile
public var memberContactId: Int64?
public var memberContactProfileId: Int64
public var activeConn: Connection?
public var id: String { "#\(groupId) @\(groupMemberId)" }
public var displayName: String { get { memberProfile.displayName } }
public var displayName: String {
get {
let p = memberProfile
return p.localAlias == "" ? p.displayName : p.localAlias
}
}
public var fullName: String { get { memberProfile.fullName } }
public var image: String? { get { memberProfile.image } }
@@ -526,7 +629,9 @@ public struct GroupMember: Identifiable, Decodable {
public var chatViewName: String {
get {
let p = memberProfile
return p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)")
return p.localAlias == ""
? p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)")
: p.localAlias
}
}
@@ -568,6 +673,10 @@ public struct GroupMember: Identifiable, Decodable {
&& userRole >= .admin && userRole >= memberRole && membership.memberCurrent
}
public var memberIncognito: Bool {
memberProfile.profileId != memberContactProfileId
}
public static let sampleData = GroupMember(
groupMemberId: 1,
groupId: 1,
@@ -577,8 +686,9 @@ public struct GroupMember: Identifiable, Decodable {
memberStatus: .memComplete,
invitedBy: .user,
localDisplayName: "alice",
memberProfile: Profile.sampleData,
memberProfile: LocalProfile.sampleData,
memberContactId: 1,
memberContactProfileId: 1,
activeConn: Connection.sampleData
)
}
@@ -783,7 +893,7 @@ public struct ChatItem: Identifiable, Decodable {
public var memberDisplayName: String? {
get {
if case let .groupRcv(groupMember) = chatDir {
return groupMember.memberProfile.displayName
return groupMember.displayName
} else {
return nil
}
+148 -10
View File
@@ -1,3 +1,6 @@
/* No comment provided by engineer. */
"\n" = "\n";
/* No comment provided by engineer. */
" " = " ";
@@ -106,6 +109,12 @@
/* notification title */
"A new contact" = "Новый контакт";
/* No comment provided by engineer. */
"A random profile will be sent to the contact that you received this link from" = "Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль";
/* No comment provided by engineer. */
"A random profile will be sent to your contact" = "Вашему контакту будет отправлен случайный профиль";
/* No comment provided by engineer. */
"About SimpleX" = "О SimpleX";
@@ -115,6 +124,9 @@
/* No comment provided by engineer. */
"above, then choose:" = "наверху, затем выберите:";
/* No comment provided by engineer. */
"Accent color" = "Основной цвет";
/* accept contact request via notification
accept incoming call via notification */
"Accept" = "Принять";
@@ -125,6 +137,9 @@
/* notification body */
"Accept contact request from %@?" = "Принять запрос на соединение от %@?";
/* No comment provided by engineer. */
"Accept incognito" = "Принять инкогнито";
/* call status */
"accepted call" = " принятый звонок";
@@ -194,6 +209,12 @@
/* call status */
"calling…" = "входящий звонок…";
/* No comment provided by engineer. */
"Can't invite contact!" = "Нельзя пригласить контакт!";
/* No comment provided by engineer. */
"Can't invite contacts!" = "Нельзя пригласить контакты!";
/* No comment provided by engineer. */
"Cancel" = "Отменить";
@@ -221,7 +242,7 @@
/* No comment provided by engineer. */
"Chat with the developers" = "Соединиться с разработчиками";
/* back button to return to chats list */
/* No comment provided by engineer. */
"Chats" = "Чаты";
/* No comment provided by engineer. */
@@ -242,6 +263,9 @@
/* No comment provided by engineer. */
"colored" = "цвет";
/* No comment provided by engineer. */
"Colors" = "Цвета";
/* No comment provided by engineer. */
"complete" = "соединение завершено";
@@ -350,7 +374,7 @@
/* No comment provided by engineer. */
"Contact name" = "Имена контактов";
/* No comment provided by engineer. */
/* chat item action */
"Copy" = "Скопировать";
/* No comment provided by engineer. */
@@ -392,7 +416,7 @@
/* No comment provided by engineer. */
"Decentralized" = "Децентрализованный";
/* No comment provided by engineer. */
/* chat item action */
"Delete" = "Удалить";
/* No comment provided by engineer. */
@@ -494,7 +518,7 @@
/* No comment provided by engineer. */
"e2e encrypted" = "e2e зашифровано";
/* No comment provided by engineer. */
/* chat item action */
"Edit" = "Редактировать";
/* No comment provided by engineer. */
@@ -530,6 +554,9 @@
/* No comment provided by engineer. */
"Error accessing database file" = "Ошибка при доступе к данным чата";
/* No comment provided by engineer. */
"Error adding member(s)" = "Ошибка при добавлении членов группы";
/* No comment provided by engineer. */
"Error creating group" = "Ошибка при создании группы";
@@ -692,6 +719,24 @@
/* No comment provided by engineer. */
"In person or via a video call the most secure way to connect." = "При встрече или в видеозвонке – самый безопасный способ установить соединение";
/* No comment provided by engineer. */
"Incognito" = "Инкогнито";
/* No comment provided by engineer. */
"Incognito mode" = "Режим Инкогнито";
/* No comment provided by engineer. */
"Incognito mode is not supported here - your main profile will be sent to group members" = "Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы";
/* No comment provided by engineer. */
"Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created." = "Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.";
/* chat list item description */
"incognito via contact address link" = "инкогнито через ссылку-контакт";
/* chat list item description */
"incognito via one-time link" = "инкогнито через одноразовую ссылку";
/* notification */
"Incoming audio call" = "Входящий аудиозвонок";
@@ -734,6 +779,9 @@
/* chat list item title */
"invited to connect" = "приглашение";
/* No comment provided by engineer. */
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.";
/* No comment provided by engineer. */
"It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages." = "Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.";
@@ -749,9 +797,15 @@
/* No comment provided by engineer. */
"Join" = "Вступить";
/* No comment provided by engineer. */
"join as %@" = "вступить как %@";
/* No comment provided by engineer. */
"Join group" = "Вступить в группу";
/* No comment provided by engineer. */
"Join incognito" = "Вступить инкогнито";
/* No comment provided by engineer. */
"Joining group" = "Вступление в группу";
@@ -785,6 +839,9 @@
/* No comment provided by engineer. */
"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?*";
/* No comment provided by engineer. */
"Mark read" = "Прочитано";
/* No comment provided by engineer. */
"Markdown in messages" = "Форматирование сообщений";
@@ -827,6 +884,9 @@
/* No comment provided by engineer. */
"Most likely this contact has deleted the connection with you." = "Скорее всего, этот контакт удалил соединение с вами.";
/* No comment provided by engineer. */
"Mute" = "Без звука";
/* No comment provided by engineer. */
"Network & servers" = "Сеть & серверы";
@@ -854,6 +914,9 @@
/* notification */
"New message" = "Новое сообщение";
/* No comment provided by engineer. */
"No" = "Нет";
/* No comment provided by engineer. */
"No contacts selected" = "Контакты не выбраны";
@@ -890,6 +953,15 @@
/* No comment provided by engineer. */
"One-time invitation link" = "Одноразовая ссылка";
/* No comment provided by engineer. */
"Onion hosts will be required for connection. Requires enabling VPN." = "Подключаться только к onion хостам. Требуется включенный VPN.";
/* No comment provided by engineer. */
"Onion hosts will be used when available. Requires enabling VPN." = "Onion хосты используются, если возможно. Требуется включенный VPN.";
/* No comment provided by engineer. */
"Onion hosts will not be used." = "Onion хосты не используются.";
/* No comment provided by engineer. */
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**";
@@ -992,6 +1064,12 @@
/* call status */
"rejected call" = "отклонённый звонок";
/* No comment provided by engineer. */
"Relay server is only used if necessary. Another party can observe your IP address." = "Relay сервер используется только при необходимости. Другая сторона может видеть ваш IP адрес.";
/* No comment provided by engineer. */
"Relay server protects your IP address, but it can observe the duration of the call." = "Relay сервер защищает ваш IP адрес, но может отслеживать продолжительность звонка.";
/* No comment provided by engineer. */
"Remove" = "Удалить";
@@ -1010,9 +1088,15 @@
/* rcv group event chat item */
"removed you" = "удалил(а) вас из группы";
/* No comment provided by engineer. */
/* chat item action */
"Reply" = "Ответить";
/* No comment provided by engineer. */
"Required" = "Обязательно";
/* No comment provided by engineer. */
"Reset colors" = "Сбросить цвета";
/* No comment provided by engineer. */
"Reset to defaults" = "Сбросить настройки";
@@ -1028,7 +1112,7 @@
/* No comment provided by engineer. */
"Run chat" = "Запустить chat";
/* No comment provided by engineer. */
/* chat item action */
"Save" = "Сохранить";
/* No comment provided by engineer. */
@@ -1049,12 +1133,18 @@
/* No comment provided by engineer. */
"Scan QR code" = "Сканировать QR код";
/* No comment provided by engineer. */
"Search" = "Поиск";
/* network option */
"sec" = "сек";
/* No comment provided by engineer. */
"secret" = "секрет";
/* No comment provided by engineer. */
"Send direct message" = "Отправить сообщение";
/* No comment provided by engineer. */
"Send link previews" = "Отправлять картинки ссылок";
@@ -1073,13 +1163,16 @@
/* No comment provided by engineer. */
"Servers" = "Серверы";
/* No comment provided by engineer. */
"Set contact name…" = "Имя контакта…";
/* No comment provided by engineer. */
"Set timeouts for proxy/VPN" = "Установить таймауты для прокси/VPN";
/* No comment provided by engineer. */
"Settings" = "Настройки";
/* No comment provided by engineer. */
/* chat item action */
"Share" = "Поделиться";
/* No comment provided by engineer. */
@@ -1148,6 +1241,9 @@
/* No comment provided by engineer. */
"Tap to join" = "Нажмите, чтобы вступить";
/* No comment provided by engineer. */
"Tap to join incognito" = "Нажмите, чтобы вступить инкогнито";
/* No comment provided by engineer. */
"TCP connection timeout" = "Таймаут TCP соединения";
@@ -1211,6 +1307,9 @@
/* No comment provided by engineer. */
"To ask any questions and to receive updates:" = "Чтобы задать вопросы и получать уведомления о новых версиях,";
/* No comment provided by engineer. */
"To find the profile used for an incognito connection, tap the contact or group name on top of the chat." = "Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.";
/* No comment provided by engineer. */
"To make a new connection" = "Чтобы соединиться";
@@ -1259,6 +1358,12 @@
/* authentication reason */
"Unlock" = "Разблокировать";
/* No comment provided by engineer. */
"Unmute" = "Уведомлять";
/* No comment provided by engineer. */
"Update .onion hosts setting?" = "Обновить настройки .onion хостов?";
/* No comment provided by engineer. */
"Update network settings?" = "Обновить настройки сети?";
@@ -1266,7 +1371,13 @@
"updated group profile" = "обновил(а) профиль группы";
/* No comment provided by engineer. */
"Updating settings will re-connect the client to all servers." = "Обновление настроек приведет к переподключению клиента ко всем серверам.";
"Updating settings will re-connect the client to all servers." = "Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами.";
/* No comment provided by engineer. */
"Updating this setting will re-connect the client to all servers." = "Обновление этих настроек приведет к сбросу и установке нового соединения со всеми серверами.";
/* No comment provided by engineer. */
"Use .onion hosts" = "Использовать .onion хосты";
/* No comment provided by engineer. */
"Use chat" = "Использовать чат";
@@ -1274,6 +1385,9 @@
/* No comment provided by engineer. */
"Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?";
/* No comment provided by engineer. */
"Using .onion hosts requires compatible VPN provider." = "Для использования .onion хостов требуется совместимый VPN провайдер.";
/* No comment provided by engineer. */
"Using SimpleX Chat servers." = "Используются серверы, предоставленные SimpleX Chat.";
@@ -1289,6 +1403,9 @@
/* No comment provided by engineer. */
"via relay" = "через relay сервер";
/* No comment provided by engineer. */
"Video call" = "Видеозвонок";
/* No comment provided by engineer. */
"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)";
@@ -1310,6 +1427,12 @@
/* No comment provided by engineer. */
"Welcome %@!" = "Здравствуйте %@!";
/* No comment provided by engineer. */
"When available" = "Когда возможно";
/* No comment provided by engineer. */
"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.";
/* No comment provided by engineer. */
"You" = "Вы";
@@ -1329,7 +1452,7 @@
"You are invited to group" = "Вы приглашены в группу";
/* No comment provided by engineer. */
"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" = "Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**.";
"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**.";
/* notification body */
"You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@";
@@ -1379,6 +1502,9 @@
/* chat list item description */
"you shared one-time link" = "вы создали ссылку";
/* chat list item description */
"you shared one-time link incognito" = "вы создали ссылку инкогнито";
/* No comment provided by engineer. */
"You will be connected when your connection request is accepted, please wait or check later!" = "Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!";
@@ -1394,6 +1520,12 @@
/* No comment provided by engineer. */
"you: " = "вы: ";
/* No comment provided by engineer. */
"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" = "Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль";
/* No comment provided by engineer. */
"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено";
/* No comment provided by engineer. */
"Your calls" = "Ваши звонки";
@@ -1406,6 +1538,9 @@
/* No comment provided by engineer. */
"Your chat profile" = "Ваш профиль";
/* No comment provided by engineer. */
"Your chat profile will be sent to group members" = "Ваш профиль чата будет отправлен членам группы";
/* No comment provided by engineer. */
"Your chat profile will be sent to your contact" = "Ваш профиль будет отправлен вашему контакту";
@@ -1413,7 +1548,7 @@
"Your chats" = "Ваши чаты";
/* No comment provided by engineer. */
"Your contact can scan it from the app" = "Ваш контакт может сосканировать QR в приложении";
"Your contact can scan it from the app." = "Ваш контакт может сосканировать QR код в приложении";
/* No comment provided by engineer. */
"Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).";
@@ -1436,6 +1571,9 @@
/* No comment provided by engineer. */
"Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве.";
/* No comment provided by engineer. */
"Your random profile" = "Ваш случайный профиль";
/* No comment provided by engineer. */
"Your settings" = "Настройки";
+1 -1
View File
@@ -5,7 +5,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: a7b39b710c3aab9b2a38bd6841e52e0342b3a7ef
tag: f2c1455a2755e1275983dc154321fc0a5c0d7b17
source-repository-package
type: git
-12
View File
@@ -108,12 +108,6 @@
"invitedMember": {"ref": "memberIdRole"},
"connRequest": {"ref": "connReqUri"},
"groupProfile": {"ref": "profile"}
},
"optionalProperties": {
"fromMemberProfile": {"ref": "profile"},
"metadata": {
"comment": "fromMemberProfile is user's custom profile to be used in the group - invitee should use this profile for the host's group member"
}
}
},
"memberIdRole": {
@@ -323,12 +317,6 @@
"params": {
"properties": {
"memberId": {"ref": "base64url"}
},
"optionalProperties": {
"memberProfile": {"ref": "profile"},
"metadata": {
"comment": "memberProfile is user's custom profile to be used in the group - host should use this profile for the invitee's group member"
}
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 3.2.0
version: 3.2.1
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."a7b39b710c3aab9b2a38bd6841e52e0342b3a7ef" = "0iqk58dhckpij9l1z8bm83hghw5cwj9hmpkbk7j8vws123g1bd73";
"https://github.com/simplex-chat/simplexmq.git"."f2c1455a2755e1275983dc154321fc0a5c0d7b17" = "10l74d751jmgsr0ifyprglsvqdpcir86qs1vkwc4dn4n4q503p5q";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
"https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
+4 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 3.2.0
version: 3.2.1
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -45,6 +45,9 @@ library
Simplex.Chat.Migrations.M20220811_chat_items_indices
Simplex.Chat.Migrations.M20220812_incognito_profiles
Simplex.Chat.Migrations.M20220818_chat_notifications
Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id
Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items
Simplex.Chat.Migrations.M20220824_profiles_local_alias
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.ProfileGenerator
+60 -76
View File
@@ -578,6 +578,11 @@ processChatCommand = \case
withCurrentCall contactId $ \userId ct call ->
updateCallItemStatus userId ct call receivedStatus Nothing $> Just call
APIUpdateProfile profile -> withUser (`updateProfile` profile)
APISetContactAlias contactId localAlias -> withUser $ \User {userId} -> do
ct' <- withStore $ \db -> do
ct <- getContact db userId contactId
liftIO $ updateContactAlias db userId ct localAlias
pure $ CRContactAliasUpdated ct'
APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text
APIGetNtfToken -> withUser $ \_ -> crNtfToken <$> withAgent getNtfToken
APIRegisterToken token mode -> CRNtfTokenStatus <$> withUser (\_ -> withAgent $ \a -> registerNtfToken a token mode)
@@ -619,13 +624,11 @@ processChatCommand = \case
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db userId contactId
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
pure $ CRContactInfo ct connectionStats incognitoProfile
APIGroupMemberInfo gId gMemberId -> withUser $ \user@User {userId} -> do
-- [incognito] print group member main profile
(g, m@GroupMember {memberContactProfileId}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile)
APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
pure $ CRGroupMemberInfo g m connectionStats mainProfile
pure $ CRGroupMemberInfo g m connectionStats
ContactInfo cName -> withUser $ \User {userId} -> do
contactId <- withStore $ \db -> getContactIdByName db userId cName
processChatCommand $ APIContactInfo contactId
@@ -719,32 +722,29 @@ processChatCommand = \case
processChatCommand $ APIUpdateChatItem chatRef editedItemId mc
NewGroup gProfile -> withUser $ \user -> do
gVar <- asks idsDrg
-- [incognito] create membership with incognito profile
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile incognitoProfile)
pure $ CRGroupCreated groupInfo incognitoProfile
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile)
pure $ CRGroupCreated groupInfo
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock $ do
-- TODO for large groups: no need to load all members to determine if contact is a member
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId
-- [incognito] forbid to invite contact to whom user is connected as incognito if user's membership is not incognito
let Group gInfo@GroupInfo {localDisplayName, groupProfile, membership} members = group
GroupMember {memberRole = userRole, memberId = userMemberId} = membership
Contact {localDisplayName = cName} = contact
when (contactConnIncognito contact && not (memberIncognito membership)) $ throwChatError CEGroupNotIncognitoCantInvite
-- [incognito] forbid to invite contact to whom user is connected incognito
when (contactConnIncognito contact) $ throwChatError CEContactIncognitoCantInvite
-- [incognito] forbid to invite contacts if user joined the group using an incognito profile
when (memberIncognito membership) $ throwChatError CEGroupIncognitoCantInvite
when (userRole < GRAdmin || userRole < memRole) $ throwChatError CEGroupUserRole
when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo)
unless (memberActive membership) $ throwChatError CEGroupMemberNotActive
let sendInvitation member@GroupMember {groupMemberId, memberId} cReq = do
-- [incognito] if membership is incognito, send its incognito profile in GroupInvitation
let incognitoProfile = if memberIncognito membership then Just (fromLocalProfile $ memberProfile membership) else Nothing
groupInv = GroupInvitation (MemberIdRole userMemberId userRole) incognitoProfile (MemberIdRole memberId memRole) cReq groupProfile
let groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile
msg <- sendDirectContactMessage contact $ XGrpInv groupInv
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, invitedIncognito = Just $ memberIncognito membership}) memRole
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
ci <- saveSndChatItem user (CDDirectSnd contact) msg content Nothing Nothing
toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci
setActive $ ActiveG localDisplayName
pure $ CRSentGroupInvitation gInfo contact member incognitoProfile
pure $ CRSentGroupInvitation gInfo contact member
case contactMember contact members of
Nothing -> do
gVar <- asks idsDrg
@@ -760,24 +760,13 @@ processChatCommand = \case
APIJoinGroup groupId -> withUser $ \user@User {userId} -> do
ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId
withChatLock . procCmd $ do
-- [incognito] if incognito mode is enabled [AND membership is not incognito] update membership to use incognito profile
incognito <- readTVarIO =<< asks incognitoMode
g'@GroupInfo {membership = membership'} <-
if incognito && not (memberIncognito membership)
then do
incognitoProfile <- liftIO generateRandomProfile
membership' <- withStore $ \db -> createMemberIncognitoProfile db userId membership (Just incognitoProfile)
pure g {membership = membership'}
else pure g
-- [incognito] if membership is incognito, send its incognito profile in XGrpAcpt
let incognitoProfile = if memberIncognito membership' then Just (fromLocalProfile $ memberProfile membership') else Nothing
agentConnId <- withAgent $ \a -> joinConnection a True connRequest . directMessage $ XGrpAcpt (memberId (membership' :: GroupMember)) incognitoProfile
agentConnId <- withAgent $ \a -> joinConnection a True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember))
withStore' $ \db -> do
createMemberConnection db userId fromMember agentConnId
updateGroupMemberStatus db userId fromMember GSMemAccepted
updateGroupMemberStatus db userId membership' GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
updateCIGroupInvitationStatus user
pure $ CRUserAcceptedGroupSent g' {membership = membership' {memberStatus = GSMemAccepted}}
pure $ CRUserAcceptedGroupSent g {membership = membership {memberStatus = GSMemAccepted}}
where
updateCIGroupInvitationStatus user@User {userId} = do
AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId
@@ -984,11 +973,11 @@ processChatCommand = \case
unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f
(,) <$> getFileSize fsFilePath <*> asks (fileChunkSize . config)
updateProfile :: User -> Profile -> m ChatResponse
updateProfile user@User {profile = p@LocalProfile {profileId}} p'@Profile {displayName}
updateProfile user@User {profile = p@LocalProfile {profileId, localAlias}} p'@Profile {displayName}
| p' == fromLocalProfile p = pure CRUserProfileNoChange
| otherwise = do
withStore $ \db -> updateUserProfile db user p'
let user' = (user :: User) {localDisplayName = displayName, profile = toLocalProfile profileId p'}
let user' = (user :: User) {localDisplayName = displayName, profile = toLocalProfile profileId p' localAlias}
asks currentUser >>= atomically . (`writeTVar` Just user')
-- [incognito] filter out contacts with whom user has incognito connections
contacts <-
@@ -1365,7 +1354,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
CONF confId _ connInfo -> do
-- [incognito] send saved profile
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile
let profileToSend = fromLocalProfile $ fromMaybe profile incognitoProfile
saveConnInfo conn connInfo
allowAgentConnection conn confId $ XInfo profileToSend
INFO connInfo ->
@@ -1430,7 +1419,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
Nothing -> do
-- [incognito] print incognito profile used for this contact
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
toView $ CRContactConnected ct incognitoProfile
toView $ CRContactConnected ct (fmap fromLocalProfile incognitoProfile)
setActive $ ActiveC c
showToast (c <> "> ") "connected"
forM_ viaUserContactLink $ \userContactLinkId -> do
@@ -1440,10 +1429,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) Nothing Nothing
toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
_ -> pure ()
Just (gInfo, m@GroupMember {activeConn}) -> do
Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> do
when (maybe False ((== ConnReady) . connStatus) activeConn) $ do
notifyMemberConnected gInfo m
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct
let connectedIncognito = contactConnIncognito ct || memberIncognito membership
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito
SENT msgId -> do
sentMsgDeliveryEvent conn msgId
withStore' (\db -> getDirectChatItemByAgentMsgId db userId contactId connId msgId) >>= \case
@@ -1466,18 +1456,15 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
_ -> pure ()
processGroupMessage :: ACommand 'Agent -> Connection -> GroupInfo -> GroupMember -> m ()
processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership, chatSettings} m@GroupMember {memberContactProfileId} = case agentMsg of
processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership, chatSettings} m = case agentMsg of
CONF confId _ connInfo -> do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo
case memberCategory m of
GCInviteeMember ->
case chatMsgEvent of
XGrpAcpt memId incognitoProfile
XGrpAcpt memId
| sameMemberId memId m -> do
-- [incognito] update member profile to incognito profile
withStore $ \db -> do
liftIO $ updateGroupMemberStatus db userId m GSMemAccepted
void $ createMemberIncognitoProfile db userId m incognitoProfile
withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted
allowAgentConnection conn confId XOk
| otherwise -> messageError "x.grp.acpt: memberId is different from expected"
_ -> messageError "CONF from invited member must have x.grp.acpt"
@@ -1486,7 +1473,6 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
XGrpMemInfo memId _memProfile
| sameMemberId memId m -> do
-- TODO update member profile
-- [incognito] send membership incognito profile
allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership)
| otherwise -> messageError "x.grp.mem.info: memberId is different from expected"
_ -> messageError "CONF from member must have x.grp.mem.info"
@@ -1511,17 +1497,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
unless (enableNtfs chatSettings) . withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) False
case memberCategory m of
GCHostMember -> do
-- [incognito] chat item & event with indication that host connected incognito
mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
memberConnectedChatItem gInfo m mainProfile
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} (memberIncognito membership)
memberConnectedChatItem gInfo m
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected}
setActive $ ActiveG gName
showToast ("#" <> gName) "you are connected to group"
GCInviteeMember -> do
-- [incognito] chat item & event with indication that invitee connected incognito
mainProfile <- if memberIncognito m then Just <$> withStore (\db -> getProfileById db userId memberContactProfileId) else pure Nothing
memberConnectedChatItem gInfo m mainProfile
toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected} mainProfile
memberConnectedChatItem gInfo m
toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected}
setActive $ ActiveG gName
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
intros <- withStore' $ \db -> createIntroductions db members m
@@ -1539,7 +1521,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
Just ct@Contact {activeConn = Connection {connStatus}} ->
when (connStatus == ConnReady) $ do
notifyMemberConnected gInfo m
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct
let connectedIncognito = contactConnIncognito ct || memberIncognito membership
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito
MSG msgMeta _msgFlags msgBody -> do
msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody
withAckMessage agentConnId msgMeta $
@@ -1708,12 +1691,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
cancelRcvFileTransfer user ft
throwChatError $ CEFileRcvChunk err
memberConnectedChatItem :: GroupInfo -> GroupMember -> Maybe Profile -> m ()
memberConnectedChatItem gInfo m mainProfile_ = do
memberConnectedChatItem :: GroupInfo -> GroupMember -> m ()
memberConnectedChatItem gInfo m = do
createdAt <- liftIO getCurrentTime
let content = CIRcvGroupEvent $ case mainProfile_ of
Just mainProfile -> RGEMemberConnected $ Just mainProfile
_ -> RGEMemberConnected Nothing
let content = CIRcvGroupEvent RGEMemberConnected
cd = CDGroupRcv gInfo m
-- first ts should be broker ts but we don't have it for CON
ciId <- withStore' $ \db -> createNewChatItemNoMsg db user cd content createdAt createdAt
@@ -1722,20 +1703,24 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
notifyMemberConnected :: GroupInfo -> GroupMember -> m ()
notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do
memberConnectedChatItem gInfo m Nothing
memberConnectedChatItem gInfo m
toView $ CRConnectedToGroupMember gInfo m
let g = groupName' gInfo
setActive $ ActiveG g
showToast ("#" <> g) $ "member " <> c <> " is connected"
probeMatchingContacts :: Contact -> m ()
probeMatchingContacts ct = do
probeMatchingContacts :: Contact -> Bool -> m ()
probeMatchingContacts ct connectedIncognito = do
gVar <- asks idsDrg
(probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId ct
void . sendDirectContactMessage ct $ XInfoProbe probe
cs <- withStore' $ \db -> getMatchingContacts db userId ct
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
if connectedIncognito
then
withStore' $ \db -> deleteSentProbe db userId probeId
else do
cs <- withStore' $ \db -> getMatchingContacts db userId ct
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
where
sendProbeHash :: Contact -> ProbeHash -> Int64 -> m ()
sendProbeHash c probeHash probeId = do
@@ -1910,20 +1895,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m ()
processGroupInvitation ct@Contact {contactId, localDisplayName = c} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), fromMemberProfile, invitedMember = (MemberIdRole memId memRole)} msg msgMeta = do
processGroupInvitation ct@Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole)} msg msgMeta = do
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
-- [incognito] if (received group invitation has host's incognito profile OR direct connection with host is incognito), create membership with new incognito profile; incognito mode is checked when joining group
hostContact <- withStore $ \db -> getContact db userId contactId
let joinGroupIncognito = isJust fromMemberProfile || contactConnIncognito hostContact
incognitoProfile <- if joinGroupIncognito then Just <$> liftIO generateRandomProfile else pure Nothing
gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv incognitoProfile
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, invitedIncognito = Just joinGroupIncognito}) memRole
-- [incognito] if direct connection with host is incognito, create membership using the same incognito profile
gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content Nothing
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
toView $ CRReceivedGroupInvitation gInfo ct memRole fromMemberProfile
toView $ CRReceivedGroupInvitation gInfo ct memRole
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m ()
@@ -2106,8 +2088,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
else do
(groupConnId, groupConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation
(directConnId, directConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation
-- [incognito] direct connection with member has to be established using same incognito profile
customUserProfileId <- if memberIncognito membership then Just <$> withStore (\db -> getGroupMemberProfileId db userId membership) else pure Nothing
-- [incognito] direct connection with member has to be established using the same incognito profile [that was known to host and used for group membership]
let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing
newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId customUserProfileId
let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq}
void $ sendDirectMessage conn msg (GroupId groupId)
@@ -2141,7 +2123,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
let msg = XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership)
groupConnId <- withAgent $ \a -> joinConnection a True groupConnReq $ directMessage msg
directConnId <- withAgent $ \a -> joinConnection a True directConnReq $ directMessage msg
customUserProfileId <- if memberIncognito membership then Just <$> withStore (\db -> getGroupMemberProfileId db userId membership) else pure Nothing
let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing
withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId customUserProfileId
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> MsgMeta -> m ()
@@ -2575,6 +2557,7 @@ chatCommandP =
"/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP),
"/_call get" $> APIGetCallInvitations,
"/_profile " *> (APIUpdateProfile <$> jsonP),
"/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
"/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString),
"/_ntf get" $> APIGetNtfToken,
"/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP),
@@ -2685,6 +2668,7 @@ chatCommandP =
fullNameP name = do
n <- (A.space *> A.takeByteString) <|> pure ""
pure $ if B.null n then name else safeDecodeUtf8 n
textP = safeDecodeUtf8 <$> A.takeByteString
filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString
searchP = T.unpack . safeDecodeUtf8 <$> (" search=" *> A.takeByteString)
memberRole =
+10 -7
View File
@@ -133,6 +133,7 @@ data ChatCommand
| APIGetCallInvitations
| APICallStatus ContactId WebRTCCallStatus
| APIUpdateProfile Profile
| APISetContactAlias ContactId LocalAlias
| APIParseMarkdown Text
| APIGetNtfToken
| APIRegisterToken DeviceToken NotificationsMode
@@ -214,7 +215,7 @@ data ChatResponse
| CRUserSMPServers {smpServers :: [SMPServer]}
| CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
| CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats, mainProfile :: Maybe Profile}
| CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats}
| CRNewChatItem {chatItem :: AChatItem}
| CRChatItemStatusUpdated {chatItem :: AChatItem}
| CRChatItemUpdated {chatItem :: AChatItem}
@@ -226,7 +227,7 @@ data ChatResponse
| CRCmdOk
| CRChatHelp {helpSection :: HelpSection}
| CRWelcome {user :: User}
| CRGroupCreated {groupInfo :: GroupInfo, customUserProfile :: Maybe Profile}
| CRGroupCreated {groupInfo :: GroupInfo}
| CRGroupMembers {group :: Group}
| CRContactsList {contacts :: [Contact]}
| CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent}
@@ -235,7 +236,7 @@ data ChatResponse
| CRUserAcceptedGroupSent {groupInfo :: GroupInfo}
| CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember}
| CRGroupsList {groups :: [GroupInfo]}
| CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember, sentCustomProfile :: Maybe Profile}
| CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember}
| CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
| CRUserProfile {profile :: Profile}
| CRUserProfileNoChange
@@ -267,6 +268,7 @@ data ChatResponse
| CRSndFileRcvCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer}
| CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]}
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
| CRContactAliasUpdated {toContact :: Contact}
| CRContactConnecting {contact :: Contact}
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
| CRContactAnotherClient {contact :: Contact}
@@ -277,9 +279,9 @@ data ChatResponse
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRGroupInvitation {groupInfo :: GroupInfo}
| CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole, receivedCustomProfile :: Maybe Profile}
| CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember, usedCustomProfile :: Bool}
| CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember, mainProfile :: Maybe Profile}
| CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole}
| CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember}
| CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember}
| CRJoinedGroupMemberConnecting {groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember}
| CRConnectedToGroupMember {groupInfo :: GroupInfo, member :: GroupMember}
| CRDeletedMember {groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember}
@@ -386,7 +388,8 @@ data ChatErrorType
| CEContactNotReady {contact :: Contact}
| CEContactGroups {contact :: Contact, groupNames :: [GroupName]}
| CEGroupUserRole
| CEGroupNotIncognitoCantInvite
| CEContactIncognitoCantInvite
| CEGroupIncognitoCantInvite
| CEGroupContactRole {contactName :: ContactName}
| CEGroupDuplicateMember {contactName :: ContactName}
| CEGroupDuplicateMemberId
+32 -12
View File
@@ -502,9 +502,7 @@ ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayN
rcvGroupEventToText :: RcvGroupEvent -> Text
rcvGroupEventToText = \case
RGEMemberAdded _ p -> "added " <> profileToText p
RGEMemberConnected contactMainProfile -> case contactMainProfile of
Just p -> profileToText p <> " connected incognito"
Nothing -> "connected"
RGEMemberConnected -> "connected"
RGEMemberLeft -> "left"
RGEMemberDeleted _ p -> "removed " <> profileToText p
RGEUserDeleted -> "removed you"
@@ -521,6 +519,8 @@ profileToText :: Profile -> Text
profileToText Profile {displayName, fullName} = displayName <> optionalFullName displayName fullName
-- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
-- ! Nested sum types also have to use different encodings for database and API
-- ! to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent
data CIContent (d :: MsgDirection) where
CISndMsgContent :: MsgContent -> CIContent 'MDSnd
CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv
@@ -533,12 +533,15 @@ data CIContent (d :: MsgDirection) where
CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd
CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv
CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd
-- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
-- ! ^ Nested sum types also have to use different encodings for database and API
-- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent
deriving instance Show (CIContent d)
data RcvGroupEvent
= RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting
| RGEMemberConnected {contactMainProfile :: Maybe Profile} -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember
| RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember
| RGEMemberLeft -- CRLeftMember
| RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember
| RGEUserDeleted -- CRDeletedMemberUser
@@ -553,6 +556,15 @@ instance ToJSON RcvGroupEvent where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RGE"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RGE"
newtype DBRcvGroupEvent = RGE RcvGroupEvent
instance FromJSON DBRcvGroupEvent where
parseJSON v = RGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RGE") v
instance ToJSON DBRcvGroupEvent where
toJSON (RGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RGE") v
toEncoding (RGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RGE") v
data SndGroupEvent
= SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember
| SGEUserLeft -- CRLeftMemberUser
@@ -566,13 +578,21 @@ instance ToJSON SndGroupEvent where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SGE"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SGE"
newtype DBSndGroupEvent = SGE SndGroupEvent
instance FromJSON DBSndGroupEvent where
parseJSON v = SGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SGE") v
instance ToJSON DBSndGroupEvent where
toJSON (SGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SGE") v
toEncoding (SGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SGE") v
data CIGroupInvitation = CIGroupInvitation
{ groupId :: GroupId,
groupMemberId :: GroupMemberId,
localDisplayName :: GroupName,
groupProfile :: GroupProfile,
status :: CIGroupInvitationStatus,
invitedIncognito :: Maybe Bool
status :: CIGroupInvitationStatus
}
deriving (Eq, Show, Generic, FromJSON)
@@ -703,8 +723,8 @@ data DBJSONCIContent
| DBJCIRcvIntegrityError {msgError :: MsgErrorType}
| DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| DBJCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent}
| DBJCISndGroupEvent {sndGroupEvent :: SndGroupEvent}
| DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent}
| DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent}
deriving (Generic)
instance FromJSON DBJSONCIContent where
@@ -725,8 +745,8 @@ dbJsonCIContent = \case
CIRcvIntegrityError err -> DBJCIRcvIntegrityError err
CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole}
CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole}
CIRcvGroupEvent rcvGroupEvent -> DBJCIRcvGroupEvent {rcvGroupEvent}
CISndGroupEvent sndGroupEvent -> DBJCISndGroupEvent {sndGroupEvent}
CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge
CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge
aciContentDBJSON :: DBJSONCIContent -> ACIContent
aciContentDBJSON = \case
@@ -739,8 +759,8 @@ aciContentDBJSON = \case
DBJCIRcvIntegrityError err -> ACIContent SMDRcv $ CIRcvIntegrityError err
DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole
DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole
DBJCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent
DBJCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent
DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge
DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge
data CICallStatus
= CISCallPending
@@ -10,7 +10,7 @@ m20220812_incognito_profiles =
[sql|
ALTER TABLE connections ADD COLUMN custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- only set for direct connections
ALTER TABLE group_members ADD COLUMN member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- member profile id if incognito profile was saved for member (used for hosts and invitees in incognito groups)
ALTER TABLE group_members ADD COLUMN member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- member profile id if incognito profile was saved for member (used when invitation is received via incognito direct connection with host)
ALTER TABLE contact_profiles ADD COLUMN incognito INTEGER; -- 1 for incognito
|]
@@ -0,0 +1,12 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220822_groups_host_conn_custom_user_profile_id :: Query
m20220822_groups_host_conn_custom_user_profile_id =
[sql|
ALTER TABLE groups ADD COLUMN host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- id of custom user profile used in direct connection with host
|]
@@ -0,0 +1,13 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220823_delete_broken_group_event_chat_items :: Query
m20220823_delete_broken_group_event_chat_items =
[sql|
DELETE FROM chat_items WHERE item_content LIKE '%{"rcvGroupEvent":{"rcvGroupEvent":{%';
DELETE FROM chat_items WHERE item_content LIKE '%{"sndGroupEvent":{"sndGroupEvent":{%';
|]
@@ -0,0 +1,17 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220824_profiles_local_alias where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220824_profiles_local_alias :: Query
m20220824_profiles_local_alias =
[sql|
PRAGMA ignore_check_constraints=ON;
ALTER TABLE contact_profiles ADD COLUMN local_alias TEXT DEFAULT '' CHECK (local_alias NOT NULL);
UPDATE contact_profiles SET local_alias = '';
PRAGMA ignore_check_constraints=OFF;
|]
+4 -2
View File
@@ -14,7 +14,8 @@ CREATE TABLE contact_profiles(
updated_at TEXT CHECK(updated_at NOT NULL),
image TEXT,
user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE,
incognito INTEGER
incognito INTEGER,
local_alias TEXT DEFAULT '' CHECK(local_alias NOT NULL)
);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
@@ -121,7 +122,8 @@ CREATE TABLE groups(
created_at TEXT CHECK(created_at NOT NULL),
updated_at TEXT CHECK(updated_at NOT NULL),
chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL,
enable_ntfs INTEGER, -- received
enable_ntfs INTEGER,
host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, -- received
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
+4 -4
View File
@@ -124,7 +124,7 @@ data ChatMsgEvent
| XInfo Profile
| XContact Profile (Maybe XContactId)
| XGrpInv GroupInvitation
| XGrpAcpt MemberId (Maybe Profile)
| XGrpAcpt MemberId
| XGrpMemNew MemberInfo
| XGrpMemIntro MemberInfo
| XGrpMemInv MemberId IntroInvitation
@@ -413,7 +413,7 @@ toCMEventTag = \case
XInfo _ -> XInfo_
XContact _ _ -> XContact_
XGrpInv _ -> XGrpInv_
XGrpAcpt _ _ -> XGrpAcpt_
XGrpAcpt _ -> XGrpAcpt_
XGrpMemNew _ -> XGrpMemNew_
XGrpMemIntro _ -> XGrpMemIntro_
XGrpMemInv _ _ -> XGrpMemInv_
@@ -479,7 +479,7 @@ appToChatMessage AppMessage {msgId, event, params} = do
XInfo_ -> XInfo <$> p "profile"
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberProfile"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo"
XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro"
@@ -521,7 +521,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p
XInfo profile -> o ["profile" .= profile]
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId profile -> o $ ("memberProfile" .=? profile) ["memberId" .= memId]
XGrpAcpt memId -> o ["memberId" .= memId]
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]
XGrpMemIntro memInfo -> o ["memberInfo" .= memInfo]
XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro]
+109 -115
View File
@@ -38,6 +38,7 @@ module Simplex.Chat.Store
getContactIdByName,
updateUserProfile,
updateContactProfile,
updateContactAlias,
getUserContacts,
createUserContactLink,
getUserContactLinkConnections,
@@ -81,14 +82,12 @@ module Simplex.Chat.Store
getMemberInvitation,
createMemberConnection,
updateGroupMemberStatus,
createMemberIncognitoProfile,
createNewGroupMember,
deleteGroupMember,
deleteGroupMemberConnection,
createIntroductions,
updateIntroStatus,
saveIntroInvitation,
getGroupMemberProfileId,
createIntroReMember,
createIntroToMemberContact,
saveMemberInvitation,
@@ -98,6 +97,7 @@ module Simplex.Chat.Store
randomBytes,
createSentProbe,
createSentProbeHash,
deleteSentProbe,
matchReceivedProbe,
matchReceivedProbeHash,
matchSentProbe,
@@ -230,6 +230,9 @@ import Simplex.Chat.Migrations.M20220715_groups_chat_item_id
import Simplex.Chat.Migrations.M20220811_chat_items_indices
import Simplex.Chat.Migrations.M20220812_incognito_profiles
import Simplex.Chat.Migrations.M20220818_chat_notifications
import Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id
import Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items
import Simplex.Chat.Migrations.M20220824_profiles_local_alias
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..))
@@ -261,7 +264,10 @@ schemaMigrations =
("20220715_groups_chat_item_id", m20220715_groups_chat_item_id),
("20220811_chat_items_indices", m20220811_chat_items_indices),
("20220812_incognito_profiles", m20220812_incognito_profiles),
("20220818_chat_notifications", m20220818_chat_notifications)
("20220818_chat_notifications", m20220818_chat_notifications),
("20220822_groups_host_conn_custom_user_profile_id", m20220822_groups_host_conn_custom_user_profile_id),
("20220823_delete_broken_group_event_chat_items", m20220823_delete_broken_group_event_chat_items),
("20220824_profiles_local_alias", m20220824_profiles_local_alias)
]
-- | The list of migrations in ascending order by date
@@ -327,7 +333,7 @@ getUsers db =
toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData) -> User
toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image) =
let profile = LocalProfile {profileId, displayName, fullName, image}
let profile = LocalProfile {profileId, displayName, fullName, image, localAlias = ""}
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser}
setActiveUser :: DB.Connection -> UserId -> IO ()
@@ -366,7 +372,7 @@ getConnReqContactXContactId db userId cReqHash = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.enable_ntfs, ct.created_at, ct.updated_at,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -412,20 +418,20 @@ createIncognitoProfile_ db userId createdAt incognitoProfile =
(displayName, fullName, image, userId, Just True, createdAt, createdAt)
insertedRowId db
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO Profile
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile
getProfileById db userId profileId =
ExceptT . firstRow toProfile (SEProfileNotFound profileId) $
DB.query
db
[sql|
SELECT display_name, full_name, image
SELECT display_name, full_name, image, local_alias
FROM contact_profiles
WHERE user_id = ? AND contact_profile_id = ?
|]
(userId, profileId)
where
toProfile :: (ContactName, Text, Maybe ImageData) -> Profile
toProfile (displayName, fullName, image) = Profile {displayName, fullName, image}
toProfile :: (ContactName, Text, Maybe ImageData, LocalAlias) -> LocalProfile
toProfile (displayName, fullName, image, localAlias) = LocalProfile {profileId, displayName, fullName, image, localAlias}
createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection
createConnection_ db userId connType entityId acId viaContact viaUserContactLink customUserProfileId connLevel currentTs = do
@@ -449,7 +455,7 @@ createDirectContact :: DB.Connection -> UserId -> Connection -> Profile -> Excep
createDirectContact db userId activeConn@Connection {connId} profile = do
createdAt <- liftIO getCurrentTime
(localDisplayName, contactId, profileId) <- createContact_ db userId connId profile Nothing createdAt
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile, activeConn, viaGroup = Nothing, chatSettings = defaultChatSettings, createdAt, updatedAt = createdAt}
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, chatSettings = defaultChatSettings, createdAt, updatedAt = createdAt}
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId)
createContact_ db userId connId Profile {displayName, fullName, image} viaGroup currentTs =
@@ -532,15 +538,28 @@ updateUserProfile db User {userId, userContactId, localDisplayName, profile = Lo
updateContact_ db userId userContactId localDisplayName newName currentTs
updateContactProfile :: DB.Connection -> UserId -> Contact -> Profile -> ExceptT StoreError IO Contact
updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName}} p'@Profile {displayName = newName}
updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} p'@Profile {displayName = newName}
| displayName == newName =
liftIO $ updateContactProfile_ db userId profileId p' $> (c :: Contact) {profile = toLocalProfile profileId p'}
liftIO $ updateContactProfile_ db userId profileId p' $> (c :: Contact) {profile = toLocalProfile profileId p' localAlias}
| otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime
updateContactProfile_' db userId profileId p' currentTs
updateContact_ db userId contactId localDisplayName ldn currentTs
pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p'}
pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p' localAlias}
updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact
updateContactAlias db userId c@Contact {profile = lp@LocalProfile {profileId}} localAlias = do
updatedAt <- getCurrentTime
DB.execute
db
[sql|
UPDATE contact_profiles
SET local_alias = ?, updated_at = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
(localAlias, updatedAt, userId, profileId)
pure $ (c :: Contact) {profile = lp {localAlias = localAlias}}
updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO ()
updateContactProfile_ db userId profileId profile = do
@@ -570,18 +589,18 @@ updateContact_ db userId contactId displayName newName updatedAt = do
(newName, updatedAt, userId, contactId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId)
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe Bool, UTCTime, UTCTime)
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Maybe Bool, UTCTime, UTCTime)
toContact :: ContactRow :. ConnectionRow -> Contact
toContact ((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, enableNtfs_, createdAt, updatedAt) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image}
toContact ((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, enableNtfs_, createdAt, updatedAt) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, localAlias}
activeConn = toConnection connRow
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, chatSettings, createdAt, updatedAt}
toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact
toContactOrError ((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, enableNtfs_, createdAt, updatedAt) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image}
toContactOrError ((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, enableNtfs_, createdAt, updatedAt) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in case toMaybeConnection connRow of
Just activeConn ->
@@ -765,7 +784,7 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN
[sql|
SELECT
-- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.enable_ntfs, ct.created_at, ct.updated_at,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -888,7 +907,7 @@ createAcceptedContact db userId agentConnId localDisplayName profileId profile u
(userId, localDisplayName, profileId, True, createdAt, createdAt, xContactId)
contactId <- insertedRowId db
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile, activeConn, viaGroup = Nothing, chatSettings = defaultChatSettings, createdAt = createdAt, updatedAt = createdAt}
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, chatSettings = defaultChatSettings, createdAt = createdAt, updatedAt = createdAt}
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
getLiveSndFileTransfers db User {userId} = do
@@ -1026,6 +1045,13 @@ createSentProbeHash db userId probeId _to@Contact {contactId} = do
"INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?)"
(probeId, contactId, userId, currentTs, currentTs)
deleteSentProbe :: DB.Connection -> UserId -> Int64 -> IO ()
deleteSentProbe db userId probeId =
DB.execute
db
"DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?"
(userId, probeId)
matchReceivedProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact)
matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
let probeHash = C.sha256Hash probe
@@ -1159,15 +1185,15 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
<$> DB.query
db
[sql|
SELECT c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, c.via_group, c.enable_ntfs, c.created_at, c.updated_at
SELECT c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.local_alias, c.via_group, c.enable_ntfs, c.created_at, c.updated_at
FROM contacts c
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
WHERE c.user_id = ? AND c.contact_id = ?
|]
(userId, contactId)
toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe Int64, Maybe Bool, UTCTime, UTCTime)] -> Either StoreError Contact
toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, viaGroup, enableNtfs_, createdAt, updatedAt)] =
let profile = LocalProfile {profileId, displayName, fullName, image}
toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Maybe Bool, UTCTime, UTCTime)] -> Either StoreError Contact
toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, enableNtfs_, createdAt, updatedAt)] =
let profile = LocalProfile {profileId, displayName, fullName, image, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, chatSettings, createdAt, updatedAt}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
@@ -1179,15 +1205,15 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.enable_ntfs, g.created_at, g.updated_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name, pu.image,
pu.display_name, pu.full_name, pu.image, pu.local_alias,
-- from GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
JOIN groups g ON g.group_id = m.group_id
@@ -1267,15 +1293,15 @@ getGroupAndMember db User {userId, userContactId} groupMemberId =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.enable_ntfs, g.created_at, g.updated_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name, pu.image,
pu.display_name, pu.full_name, pu.image, pu.local_alias,
-- from GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1305,8 +1331,8 @@ updateConnectionStatus db Connection {connId} connStatus = do
DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId)
-- | creates completely new group with a single member - the current user
createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo
createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do
createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo
createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
let GroupProfile {displayName, fullName, image} = groupProfile
currentTs <- getCurrentTime
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
@@ -1322,14 +1348,13 @@ createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = Except
(ldn, userId, profileId, True, currentTs, currentTs)
insertedRowId db
memberId <- liftIO $ encodedRandomBytes gVar 12
-- TODO ldn from incognito profile
membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser incognitoProfile currentTs
membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs
let chatSettings = ChatSettings {enableNtfs = True}
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, chatSettings, createdAt = currentTs, updatedAt = currentTs}
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs}
-- | creates a new group record for the group the current user was invited to, or returns an existing one
createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe Profile -> ExceptT StoreError IO GroupInfo
createGroupInvitation db user@User {userId} contact@Contact {contactId} GroupInvitation {fromMember, fromMemberProfile, invitedMember, connRequest, groupProfile} incognitoProfile = do
createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO GroupInfo
createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do
liftIO getInvitationGroupId_ >>= \case
Nothing -> createGroupInvitation_
-- TODO treat the case that the invitation details could've changed
@@ -1353,20 +1378,20 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId} GroupInv
profileId <- insertedRowId db
DB.execute
db
"INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
(profileId, localDisplayName, connRequest, userId, True, currentTs, currentTs)
"INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
(profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs)
insertedRowId db
_ <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown fromMemberProfile currentTs
membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfile currentTs
_ <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs
membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs
let chatSettings = ChatSettings {enableNtfs = True}
pure GroupInfo {groupId, localDisplayName, groupProfile, membership, chatSettings, createdAt = currentTs, updatedAt = currentTs}
pure GroupInfo {groupId, localDisplayName, groupProfile, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}
createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe Profile -> UTCTime -> ExceptT StoreError IO GroupMember
createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfile createdAt = do
customUserProfileId <- liftIO $ createIncognitoProfile_ db userId createdAt incognitoProfile
(localDisplayName, memberProfile) <- case (incognitoProfile, customUserProfileId) of
(Just profile@Profile {displayName}, Just profileId) ->
(,toLocalProfile profileId profile) <$> insertMemberIncognitoProfile_ displayName profileId
createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> ExceptT StoreError IO GroupMember
createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt = do
incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId
(localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of
(Just profile@LocalProfile {displayName}, Just profileId) ->
(,profile) <$> insertMemberIncognitoProfile_ displayName profileId
_ -> (,profile' userOrContact) <$> liftIO insertMember_
groupMemberId <- liftIO $ insertedRowId db
pure
@@ -1470,14 +1495,14 @@ getUserGroupDetails db User {userId, userContactId} =
<$> DB.query
db
[sql|
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.enable_ntfs, g.created_at, g.updated_at,
m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, mp.contact_profile_id, mp.display_name, mp.full_name, mp.image
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at,
mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status,
mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.local_alias
FROM groups g
JOIN group_profiles gp USING (group_profile_id)
JOIN group_members m USING (group_id)
JOIN contact_profiles mp ON mp.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
WHERE g.user_id = ? AND m.contact_id = ?
JOIN group_members mu USING (group_id)
JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id)
WHERE g.user_id = ? AND mu.contact_id = ?
|]
(userId, userContactId)
@@ -1486,13 +1511,13 @@ getGroupInfoByName db user gName = do
gId <- getGroupIdByName db user gName
getGroupInfo db user gId
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, Maybe Bool, UTCTime, UTCTime) :. GroupMemberRow
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, UTCTime, UTCTime) :. GroupMemberRow
toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, enableNtfs_, createdAt, updatedAt) :. userMemberRow) =
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, createdAt, updatedAt) :. userMemberRow) =
let membership = toGroupMember userContactId userMemberRow
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, chatSettings, createdAt, updatedAt}
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember
getGroupMember db user@User {userId} groupId groupMemberId =
@@ -1502,7 +1527,7 @@ getGroupMember db user@User {userId} groupId groupMemberId =
[sql|
SELECT
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1524,7 +1549,7 @@ getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do
[sql|
SELECT
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1562,20 +1587,20 @@ getGroupInvitation db user groupId = do
findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId)
findFromContact _ = const Nothing
type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData))
type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, LocalAlias))
type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData))
type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe LocalAlias))
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image)) =
let memberProfile = LocalProfile {profileId, displayName, fullName, image}
toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, localAlias)) =
let memberProfile = LocalProfile {profileId, displayName, fullName, image, localAlias}
invitedBy = toInvitedBy userContactId invitedById
activeConn = Nothing
in GroupMember {..}
toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember
toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image)) =
Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image))
toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, Just localAlias)) =
Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, localAlias))
toMaybeGroupMember _ _ = Nothing
createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> ExceptT StoreError IO GroupMember
@@ -1640,25 +1665,6 @@ updateGroupMemberStatus db userId GroupMember {groupMemberId} memStatus = do
|]
(memStatus, currentTs, userId, groupMemberId)
createMemberIncognitoProfile :: DB.Connection -> UserId -> GroupMember -> Maybe Profile -> ExceptT StoreError IO GroupMember
createMemberIncognitoProfile db userId m@GroupMember {groupMemberId} incognitoProfile = do
currentTs <- liftIO getCurrentTime
customUserProfileId <- liftIO $ createIncognitoProfile_ db userId currentTs incognitoProfile
case (incognitoProfile, customUserProfileId) of
(Just profile@Profile {displayName}, Just profileId) ->
ExceptT $
withLocalDisplayName db userId displayName $ \incognitoLdn -> do
DB.execute
db
[sql|
UPDATE group_members
SET local_display_name = ?, member_profile_id = ?, updated_at = ?
WHERE user_id = ? AND group_member_id = ?
|]
(incognitoLdn, profileId, currentTs, userId, groupMemberId)
pure . Right $ m {localDisplayName = incognitoLdn, memberProfile = toLocalProfile profileId profile}
_ -> pure m
-- | add new member with profile
createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember
createNewGroupMember db user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image}) memCategory memStatus =
@@ -1708,7 +1714,7 @@ createNewMember_
|]
(groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt)
groupMemberId <- insertedRowId db
pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile, memberContactId, memberContactProfileId, activeConn}
pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn}
deleteGroupMember :: DB.Connection -> User -> GroupMember -> IO ()
deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId} = do
@@ -1814,18 +1820,6 @@ getIntroduction_ db reMember toMember = ExceptT $ do
in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation}
toIntro _ = Left SEIntroNotFound
getGroupMemberProfileId :: DB.Connection -> UserId -> GroupMember -> ExceptT StoreError IO Int64
getGroupMemberProfileId db userId GroupMember {groupMemberId, groupId} =
ExceptT . firstRow fromOnly (SEGroupMemberNotFound {groupId, groupMemberId}) $
DB.query
db
[sql|
SELECT contact_profile_id
FROM group_members
WHERE user_id = ? AND group_member_id = ?
|]
(userId, groupMemberId)
createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> ConnId -> ConnId -> Maybe ProfileId -> ExceptT StoreError IO GroupMember
createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId customUserProfileId = do
let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn
@@ -1892,15 +1886,15 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.enable_ntfs, g.created_at, g.updated_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name, pu.image,
pu.display_name, pu.full_name, pu.image, pu.local_alias,
-- via GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image,
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1932,7 +1926,7 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} =
db
[sql|
SELECT
ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.enable_ntfs, ct.created_at, ct.updated_at,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.local_alias, ct.via_group, ct.enable_ntfs, ct.created_at, ct.updated_at,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM contacts ct
@@ -1948,9 +1942,9 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} =
|]
(userId, groupMemberId)
where
toContact' :: (ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe Int64, Maybe Bool, UTCTime, UTCTime) :. ConnectionRow -> Contact
toContact' ((contactId, profileId, localDisplayName, displayName, fullName, image, viaGroup, enableNtfs_, createdAt, updatedAt) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image}
toContact' :: (ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Maybe Bool, UTCTime, UTCTime) :. ConnectionRow -> Contact
toContact' ((contactId, profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, enableNtfs_, createdAt, updatedAt) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
activeConn = toConnection connRow
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, chatSettings, createdAt, updatedAt}
@@ -2654,7 +2648,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.image
p.display_name, p.full_name, p.image, p.local_alias
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
LEFT JOIN contacts c ON m.contact_id = c.contact_id
@@ -2691,7 +2685,7 @@ getDirectChatPreviews_ db User {userId} = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.enable_ntfs, ct.created_at, ct.updated_at,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at,
@@ -2756,11 +2750,11 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.enable_ntfs, g.created_at, g.updated_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.image,
pu.display_name, pu.full_name, pu.image, pu.local_alias,
-- ChatStats
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0),
-- ChatItem
@@ -2770,13 +2764,13 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
-- Maybe GroupMember - sender
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.image,
p.display_name, p.full_name, p.image, p.local_alias,
-- quoted ChatItem
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent,
-- quoted GroupMember
rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category,
rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id,
rp.display_name, rp.full_name, rp.image
rp.display_name, rp.full_name, rp.image, rp.local_alias
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
@@ -3031,7 +3025,7 @@ getContact db userId contactId =
[sql|
SELECT
-- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.enable_ntfs, ct.created_at, ct.updated_at,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -3156,11 +3150,11 @@ getGroupInfo db User {userId, userContactId} groupId =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.enable_ntfs, g.created_at, g.updated_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.image
pu.display_name, pu.full_name, pu.image, pu.local_alias
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
@@ -3529,13 +3523,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.image,
p.display_name, p.full_name, p.image, p.local_alias,
-- quoted ChatItem
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent,
-- quoted GroupMember
rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category,
rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id,
rp.display_name, rp.full_name, rp.image
rp.display_name, rp.full_name, rp.image, rp.local_alias
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
+12 -9
View File
@@ -191,6 +191,7 @@ data GroupInfo = GroupInfo
localDisplayName :: GroupName,
groupProfile :: GroupProfile,
membership :: GroupMember,
hostConnCustomUserProfileId :: Maybe ProfileId,
chatSettings :: ChatSettings,
createdAt :: UTCTime,
updatedAt :: UTCTime
@@ -217,7 +218,10 @@ data Profile = Profile
{ displayName :: ContactName,
fullName :: Text,
image :: Maybe ImageData
-- incognito field should not be read as is into this data type to prevent sending it as part of profile to contacts
-- fields that should not be read into this data type to prevent sending them as part of profile to contacts:
-- - contact_profile_id
-- - incognito
-- - local_alias
}
deriving (Eq, Show, Generic, FromJSON)
@@ -225,11 +229,14 @@ instance ToJSON Profile where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
type LocalAlias = Text
data LocalProfile = LocalProfile
{ profileId :: ProfileId,
displayName :: ContactName,
fullName :: Text,
image :: Maybe ImageData
image :: Maybe ImageData,
localAlias :: LocalAlias
}
deriving (Eq, Show, Generic, FromJSON)
@@ -240,9 +247,9 @@ instance ToJSON LocalProfile where
localProfileId :: LocalProfile -> ProfileId
localProfileId = profileId
toLocalProfile :: ProfileId -> Profile -> LocalProfile
toLocalProfile profileId Profile {displayName, fullName, image} =
LocalProfile {profileId, displayName, fullName, image}
toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile
toLocalProfile profileId Profile {displayName, fullName, image} localAlias =
LocalProfile {profileId, displayName, fullName, image, localAlias}
fromLocalProfile :: LocalProfile -> Profile
fromLocalProfile LocalProfile {displayName, fullName, image} =
@@ -275,7 +282,6 @@ instance FromField ImageData where fromField = fmap ImageData . fromField
data GroupInvitation = GroupInvitation
{ fromMember :: MemberIdRole,
fromMemberProfile :: Maybe Profile,
invitedMember :: MemberIdRole,
connRequest :: ConnReqInvitation,
groupProfile :: GroupProfile
@@ -758,9 +764,6 @@ data Connection = Connection
aConnId :: Connection -> ConnId
aConnId Connection {agentConnId = AgentConnId cId} = cId
connCustomUserProfileId :: Connection -> Maybe Int64
connCustomUserProfileId Connection {customUserProfileId} = customUserProfileId
instance ToJSON Connection where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
+55 -75
View File
@@ -65,7 +65,7 @@ responseToView testView = \case
CRUserSMPServers smpServers -> viewSMPServers smpServers testView
CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
CRGroupMemberInfo g m cStats mainProfile -> viewGroupMemberInfo g m cStats mainProfile
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item False
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems
CRChatItemStatusUpdated _ -> []
@@ -89,10 +89,10 @@ responseToView testView = \case
CRUserContactLink cReqUri autoAccept autoReply -> connReqContact_ "Your chat address:" cReqUri <> autoAcceptStatus_ autoAccept autoReply
CRUserContactLinkUpdated _ autoAccept autoReply -> autoAcceptStatus_ autoAccept autoReply
CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"]
CRGroupCreated g customUserProfile -> viewGroupCreated g customUserProfile testView
CRGroupCreated g -> viewGroupCreated g
CRGroupMembers g -> viewGroupMembers g
CRGroupsList gs -> viewGroupsList gs
CRSentGroupInvitation g c _ sentCustomProfile -> viewSentGroupInvitation g c sentCustomProfile
CRSentGroupInvitation g c _ -> viewSentGroupInvitation g c
CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus
CRUserProfile p -> viewUserProfile p
CRUserProfileNoChange -> ["user profile did not change"]
@@ -117,6 +117,7 @@ responseToView testView = \case
CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts
CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft
CRUserProfileUpdated p p' -> viewUserProfileUpdated p p'
CRContactAliasUpdated c -> viewContactAliasUpdated c
CRContactUpdated c c' -> viewContactUpdated c c'
CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt
CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile
@@ -138,11 +139,10 @@ responseToView testView = \case
[sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors"
where
(errors, subscribed) = partition (isJust . contactError) summary
CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} ->
[groupInvitation' ldn fullName $ memberIncognito membership]
CRReceivedGroupInvitation g c role receivedCustomProfile -> viewReceivedGroupInvitation g c role receivedCustomProfile
CRUserJoinedGroup g _ usedCustomProfile -> viewUserJoinedGroup g usedCustomProfile testView
CRJoinedGroupMember g m mainProfile -> viewJoinedGroupMember g m mainProfile
CRGroupInvitation g -> [groupInvitation' g]
CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role
CRUserJoinedGroup g _ -> viewUserJoinedGroup g
CRJoinedGroupMember g m -> viewJoinedGroupMember g m
CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h]
CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h]
CRJoinedGroupMemberConnecting g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"]
@@ -369,11 +369,9 @@ viewConnReqInvitation cReq =
"and ask them to connect: " <> highlight' "/c <invitation_link_above>"
]
viewSentGroupInvitation :: GroupInfo -> Contact -> Maybe Profile -> [StyledString]
viewSentGroupInvitation g c sentCustomProfile =
if isJust sentCustomProfile
then ["invitation to join the group " <> ttyGroup' g <> " incognito sent to " <> ttyContact' c]
else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
viewSentGroupInvitation :: GroupInfo -> Contact -> [StyledString]
viewSentGroupInvitation g c =
["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
viewChatCleared :: AChatInfo -> [StyledString]
viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
@@ -427,22 +425,11 @@ viewReceivedContactRequest c Profile {fullName} =
"to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)"
]
viewGroupCreated :: GroupInfo -> Maybe Profile -> Bool -> [StyledString]
viewGroupCreated g@GroupInfo {localDisplayName} incognitoProfile testView =
case incognitoProfile of
Just profile ->
if testView
then incognitoProfile' profile : message
else message
where
message =
[ "group " <> ttyFullGroup g <> " is created incognito, your profile for this group: " <> incognitoProfile' profile,
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
]
Nothing ->
[ "group " <> ttyFullGroup g <> " is created",
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
]
viewGroupCreated :: GroupInfo -> [StyledString]
viewGroupCreated g@GroupInfo {localDisplayName} =
[ "group " <> ttyFullGroup g <> " is created",
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
]
viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString]
viewCannotResendInvitation GroupInfo {localDisplayName = gn} c =
@@ -450,33 +437,22 @@ viewCannotResendInvitation GroupInfo {localDisplayName = gn} c =
"to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c)
]
viewUserJoinedGroup :: GroupInfo -> Bool -> Bool -> [StyledString]
viewUserJoinedGroup g@GroupInfo {membership = GroupMember {memberProfile}} incognito testView =
if incognito
then
if testView
then incognitoProfile' (fromLocalProfile memberProfile) : incognitoMessage
else incognitoMessage
viewUserJoinedGroup :: GroupInfo -> [StyledString]
viewUserJoinedGroup g@GroupInfo {membership = membership@GroupMember {memberProfile}} =
if memberIncognito membership
then [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile memberProfile)]
else [ttyGroup' g <> ": you joined the group"]
where
incognitoMessage = [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile memberProfile)]
viewJoinedGroupMember :: GroupInfo -> GroupMember -> Maybe Profile -> [StyledString]
viewJoinedGroupMember g m@GroupMember {localDisplayName} = \case
Just Profile {displayName = mainProfileName} -> [ttyGroup' g <> ": " <> ttyContact mainProfileName <> " joined the group incognito as " <> styleIncognito localDisplayName]
Nothing -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "]
viewJoinedGroupMember :: GroupInfo -> GroupMember -> [StyledString]
viewJoinedGroupMember g m =
[ttyGroup' g <> ": " <> ttyMember m <> " joined the group "]
viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> Maybe Profile -> [StyledString]
viewReceivedGroupInvitation g c role hostIncognitoProfile =
case hostIncognitoProfile of
Just profile ->
[ ttyFullGroup g <> ": " <> ttyContact' c <> " (known to the group as " <> incognitoProfile' profile <> ") invites you to join the group incognito as " <> plain (strEncode role),
"use " <> highlight ("/j " <> groupName' g) <> " to join this group incognito"
]
Nothing ->
[ ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role),
"use " <> highlight ("/j " <> groupName' g) <> " to accept"
]
viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString]
viewReceivedGroupInvitation g@GroupInfo {membership = membership@GroupMember {memberProfile}} c role =
ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) :
if memberIncognito membership
then ["use " <> highlight ("/j " <> groupName' g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile memberProfile)]
else ["use " <> highlight ("/j " <> groupName' g) <> " to accept"]
groupPreserved :: GroupInfo -> [StyledString]
groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"]
@@ -528,9 +504,9 @@ viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g <nam
viewGroupsList gs = map groupSS $ sortOn ldn_ gs
where
ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName)
groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} =
groupSS g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} =
case memberStatus membership of
GSMemInvited -> groupInvitation' ldn fullName $ memberIncognito membership
GSMemInvited -> groupInvitation' g
s -> incognito <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s
where
incognito = if memberIncognito membership then incognitoPrefix else ""
@@ -541,20 +517,20 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs
_ -> ""
delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")"
groupInvitation' :: GroupName -> Text -> Bool -> StyledString
groupInvitation' displayName fullName membershipIncognito =
highlight ("#" <> displayName)
<> optFullName displayName fullName
<> invitationText
<> highlight ("/j " <> displayName)
<> " to join, "
<> highlight ("/d #" <> displayName)
groupInvitation' :: GroupInfo -> StyledString
groupInvitation' GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership = membership@GroupMember {memberProfile}} =
highlight ("#" <> ldn)
<> optFullName ldn fullName
<> " - you are invited ("
<> highlight ("/j " <> ldn)
<> joinText
<> highlight ("/d #" <> ldn)
<> " to delete invitation)"
where
invitationText =
if membershipIncognito
then " - you are invited incognito ("
else " - you are invited ("
joinText =
if memberIncognito membership
then " to join as " <> incognitoProfile' (fromLocalProfile memberProfile) <> ", "
else " to join, "
viewContactsMerged :: Contact -> Contact -> [StyledString]
viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} =
@@ -594,23 +570,21 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
]
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
viewContactInfo Contact {contactId} stats incognitoProfile =
viewContactInfo Contact {contactId, profile = LocalProfile {localAlias}} stats incognitoProfile =
["contact ID: " <> sShow contactId] <> viewConnectionStats stats
<> maybe
["you've shared main profile with this contact"]
(\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p])
incognitoProfile
<> if localAlias /= "" then ["alias: " <> plain localAlias] else ["alias not set"]
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId} stats mainProfile =
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString]
viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats =
[ "group ID: " <> sShow groupId,
"member ID: " <> sShow groupMemberId
]
<> maybe ["member not connected"] viewConnectionStats stats
<> maybe
["unknown whether group member uses his main profile or incognito one for the group"]
(\Profile {displayName, fullName} -> ["member is using " <> styleIncognito' "incognito" <> " profile for the group, main profile known: " <> ttyFullName displayName fullName])
mainProfile
<> if localAlias /= "" then ["alias: " <> plain localAlias] else ["no alias for contact"]
viewConnectionStats :: ConnectionStats -> [StyledString]
viewConnectionStats ConnectionStats {rcvServers, sndServers} =
@@ -644,6 +618,11 @@ viewGroupUpdated
where
byMember = maybe "" ((" by " <>) . ttyMember) m
viewContactAliasUpdated :: Contact -> [StyledString]
viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}}
| localAlias == "" = ["contact " <> ttyContact n <> " alias removed"]
| otherwise = ["contact " <> ttyContact n <> " alias updated: " <> plain localAlias]
viewContactUpdated :: Contact -> Contact -> [StyledString]
viewContactUpdated
Contact {localDisplayName = n, profile = LocalProfile {fullName}}
@@ -904,7 +883,8 @@ viewChatError = \case
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"]
CEGroupUserRole -> ["you have insufficient permissions for this group command"]
CEGroupNotIncognitoCantInvite -> ["you're using main profile for this group - prohibited to invite contact to whom you are connected incognito"]
CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"]
CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"]
CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"]
CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)]
CEGroupMemberNotActive -> ["you cannot invite other members yet, try later"]
+1 -1
View File
@@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: a7b39b710c3aab9b2a38bd6841e52e0342b3a7ef
commit: f2c1455a2755e1275983dc154321fc0a5c0d7b17
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
- github: simplex-chat/aeson
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
+150 -220
View File
@@ -92,9 +92,9 @@ chatTests = do
it "connect incognito via invitation link" testConnectIncognitoInvitationLink
it "connect incognito via contact address" testConnectIncognitoContactAddress
it "accept contact request incognito" testAcceptContactRequestIncognito
it "create group incognito" testCreateGroupIncognito
it "join group incognito" testJoinGroupIncognito
it "can't invite contact to whom user connected incognito to non incognito group" testCantInviteIncognitoConnectionNonIncognitoGroup
it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito
it "set contact alias" testSetAlias
describe "SMP servers" $
it "get and set SMP servers" testGetSetSMPServers
describe "async connection handshake" $ do
@@ -2146,278 +2146,199 @@ testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $
bob #> ("@" <> aliceIncognito <> " I know!")
alice ?<# "bob> I know!"
testCreateGroupIncognito :: IO ()
testCreateGroupIncognito = testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
-- non incognito connections
connectUsers alice cath
connectUsers bob cath
-- bob connected incognito to alice
alice ##> "/c"
inv <- getInvitation alice
bob #$> ("/incognito on", id, "ok")
bob ##> ("/c " <> inv)
bob <## "confirmation sent!"
bobIncognito <- getTermLine bob
concurrentlyN_
[ do
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
bob <## "use /info alice to print out this incognito profile again",
alice <## (bobIncognito <> ": contact is connected")
]
-- alice creates group incognito
alice #$> ("/incognito on", id, "ok")
alice ##> "/g secret_club"
aliceMemIncognito <- getTermLine alice
alice <## ("group #secret_club is created incognito, your profile for this group: " <> aliceMemIncognito)
alice <## "use /a secret_club <name> to add members"
alice ##> ("/a secret_club " <> bobIncognito)
concurrentlyN_
[ alice <## ("invitation to join the group #secret_club incognito sent to " <> bobIncognito),
do
bob <## ("#secret_club: alice (known to the group as " <> aliceMemIncognito <> ") invites you to join the group incognito as admin")
bob <## "use /j secret_club to join this group incognito"
]
-- bob uses different profile when joining group
bob ##> "/j secret_club"
bobMemIncognito <- getTermLine bob
concurrently_
(alice <## ("#secret_club: " <> bobIncognito <> " joined the group incognito as " <> bobMemIncognito))
(bob <## ("#secret_club: you joined the group incognito as " <> bobMemIncognito))
-- cath is invited incognito
alice ##> "/a secret_club cath"
concurrentlyN_
[ alice <## "invitation to join the group #secret_club incognito sent to cath",
do
cath <## ("#secret_club: alice (known to the group as " <> aliceMemIncognito <> ") invites you to join the group incognito as admin")
cath <## "use /j secret_club to join this group incognito"
]
cath ##> "/j secret_club"
cathMemIncognito <- getTermLine cath
-- bob and cath don't merge contacts
concurrentlyN_
[ alice <## ("#secret_club: cath joined the group incognito as " <> cathMemIncognito),
do
cath <## ("#secret_club: you joined the group incognito as " <> cathMemIncognito)
cath <## ("#secret_club: member " <> bobMemIncognito <> " is connected"),
do
bob <## ("#secret_club: " <> aliceMemIncognito <> " added " <> cathMemIncognito <> " to the group (connecting...)")
bob <## ("#secret_club: new member " <> cathMemIncognito <> " is connected")
]
-- send messages - group is incognito for everybody
alice #$> ("/incognito off", id, "ok")
bob #$> ("/incognito off", id, "ok")
cath #$> ("/incognito off", id, "ok")
alice ?#> "#secret_club hello"
concurrently_
(bob ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
(cath ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
bob ?#> "#secret_club hi there"
concurrently_
(alice ?<# ("#secret_club " <> bobMemIncognito <> "> hi there"))
(cath ?<# ("#secret_club " <> bobMemIncognito <> "> hi there"))
cath ?#> "#secret_club hey"
concurrently_
(alice ?<# ("#secret_club " <> cathMemIncognito <> "> hey"))
(bob ?<# ("#secret_club " <> cathMemIncognito <> "> hey"))
-- bob and cath can send messages via direct incognito connections
bob ?#> ("@" <> cathMemIncognito <> " hi, I'm bob")
cath ?<# (bobMemIncognito <> "> hi, I'm bob")
cath ?#> ("@" <> bobMemIncognito <> " hey, I'm cath")
bob ?<# (cathMemIncognito <> "> hey, I'm cath")
-- non incognito connections are separate
bob <##> cath
-- list groups
alice ##> "/gs"
alice <## "i #secret_club"
-- list group members
alice ##> "/ms secret_club"
alice
<### [ "i " <> aliceMemIncognito <> ": owner, you, created group",
"i " <> bobMemIncognito <> ": admin, invited, connected",
"i " <> cathMemIncognito <> ": admin, invited, connected"
]
-- remove member
bob ##> ("/rm secret_club " <> cathMemIncognito)
concurrentlyN_
[ bob <## ("#secret_club: you removed " <> cathMemIncognito <> " from the group"),
alice <## ("#secret_club: " <> bobMemIncognito <> " removed " <> cathMemIncognito <> " from the group"),
do
cath <## ("#secret_club: " <> bobMemIncognito <> " removed you from the group")
cath <## "use /d #secret_club to delete the group"
]
bob ?#> "#secret_club hi"
concurrently_
(alice ?<# ("#secret_club " <> bobMemIncognito <> "> hi"))
(cath </)
alice ?#> "#secret_club hello"
concurrently_
(bob ?<# ("#secret_club " <> aliceMemIncognito <> "> hello"))
(cath </)
cath ##> "#secret_club hello"
cath <## "you are no longer a member of the group"
bob ?#> ("@" <> cathMemIncognito <> " I removed you from group")
cath ?<# (bobMemIncognito <> "> I removed you from group")
cath ?#> ("@" <> bobMemIncognito <> " ok")
bob ?<# (cathMemIncognito <> "> ok")
testJoinGroupIncognito :: IO ()
testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $
\alice bob cath dan -> do
-- non incognito connections
connectUsers alice cath
connectUsers alice bob
connectUsers alice dan
connectUsers bob cath
connectUsers bob dan
connectUsers cath dan
-- bob connected incognito to alice
-- cath connected incognito to alice
alice ##> "/c"
inv <- getInvitation alice
bob #$> ("/incognito on", id, "ok")
bob ##> ("/c " <> inv)
bob <## "confirmation sent!"
bobIncognito <- getTermLine bob
cath #$> ("/incognito on", id, "ok")
cath ##> ("/c " <> inv)
cath <## "confirmation sent!"
cathIncognito <- getTermLine cath
concurrentlyN_
[ do
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
bob <## "use /info alice to print out this incognito profile again",
alice <## (bobIncognito <> ": contact is connected")
cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito)
cath <## "use /info alice to print out this incognito profile again",
alice <## (cathIncognito <> ": contact is connected")
]
-- alice creates group non incognito
alice ##> "/g club"
alice <## "group #club is created"
alice <## "use /a club <name> to add members"
alice ##> ("/a club " <> bobIncognito)
-- alice creates group
alice ##> "/g secret_club"
alice <## "group #secret_club is created"
alice <## "use /a secret_club <name> to add members"
-- alice invites bob
alice ##> "/a secret_club bob"
concurrentlyN_
[ alice <## ("invitation to join the group #club sent to " <> bobIncognito),
[ alice <## "invitation to join the group #secret_club sent to bob",
do
bob <## "#club: alice invites you to join the group as admin"
bob <## "use /j club to accept"
bob <## "#secret_club: alice invites you to join the group as admin"
bob <## "use /j secret_club to accept"
]
-- since bob is connected incognito to host, he uses different profile when joining group even though he turned incognito mode off
bob #$> ("/incognito off", id, "ok")
bob ##> "/j club"
bobMemIncognito <- getTermLine bob
bob ##> "/j secret_club"
concurrently_
(alice <## ("#club: " <> bobIncognito <> " joined the group incognito as " <> bobMemIncognito))
(bob <## ("#club: you joined the group incognito as " <> bobMemIncognito))
-- cath joins incognito
alice ##> "/a club cath"
(alice <## "#secret_club: bob joined the group")
(bob <## "#secret_club: you joined the group")
-- alice invites cath
alice ##> ("/a secret_club " <> cathIncognito)
concurrentlyN_
[ alice <## "invitation to join the group #club sent to cath",
[ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito),
do
cath <## "#club: alice invites you to join the group as admin"
cath <## "use /j club to accept"
cath <## "#secret_club: alice invites you to join the group as admin"
cath <## ("use /j secret_club to join incognito as " <> cathIncognito)
]
cath #$> ("/incognito on", id, "ok")
cath ##> "/j club"
cathMemIncognito <- getTermLine cath
-- bob and cath don't merge contacts
-- cath uses the same incognito profile when joining group, disabling incognito mode doesn't affect it
cath #$> ("/incognito off", id, "ok")
cath ##> "/j secret_club"
-- cath and bob don't merge contacts
concurrentlyN_
[ alice <## ("#club: cath joined the group incognito as " <> cathMemIncognito),
[ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"),
do
cath <## ("#club: you joined the group incognito as " <> cathMemIncognito)
cath <## ("#club: member " <> bobMemIncognito <> " is connected"),
cath <## ("#secret_club: you joined the group incognito as " <> cathIncognito)
cath <## "#secret_club: member bob_1 (Bob) is connected",
do
bob <## ("#club: alice added " <> cathMemIncognito <> " to the group (connecting...)")
bob <## ("#club: new member " <> cathMemIncognito <> " is connected")
bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)")
bob <## ("#secret_club: new member " <> cathIncognito <> " is connected")
]
-- cath invites dan incognito
cath ##> "/a club dan"
-- cath cannot invite to the group because her membership is incognito
cath ##> "/a secret_club dan"
cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts"
-- alice invites dan
alice ##> "/a secret_club dan"
concurrentlyN_
[ cath <## "invitation to join the group #club incognito sent to dan",
[ alice <## "invitation to join the group #secret_club sent to dan",
do
dan <## ("#club: cath (known to the group as " <> cathMemIncognito <> ") invites you to join the group incognito as admin")
dan <## "use /j club to join this group incognito"
dan <## "#secret_club: alice invites you to join the group as admin"
dan <## "use /j secret_club to accept"
]
dan ##> "/j club"
danMemIncognito <- getTermLine dan
dan ##> "/j secret_club"
-- cath and dan don't merge contacts
concurrentlyN_
[ cath <## ("#club: dan joined the group incognito as " <> danMemIncognito),
[ alice <## "#secret_club: dan joined the group",
do
dan <## ("#club: you joined the group incognito as " <> danMemIncognito)
dan <## "#secret_club: you joined the group"
dan
<### [ "#club: member alice (Alice) is connected",
"#club: member " <> bobMemIncognito <> " is connected"
<### [ "#secret_club: member " <> cathIncognito <> " is connected",
"#secret_club: member bob_1 (Bob) is connected",
"contact bob_1 is merged into bob",
"use @bob <message> to send messages"
],
do
alice <## ("#club: " <> cathMemIncognito <> " added " <> danMemIncognito <> " to the group (connecting...)")
alice <## ("#club: new member " <> danMemIncognito <> " is connected"),
bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)"
bob <## "#secret_club: new member dan_1 is connected"
bob <## "contact dan_1 is merged into dan"
bob <## "use @dan <message> to send messages",
do
bob <## ("#club: " <> cathMemIncognito <> " added " <> danMemIncognito <> " to the group (connecting...)")
bob <## ("#club: new member " <> danMemIncognito <> " is connected")
cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)"
cath <## "#secret_club: new member dan_1 is connected"
]
-- send messages - group is incognito for cath and dan
alice #$> ("/incognito off", id, "ok")
bob #$> ("/incognito off", id, "ok")
cath #$> ("/incognito off", id, "ok")
dan #$> ("/incognito off", id, "ok")
alice #> "#club hello"
-- send messages - group is incognito for cath
alice #> "#secret_club hello"
concurrentlyN_
[ bob ?<# "#club alice> hello",
cath ?<# "#club alice> hello",
dan ?<# "#club alice> hello"
[ bob <# "#secret_club alice> hello",
cath ?<# "#secret_club alice> hello",
dan <# "#secret_club alice> hello"
]
bob ?#> "#club hi there"
bob #> "#secret_club hi there"
concurrentlyN_
[ alice <# ("#club " <> bobMemIncognito <> "> hi there"),
cath ?<# ("#club " <> bobMemIncognito <> "> hi there"),
dan ?<# ("#club " <> bobMemIncognito <> "> hi there")
[ alice <# "#secret_club bob> hi there",
cath ?<# "#secret_club bob_1> hi there",
dan <# "#secret_club bob> hi there"
]
cath ?#> "#club hey"
cath ?#> "#secret_club hey"
concurrentlyN_
[ alice <# ("#club " <> cathMemIncognito <> "> hey"),
bob ?<# ("#club " <> cathMemIncognito <> "> hey"),
dan ?<# ("#club " <> cathMemIncognito <> "> hey")
[ alice <# ("#secret_club " <> cathIncognito <> "> hey"),
bob <# ("#secret_club " <> cathIncognito <> "> hey"),
dan <# ("#secret_club " <> cathIncognito <> "> hey")
]
dan ?#> "#club how is it going?"
dan #> "#secret_club how is it going?"
concurrentlyN_
[ alice <# ("#club " <> danMemIncognito <> "> how is it going?"),
bob ?<# ("#club " <> danMemIncognito <> "> how is it going?"),
cath ?<# ("#club " <> danMemIncognito <> "> how is it going?")
[ alice <# "#secret_club dan> how is it going?",
bob <# "#secret_club dan> how is it going?",
cath ?<# "#secret_club dan_1> how is it going?"
]
-- bob and cath can send messages via direct incognito connections
bob ?#> ("@" <> cathMemIncognito <> " hi, I'm bob")
cath ?<# (bobMemIncognito <> "> hi, I'm bob")
cath ?#> ("@" <> bobMemIncognito <> " hey, I'm cath")
bob ?<# (cathMemIncognito <> "> hey, I'm cath")
-- cath and bob can send messages via new direct connection, cath is incognito
bob #> ("@" <> cathIncognito <> " hi, I'm bob")
cath ?<# "bob_1> hi, I'm bob"
cath ?#> "@bob_1 hey, I'm incognito"
bob <# (cathIncognito <> "> hey, I'm incognito")
-- cath and dan can send messages via new direct connection, cath is incognito
dan #> ("@" <> cathIncognito <> " hi, I'm dan")
cath ?<# "dan_1> hi, I'm dan"
cath ?#> "@dan_1 hey, I'm incognito"
dan <# (cathIncognito <> "> hey, I'm incognito")
-- non incognito connections are separate
bob <##> cath
-- bob and dan can send messages via direct incognito connections
bob ?#> ("@" <> danMemIncognito <> " hi, I'm bob")
dan ?<# (bobMemIncognito <> "> hi, I'm bob")
dan ?#> ("@" <> bobMemIncognito <> " hey, I'm dan")
bob ?<# (danMemIncognito <> "> hey, I'm dan")
dan <##> cath
-- list groups
cath ##> "/gs"
cath <## "i #secret_club"
-- list group members
alice ##> "/ms club"
alice ##> "/ms secret_club"
alice
<### [ "alice (Alice): owner, you, created group",
"i " <> bobMemIncognito <> ": admin, invited, connected",
"i " <> cathMemIncognito <> ": admin, invited, connected",
danMemIncognito <> ": admin, connected"
"bob (Bob): admin, invited, connected",
cathIncognito <> ": admin, invited, connected",
"dan (Daniel): admin, invited, connected"
]
bob ##> "/ms club"
bob ##> "/ms secret_club"
bob
<### [ "alice (Alice): owner, host, connected",
"i " <> bobMemIncognito <> ": admin, you, connected",
cathMemIncognito <> ": admin, connected",
danMemIncognito <> ": admin, connected"
"bob (Bob): admin, you, connected",
cathIncognito <> ": admin, connected",
"dan (Daniel): admin, connected"
]
cath ##> "/ms club"
cath ##> "/ms secret_club"
cath
<### [ "alice (Alice): owner, host, connected",
bobMemIncognito <> ": admin, connected",
"i " <> cathMemIncognito <> ": admin, you, connected",
"i " <> danMemIncognito <> ": admin, invited, connected"
"bob_1 (Bob): admin, connected",
"i " <> cathIncognito <> ": admin, you, connected",
"dan_1 (Daniel): admin, connected"
]
dan ##> "/ms club"
dan ##> "/ms secret_club"
dan
<### [ "alice (Alice): owner, connected",
bobMemIncognito <> ": admin, connected",
"i " <> cathMemIncognito <> ": admin, host, connected",
"i " <> danMemIncognito <> ": admin, you, connected"
<### [ "alice (Alice): owner, host, connected",
"bob (Bob): admin, connected",
cathIncognito <> ": admin, connected",
"dan (Daniel): admin, you, connected"
]
-- remove member
bob ##> ("/rm secret_club " <> cathIncognito)
concurrentlyN_
[ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"),
alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"),
dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"),
do
cath <## "#secret_club: bob_1 removed you from the group"
cath <## "use /d #secret_club to delete the group"
]
bob #> "#secret_club hi"
concurrentlyN_
[ alice <# "#secret_club bob> hi",
dan <# "#secret_club bob> hi",
(cath </)
]
alice #> "#secret_club hello"
concurrentlyN_
[ bob <# "#secret_club alice> hello",
dan <# "#secret_club alice> hello",
(cath </)
]
cath ##> "#secret_club hello"
cath <## "you are no longer a member of the group"
-- cath can still message members directly
bob #> ("@" <> cathIncognito <> " I removed you from group")
cath ?<# "bob_1> I removed you from group"
cath ?#> "@bob_1 ok"
bob <# (cathIncognito <> "> ok")
testCantInviteIncognitoConnectionNonIncognitoGroup :: IO ()
testCantInviteIncognitoConnectionNonIncognitoGroup = testChat2 aliceProfile bobProfile $
testCantInviteContactIncognito :: IO ()
testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $
\alice bob -> do
-- alice connected incognito to bob
alice #$> ("/incognito on", id, "ok")
@@ -2438,7 +2359,16 @@ testCantInviteIncognitoConnectionNonIncognitoGroup = testChat2 aliceProfile bobP
alice <## "group #club is created"
alice <## "use /a club <name> to add members"
alice ##> "/a club bob"
alice <## "you're using main profile for this group - prohibited to invite contact to whom you are connected incognito"
alice <## "you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"
-- bob doesn't receive invitation
(bob </)
testSetAlias :: IO ()
testSetAlias = testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
alice #$> ("/_set alias @2 my friend bob", id, "contact bob alias updated: my friend bob")
alice #$> ("/_set alias @2", id, "contact bob alias removed")
testGetSetSMPServers :: IO ()
testGetSetSMPServers =
+2 -2
View File
@@ -31,9 +31,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\"
activeUser :: String
#if defined(darwin_HOST_OS) && defined(swiftJSON)
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}"
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}}"
#else
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}"
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}"
#endif
chatStarted :: String
+3 -9
View File
@@ -187,18 +187,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.contact with content (ignored)" $
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing
it "x.grp.inv with incognito profile" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"},\"fromMemberProfile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberProfile = Just testProfile, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
it "x.grp.inv without incognito profile" $
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberProfile = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
it "x.grp.acpt with incognito profile" $
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\", \"memberProfile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XGrpAcpt (MemberId "\1\2\3\4") (Just testProfile)
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
it "x.grp.acpt without incognito profile" $
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
#==# XGrpAcpt (MemberId "\1\2\3\4") Nothing
#==# XGrpAcpt (MemberId "\1\2\3\4")
it "x.grp.mem.new" $
"{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}