Merge branch 'master' into website

This commit is contained in:
Evgeny Poberezkin
2022-09-05 15:56:06 +01:00
142 changed files with 10362 additions and 1760 deletions
+14 -11
View File
@@ -79,18 +79,14 @@ You can use SimpleX with your own servers and still communicate with people usin
## News and updates
Selected updates:
Recent updates:
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced netwrok settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md)
[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md)
[All updates](./blog)
## Make a private connection
@@ -177,10 +173,14 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Chat database export and import
- ✅ Chat groups in mobile apps.
- ✅ Connecting to messaging servers via Tor.
- 🏗 Dual server addresses to access messaging servers as v3 hidden services (in progress).
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
- Chat database encryption.
- Dual server addresses to access messaging servers as v3 hidden services.
- Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement).
- ✅ Incognito mode to share a new random name with each contact.
- 🏗 Chat database encryption.
- 🏗 Links to join groups and improve groups stability.
- Disappearing messages, with mutual agreement.
- Voice messages
- Video messages
- Web widgets for custom interactivity in the chats.
- SMP protocol improvements:
- SMP queue redundancy and rotation.
@@ -191,6 +191,8 @@ If you are considering developing with SimpleX platform please get in touch for
- the server doesn't have information about your contacts and groups.
- Channels server for large groups and broadcast channels.
- Media server to optimize sending large files to groups.
- Desktop client.
- Using the same profile on multiple devices.
## Help us pay for 3rd party security audit
@@ -209,6 +211,7 @@ It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
Thank you,
+1
View File
@@ -9,6 +9,7 @@
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetDropDown.xml
/.idea/misc.xml
/.idea/uiDesigner.xml
.DS_Store
/build
/captures
+81 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 48
versionName "3.1"
versionCode 52
versionName "3.2.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -26,9 +26,19 @@ android {
cppFlags ''
}
}
manifestPlaceholders.app_name = "@string/app_name"
manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
manifestPlaceholders.extract_native_libs = compression_level != "0"
}
buildTypes {
debug {
applicationIdSuffix "$application_id_suffix"
debuggable new Boolean("$enable_debuggable")
manifestPlaceholders.app_name = "$app_name"
// Provider can't be the same for different apps on the same device
manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -64,6 +74,7 @@ android {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
jniLibs.useLegacyPackaging = compression_level != "0"
}
}
@@ -83,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"
@@ -113,3 +125,70 @@ dependencies {
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}
def buildType = "unknown"
// Don't do anything if no compression is needed
if (compression_level != "0") {
tasks.whenTaskAdded { task ->
if (task.name == 'packageDebug') {
task.doLast {
buildType = "debug"
}
task.finalizedBy compressApk
} else if (task.name == 'packageRelease') {
task.doLast {
buildType = "release"
}
task.finalizedBy compressApk
}
}
}
tasks.register("compressApk") {
doLast {
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
def sdkDir = android.getSdkDirectory().getAbsolutePath()
def keyAlias = ""
def keyPassword = ""
def storeFile = ""
def storePassword = ""
if (project.properties['android.injected.signing.key.alias'] != null) {
keyAlias = project.properties['android.injected.signing.key.alias']
keyPassword = project.properties['android.injected.signing.key.password']
storeFile = project.properties['android.injected.signing.store.file']
storePassword = project.properties['android.injected.signing.store.password']
} else if (android.signingConfigs.hasProperty(buildType)) {
def gradleConfig = android.signingConfigs[buildType]
keyAlias = gradleConfig.keyAlias
keyPassword = gradleConfig.keyPassword
storeFile = gradleConfig.storeFile
storePassword = gradleConfig.storePassword
} else {
// There is no signing config for current build type, can't sign the apk
println("No signing configs for this build type: $buildType")
return
}
def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
exec {
workingDir '../../../scripts/android'
setEnvironment(['JAVA_HOME': "$javaHome"])
commandLine './compress-and-sign-apk.sh', \
"$compression_level", \
"$outputDir", \
"$sdkDir", \
"$storeFile", \
"$storePassword", \
"$keyAlias", \
"$keyPassword"
}
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
}
// View all gradle properties set
// project.properties.each { k, v -> println "$k -> $v" }
}
}
@@ -25,7 +25,8 @@
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
@@ -34,7 +35,7 @@
android:name=".MainActivity"
android:launchMode="singleTask"
android:exported="true"
android:label="@string/app_name"
android:label="${app_name}"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.SimpleX">
<intent-filter>
@@ -66,9 +67,7 @@
<activity-alias
android:name=".MainActivity_default"
android:launchMode="singleTask"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/icon"
android:enabled="true"
android:targetActivity=".MainActivity">
@@ -80,9 +79,7 @@
<activity-alias
android:name=".MainActivity_dark_blue"
android:launchMode="singleTask"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/icon_dark_blue"
android:enabled="false"
android:targetActivity=".MainActivity">
@@ -98,7 +95,7 @@
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="chat.simplex.app.provider"
android:authorities="${provider_authorities}"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
@@ -15,13 +15,13 @@ import androidx.compose.material.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Replay
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleButton
@@ -37,21 +37,36 @@ import chat.simplex.app.views.newchat.connectViaUri
import chat.simplex.app.views.newchat.withUriAction
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit
class MainActivity: FragmentActivity(), LifecycleEventObserver {
class MainActivity: FragmentActivity() {
companion object {
/**
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
* clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
* See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
* */
val userAuthorized = mutableStateOf<Boolean?>(null)
val enteredBackground = mutableStateOf<Long?>(null)
// Remember result and show it after orientation change
private val laFailed = mutableStateOf(false)
fun clearAuthState() {
userAuthorized.value = null
enteredBackground.value = null
}
}
private val vm by viewModels<SimplexViewModel>()
private val chatController by lazy { (application as SimplexApp).chatController }
private val userAuthorized = mutableStateOf<Boolean?>(null)
private val enteredBackground = mutableStateOf<Long?>(null)
private val laFailed = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
// testJson()
val m = vm.chatModel
processNotificationIntent(intent, m)
// When call ended and orientation changes, it re-process old intent, it's unneeded.
// Only needed to be processed on first creation of activity
if (savedInstanceState == null) {
processNotificationIntent(intent, m)
}
setContent {
SimpleXTheme {
Surface(
@@ -70,7 +85,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
}
}
}
schedulePeriodicServiceRestartWorker()
SimplexApp.context.schedulePeriodicServiceRestartWorker()
}
override fun onNewIntent(intent: Intent?) {
@@ -78,20 +93,25 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
processIntent(intent, vm.chatModel)
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
withApi {
when (event) {
Lifecycle.Event.ON_STOP -> {
enteredBackground.value = elapsedRealtime()
}
Lifecycle.Event.ON_START -> {
val enteredBackgroundVal = enteredBackground.value
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
runAuthenticate()
}
}
else -> {}
}
override fun onStart() {
super.onStart()
val enteredBackgroundVal = enteredBackground.value
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
runAuthenticate()
}
}
override fun onStop() {
super.onStop()
enteredBackground.value = elapsedRealtime()
}
override fun onBackPressed() {
super.onBackPressed()
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()
laFailed.value = true
}
}
@@ -131,24 +151,6 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
}
}
private fun schedulePeriodicServiceRestartWorker() {
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
ExistingPeriodicWorkPolicy.KEEP
} else {
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
ExistingPeriodicWorkPolicy.REPLACE
}
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
.addTag(SimplexService.TAG)
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
.build()
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
private fun setPerformLA(on: Boolean) {
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
if (on) {
@@ -241,7 +243,7 @@ fun MainPage(
showLANotice: () -> Unit
) {
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
var chatsAccessAuthorized by remember { mutableStateOf(false) }
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(userAuthorized.value) {
if (chatModel.controller.appPrefs.performLA.get()) {
delay(500L)
@@ -4,14 +4,17 @@ import android.app.Application
import android.net.LocalServerSocket
import android.util.Log
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.getFilesDirectory
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.*
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
const val TAG = "SIMPLEX"
@@ -57,8 +60,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
SimplexService.start(applicationContext)
chatController.showBackgroundServiceNoticeIfNeeded()
}
}
}
@@ -80,11 +81,39 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
}
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
appPrefs.runServiceInBackground.get() && isIgnoringBatteryOptimizations(chatModel.controller.appContext)
}
/*
* It takes 1-10 milliseconds to process this function. Better to do it in a background thread
* */
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
if (!allowToStartServiceAfterAppExit()) {
return@launch
}
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
ExistingPeriodicWorkPolicy.KEEP
} else {
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
ExistingPeriodicWorkPolicy.REPLACE
}
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
.addTag(SimplexService.TAG)
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
.build()
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
companion object {
lateinit var context: SimplexApp private set
init {
val socketName = "local.socket.address.listen.native.cmd2"
val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d(TAG, "starting server")
@@ -2,6 +2,7 @@ package chat.simplex.app
import android.app.*
import android.content.*
import android.content.pm.PackageManager
import android.os.*
import android.provider.Settings
import android.util.Log
@@ -54,7 +55,10 @@ class SimplexService: Service() {
override fun onDestroy() {
Log.d(TAG, "Simplex service destroyed")
stopService()
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary!
// If private notifications are enabled and battery optimization is disabled, restart the service
if (SimplexApp.context.allowToStartServiceAfterAppExit())
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
super.onDestroy()
}
@@ -147,6 +151,14 @@ class SimplexService: Service() {
// re-schedules the task when "Clear recent apps" is pressed
override fun onTaskRemoved(rootIntent: Intent) {
// Just to make sure that after restart of the app the user will need to re-authenticate
MainActivity.clearAuthState()
// If private notifications aren't enabled or battery optimization isn't disabled, we shouldn't restart the service
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
return
}
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
it.setPackage(packageName)
};
@@ -162,6 +174,17 @@ class SimplexService: Service() {
Log.d(TAG, "StartReceiver: onReceive called")
scheduleStart(context)
}
companion object {
fun toggleReceiver(enable: Boolean) {
Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable")
val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name)
SimplexApp.context.packageManager.setComponentEnabledSetting(
component,
if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
}
}
// restart on destruction
@@ -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)
@@ -211,27 +213,39 @@ class ChatModel(val controller: ChatController) {
}
}
fun markChatItemsRead(cInfo: ChatInfo) {
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
val markedRead = markItemsReadInCurrentChat(cInfo, range)
// update preview
val chatIdx = getChatIndex(cInfo.id)
if (chatIdx >= 0) {
val chat = chats[chatIdx]
val lastId = chat.chatItems.lastOrNull()?.id
if (lastId != null) {
chats[chatIdx] = chat.copy(chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = lastId + 1))
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0,
// Can't use minUnreadItemId currently since chat items can have unread items between read items
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
)
)
}
}
// update current chat
}
private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int {
var markedRead = 0
if (chatId.value == cInfo.id) {
var i = 0
while (i < chatItems.count()) {
val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew) {
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
chatItems[i] = item.withStatus(CIStatus.RcvRead())
markedRead++
}
i += 1
}
}
return markedRead
}
// func popChat(_ id: String) {
@@ -248,6 +262,22 @@ class ChatModel(val controller: ChatController) {
fun removeChat(id: String) {
chats.removeAll { it.id == id }
}
fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean {
// update current chat
return if (chatId.value == groupInfo.id) {
val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
if (memberIndex >= 0) {
groupMembers[memberIndex] = member
false
} else {
groupMembers.add(member)
true
}
} else {
false
}
}
}
enum class ChatType(val type: String) {
@@ -262,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
)
}
@@ -286,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 {
@@ -297,6 +329,7 @@ interface SomeChat {
val apiId: Long
val ready: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val createdAt: Instant
val updatedAt: Instant
}
@@ -347,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
@@ -355,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)
@@ -374,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)
@@ -393,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)
@@ -412,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 =
@@ -426,12 +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,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -440,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()
)
@@ -474,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)
}
}
@@ -485,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",
@@ -500,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,
@@ -507,11 +586,13 @@ class Group (
)
@Serializable
class GroupInfo (
data class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
val membership: GroupMember,
val hostConnCustomUserProfileId: Long? = null,
val chatSettings: ChatSettings,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -520,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
@@ -539,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()
)
@@ -549,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(
@@ -569,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
@@ -615,6 +702,8 @@ class GroupMember (
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent
}
val memberIncognito = memberProfile.profileId != memberContactProfileId
companion object {
val sampleData = GroupMember(
groupMemberId = 1,
@@ -625,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
)
}
@@ -740,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(
@@ -761,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 {
@@ -769,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
@@ -784,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
)
}
@@ -802,6 +903,7 @@ class PendingContactConnection(
pccAgentConnId = "abcd",
pccConnStatus = status,
viaContactUri = viaContactUri,
customUserProfileId = null,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -856,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() =
@@ -1149,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))
}
@@ -15,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
@@ -86,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)
@@ -93,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(
@@ -153,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"
@@ -160,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"
}
}
@@ -172,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) {
@@ -182,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()
@@ -290,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
@@ -314,8 +335,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
}
suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(100)): Chat? {
val r = sendCmd(CC.ApiGetChat(type, id, pagination))
suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
val r = sendCmd(CC.ApiGetChat(type, id, pagination, search))
if (r is CR.ApiChat ) return r.chat
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
return null
@@ -387,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
}
@@ -500,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
@@ -599,10 +638,11 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole) {
suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole))
if (r is CR.SentGroupInvitation) return
if (r is CR.SentGroupInvitation) return r.member
Log.e(TAG, "apiAddMember bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiJoinGroup(groupId: Long) {
@@ -756,12 +796,22 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf()))
// TODO NtfManager.shared.notifyGroupInvitation
}
is CR.JoinedGroupMemberConnecting ->
chatModel.upsertGroupMember(r.groupInfo, r.member)
is CR.DeletedMemberUser -> // TODO update user member
chatModel.updateGroup(r.groupInfo)
is CR.DeletedMember ->
chatModel.upsertGroupMember(r.groupInfo, r.deletedMember)
is CR.LeftMember ->
chatModel.upsertGroupMember(r.groupInfo, r.member)
is CR.GroupDeleted -> // TODO update user member
chatModel.updateGroup(r.groupInfo)
is CR.UserJoinedGroup ->
chatModel.updateGroup(r.groupInfo)
is CR.GroupDeleted ->
chatModel.updateGroup(r.groupInfo)
is CR.DeletedMemberUser ->
chatModel.updateGroup(r.groupInfo)
is CR.JoinedGroupMember ->
chatModel.upsertGroupMember(r.groupInfo, r.member)
is CR.ConnectedToGroupMember ->
chatModel.upsertGroupMember(r.groupInfo, r.member)
is CR.GroupUpdated ->
chatModel.updateGroup(r.toGroup)
is CR.RcvFileStart ->
@@ -879,7 +929,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
fun showBackgroundServiceNoticeIfNeeded() {
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
if (!appPrefs.backgroundServiceNoticeShown.get()) {
// the branch for the new users who has never seen service notice
// the branch for the new users who have never seen service notice
if (isIgnoringBatteryOptimizations(appContext)) {
showBGServiceNotice()
} else {
@@ -892,15 +942,19 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
// the branch for users who have app installed, and have seen the service notice,
// but the battery optimization for the app is on (Android 12) AND the service is running
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
// users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service
// users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service
showDisablingServiceNotice()
appPrefs.runServiceInBackground.set(false)
chatModel.runServiceInBackground.value = false
SimplexService.StartReceiver.toggleReceiver(false)
} else {
// show battery optimization notice
showBGServiceNoticeIgnoreOptimization()
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
}
} else {
// service is allowed and battery optimization is disabled
SimplexApp.context.schedulePeriodicServiceRestartWorker()
}
}
@@ -926,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)) }
}
)
}
@@ -957,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)) }
}
)
}
@@ -983,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)) }
}
)
}
@@ -1053,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()
@@ -1067,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,
@@ -1076,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)
@@ -1100,11 +1160,12 @@ 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()
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
@@ -1120,6 +1181,7 @@ sealed class CC {
class SetUserSMPServers(val smpServers: List<String>): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
class APIContactInfo(val contactId: Long): CC()
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
class AddContact: CC()
@@ -1129,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()
@@ -1151,11 +1214,12 @@ 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"
is ApiGetChats -> "/_get chats pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
@@ -1170,6 +1234,7 @@ sealed class CC {
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
is APIContactInfo -> "/_info @$contactId"
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
is AddContact -> "/connect"
@@ -1179,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"
@@ -1202,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"
@@ -1221,6 +1288,7 @@ sealed class CC {
is SetUserSMPServers -> "setUserSMPServers"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
is APIGetNetworkConfig -> "/apiGetNetworkConfig"
is APISetChatSettings -> "/apiSetChatSettings"
is APIContactInfo -> "apiContactInfo"
is APIGroupMemberInfo -> "apiGroupMemberInfo"
is AddContact -> "addContact"
@@ -1230,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"
@@ -1265,6 +1334,12 @@ sealed class ChatPagination {
is After -> "after=${this.chatItemId} count=${this.count}"
is Before -> "before=${this.chatItemId} count=${this.count}"
}
companion object {
const val INITIAL_COUNT = 100
const val PRELOAD_COUNT = 100
const val UNTIL_PRELOAD_COUNT = 50
}
}
@Serializable
@@ -1276,6 +1351,8 @@ class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? =
@Serializable
data class NetCfg(
val socksProxy: String? = null,
val hostMode: HostMode = HostMode.OnionViaSocks,
val requiredHostMode: Boolean = false,
val tcpConnectTimeout: Long, // microseconds
val tcpTimeout: Long, // microseconds
val tcpKeepAlive: KeepAliveOpts?,
@@ -1303,6 +1380,33 @@ 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
enum class HostMode {
@SerialName("onionViaSocks") OnionViaSocks,
@SerialName("onion") Onion,
@SerialName("public") Public;
}
@Serializable
@@ -1317,9 +1421,15 @@ data class KeepAliveOpts(
}
}
@Serializable
data class ChatSettings(
val enableNtfs: Boolean
)
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
@Serializable
@@ -1355,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()
@@ -1365,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()
@@ -1390,7 +1501,7 @@ sealed class CR {
@Serializable @SerialName("contactsList") class ContactsList(val contacts: List<Contact>): CR()
// group events
@Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val groupInfo: GroupInfo): CR()
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val groupInfo: GroupInfo): CR()
@@ -1403,11 +1514,11 @@ sealed class CR {
@Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupDeleted") class GroupDeleted(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("contactsMerged") class ContactsMerged(val intoContact: Contact, val mergedContact: Contact): CR()
@Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
// receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
@@ -1451,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"
@@ -1535,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
@@ -1559,7 +1672,7 @@ sealed class CR {
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
is ContactsList -> json.encodeToString(contacts)
is GroupCreated -> json.encodeToString(groupInfo)
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact"
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member"
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
is UserDeletedMember -> "groupInfo: $groupInfo\nmember: $member"
is LeftMemberUser -> json.encodeToString(groupInfo)
@@ -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)
@@ -15,8 +16,8 @@ val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(139, 135, 134, 255)
val MessagePreviewDark = Color(179, 175, 174, 255)
val MessagePreviewLight = Color(49, 45, 44, 255)
val ToolbarLight = Color(220, 220, 220, 20)
val ToolbarDark = Color(80, 80, 80, 20)
val ToolbarLight = Color(220, 220, 220, 12)
val ToolbarDark = Color(80, 80, 80, 12)
val SettingsBackgroundLight = Color(220, 216, 215, 90)
val SettingsSecondaryLight = Color(200, 196, 195, 90)
val GroupDark = Color(80, 80, 80, 60)
@@ -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)
}
}
@@ -82,13 +82,9 @@ fun TerminalLayout(
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>) {
val listState = rememberLazyListState()
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
mutableStateOf(CIListState(false, terminalItems.count(), keyboardState))
}
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(terminalItems) { item ->
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
maxLines = 1,
@@ -104,13 +100,6 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
}
)
}
val len = terminalItems.count()
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
}
}
}
@@ -1,7 +1,10 @@
package chat.simplex.app.views.call
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.media.AudioManager
import android.util.Log
import android.view.ViewGroup
@@ -41,6 +44,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@SuppressLint("SourceLockedOrientationActivity")
@Composable
fun ActiveCallView(chatModel: ChatModel) {
BackHandler(onBack = {
@@ -122,6 +126,17 @@ fun ActiveCallView(chatModel: ChatModel) {
val call = chatModel.activeCall.value
if (call != null) ActiveCallOverlay(call, chatModel)
}
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
// Lock orientation to portrait in order to have good experience with calls
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
onDispose {
// Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
@Composable
@@ -337,6 +352,8 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer)
webView.value?.destroy()
webView.value = null
}
}
LaunchedEffect(callCommand.value, webView.value) {
@@ -45,16 +45,19 @@ fun IncomingCallAlertLayout(
ignoreCall: () -> Unit,
acceptCall: () -> Unit
) {
val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
IncomingCallInfo(invitation)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
Spacer(Modifier.fillMaxWidth().weight(1f))
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
}
Row(verticalAlignment = Alignment.CenterVertically) {
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
}
}
}
}
@@ -1,47 +1,105 @@
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.runtime.saveable.rememberSaveable
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.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@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 +140,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 +160,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 +193,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 +227,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 +251,41 @@ fun ChatInfoHeader(cInfo: ChatInfo) {
}
}
@Composable
private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) {
var value by rememberSaveable { mutableStateOf(initialValue) }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
DefaultBasicTextField(
Modifier.padding(horizontal = 10.dp).widthIn(min = 100.dp),
value,
{
Text(
generalGetString(R.string.text_field_set_contact_placeholder),
textAlign = TextAlign.Center,
color = HighOrLowlight
)
},
color = HighOrLowlight,
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty()) TextAlign.Start else TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
) {
value = it
}
}
LaunchedEffect(Unit) {
snapshotFlow { value }
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
.conflate() // get the latest value
.filter { it == value } // don't process old ones
.collect {
updateValue(value)
}
}
DisposableEffect(Unit) {
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
}
}
@Composable
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
Row(
@@ -223,7 +343,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 +418,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 +435,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 = {}
)
}
@@ -1,16 +1,17 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
@@ -19,64 +20,77 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
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.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.chatlist.openChat
import chat.simplex.app.views.chatlist.populateGroupMembers
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chatlist.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.AppBarHeight
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
@Composable
fun ChatView(chatModel: ChatModel) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
val composeState = rememberSaveable(saver = ComposeState.saver()) {
mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews))
}
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
if (chat == null || user == null) {
chatModel.chatId.value = null
} else {
BackHandler { chatModel.chatId.value = null }
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
delay(750L)
if (chat.chatItems.isNotEmpty()) {
chatModel.markChatItemsRead(chat.chatInfo)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
)
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 {
val chat = activeChat!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
}
}
ChatLayout(
user,
chat,
unreadCount,
composeState,
composeView = {
if (chat.chatInfo.sendMsgEnabled) {
@@ -90,27 +104,31 @@ fun ChatView(chatModel: ChatModel) {
scope,
attachmentBottomSheetState,
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) {
populateGroupMembers(cInfo.groupInfo, chatModel)
setGroupMembers(cInfo.groupInfo, 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)
}
@@ -118,11 +136,27 @@ fun ChatView(chatModel: ChatModel) {
}
}
},
openDirectChat = { contactId ->
val c = chatModel.chats.firstOrNull {
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
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)
}
}
}
},
loadPrevMessages = { cInfo ->
val c = chatModel.getChat(cInfo.id)
val firstId = chatModel.chatItems.firstOrNull()?.id
if (c != null && firstId != null) {
withApi {
apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value)
}
}
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
},
deleteMessage = { itemId, mode ->
withApi {
@@ -160,16 +194,35 @@ fun ChatView(chatModel: ChatModel) {
},
addMembers = { groupInfo ->
withApi {
populateGroupMembers(groupInfo, chatModel)
setGroupMembers(groupInfo, 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)
}
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
range
)
}
},
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
withApi {
apiFindMessages(c.chatInfo, chatModel, value)
searchText.value = value
}
}
)
}
@@ -179,22 +232,28 @@ fun ChatView(chatModel: ChatModel) {
fun ChatLayout(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
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,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
addMembers: (GroupInfo) -> Unit
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
onSearchValueChanged: (String) -> Unit,
) {
Surface(
Modifier
@@ -214,13 +273,23 @@ fun ChatLayout(
sheetState = attachmentBottomSheetState,
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) },
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, joinGroup, acceptCall)
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
)
}
}
}
@@ -234,54 +303,79 @@ fun ChatInfoToolbar(
back: () -> Unit,
info: () -> Unit,
startCall: (CallMediaType) -> Unit,
addMembers: (GroupInfo) -> Unit
addMembers: (GroupInfo) -> Unit,
onSearchValueChanged: (String) -> Unit,
) {
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
IconButton(onClick, modifier = modifier) {
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
var showMenu by rememberSaveable { mutableStateOf(false) }
var showSearch by rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch) {
back()
} else {
onSearchValueChanged("")
showSearch = false
}
}
Column {
Box(
Modifier
.fillMaxWidth()
.height(52.dp)
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
val cInfo = chat.chatInfo
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
if (cInfo is ChatInfo.Direct) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
startCall(CallMediaType.Audio)
}
}
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
startCall(CallMediaType.Video)
}
}
} else if (cInfo is ChatInfo.Group) {
if (cInfo.groupInfo.canAddMembers) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
toolbarButton(Icons.Outlined.PersonAdd, R.string.icon_descr_add_members) {
addMembers(cInfo.groupInfo)
}
}
}
}
Box(
Modifier
.padding(horizontal = 80.dp).fillMaxWidth()
.clickable(onClick = info),
contentAlignment = Alignment.Center
) {
ChatInfoToolbarTitle(cInfo)
BackHandler(onBack = onBackClicked)
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>()
menuItems.add {
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
showMenu = false
showSearch = true
})
}
if (chat.chatInfo is ChatInfo.Direct) {
barButtons.add {
IconButton({
showMenu = false
startCall(CallMediaType.Audio)
}) {
Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
}
}
Divider()
menuItems.add {
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
showMenu = false
startCall(CallMediaType.Video)
})
}
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
barButtons.add {
IconButton({
showMenu = false
addMembers(chat.chatInfo.groupInfo)
}) {
Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
}
}
}
barButtons.add {
IconButton({ showMenu = true }) {
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
}
}
DefaultTopAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { ChatInfoToolbarTitle(chat.chatInfo) },
onTitleClick = info,
showSearch = showSearch,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
Divider(Modifier.padding(top = AppBarHeight))
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
Modifier.widthIn(min = 220.dp)
) {
menuItems.forEach { it() }
}
}
}
@@ -291,6 +385,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),
@@ -300,7 +397,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
@@ -323,81 +420,274 @@ val CIListStateSaver = run {
}
@Composable
fun ChatItemsList(
fun BoxWithConstraintsScope.ChatItemsList(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
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
acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
}
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
LazyColumn(state = listState) {
itemsIndexed(chatItems) { i, cItem ->
if (i == 0) {
Spacer(Modifier.size(8.dp))
}
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i > 0) chatItems[i - 1] else null
val member = cItem.chatDir.groupMember
val showMember = showMemberImage(member, prevItem)
Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
if (showMember) {
val contactId = member.memberContactId
if (contactId == null) {
MemberImage(member)
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) {
if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
scope.launch { listState.scrollToItem(0) }
}
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false
}
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
// They are equal when orientation was changed, don't need to scroll.
// LaunchedEffect unaware of this event since it uses remember, not rememberSaveable
if (prevSearchEmptiness == searchValue.value.isEmpty()) return@LaunchedEffect
prevSearchEmptiness = searchValue.value.isEmpty()
if (listState.firstVisibleItemIndex != 0) {
scope.launch { listState.scrollToItem(0) }
}
}
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c ->
loadPrevMessages(c.chatInfo)
}
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
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) }
) {
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)) {
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,
)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
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)
}
}
}
}
}
val len = chatItems.count()
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
}
@Composable
fun BoxWithConstraintsScope.FloatingButtons(
chatItems: List<ChatItem>,
unreadCount: State<Int>,
minUnreadItemId: Long,
searchValue: State<String>,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
listState: LazyListState
) {
val scope = rememberCoroutineScope()
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect {
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 {
lastIndexOfVisibleItems = it
}
}
val bottomUnreadCount by remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
if (chatItems.size <= from || from < 0) return@derivedStateOf 0
chatItems.subList(from, chatItems.size).count { it.isRcvNew }
}
}
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
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
val fabSize = 56.dp
val topUnreadCount by remember {
derivedStateOf { unreadCount.value - bottomUnreadCount }
}
val showButtonWithCounter = topUnreadCount > 0
val height = with(LocalDensity.current) { maxHeight.toPx() }
var showDropDown by remember { mutableStateOf(false) }
TopEndFloatingButton(
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
topUnreadCount,
showButtonWithCounter,
onClick = { scope.launch { listState.animateScrollBy(height) } },
onLongClick = { showDropDown = true }
)
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
Modifier.width(220.dp),
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
) {
DropdownMenuItem(
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown = false
}
) {
Text(
generalGetString(R.string.mark_read),
maxLines = 1,
)
}
}
}
@Composable
fun PreloadItems(
listState: LazyListState,
remaining: Int = 10,
chat: Chat,
items: List<*>,
onLoadMore: (chat: Chat) -> Unit,
) {
LaunchedEffect(listState, chat, items) {
snapshotFlow { listState.layoutInfo }
.map {
val totalItemsNumber = it.totalItemsCount
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
totalItemsNumber
else
0
}
.distinctUntilChanged()
.filter { it > 0 }
.collect {
onLoadMore(chat)
}
}
}
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
@@ -410,6 +700,88 @@ fun MemberImage(member: GroupMember) {
ProfileImage(38.dp, member.memberProfile.image)
}
@Composable
private fun TopEndFloatingButton(
modifier: Modifier = Modifier,
unreadCount: Int,
showButtonWithCounter: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) = when {
showButtonWithCounter -> {
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
FloatingActionButton(
{}, // no action here
modifier.size(48.dp),
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
interactionSource = interactionSource,
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
else -> {
}
}
private fun bottomEndFloatingButton(
unreadCount: Int,
showButtonWithCounter: Boolean,
showButtonWithArrow: Boolean,
onClickArrowDown: () -> Unit,
onClickCounter: () -> Unit
): @Composable () -> Unit = when {
showButtonWithCounter -> {
{
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp)
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
}
showButtonWithArrow -> {
{
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
}
}
else -> {
{}
}
}
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
override val longPressTimeoutMillis
get() =
this@bigTouchSlop.longPressTimeoutMillis
override val doubleTapTimeoutMillis
get() =
this@bigTouchSlop.doubleTapTimeoutMillis
override val doubleTapMinTimeMillis
get() =
this@bigTouchSlop.doubleTapMinTimeMillis
override val touchSlop: Float get() = slop
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -437,6 +809,8 @@ fun PreviewChatLayout() {
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
@@ -444,22 +818,28 @@ fun PreviewChatLayout() {
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
chatModelIncognito = false,
back = {},
info = {},
openDirectChat = {},
showMemberInfo = {_, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
addMembers = { _ -> }
addMembers = { _ -> },
markRead = { _, _ -> },
onSearchValueChanged = {},
)
}
}
@@ -486,6 +866,8 @@ fun PreviewGroupChatLayout() {
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
)
)
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
@@ -493,22 +875,28 @@ fun PreviewGroupChatLayout() {
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
chatModelIncognito = false,
back = {},
info = {},
openDirectChat = {},
showMemberInfo = {_, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> },
addMembers = { _ -> }
addMembers = { _ -> },
markRead = { _, _ -> },
onSearchValueChanged = {},
)
}
}
@@ -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))
@@ -4,8 +4,7 @@ import ComposeFileView
import ComposeImageView
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.*
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
@@ -26,6 +25,7 @@ import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -42,21 +42,26 @@ import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import java.io.File
@Serializable
sealed class ComposePreview {
object NoPreview: ComposePreview()
class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
class ImagePreview(val image: String): ComposePreview()
class FilePreview(val fileName: String): ComposePreview()
@Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val image: String): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview()
}
@Serializable
sealed class ComposeContextItem {
object NoContextItem: ComposeContextItem()
class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
class EditingItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable object NoContextItem: ComposeContextItem()
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
}
@Serializable
data class ComposeState(
val message: String = "",
val preview: ComposePreview = ComposePreview.NoPreview,
@@ -99,6 +104,15 @@ data class ComposeState(
is ComposePreview.CLinkPreview -> preview.linkPreview
else -> null
}
companion object {
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
save = { json.encodeToString(serializer(), it.value) },
restore = {
mutableStateOf(json.decodeFromString(it))
}
)
}
}
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
@@ -180,7 +194,16 @@ fun ComposeView(
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
val galleryLauncherFallback = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
@@ -221,7 +244,11 @@ fun ComposeView(
attachmentOption.value = null
}
AttachmentOption.PickImage -> {
galleryLauncher.launch("image/*")
try {
galleryLauncher.launch(0)
} catch (e: ActivityNotFoundException) {
galleryLauncherFallback.launch("image/*")
}
attachmentOption.value = null
}
AttachmentOption.PickFile -> {
@@ -486,3 +513,9 @@ fun ComposeView(
}
}
}
class PickFromGallery: ActivityResultContract<Int, Uri?>() {
override fun createIntent(context: Context, input: Int) = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI)
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
}
@@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -26,7 +28,9 @@ import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
@@ -35,6 +39,16 @@ fun SendMsgView(
textStyle: MutableState<TextStyle>
) {
val cs = composeState.value
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(cs.contextItem) {
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
// In replying state
focusRequester.requestFocus()
delay(50)
keyboard?.show()
}
BasicTextField(
value = cs.message,
onValueChange = onMessageChange,
@@ -44,7 +58,7 @@ fun SendMsgView(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(vertical = 8.dp),
modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
@@ -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
@@ -41,7 +44,10 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
inviteMembers = {
withApi {
selectedContacts.forEach {
chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
val member = chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
if (member != null) {
chatModel.upsertGroupMember(groupInfo, member)
}
}
close.invoke()
}
@@ -90,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()
@@ -122,7 +128,7 @@ fun AddGroupMembersLayout(
SectionSpacer()
SectionView {
ContactList(contacts = contactsToAdd, selectedContacts, addContact, removeContact)
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
}
SectionSpacer()
}
@@ -252,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
) {
@@ -259,7 +266,7 @@ fun ContactList(
contacts.forEachIndexed { index, contact ->
SectionItemView {
ContactCheckRow(
contact, addContact, removeContact,
contact, groupInfo, addContact, removeContact,
checked = selectedContacts.contains(contact.apiId)
)
}
@@ -273,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
) {
@@ -289,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,7 +25,8 @@ 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.populateGroupMembers
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
@Composable
@@ -43,11 +45,11 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
developerTools,
addMembers = {
withApi {
populateGroupMembers(groupInfo, chatModel)
setGroupMembers(groupInfo, 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)
}
@@ -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,19 +45,38 @@ fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: Co
member,
connStats,
developerTools,
removeMember = { removeMemberDialog(member, chatModel, close) }
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) }
)
}
}
fun removeMemberDialog(member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.button_remove_member),
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
confirmText = generalGetString(R.string.remove_member_confirmation),
onConfirm = {
withApi {
chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
if (removedMember != null) {
chatModel.upsertGroupMember(groupInfo, removedMember)
}
close?.invoke()
}
}
@@ -62,6 +89,7 @@ fun GroupMemberInfoLayout(
member: GroupMember,
connStats: ConnectionStats?,
developerTools: Boolean,
openDirectChat: () -> Unit,
removeMember: () -> Unit,
) {
Column(
@@ -78,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
@@ -136,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,
@@ -172,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() {
@@ -181,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)
}
@@ -164,7 +165,8 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
text,
modifier = Modifier
.fillMaxWidth()
.weight(1F),
.weight(1F)
.padding(end = 15.dp),
color = color
)
Icon(icon, text, tint = color)
@@ -183,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)) }
@@ -212,6 +214,7 @@ fun PreviewChatItemView() {
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
@@ -231,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,
@@ -96,7 +96,19 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
}
}
suspend fun populateGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
chatModel.chatItems.addAll(0, chat.chatItems)
}
suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) {
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return
chatModel.chatItems.clear()
chatModel.chatItems.addAll(0, chat.chatItems)
}
suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
chatModel.groupMembers.clear()
chatModel.groupMembers.addAll(groupMembers)
@@ -107,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)
}
@@ -115,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)
}
@@ -124,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)
@@ -148,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(
@@ -188,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
}
)
@@ -215,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
@@ -247,12 +276,16 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
}
fun markChatRead(chat: Chat, chatModel: ChatModel) {
// Just to be sure
if (chat.chatStats.unreadCount == 0) return
val minUnreadItemId = chat.chatStats.minUnreadItemId
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
)
}
}
@@ -261,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) }
@@ -302,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))
}
}
@@ -358,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)
@@ -443,6 +484,8 @@ fun PreviewChatListNavLinkDirect() {
),
chatStats = Chat.ChatStats()
),
false,
null,
stopped = false
)
},
@@ -478,6 +521,8 @@ fun PreviewChatListNavLinkGroup() {
),
chatStats = Chat.ChatStats()
),
false,
null,
stopped = false
)
},
@@ -500,7 +545,7 @@ fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chatLinkPreview = {
ContactRequestView(ChatInfo.ContactRequest.sampleData)
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
},
click = {},
dropdownMenuItems = null,
@@ -1,5 +1,6 @@
package chat.simplex.app.views.chatlist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -7,21 +8,22 @@ import androidx.compose.foundation.lazy.items
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.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.ToolbarDark
import chat.simplex.app.ui.theme.ToolbarLight
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.model.*
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
import chat.simplex.app.views.usersettings.SettingsView
@@ -71,7 +73,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
}
var searchInList by rememberSaveable { mutableStateOf("") }
BottomSheetScaffold(
topBar = { ChatListToolbar(chatModel, scaffoldCtrl, stopped) { searchInList = it.trim() } },
scaffoldState = scaffoldCtrl.state,
drawerContent = { SettingsView(chatModel, setPerformLA) },
sheetPeekHeight = 0.dp,
@@ -84,10 +88,8 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl, stopped)
Divider()
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
ChatList(chatModel, search = searchInList)
} else {
MakeConnection(chatModel)
}
@@ -105,58 +107,80 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
}
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(horizontal = 8.dp)
) {
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
Icons.Outlined.Menu,
stringResource(R.string.icon_descr_settings),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
BackHandler(onBack = hideSearchOnBack)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (chatModel.chats.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
}
}
Text(
stringResource(R.string.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(5.dp)
)
if (!stopped) {
}
if (!stopped) {
barButtons.add {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.AddCircle,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
} else {
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
}
} else {
barButtons.add {
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication),
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
Icon(
Icons.Filled.Report,
generalGetString(R.string.chat_is_stopped_indication),
tint = Color.Red,
modifier = Modifier.padding(10.dp)
)
}
}
}
DefaultTopAppBar(
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } },
title = {
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 = showSearch,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
Divider()
}
@Composable
fun ChatList(chatModel: ChatModel) {
fun ChatList(chatModel: ChatModel, search: String) {
val filter: (Chat) -> Boolean = { chat: Chat ->
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
}
val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chatModel.chats) { chat ->
items(chats) { chat ->
ChatListNavLinkView(chat, chatModel)
}
}
@@ -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,22 +133,38 @@ 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),
contentAlignment = Alignment.Center
) {
Text(
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
unreadCountStr(n),
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,21 @@ 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)
}
@Composable
fun ChatStatusImage(chat: Chat) {
val s = chat.serverInfo.networkStatus
@@ -195,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
)
}
)
}
@@ -0,0 +1,123 @@
package chat.simplex.app.views.helpers
import chat.simplex.app.R
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBackIos
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.*
@Composable
fun DefaultTopAppBar(
navigationButton: @Composable RowScope.() -> Unit,
title: @Composable () -> Unit,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
onSearchValueChanged: (String) -> Unit,
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
) {
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
val modifier = if (!showSearch) {
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
} else Modifier
TopAppBar(
modifier = modifier,
title = {
if (!showSearch) {
title()
} else {
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
}
},
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
navigationIcon = navigationButton,
buttons = if (!showSearch) buttons else emptyList(),
centered = !showSearch
)
}
@Composable
fun NavigationButtonBack(onButtonClicked: () -> Unit) {
IconButton(onButtonClicked) {
Icon(
Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary
)
}
}
@Composable
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
IconButton(onClick = onButtonClicked) {
Icon(
Icons.Outlined.Menu,
stringResource(R.string.icon_descr_settings),
tint = MaterialTheme.colors.primary,
)
}
}
@Composable
private fun TopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
backgroundColor: Color = MaterialTheme.colors.primarySurface,
centered: Boolean,
) {
Box(
modifier
.fillMaxWidth()
.height(AppBarHeight)
.background(backgroundColor)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
if (navigationIcon != null) {
Row(
Modifier
.fillMaxHeight()
.width(TitleInsetWithIcon - AppBarHorizontalPadding),
verticalAlignment = Alignment.CenterVertically,
content = navigationIcon
)
}
Row(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
buttons.forEach { it() }
}
val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon
val endPadding = (buttons.size * 50f).dp
Box(
Modifier
.fillMaxWidth()
.padding(
start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding,
end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding
),
contentAlignment = Alignment.Center
) {
title()
}
}
}
val AppBarHeight = 56.dp
private val AppBarHorizontalPadding = 4.dp
private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding
private val TitleInsetWithIcon = 72.dp
@@ -16,7 +16,13 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.interaction.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
@@ -31,19 +37,19 @@ import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.consumeDownChange
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import chat.simplex.app.TAG
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
/**
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
* */
interface PressGestureScope : Density {
interface PressGestureScope: Density {
suspend fun tryAwaitRelease(): Boolean
}
@@ -67,7 +73,6 @@ suspend fun PointerInputScope.detectGesture(
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
viewConfiguration.longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
@@ -81,12 +86,7 @@ suspend fun PointerInputScope.detectGesture(
} else {
if (shouldConsume)
upOrCancel.consumeDownChange()
// If onLongPress event is needed, cancel short press event
if (onLongPress != null)
pressScope.cancel()
else
pressScope.release()
pressScope.release()
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress?.invoke(down.position)
@@ -138,7 +138,6 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
) {
return null
}
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
return null
@@ -148,7 +147,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
): PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
@@ -173,6 +172,52 @@ private class PressGestureScopeImpl(
if (!isReleased && !isCanceled) {
mutex.lock()
}
return isCanceled
return isReleased && !isCanceled
}
}
/**
* Captures click events and calls [onLongClick] or [onClick] when such even happens. Otherwise, does nothing.
* Apply [MutableInteractionSource] to any element that allows to pass it in (for example, in [Modifier.clickable]).
* Works in situations when using [Modifier.combinedClickable] doesn't work because external element overrides [Modifier.clickable]
* */
@Composable
fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit): MutableInteractionSource {
val interactionSource = remember { MutableInteractionSource() }
val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
var topLevelInteraction: Interaction? by remember { mutableStateOf(null) }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
topLevelInteraction = interaction
}
}
LaunchedEffect(topLevelInteraction is PressInteraction.Press) {
if (topLevelInteraction !is PressInteraction.Press) return@LaunchedEffect
try {
withTimeout(longPressTimeoutMillis) {
while (isActive) {
delay(10)
when (topLevelInteraction) {
is PressInteraction.Press -> {}
is PressInteraction.Release -> {
onClick(); break
}
is PressInteraction.Cancel -> break
}
}
}
} catch (_: TimeoutCancellationException) {
// Long click happened
onLongClick()
} catch (ex: CancellationException) {
// Canceled coroutine + PressInteraction.Release == short click
if (topLevelInteraction is PressInteraction.Release)
onClick()
Log.e(TAG, ex.stackTraceToString())
} catch (ex: Exception) {
// Should never be called
Log.e(TAG, ex.stackTraceToString())
}
}
return interactionSource
}
@@ -1,7 +1,15 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.offset
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.roundToInt
fun Modifier.badgeLayout() =
layout { measurable, constraints ->
@@ -15,3 +23,22 @@ fun Modifier.badgeLayout() =
placeable.place((width - placeable.width) / 2, 0)
}
}
@Composable
fun SwipeToDismissModifier(
state: DismissState,
directions: Set<DismissDirection> = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd),
swipeDistance: Float,
): Modifier {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val anchors = mutableMapOf(0f to DismissValue.Default)
if (DismissDirection.StartToEnd in directions) anchors += swipeDistance to DismissValue.DismissedToEnd
if (DismissDirection.EndToStart in directions) anchors += -swipeDistance to DismissValue.DismissedToStart
return Modifier.swipeable(
state = state,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.5f) },
orientation = Orientation.Horizontal,
reverseDirection = isRtl,
).offset { IntOffset(state.offset.value.roundToInt(), 0) }
}
@@ -0,0 +1,97 @@
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.material.TextFieldDefaults.textFieldWithLabelPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
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 chat.simplex.app.R
import kotlinx.coroutines.delay
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
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 = searchText,
modifier = modifier
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors)
.focusRequester(focusRequester)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = {
searchText = it
onValueChange(it.text)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = VisualTransformation.None,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
singleLine = true,
textStyle = TextStyle(
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = searchText.text,
innerTextField = innerTextField,
placeholder = {
Text(placeholder)
},
trailingIcon = if (searchText.text.isNotEmpty()) {{
IconButton({ searchText = TextFieldValue(""); onValueChange("") }) {
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
}
}} else null,
singleLine = true,
enabled = enabled,
interactionSource = interactionSource,
contentPadding = textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
visualTransformation = VisualTransformation.None,
colors = colors
)
}
)
}
@@ -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
@@ -21,7 +22,7 @@ import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.ProfileNameField
import chat.simplex.app.views.chat.group.AddGroupMembersView
import chat.simplex.app.views.chatlist.populateGroupMembers
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.onboarding.ReadableText
@@ -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)
@@ -41,12 +43,12 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf()))
chatModel.chatItems.clear()
chatModel.chatId.value = groupInfo.id
populateGroupMembers(groupInfo, chatModel)
setGroupMembers(groupInfo, chatModel)
close.invoke()
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
@@ -21,9 +21,10 @@ import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.painterResource
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.*
import chat.simplex.app.BuildConfig
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@@ -37,6 +38,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)) {
@@ -44,12 +47,15 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
chatModel.runServiceInBackground.value = on
SimplexService.StartReceiver.toggleReceiver(on)
}
if (user != null) {
SettingsLayout(
profile = user.profile,
stopped,
chatModel.incognito,
chatModel.controller.appPrefs.incognito,
runServiceInBackground = chatModel.runServiceInBackground,
developerTools = chatModel.controller.appPrefs.developerTools,
setRunServiceInBackground = ::setRunServiceInBackground,
@@ -57,24 +63,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) } },
)
}
@@ -87,7 +81,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)
// }
// }
@@ -95,8 +89,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,
@@ -105,7 +101,6 @@ fun SettingsLayout(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
showAppearance: () -> Unit
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
@@ -113,7 +108,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(
@@ -128,6 +123,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)
@@ -141,7 +138,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)
}
@@ -161,7 +158,7 @@ fun SettingsLayout(
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_develop)) {
ChatConsoleItem(showTerminal, stopped)
ChatConsoleItem(showTerminal)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools)
SectionDivider()
@@ -175,6 +172,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(
@@ -264,18 +302,15 @@ fun SettingsLayout(
}
}
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
SectionItemView(showTerminal, disabled = stopped) {
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
SectionItemView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = stringResource(R.string.chat_console),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.chat_console),
color = if (stopped) HighOrLowlight else Color.Unspecified
)
Text(stringResource(R.string.chat_console))
}
}
@@ -287,7 +322,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)
}
}
@@ -305,11 +340,15 @@ fun SettingsLayout(
profileOf.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (stopped) HighOrLowlight else Color.Unspecified
color = if (stopped) HighOrLowlight else Color.Unspecified,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
profileOf.fullName,
color = if (stopped) HighOrLowlight else Color.Unspecified
color = if (stopped) HighOrLowlight else Color.Unspecified,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -334,6 +373,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,
@@ -344,8 +402,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 = {},
@@ -354,7 +414,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>
@@ -233,6 +247,7 @@
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> команда</string>
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> логотип</string>
<string name="icon_descr_email">Email</string>
<string name="icon_descr_more_button">Больше</string>
<!-- Add Contact - AddContactView.kt -->
<string name="invalid_QR_code">Неверный QR код</string>
@@ -242,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>
@@ -263,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>
@@ -272,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>
@@ -280,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 -->
@@ -459,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>
@@ -509,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>
@@ -521,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>
@@ -571,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>
@@ -589,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>
@@ -608,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>
@@ -626,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>
@@ -233,6 +247,7 @@
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> Team</string>
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> Logo</string>
<string name="icon_descr_email">Email</string>
<string name="icon_descr_more_button">More</string>
<!-- Add Contact - AddContactView.kt -->
<string name="invalid_QR_code">Invalid QR code</string>
@@ -242,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>
@@ -268,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>
@@ -285,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 -->
@@ -461,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>
@@ -511,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>
@@ -523,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>
@@ -573,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>
@@ -591,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>
@@ -610,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>
@@ -628,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>
+29 -7
View File
@@ -1,14 +1,36 @@
buildscript {
Properties localProperties = new Properties()
if (rootProject.file('local.properties').canRead()) {
localProperties.load(rootProject.file("local.properties").newDataInputStream())
}
ext {
compose_version = '1.2.0-beta02'
compose_version = localProperties['compose_version'] ?: '1.2.0-beta02'
kotlin_version = localProperties['kotlin_version'] ?: '1.6.21'
gradle_plugin_version = localProperties['gradle_plugin_version'] ?: '7.2.0'
// Name that will be shown for debug build. By default it is from strings
app_name = localProperties['app_name'] ?: "@string/app_name"
// Whether the app is debuggable or not. Specify `false` if you want good performance in debug builds
enable_debuggable = localProperties['debuggable'] ?: true
// Ending part of package name.
// Provide, for example, `application_id_suffix=.debug` in local.properties
// to allow debug & release versions to coexist
application_id_suffix = localProperties['application_id_suffix'] ?: ''
// Compression level for debug AND release apk. 0 = disable compression. Max is 9
compression_level = localProperties['compression_level'] ?: '0'
// NOTE: If you need a different version of something, provide it in `local.properties`
// like so: compose_version=123, or gradle_plugin_version=1.2.3, etc
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21"
classpath "com.android.tools.build:gradle:$gradle_plugin_version"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
// NOTE: Do not place your application dependencies here; they belong
@@ -16,10 +38,10 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.2.0' apply false
id 'com.android.library' version '7.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.6.21' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21'
id 'com.android.application' version "$gradle_plugin_version" apply false
id 'com.android.library' version "$gradle_plugin_version" apply false
id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
}
task clean(type: Delete) {
+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()
}
}
+110 -30
View File
@@ -22,8 +22,9 @@ final class ChatModel: ObservableObject {
@Published var chats: [Chat] = []
// current chat
@Published var chatId: String?
@Published var chatItems: [ChatItem] = []
@Published var reversedChatItems: [ChatItem] = []
@Published var chatToTop: String?
@Published var groupMembers: [GroupMember] = []
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: String?
@@ -35,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)?
@@ -57,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 })
}
@@ -78,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) {
@@ -158,17 +170,7 @@ final class ChatModel: ObservableObject {
}
// add to current chat
if chatId == cInfo.id {
withAnimation { chatItems.append(cItem) }
if case .rcvNew = cItem.meta.itemStatus {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if self.chatId == cInfo.id {
Task {
await apiMarkChatItemRead(cInfo, cItem)
NtfManager.shared.decNtfBadgeCount()
}
}
}
}
withAnimation { reversedChatItems.insert(cItem, at: 0) }
}
}
@@ -186,13 +188,14 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == cInfo.id {
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
withAnimation(.default) {
self.chatItems[i] = cItem
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
}
return false
} else {
withAnimation { chatItems.append(cItem) }
withAnimation { reversedChatItems.insert(cItem, at: 0) }
return true
}
} else {
@@ -209,12 +212,12 @@ final class ChatModel: ObservableObject {
}
// remove from current chat
if chatId == cInfo.id {
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
if chatItems[i].isRcvNew() == true {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
if reversedChatItems[i].isRcvNew() == true {
NtfManager.shared.decNtfBadgeCount()
}
_ = withAnimation {
self.chatItems.remove(at: i)
self.reversedChatItems.remove(at: i)
}
}
}
@@ -228,13 +231,44 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == cInfo.id {
var i = 0
while i < chatItems.count {
if case .rcvNew = chatItems[i].meta.itemStatus {
chatItems[i].meta.itemStatus = .rcvRead
}
i = i + 1
markCurrentChatRead()
}
}
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < reversedChatItems.count {
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
}
j += 1
}
}
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem {
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
markCurrentChatRead(fromIndex: i)
if let chat = getChat(cInfo.id) {
var unreadBelow = 0
var j = i - 1
while j >= 0 {
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
unreadBelow += 1
}
j -= 1
}
// update preview
let markedCount = chat.chatStats.unreadCount - unreadBelow
if markedCount > 0 {
NtfManager.shared.decNtfBadgeCount(by: markedCount)
chat.chatStats.unreadCount -= markedCount
}
}
}
} else {
markChatItemsRead(cInfo)
}
}
@@ -248,7 +282,7 @@ final class ChatModel: ObservableObject {
}
// clear current chat
if chatId == cInfo.id {
chatItems = []
reversedChatItems = []
}
}
@@ -258,8 +292,9 @@ final class ChatModel: ObservableObject {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
}
// update current chat
if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) {
chatItems[j].meta.itemStatus = .rcvRead
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
reversedChatItems[j].meta.itemStatus = .rcvRead
reversedChatItems[j].viewTimestamp = .now
}
}
@@ -268,8 +303,8 @@ final class ChatModel: ObservableObject {
}
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 {
return chatItems[i - 1]
if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 {
return reversedChatItems[i + 1]
} else {
return nil
}
@@ -291,6 +326,51 @@ final class ChatModel: ObservableObject {
chats.removeAll(where: { $0.id == id })
}
}
func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool {
// update current chat
if chatId == groupInfo.id {
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) {
withAnimation(.default) {
self.groupMembers[i] = member
}
return false
} else {
withAnimation { groupMembers.append(member) }
return true
}
} else {
return false
}
}
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
var i = 0
var totalBelow = 0
var unreadBelow = 0
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1
if reversedChatItems[i].isRcvNew() {
unreadBelow += 1
}
i += 1
}
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = reversedChatItems.count - 1
var i = 0
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 }
return reversedChatItems[min(i - 1, maxIx)]
}
}
struct UnreadChatItemCounts {
var totalBelow: Int
var unreadBelow: Int
}
final class Chat: ObservableObject, Identifiable {
+3 -1
View File
@@ -200,7 +200,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
addNotification(createMessageReceivedNtf(cInfo, cItem))
if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(cInfo, cItem))
}
}
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
+56 -29
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))
}
@@ -185,19 +191,26 @@ func apiGetChats() throws -> [ChatData] {
throw r
}
func apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination = .last(count: 100)) throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: pagination))
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
if case let .apiChat(chat) = r { return Chat.init(chat) }
throw r
}
func loadChat(chat: Chat) {
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search))
if case let .apiChat(chat) = r { return chat.chatItems }
throw r
}
func loadChat(chat: Chat, search: String = "") {
do {
let cInfo = chat.chatInfo
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
let m = ChatModel.shared
m.reversedChatItems = []
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
m.updateChatInfo(chat.chatInfo)
m.chatItems = chat.chatItems
m.reversedChatItems = chat.chatItems.reversed()
} catch let error {
logger.error("loadChat error: \(responseError(error))")
}
@@ -302,15 +315,19 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
throw r
}
func apiContactInfo(contactId: Int64) async throws -> ConnectionStats? {
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
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
}
@@ -419,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 }
@@ -534,13 +557,13 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
}
}
func markChatRead(_ chat: Chat) async {
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
do {
let minItemId = chat.chatStats.minUnreadItemId
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) }
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
} catch {
logger.error("markChatRead apiChatRead error: \(responseError(error))")
}
@@ -548,10 +571,11 @@ func markChatRead(_ chat: Chat) async {
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) }
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch {
logger.error("markChatItemRead apiChatRead error: \(responseError(error))")
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
}
}
@@ -567,17 +591,9 @@ func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo {
throw r
}
func addMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async {
do {
try await apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)
} catch let error {
logger.error("addMember error: \(responseError(error))")
}
}
func apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async throws {
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
if case .sentGroupInvitation = r { return }
if case let .sentGroupInvitation(_, _, member) = r { return member }
throw r
}
@@ -597,7 +613,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
}
}
func apiRemoveMember(groupId: Int64, memberId: Int64) async throws -> GroupMember {
func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember {
let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false)
if case let .userDeletedMember(_, member) = r { return member }
throw r
@@ -643,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
@@ -820,12 +837,22 @@ func processReceivedMsg(_ res: ChatResponse) async {
chatItems: []
))
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
case let .joinedGroupMemberConnecting(groupInfo, _, member):
_ = m.upsertGroupMember(groupInfo, member)
case let .deletedMemberUser(groupInfo, _): // TODO update user member
m.updateGroup(groupInfo)
case let .deletedMember(groupInfo, _, deletedMember):
_ = m.upsertGroupMember(groupInfo, deletedMember)
case let .leftMember(groupInfo, member):
_ = m.upsertGroupMember(groupInfo, member)
case let .groupDeleted(groupInfo, _): // TODO update user member
m.updateGroup(groupInfo)
case let .userJoinedGroup(groupInfo):
m.updateGroup(groupInfo)
case let .groupDeleted(groupInfo, _):
m.updateGroup(groupInfo)
case let .deletedMemberUser(groupInfo, _):
m.updateGroup(groupInfo)
case let .joinedGroupMember(groupInfo, member):
_ = m.upsertGroupMember(groupInfo, member)
case let .connectedToGroupMember(groupInfo, member):
_ = m.upsertGroupMember(groupInfo, member)
case let .groupUpdated(toGroup):
m.updateGroup(toGroup)
case let .rcvFileStart(aChatItem):
-5
View File
@@ -116,11 +116,6 @@ struct SimpleXApp: App {
if let id = chatModel.chatId,
let chat = chatModel.getChat(id) {
loadChat(chat: chat)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if chatModel.chatId == chat.id {
Task { await markChatRead(chat) }
}
}
}
if let chatId = chatModel.ntfContactRequest {
chatModel.ntfContactRequest = nil
@@ -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 {
+435 -156
View File
@@ -17,64 +17,35 @@ struct ChatView: View {
@ObservedObject var chat: Chat
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var membersToAdd: [Contact] = []
@State private var composeState = ComposeState()
@State private var deletingItem: ChatItem? = nil
@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
@State private var itemsInView: Set<String> = []
@State private var scrollProxy: ScrollViewProxy?
@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
return VStack {
GeometryReader { g in
let maxWidth =
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 5) {
ForEach(chatModel.chatItems) { ci in
if case let .groupRcv(member) = ci.chatDir {
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)
} else {
Rectangle().fill(.clear)
.frame(width: memberImageSize, height: memberImageSize)
}
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
}
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
}
}
.onAppear {
DispatchQueue.main.async {
scrollToFirstUnread(proxy)
}
markAllRead()
}
.onChange(of: chatModel.chatItems.last?.id) { _ in
scrollToBottom(proxy)
}
.onChange(of: keyboardVisible) { _ in
if keyboardVisible {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
scrollToBottom(proxy, animation: .easeInOut(duration: 1))
}
}
}
}
}
.onTapGesture { hideKeyboard() }
return VStack(spacing: 0) {
if searchMode {
searchToolbar()
Divider()
}
ZStack(alignment: .trailing) {
chatItemsList()
if let proxy = scrollProxy {
floatingButtons(proxy)
}
}
@@ -85,45 +56,66 @@ struct ChatView: View {
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!chat.chatInfo.sendMsgEnabled)
.disabled(!cInfo.sendMsgEnabled)
}
.padding(.top, 1)
.navigationTitle(cInfo.chatViewName)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { chatModel.chatId = nil } label: {
HStack(spacing: 4) {
Button {
chatModel.chatId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if chatModel.chatId == nil {
chatModel.reversedChatItems = []
}
}
} label: {
HStack(spacing: 0) {
Image(systemName: "chevron.backward")
Text("Chats", comment: "back button to return to chats list")
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 {
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, 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 {
ChatModel.shared.groupMembers = groupMembers
showChatInfoSheet = true
}
}
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatInfoSheet) {
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
default:
EmptyView()
}
}
}
@@ -132,21 +124,183 @@ struct ChatView: View {
case let .direct(contact):
HStack {
callButton(contact, .audio, imageName: "phone")
callButton(contact, .video, imageName: "video")
Menu {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
}
searchButton()
toggleNtfsButton(chat)
} label: {
Image(systemName: "ellipsis")
}
}
case let .group(groupInfo):
if groupInfo.canAddMembers {
addMembersButton()
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo, membersToAdd: membersToAdd)
HStack {
if groupInfo.canAddMembers {
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()
toggleNtfsButton(chat)
} label: {
Image(systemName: "ellipsis")
}
}
default:
EmptyView()
}
}
}
.navigationBarBackButtonHidden(true)
}
private func searchToolbar() -> some View {
HStack {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
.focused($searchFocussed)
.foregroundColor(.primary)
.frame(maxWidth: .infinity)
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1)
}
}
.padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6))
.foregroundColor(.secondary)
.background(Color(.secondarySystemBackground))
.cornerRadius(10.0)
Button ("Cancel") {
searchText = ""
searchMode = false
searchFocussed = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
chatModel.reversedChatItems = []
loadChat(chat: chat)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
return GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
let maxWidth =
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
LazyVStack(spacing: 5) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
chatItemView(ci, maxWidth)
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
itemsInView.insert(ci.viewId)
loadChatItems(cInfo, ci, proxy)
if ci.isRcvNew() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
Task {
await apiMarkChatItemRead(cInfo, ci)
NtfManager.shared.decNtfBadgeCount()
}
}
}
}
}
.onDisappear {
itemsInView.remove(ci.viewId)
}
}
}
}
.onAppear {
scrollProxy = proxy
}
.onTapGesture { hideKeyboard() }
.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)
}
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
return VStack {
let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
if unreadAbove > 0 {
circleButton {
unreadCountText(unreadAbove)
.font(.callout)
.foregroundColor(.accentColor)
}
.onTapGesture { scrollUp(proxy) }
.contextMenu {
Button {
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
Task {
await markChatRead(chat, aboveItem: ci)
}
}
} label: {
Label("Mark read", systemImage: "checkmark")
}
}
}
Spacer()
if counts.unreadBelow > 0 {
circleButton {
unreadCountText(counts.unreadBelow)
.font(.callout)
.foregroundColor(.accentColor)
}
.onTapGesture { scrollToBottom(proxy) }
} else if counts.totalBelow > 16 {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(.accentColor)
}
.onTapGesture { scrollToBottom(proxy) }
}
}
.padding()
}
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
ZStack {
Circle()
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
.frame(width: 44, height: 44)
content()
}
}
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
@@ -157,13 +311,23 @@ struct ChatView: View {
}
}
private func searchButton() -> some View {
Button {
searchMode = true
searchFocussed = true
searchText = ""
} label: {
Label("Search", systemImage: "magnifyingglass")
}
}
private func addMembersButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
Task {
let ms = await apiListMembers(gInfo.apiId)
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
membersToAdd = filterMembersToAdd(ms)
ChatModel.shared.groupMembers = groupMembers
showAddMembersSheet = true
}
}
@@ -173,76 +337,172 @@ struct ChatView: View {
}
}
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
.contextMenu {
if ci.isMsgContent() {
Button {
withAnimation {
if composeState.editing() {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
}
}
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
Button {
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
} label: { Label("Share", systemImage: "square.and.arrow.up") }
Button {
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getLoadedImage(ci.file) {
UIPasteboard.general.image = image
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
if loadingItems || firstPage { return }
loadingItems = true
Task {
do {
let items = try await apiGetChatItems(
type: cInfo.chatType,
id: cInfo.apiId,
pagination: .before(chatItemId: firstItem.id, count: 50),
search: searchText
)
await MainActor.run {
if items.count == 0 {
firstPage = true
} else {
UIPasteboard.general.string = ci.content.text
chatModel.reversedChatItems.append(contentsOf: items.reversed())
}
} label: { Label("Copy", systemImage: "doc.on.doc") }
if case .image = ci.content.msgContent,
let image = getLoadedImage(ci.file) {
Button {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
} label: { Label("Save", systemImage: "square.and.arrow.down") }
loadingItems = false
}
if ci.meta.editable {
Button {
withAnimation {
composeState = ComposeState(editingItem: ci)
}
} label: { Label("Edit", systemImage: "square.and.pencil") }
}
Button(role: .destructive) {
showDeleteMessage = true
deletingItem = ci
} label: { Label("Delete", systemImage: "trash") }
} else if ci.isDeletedContent() {
Button(role: .destructive) {
showDeleteMessage = true
deletingItem = ci
} label: { Label("Delete", systemImage: "trash") }
} catch let error {
logger.error("apiGetChat error: \(responseError(error))")
await MainActor.run { loadingItems = false }
}
}
}
}
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
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)
}
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
}
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
}
}
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
var menu: [UIAction] = []
if ci.isMsgContent() {
menu.append(contentsOf: [
UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left")
) { _ in
withAnimation {
if composeState.editing() {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
}
}
},
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up")
) { _ in
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
},
UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc")
) { _ in
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getLoadedImage(ci.file) {
UIPasteboard.general.image = image
} else {
UIPasteboard.general.string = ci.content.text
}
}
])
if case .image = ci.content.msgContent,
let image = getLoadedImage(ci.file) {
menu.append(
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
)
}
if ci.meta.editable {
menu.append(
UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"),
image: UIImage(systemName: "square.and.pencil")
) { _ in
withAnimation {
composeState = ComposeState(editingItem: ci)
}
}
)
}
menu.append(
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
)
} else if ci.isDeletedContent() {
menu.append(
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
)
}
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
.uiKitContextMenu(actions: menu)
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
}
if let di = deletingItem {
if di.meta.editable {
Button("Delete for everyone",role: .destructive) {
deleteMessage(.cidmBroadcast)
}
if let di = deletingItem, di.meta.editable {
Button("Delete for everyone",role: .destructive) {
deleteMessage(.cidmBroadcast)
}
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
}
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
switch (prevItem?.chatDir) {
case .groupSnd: return true
@@ -251,34 +511,19 @@ struct ChatView: View {
}
}
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
withAnimation(animation) { scrollToBottom_(proxy) }
}
func scrollToBottom_(_ proxy: ScrollViewProxy) {
if let id = chatModel.chatItems.last?.id {
proxy.scrollTo(id, anchor: .bottom)
private func scrollToBottom(_ proxy: ScrollViewProxy) {
if let ci = chatModel.reversedChatItems.first {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
}
}
// align first unread with the top or the last unread with bottom
func scrollToFirstUnread(_ proxy: ScrollViewProxy) {
if let cItem = chatModel.chatItems.first(where: { $0.isRcvNew() }) {
proxy.scrollTo(cItem.id)
} else {
scrollToBottom_(proxy)
private func scrollUp(_ proxy: ScrollViewProxy) {
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
}
}
func markAllRead() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if chatModel.chatId == chat.id {
Task { await markChatRead(chat) }
}
}
}
func deleteMessage(_ mode: CIDeleteMode) {
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
@@ -302,11 +547,45 @@ struct ChatView: View {
}
}
@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {
Button {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} label: {
if chat.chatInfo.ntfsEnabled {
Label("Mute", systemImage: "speaker.slash")
} else {
Label("Unmute", systemImage: "speaker.wave.2")
}
}
}
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
Task {
do {
let chatSettings = ChatSettings(enableNtfs: enableNtfs)
try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings)
await MainActor.run {
switch chat.chatInfo {
case var .direct(contact):
contact.chatSettings = chatSettings
ChatModel.shared.updateContact(contact)
case var .group(groupInfo):
groupInfo.chatSettings = chatSettings
ChatModel.shared.updateGroup(groupInfo)
default: ()
}
}
} catch let error {
logger.error("apiSetChatSettings error \(responseError(error))")
}
}
}
struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
chatModel.chatItems = [
chatModel.reversedChatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
@@ -14,14 +14,29 @@ struct AddGroupMembersView: View {
@Environment(\.dismiss) var dismiss: DismissAction
var chat: Chat
var groupInfo: GroupInfo
var membersToAdd: [Contact]
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 {
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
let v = List {
ChatInfoToolbar(chat: chat, imageSize: 48)
.frame(maxWidth: .infinity, alignment: .center)
@@ -41,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)
}
}
@@ -76,17 +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 {
for contactId in selectedContacts {
await addMember(groupId: chat.chatInfo.apiId, contactId: contactId, memberRole: selectedRole)
}
await MainActor.run { dismiss() }
if let cb = addedMembersCb { cb(selectedContacts) }
}
inviteMembers()
} label: {
HStack {
Text("Invite to group")
@@ -96,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 {
@@ -106,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{
@@ -120,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)
}
}
}
@@ -132,6 +188,6 @@ struct AddGroupMembersView: View {
struct AddGroupMembersView_Previews: PreviewProvider {
static var previews: some View {
AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData, membersToAdd: [])
AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData)
}
}
@@ -15,7 +15,6 @@ struct GroupChatInfoView: View {
@ObservedObject var chat: Chat
var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared
@State private var members: [GroupMember] = []
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil
@@ -27,19 +26,30 @@ struct GroupChatInfoView: View {
case deleteGroupAlert
case clearChatAlert
case leaveGroupAlert
case cantInviteIncognitoAlert
var id: GroupChatInfoViewAlert { get { self } }
}
var body: some View {
NavigationView {
let members = chatModel.groupMembers
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved }
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
List {
groupInfoHeader()
.listRowBackground(Color.clear)
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
@@ -57,7 +67,7 @@ struct GroupChatInfoView: View {
}
}
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo, membersToAdd: filterMembersToAdd(members))
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
.sheet(item: $selectedMember, onDismiss: { connectionStats = nil }) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: connectionStats)
@@ -94,14 +104,8 @@ struct GroupChatInfoView: View {
case .deleteGroupAlert: return deleteGroupAlert()
case .clearChatAlert: return clearChatAlert()
case .leaveGroupAlert: return leaveGroupAlert()
}
}
.task {
let ms = await apiListMembers(chat.chatInfo.apiId)
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved }
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
await MainActor.run {
members = ms
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
}
}
}
@@ -128,7 +132,13 @@ struct GroupChatInfoView: View {
private func addMembersButton() -> some View {
Button {
showAddMembersSheet = true
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showAddMembersSheet = true
}
}
} label: {
Label("Invite members", systemImage: "plus")
}
@@ -149,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)
@@ -211,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 {
@@ -251,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))
@@ -107,10 +137,13 @@ struct GroupMemberInfoView: View {
primaryButton: .destructive(Text("Remove")) {
Task {
do {
_ = try await apiRemoveMember(groupId: member.groupId, memberId: member.groupMemberId)
dismiss()
let member = try await apiRemoveMember(groupInfo.groupId, member.groupMemberId)
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(groupInfo, member)
dismiss()
}
} catch let error {
logger.error("removeMemberAlert apiRemoveMember error: \(error.localizedDescription)")
logger.error("apiRemoveMember error: \(responseError(error))")
}
}
},
@@ -25,7 +25,7 @@ struct GroupProfileView: View {
var body: some View {
return VStack(alignment: .leading) {
Text("Group profile is stored on members' devices, not on the servers.")
.padding(.bottom)
.padding(.vertical)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
@@ -109,7 +109,7 @@ struct GroupProfileView: View {
.onTapGesture { hideKeyboard() }
}
func profileNameTextEdit(_ label: String, _ name: Binding<String>) -> some View {
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
TextField(label, text: name)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
@@ -28,28 +28,20 @@ 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
)
.swipeActions(edge: .leading) {
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if chat.chatStats.unreadCount > 0 {
markReadButton()
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
clearChatButton()
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
AlertManager.shared.showAlert(
contact.ready
@@ -77,16 +69,16 @@ struct ChatListNavLink: View {
ChatPreviewView(chat: chat)
.frame(height: 80)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
}
.swipeActions(edge: .trailing) {
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:
@@ -99,27 +91,24 @@ struct ChatListNavLink: View {
NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
destination: { chatView() },
label: { ChatPreviewView(chat: chat) },
disabled: !groupInfo.ready
)
.frame(height: 80)
.swipeActions(edge: .leading) {
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if chat.chatStats.unreadCount > 0 {
markReadButton()
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
clearChatButton()
}
.swipeActions(edge: .trailing) {
if (groupInfo.membership.memberCurrent) {
Button {
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
} label: {
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
}
.tint(Color.indigo)
.tint(Color.yellow)
}
}
.swipeActions(edge: .trailing) {
@@ -130,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 {
@@ -168,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: {
@@ -180,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) } }
}
}
@@ -331,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)
@@ -36,7 +36,6 @@ struct ChatPreviewView: View {
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
.padding(.top, 4)
}
.padding(.top, 4)
.padding(.horizontal, 8)
@@ -90,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
@@ -108,13 +107,16 @@ struct ChatPreviewView: View {
.padding(.trailing, 36)
.padding(.bottom, 4)
if unread > 0 {
Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)")
unreadCountText(unread)
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(Color.accentColor)
.background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary)
.cornerRadius(10)
} else if !chat.chatInfo.ntfsEnabled {
Image(systemName: "speaker.slash.fill")
.foregroundColor(.secondary)
}
}
} else {
@@ -125,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()
}
@@ -134,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)
@@ -170,6 +181,10 @@ struct ChatPreviewView: View {
}
}
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : "\(n)")
}
struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View {
Group {
@@ -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)
@@ -0,0 +1,88 @@
//
// ContextMenu2.swift
// SimpleX (iOS)
//
// Created by Evgeny on 09/08/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import UIKit
import SwiftUI
extension View {
func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View {
self.overlay(Color(uiColor: .systemBackground))
.overlay(
InteractionView(content: self, menu: UIMenu(title: title, children: actions))
)
}
}
private struct InteractionConfig<Content: View> {
let content: Content
let menu: UIMenu
}
private struct InteractionView<Content: View>: UIViewRepresentable {
let content: Content
let menu: UIMenu
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let hostView = UIHostingController(rootView: content)
hostView.view.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor),
hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor)
]
view.addSubview(hostView.view)
view.addConstraints(constraints)
let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
view.addInteraction(menuInteraction)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
let parent: InteractionView<Content>
init(_ parent: InteractionView<Content>) {
self.parent = parent
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(
identifier: nil,
previewProvider: nil,
actionProvider: { [weak self] _ in
guard let self = self else { return nil }
return self.parent.menu
}
)
}
// func contextMenuInteraction(
// _ interaction: UIContextMenuInteraction,
// willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
// animator: UIContextMenuInteractionCommitAnimating
// ) {
// animator.addCompletion {
// print("user tapped")
// }
// }
}
}
@@ -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,10 +24,12 @@ struct AddGroupView: View {
var body: some View {
if let chat = chat, let groupInfo = groupInfo {
AddGroupMembersView(chat: chat,
groupInfo: groupInfo,
membersToAdd: filterMembersToAdd([]),
showSkip: true) { _ in
AddGroupMembersView(
chat: chat,
groupInfo: groupInfo,
showSkip: true,
showFooterCounter: false
) { _ in
dismiss()
DispatchQueue.main.async {
m.chatId = groupInfo.id
@@ -45,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) {
@@ -148,6 +164,12 @@ struct AddGroupView: View {
hideKeyboard()
do {
let gInfo = try apiNewGroup(profile)
Task {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
}
}
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
m.addChat(c)
withAnimation {
@@ -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()
}
}
@@ -7,14 +7,32 @@
//
import SwiftUI
import SimpleXChat
enum OnionHostsAlert: Identifiable {
case update(hosts: OnionHosts)
case error(err: String)
var id: String {
switch self {
case let .update(hosts): return "update \(hosts)"
case let .error(err): return "error \(err)"
}
}
}
struct NetworkAndServers: View {
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var cfgLoaded = false
@State private var currentNetCfg = NetCfg.defaults
@State private var netCfg = NetCfg.defaults
@State private var onionHosts: OnionHosts = .no
@State private var showOnionHostsAlert: OnionHostsAlert?
var body: some View {
VStack {
List {
Section("") {
Section {
NavigationLink {
SMPServers()
.navigationTitle("Your SMP servers")
@@ -22,6 +40,10 @@ struct NetworkAndServers: View {
settingsRow("server.rack") { Text("SMP servers") }
}
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}
if developerTools {
NavigationLink {
AdvancedNetworkSettings()
@@ -30,9 +52,76 @@ 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.")
}
}
}
.onAppear {
if cfgLoaded { return }
cfgLoaded = true
currentNetCfg = getNetCfg()
resetNetCfgView()
}
.onChange(of: onionHosts) { _ in
if onionHosts != OnionHosts(netCfg: currentNetCfg) {
showOnionHostsAlert = .update(hosts: onionHosts)
}
}
.alert(item: $showOnionHostsAlert) { a in
switch a {
case let .update(hosts):
return Alert(
title: Text("Update .onion hosts setting?"),
message: Text(onionHostsInfo()) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
primaryButton: .default(Text("Ok")) {
saveNetCfg(hosts)
},
secondaryButton: .cancel() {
resetNetCfgView()
}
)
case let .error(err):
return Alert(
title: Text("Error updating settings"),
message: Text(err)
)
}
}
}
private func saveNetCfg(_ hosts: OnionHosts) {
do {
let (hostMode, requiredHostMode) = hosts.hostMode
netCfg.hostMode = hostMode
netCfg.requiredHostMode = requiredHostMode
let def = netCfg.hostMode == .onionHost ? NetCfg.proxyDefaults : NetCfg.defaults
netCfg.tcpConnectTimeout = def.tcpConnectTimeout
netCfg.tcpTimeout = def.tcpTimeout
try setNetworkConfig(netCfg)
currentNetCfg = netCfg
setNetCfg(netCfg)
} catch let error {
let err = responseError(error)
resetNetCfgView()
showOnionHostsAlert = .error(err: err)
logger.error("\(err)")
}
}
private func resetNetCfgView() {
netCfg = currentNetCfg
onionHosts = OnionHosts(netCfg: netCfg)
}
private func onionHostsInfo() -> LocalizedStringKey {
switch onionHosts {
case .no: return "Onion hosts will not be used."
case .prefer: return "Onion hosts will be used when available. Requires enabling VPN."
case .require: return "Onion hosts will be required for connection. Requires enabling VPN."
}
}
}
@@ -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")
@@ -173,7 +185,6 @@ struct SettingsView: View {
} label: {
settingsRow("terminal") { Text("Chat console") }
}
.disabled(chatModel.chatRunning != true)
settingsRow("gear") {
Toggle("Developer tools", isOn: $developerTools)
}
@@ -196,9 +207,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
}
}
@@ -2,9 +2,16 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="en.lproj/Localizable.strings" source-language="en" target-language="en" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.4.1" build-num="13F100"/>
</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>
@@ -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>
@@ -491,6 +533,11 @@
<target>Currently maximum supported file size is %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Dark" xml:space="preserve">
<source>Dark</source>
<target>Dark</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database ID" xml:space="preserve">
<source>Database ID</source>
<target>Database ID</target>
@@ -514,7 +561,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 +706,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 +748,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 +1013,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 +1078,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 +1118,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>
@@ -1066,6 +1148,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Leave group?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Light" xml:space="preserve">
<source>Light</source>
<target>Light</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Limitations" xml:space="preserve">
<source>Limitations</source>
<target>Limitations</target>
@@ -1091,6 +1178,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 +1233,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 +1278,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 +1338,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 +1493,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 +1521,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 +1571,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 +1603,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 +1643,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 +1661,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>
@@ -1586,6 +1738,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Stop chat?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="System" xml:space="preserve">
<source>System</source>
<target>System</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="TCP connection timeout" xml:space="preserve">
<source>TCP connection timeout</source>
<target>TCP connection timeout</target>
@@ -1621,6 +1778,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>
@@ -1681,6 +1843,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>The sender will NOT be notified</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Theme" xml:space="preserve">
<source>Theme</source>
<target>Theme</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." xml:space="preserve">
<source>This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</source>
<target>This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</target>
@@ -1701,6 +1868,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 +1952,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 +1972,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 +1992,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 +2022,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 +2057,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 +2147,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 +2187,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 +2202,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 +2246,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 +2451,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 +2491,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 +2671,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>
@@ -2438,7 +2690,7 @@ SimpleX servers cannot see your profile.</target>
</file>
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="en" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.4.1" build-num="13F100"/>
</header>
<body>
<trans-unit id="CFBundleName" xml:space="preserve">
@@ -2470,7 +2722,7 @@ SimpleX servers cannot see your profile.</target>
</file>
<file original="SimpleX NSE/en.lproj/InfoPlist.strings" source-language="en" target-language="en" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.4.1" build-num="13F100"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "en",
"toolInfo" : {
"toolBuildNumber" : "13E113",
"toolBuildNumber" : "13F100",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "13.3"
"toolVersion" : "13.4.1"
},
"version" : "1.0"
}
@@ -2,9 +2,16 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="en.lproj/Localizable.strings" source-language="en" target-language="ru" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.4.1" build-num="13F100"/>
</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>
@@ -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>
@@ -491,6 +533,11 @@
<target>Максимальный размер файла - %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Dark" xml:space="preserve">
<source>Dark</source>
<target>Тёмная</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database ID" xml:space="preserve">
<source>Database ID</source>
<target>ID базы данных</target>
@@ -514,7 +561,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 +706,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 +748,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 +1013,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 +1078,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 +1118,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>
@@ -1066,6 +1148,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="Light" xml:space="preserve">
<source>Light</source>
<target>Светлая</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Limitations" xml:space="preserve">
<source>Limitations</source>
<target>Ограничения</target>
@@ -1091,6 +1178,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 +1233,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 +1278,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 +1338,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 +1493,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 +1521,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 +1571,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 +1603,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 +1643,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 +1661,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>
@@ -1586,6 +1738,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="System" xml:space="preserve">
<source>System</source>
<target>Системная</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="TCP connection timeout" xml:space="preserve">
<source>TCP connection timeout</source>
<target>Таймаут TCP соединения</target>
@@ -1621,6 +1778,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>
@@ -1681,6 +1843,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="Theme" xml:space="preserve">
<source>Theme</source>
<target>Тема</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." xml:space="preserve">
<source>This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</source>
<target>Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.</target>
@@ -1701,6 +1868,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 +1952,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 +1969,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 +1992,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 +2022,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 +2057,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 +2147,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 +2187,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 +2202,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 +2246,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 +2451,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 +2491,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 +2671,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>
@@ -2438,7 +2690,7 @@ SimpleX серверы не могут получить доступ к ваше
</file>
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="ru" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.4.1" build-num="13F100"/>
</header>
<body>
<trans-unit id="CFBundleName" xml:space="preserve">
@@ -2470,7 +2722,7 @@ SimpleX серверы не могут получить доступ к ваше
</file>
<file original="SimpleX NSE/en.lproj/InfoPlist.strings" source-language="en" target-language="ru" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.3" build-num="13E113"/>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.4.1" build-num="13F100"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "ru",
"toolInfo" : {
"toolBuildNumber" : "13E113",
"toolBuildNumber" : "13F100",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "13.3"
"toolVersion" : "13.4.1"
},
"version" : "1.0"
}
@@ -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 {
+49 -32
View File
@@ -12,6 +12,12 @@
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
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 */; };
5C00166A28C119300094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166528C119300094D739 /* libgmp.a */; };
5C00166B28C119300094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166628C119300094D739 /* libffi.a */; };
5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166728C119300094D739 /* libgmpxx.a */; };
5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */; };
5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.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 */; };
@@ -52,11 +58,6 @@
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; };
5C9C2D9F28929B6900CC63B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9A28929B6900CC63B1 /* libffi.a */; };
5C9C2DA028929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */; };
5C9C2DA128929B6900CC63B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9C28929B6900CC63B1 /* libgmp.a */; };
5C9C2DA228929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */; };
5C9C2DA328929B6900CC63B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */; };
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */; };
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */; };
5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */; };
@@ -130,6 +131,7 @@
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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -194,6 +196,12 @@
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
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>"; };
5C00166528C119300094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C00166628C119300094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C00166728C119300094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a"; sourceTree = "<group>"; };
5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.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>"; };
@@ -236,11 +244,6 @@
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = "<group>"; };
5C9C2D9A28929B6900CC63B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a"; sourceTree = "<group>"; };
5C9C2D9C28929B6900CC63B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a"; sourceTree = "<group>"; };
5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupProfileView.swift; sourceTree = "<group>"; };
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedNetworkSettings.swift; sourceTree = "<group>"; };
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAndServers.swift; sourceTree = "<group>"; };
@@ -315,6 +318,7 @@
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -347,13 +351,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C9C2DA028929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a in Frameworks */,
5C9C2DA128929B6900CC63B1 /* libgmp.a in Frameworks */,
5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C9C2DA228929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a in Frameworks */,
5C9C2D9F28929B6900CC63B1 /* libffi.a in Frameworks */,
5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */,
5C00166A28C119300094D739 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C9C2DA328929B6900CC63B1 /* libgmpxx.a in Frameworks */,
5C00166B28C119300094D739 /* libffi.a in Frameworks */,
5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -408,11 +412,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C9C2D9A28929B6900CC63B1 /* libffi.a */,
5C9C2D9C28929B6900CC63B1 /* libgmp.a */,
5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */,
5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */,
5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */,
5C00166628C119300094D739 /* libffi.a */,
5C00166528C119300094D739 /* libgmp.a */,
5C00166728C119300094D739 /* libgmpxx.a */,
5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */,
5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -452,6 +456,7 @@
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */,
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */,
5C6BA666289BD954009B8ECC /* DismissSheets.swift */,
5C00164328A26FBC0094D739 /* ContextMenu.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -550,6 +555,7 @@
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
640F50E227CF991C001E05C2 /* SMPServers.swift */,
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -785,6 +791,7 @@
mainGroup = 5CA059BD279559F40002BEB4;
packageReferences = (
5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */,
5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
);
productRefGroup = 5CA059CB279559F40002BEB4 /* Products */;
projectDirPath = "";
@@ -861,9 +868,11 @@
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */,
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 */,
@@ -1155,7 +1164,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1176,7 +1185,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.1;
MARKETING_VERSION = 3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1197,7 +1206,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1218,7 +1227,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.1;
MARKETING_VERSION = 3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1276,7 +1285,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1289,7 +1298,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.1;
MARKETING_VERSION = 3.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1306,7 +1315,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1319,7 +1328,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.1;
MARKETING_VERSION = 3.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1337,7 +1346,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 70;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1361,7 +1370,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 3.1;
MARKETING_VERSION = 3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1383,7 +1392,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 68;
CURRENT_PROJECT_VERSION = 70;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1407,7 +1416,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 3.1;
MARKETING_VERSION = 3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1475,6 +1484,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.1.4;
};
};
5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/twostraws/CodeScanner";
@@ -8,6 +8,15 @@
"revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d",
"version" : "2.1.1"
}
},
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect",
"state" : {
"revision" : "f2616860a41f9d9932da412a8978fec79c06fe24",
"version" : "0.1.4"
}
}
],
"version" : 2
+74 -9
View File
@@ -20,11 +20,12 @@ 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
case apiGetChats
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
@@ -45,6 +46,7 @@ public enum ChatCommand {
case setUserSMPServers(smpServers: [String])
case apiSetNetworkConfig(networkConfig: NetCfg)
case apiGetNetworkConfig
case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
case apiContactInfo(contactId: Int64)
case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
case addContact
@@ -53,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
@@ -81,11 +84,13 @@ 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"
case .apiGetChats: return "/_get chats pcc=on"
case let .apiGetChat(type, id, pagination): return "/_get chat \(ref(type, id)) \(pagination.cmdString)"
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
case let .apiSendMessage(type, id, file, quotedItemId, mc):
let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
return "/_send \(ref(type, id)) json \(msg)"
@@ -107,6 +112,7 @@ public enum ChatCommand {
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
case .apiGetNetworkConfig: return "/network"
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
case .addContact: return "/connect"
@@ -115,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"
@@ -145,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"
@@ -169,6 +177,7 @@ public enum ChatCommand {
case .setUserSMPServers: return "setUserSMPServers"
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
case .apiGetNetworkConfig: return "apiGetNetworkConfig"
case .apiSetChatSettings: return "apiSetChatSettings"
case .apiContactInfo: return "apiContactInfo"
case .apiGroupMemberInfo: return "apiGroupMemberInfo"
case .addContact: return "addContact"
@@ -177,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"
@@ -221,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
@@ -231,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
@@ -255,7 +266,7 @@ public enum ChatResponse: Decodable, Error {
case contactsList(contacts: [Contact])
// group events
case groupCreated(groupInfo: GroupInfo)
case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact)
case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact, member: GroupMember)
case userAcceptedGroupSent(groupInfo: GroupInfo)
case userDeletedMember(groupInfo: GroupInfo, member: GroupMember)
case leftMemberUser(groupInfo: GroupInfo)
@@ -268,11 +279,11 @@ public enum ChatResponse: Decodable, Error {
case leftMember(groupInfo: GroupInfo, member: GroupMember)
case groupDeleted(groupInfo: GroupInfo, member: GroupMember)
case contactsMerged(intoContact: Contact, mergedContact: Contact)
case groupInvitation(groupInfo: GroupInfo)
case groupInvitation(groupInfo: GroupInfo) // unused
case userJoinedGroup(groupInfo: GroupInfo)
case joinedGroupMember(groupInfo: GroupInfo, member: GroupMember)
case connectedToGroupMember(groupInfo: GroupInfo, member: GroupMember)
case groupRemoved(groupInfo: GroupInfo)
case groupRemoved(groupInfo: GroupInfo) // unused
case groupUpdated(toGroup: GroupInfo)
// receiving file events
case rcvFileAccepted(chatItem: AChatItem)
@@ -322,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"
@@ -403,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
@@ -413,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
@@ -436,7 +449,7 @@ public enum ChatResponse: Decodable, Error {
case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
case let .contactsList(contacts): return String(describing: contacts)
case let .groupCreated(groupInfo): return String(describing: groupInfo)
case let .sentGroupInvitation(groupInfo, contact): return "groupInfo: \(groupInfo)\ncontact: \(contact)"
case let .sentGroupInvitation(groupInfo, contact, member): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)"
case let .userAcceptedGroupSent(groupInfo): return String(describing: groupInfo)
case let .userDeletedMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)"
case let .leftMemberUser(groupInfo): return String(describing: groupInfo)
@@ -516,6 +529,8 @@ public struct ArchiveConfig: Encodable {
public struct NetCfg: Codable, Equatable {
public var socksProxy: String? = nil
public var hostMode: HostMode = .publicHost
public var requiredHostMode = true
public var tcpConnectTimeout: Int // microseconds
public var tcpTimeout: Int // microseconds
public var tcpKeepAlive: KeepAliveOpts?
@@ -540,6 +555,46 @@ public struct NetCfg: Codable, Equatable {
public var enableKeepAlive: Bool { tcpKeepAlive != nil }
}
public enum HostMode: String, Codable {
case onionViaSocks
case onionHost = "onion"
case publicHost = "public"
}
public enum OnionHosts: String, Identifiable {
case no
case prefer
case require
public var text: LocalizedStringKey {
switch self {
case .no: return "No"
case .prefer: return "When available"
case .require: return "Required"
}
}
public var hostMode: (HostMode, Bool) {
switch self {
case .no: return (.publicHost, true)
case .prefer: return (.onionHost, false)
case .require: return (.onionHost, true)
}
}
public init(netCfg: NetCfg) {
switch netCfg.hostMode {
case .onionViaSocks: self = .no
case .onionHost: self = netCfg.requiredHostMode ? .require : .prefer
case .publicHost: self = .no
}
}
public var id: OnionHosts { self }
public static let values: [OnionHosts] = [.no, .prefer, .require]
}
public struct KeepAliveOpts: Codable, Equatable {
public var keepIdle: Int // seconds
public var keepIntvl: Int // seconds
@@ -548,6 +603,16 @@ public struct KeepAliveOpts: Codable, Equatable {
public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4)
}
public struct ChatSettings: Codable {
public var enableNtfs: Bool
public init(enableNtfs: Bool) {
self.enableNtfs = enableNtfs
}
public static let defaults: ChatSettings = ChatSettings(enableNtfs: true)
}
public struct ConnectionStats: Codable {
public var rcvServers: [String]?
public var sndServers: [String]?
+18 -1
View File
@@ -15,6 +15,7 @@ public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart"
let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode"
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount"
let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts"
let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout"
let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout"
let GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL = "networkSMPPingInterval"
@@ -22,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"
@@ -29,13 +31,15 @@ public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)!
public func registerGroupDefaults() {
groupDefaults.register(defaults: [
GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout,
GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval,
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
])
}
@@ -80,10 +84,18 @@ 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)
public let networkUseOnionHostsGroupDefault = EnumDefault<OnionHosts>(
defaults: groupDefaults,
forKey: GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS,
withDefault: .no
)
public class DateDefault {
var defaults: UserDefaults
var key: String
@@ -157,6 +169,8 @@ public class Default<T> {
}
public func getNetCfg() -> NetCfg {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (hostMode, requiredHostMode) = onionHosts.hostMode
let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT)
let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT)
let smpPingInterval = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL)
@@ -171,6 +185,8 @@ public func getNetCfg() -> NetCfg {
tcpKeepAlive = nil
}
return NetCfg(
hostMode: hostMode,
requiredHostMode: requiredHostMode,
tcpConnectTimeout: tcpConnectTimeout,
tcpTimeout: tcpTimeout,
tcpKeepAlive: tcpKeepAlive,
@@ -179,6 +195,7 @@ public func getNetCfg() -> NetCfg {
}
public func setNetCfg(_ cfg: NetCfg) {
networkUseOnionHostsGroupDefault.set(OnionHosts(netCfg: cfg))
groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT)
groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT)
groupDefaults.set(cfg.smpPingInterval, forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL)
+165 -22
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 {
@@ -189,6 +253,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var ntfsEnabled: Bool {
switch self {
case let .direct(contact): return contact.chatSettings.enableNtfs
case let .group(groupInfo): return groupInfo.chatSettings.enableNtfs
default: return false
}
}
var createdAt: Date {
switch self {
case let .direct(contact): return contact.createdAt
@@ -239,11 +311,12 @@ 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
var createdAt: Date
var updatedAt: Date
@@ -251,19 +324,25 @@ 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,
updatedAt: .now
)
@@ -285,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)" } }
@@ -326,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,
@@ -342,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
@@ -366,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 ""
}
@@ -433,6 +536,8 @@ 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
@@ -443,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
@@ -461,6 +567,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
localDisplayName: "team",
groupProfile: GroupProfile.sampleData,
membership: GroupMember.sampleData,
hostConnCustomUserProfileId: nil,
chatSettings: ChatSettings.defaults,
createdAt: .now,
updatedAt: .now
)
@@ -476,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",
@@ -492,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 } }
@@ -514,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
}
}
@@ -556,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,
@@ -565,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
)
}
@@ -710,7 +832,7 @@ public struct ChatItem: Identifiable, Decodable {
self.quotedItem = quotedItem
self.file = file
}
public var chatDir: CIDirection
public var meta: CIMeta
public var content: CIContent
@@ -718,9 +840,17 @@ public struct ChatItem: Identifiable, Decodable {
public var quotedItem: CIQuote?
public var file: CIFile?
public var id: Int64 { get { meta.itemId } }
public var viewTimestamp = Date.now
public var timestampText: Text { get { meta.timestampText } }
private enum CodingKeys: String, CodingKey {
case chatDir, meta, content, formattedText, quotedItem, file
}
public var id: Int64 { meta.itemId }
public var viewId: String { "\(meta.itemId) \(viewTimestamp.timeIntervalSince1970)" }
public var timestampText: Text { meta.timestampText }
public var text: String {
get {
@@ -763,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
}
@@ -855,6 +985,7 @@ public struct CIMeta: Decodable {
var itemText: String
public var itemStatus: CIStatus
var createdAt: Date
var updatedAt: Date
public var itemDeleted: Bool
public var itemEdited: Bool
public var editable: Bool
@@ -868,6 +999,7 @@ public struct CIMeta: Decodable {
itemText: text,
itemStatus: status,
createdAt: ts,
updatedAt: ts,
itemDeleted: itemDeleted,
itemEdited: itemEdited,
editable: editable
@@ -892,6 +1024,17 @@ public enum CIStatus: Decodable {
case sndError(agentError: AgentErrorType)
case rcvNew
case rcvRead
var id: String {
switch self {
case .sndNew: return "sndNew"
case .sndSent: return "sndSent"
case .sndErrorAuth: return "sndErrorAuth"
case .sndError: return "sndError"
case .rcvNew: return "rcvNew"
case .rcvRead: return "rcvRead"
}
}
}
public enum CIDeleteMode: String, Decodable {
+160 -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. */
@@ -380,6 +404,9 @@
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Максимальный размер файла - %@.";
/* No comment provided by engineer. */
"Dark" = "Тёмная";
/* No comment provided by engineer. */
"Database export & import" = "Экспорт и импорт архива чата";
@@ -392,7 +419,7 @@
/* No comment provided by engineer. */
"Decentralized" = "Децентрализованный";
/* No comment provided by engineer. */
/* chat item action */
"Delete" = "Удалить";
/* No comment provided by engineer. */
@@ -494,7 +521,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 +557,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 +722,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 +782,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 +800,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" = "Вступление в группу";
@@ -770,6 +827,9 @@
/* rcv group event chat item */
"left" = "покинул(а) группу";
/* No comment provided by engineer. */
"Light" = "Светлая";
/* No comment provided by engineer. */
"Limitations" = "Ограничения";
@@ -785,6 +845,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 +890,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 +920,9 @@
/* notification */
"New message" = "Новое сообщение";
/* No comment provided by engineer. */
"No" = "Нет";
/* No comment provided by engineer. */
"No contacts selected" = "Контакты не выбраны";
@@ -890,6 +959,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 +1070,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 +1094,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 +1118,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 +1139,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 +1169,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. */
@@ -1139,6 +1238,9 @@
/* No comment provided by engineer. */
"strike" = "зачеркнуть";
/* No comment provided by engineer. */
"System" = "Системная";
/* No comment provided by engineer. */
"Take picture" = "Сделать фото";
@@ -1148,6 +1250,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 соединения";
@@ -1196,6 +1301,9 @@
/* No comment provided by engineer. */
"The sender will NOT be notified" = "Отправитель не будет уведомлён";
/* No comment provided by engineer. */
"Theme" = "Тема";
/* No comment provided by engineer. */
"This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.";
@@ -1211,6 +1319,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 +1370,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 +1383,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 +1397,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 +1415,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 +1439,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 +1464,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 +1514,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 +1532,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 +1550,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 +1560,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 +1583,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 -2
View File
@@ -1,7 +1,6 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -40,7 +39,7 @@ mySquaringBot _user cc = do
race_ (forever $ void getLine) . forever $ do
(_, resp) <- atomically . readTBQueue $ outputQ cc
case resp of
CRContactConnected contact -> do
CRContactConnected contact _ -> do
contactConnected contact
void . sendMsg contact $ "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square"
CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do

Some files were not shown because too many files have changed in this diff Show More