Compare commits

...

29 Commits

Author SHA1 Message Date
Evgeny Poberezkin 41d7a47b37 core: api to load all chat items 2022-11-20 21:38:42 +00:00
Evgeny Poberezkin b8298aa458 Merge branch 'stable' 2022-11-20 11:20:10 +00:00
solus-hq c3244f1b76 Add OpenSSL for macOS description (#1370)
Co-authored-by: shark <shark@shark.work>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-18 19:29:02 +00:00
Evgeny Poberezkin 0ad74d9538 docs: CLI compilation (#1359)
* docs: CLI compilation

* update

* remove BETA

* amend CLI build steps
2022-11-18 17:54:57 +00:00
Stanislav Dmitrenko a4be68f4bd android: Audio messages (#1070)
* Audio messages testing

* Without Vorbis

* Naming

* Voice message auto-receive, voice message composing

* Experiments with audio

* More recording features

* Unused code

* Merge master

* UI

* Stability

* Size limitation

* Tap and hold && tap and wait and click logics

* Deleted unused lib

* Voice type

* Refactoring

* Refactoring

* Adapting to the latest changes

* Mini player in preview

* Different UI for some elements

* send msg view style

* *** in translation

* Animation

* Fixes animation performance

* Smaller font for recording time

* File names

* Renaming

* No edit possible for audio messages

* Prevent adding text to edittext

* Bubble layout

* Layout

* Refactor

* Paddings

* No crash, please

* Draw progress as a ring

* Padding

* Faster status updates while listening voice

* Faster status updates while listening voice

* Quote

* backend comment

* Align

* Stability

* Review

* Strings

* Just better

* Sync of recorder and players

* Replaced Icon's with ImageButton's

* Icons size

* Error processing

* Update apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt

* rename composable

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-18 21:02:24 +04:00
JRoberts 0cb8f8ad82 core: fix group preferences update (#1385) 2022-11-18 16:07:40 +04:00
sh 9d7bb06396 docker: update build (#1375) 2022-11-18 09:00:43 +00:00
JRoberts a9c2a7dcaa ios: remove accent color on chat info views navigation links (#1382) 2022-11-17 20:53:02 +04:00
Evgeny Poberezkin 38b28f866c sdk: update version 0.1.1 2022-11-17 14:46:49 +00:00
Evgeny Poberezkin bfa7ff16ff sdk: fix typescript client (#1380) 2022-11-17 14:45:48 +00:00
JRoberts 5c2b70a214 core: fix test name 2022-11-17 14:42:28 +04:00
JRoberts 7e3f91f87c core: add sanity checks in sql to include quoted items only from the same chat (#1379) 2022-11-17 14:38:14 +04:00
Evgeny Poberezkin f54faebff3 core: fix sql that was doubling a group in the list of chats when member joined the group twice (#1378) 2022-11-17 09:58:52 +00:00
JRoberts 4e5aa3dcbc ios: adjust preferences UX; fix group profile not updating; fix servers api (#1377) 2022-11-17 12:59:13 +04:00
Evgeny Poberezkin 56f3874a93 ios: move preferences, icon (#1376)
* ios: move preferences, icon

* don't disable database settings on chat stop

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-17 10:57:27 +04:00
JRoberts 828b502431 ios: load and save preferences (#1373) 2022-11-16 20:26:43 +04:00
Evgeny Poberezkin 491fe4a9bf core, ios: advanced server config (#1371)
* ios: advanced server config

* simplify UI

* core: ServerCfg

* commit migration, update schema

* add preset servers to response

* return default servers if none saved

* fix test
2022-11-16 15:37:20 +00:00
Evgeny Poberezkin f8302e2030 core: SMP server connection test (#1367)
* core: SMP server connection test

* fix test

* update simplexmq
2022-11-15 18:31:29 +00:00
JRoberts fd34c39552 core: fix voice msg content text representation 2022-11-15 15:56:38 +04:00
JRoberts b1fa1a84fe core: voice msg content type (#1368) 2022-11-15 15:24:55 +04:00
mlanp cf23399262 android / iOS: german translations for 4.2.1 (#1366)
* android/iOS: fixed german translations for v4.2.1

* Update apps/android/app/src/main/res/values-de/strings.xml

* Update apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff

* Apply suggestions from code review

* strings

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-15 11:31:07 +04:00
JRoberts b5a812769b core: full/merged preferences in User, Contact, GroupInfo types (#1365)
* core: preferences in User, Contact, GroupInfo types

* user and group preferences

* refactor

* linebreak

* remove synonyms

* refactor

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-15 10:31:44 +04:00
JRoberts 40e1b01baf android: version 4.3-beta.0 (69) 2022-11-14 17:01:29 +04:00
Stanislav Dmitrenko 9c925ab040 android: Animated switch between chat and chatList (#1175)
* android: Animated switch between chat and chatList

* Correct animation

* Testing idea

* Revert "Testing idea"

This reverts commit ecda083883.

* Experiments

* Experiments

* Experiments

* Revert "Experiments"

This reverts commit 4390de1e92.

* Revert "Experiments"

This reverts commit 0b3048aeef.

* Revert "Experiments"

This reverts commit b692803cea.

* Merge

* Gorgeous animation performance

* Undo optimization

* Formatting

* Sharing

* Box

* Continue

* Launch on Main thread only specific call to WebView

* Launch on Main thread only specific call to WebView

* Temporary made withApi() running on Main thread only

* Unneeded code

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-14 16:35:38 +04:00
Evgeny Poberezkin faceeb6fce ios: chat preferences, UI and types (#1360) 2022-11-14 10:12:17 +00:00
JRoberts 07e8c1d76e Fixing ForegroundServiceDidNotStartInTimeException (#1349)
(cherry picked from commit a5d235e559)

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2022-11-14 14:02:04 +04:00
Evgeny Poberezkin b1d8600215 cli: message search in CLI app (#1362)
* cli: message search in CLI app

* type synonym
2022-11-14 08:42:54 +00:00
Evgeny Poberezkin e14ab0fed0 core: support SMP basic auth / server password (#1358) 2022-11-14 08:04:11 +00:00
Evgeny Poberezkin cb0c499f57 core: send broadcast to direct contacts only (#1361) 2022-11-14 07:59:59 +00:00
77 changed files with 3871 additions and 669 deletions
+25 -6
View File
@@ -1,10 +1,29 @@
FROM haskell:8.10.4 AS build-stage FROM ubuntu:focal AS build
# if you encounter "version `GLIBC_2.28' not found" error when running
# chat client executable, build with the following base image instead: # Install curl and simplex-chat-related dependencies
# FROM haskell:8.10.4-stretch AS build-stage RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev
# Install ghcup
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
chmod +x /usr/bin/ghcup
# Install ghc
RUN ghcup install ghc 8.10.7
# Install cabal
RUN ghcup install cabal
# Set both as default
RUN ghcup set ghc 8.10.7 && \
ghcup set cabal
COPY . /project COPY . /project
WORKDIR /project WORKDIR /project
RUN stack install
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Compile simplex-chat
RUN cabal update
RUN cabal install
FROM scratch AS export-stage FROM scratch AS export-stage
COPY --from=build-stage /root/.local/bin/simplex-chat / COPY --from=build /root/.cabal/bin/simplex-chat /
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app" applicationId "chat.simplex.app"
minSdk 29 minSdk 29
targetSdk 32 targetSdk 32
versionCode 68 versionCode 69
versionName "4.2.1" versionName "4.3-beta.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk { ndk {
@@ -9,6 +9,7 @@ import android.os.SystemClock.elapsedRealtime
import android.util.Log import android.util.Log
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
@@ -19,7 +20,9 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.* import androidx.lifecycle.*
import chat.simplex.app.model.ChatModel import chat.simplex.app.model.ChatModel
@@ -36,6 +39,9 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.* import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.onboarding.* import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class MainActivity: FragmentActivity() { class MainActivity: FragmentActivity() {
companion object { companion object {
@@ -317,14 +323,65 @@ fun MainPage(
if (chatModel.showCallView.value) ActiveCallView(chatModel) if (chatModel.showCallView.value) ActiveCallView(chatModel)
else { else {
showAdvertiseLAAlert = true showAdvertiseLAAlert = true
val stopped = chatModel.chatRunning.value == false BoxWithConstraints {
if (chatModel.chatId.value == null) { var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
if (chatModel.sharedContent.value == null) val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
ChatListView(chatModel, setPerformLA, stopped) Box(
else Modifier
ShareListView(chatModel, stopped) .graphicsLayer {
translationX = -offset.value.dp.toPx()
}
) {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
}
val scope = rememberCoroutineScope()
val onComposed: () -> Unit = {
scope.launch {
offset.animateTo(
if (chatModel.chatId.value == null) 0f else maxWidth.value,
chatListAnimationSpec()
)
if (offset.value == 0f) {
currentChatId = null
}
}
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it != null) currentChatId = it
else onComposed()
// Deletes files that were not sent but already stored in files directory.
// Currently, it's voice records only
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
chatModel.filesToDelete.forEach { it.delete() }
chatModel.filesToDelete.clear()
}
}
}
launch {
snapshotFlow { chatModel.sharedContent.value }
.distinctUntilChanged()
.filter { it != null }
.collect {
chatModel.chatId.value = null
currentChatId = null
}
}
}
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
currentChatId?.let {
ChatView(it, chatModel, onComposed)
}
}
} }
else ChatView(chatModel)
} }
} }
} }
@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.* import androidx.work.*
import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -20,7 +19,6 @@ import kotlinx.coroutines.withContext
class SimplexService: Service() { class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private var isStartingService = false private var isStartingService = false
private var notificationManager: NotificationManager? = null private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null private var serviceNotification: Notification? = null
@@ -48,11 +46,32 @@ class SimplexService: Service() {
notificationManager = createNotificationChannel() notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text) serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification) startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
/**
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
* */
if (stopAfterStart) {
stopForeground(true)
stopSelf()
} else {
isServiceStarted = true
}
} }
override fun onDestroy() { override fun onDestroy() {
Log.d(TAG, "Simplex service destroyed") Log.d(TAG, "Simplex service destroyed")
stopService() try {
wakeLock?.let {
while (it.isHeld) it.release() // release all, in case acquired more than once
}
wakeLock = null
} catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
}
isServiceStarted = false
stopAfterStart = false
saveServiceState(this, ServiceState.STOPPED)
// If notification service is enabled and battery optimization is disabled, restart the service // If notification service is enabled and battery optimization is disabled, restart the service
if (SimplexApp.context.allowToStartServiceAfterAppExit()) if (SimplexApp.context.allowToStartServiceAfterAppExit())
@@ -62,7 +81,7 @@ class SimplexService: Service() {
private fun startService() { private fun startService() {
Log.d(TAG, "SimplexService startService") Log.d(TAG, "SimplexService startService")
if (isServiceStarted || isStartingService) return if (wakeLock != null || isStartingService) return
val self = this val self = this
isStartingService = true isStartingService = true
withApi { withApi {
@@ -73,10 +92,9 @@ class SimplexService: Service() {
if (chatDbStatus != DBMigrationResult.OK) { if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus") Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus) showPassphraseNotification(chatDbStatus)
stopService() safeStopService(self)
return@withApi return@withApi
} }
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED) saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
@@ -89,22 +107,6 @@ class SimplexService: Service() {
} }
} }
private fun stopService() {
Log.d(TAG, "Stopping foreground service")
try {
wakeLock?.let {
while (it.isHeld) it.release() // release all, in case acquired more than once
}
wakeLock = null
stopForeground(true)
stopSelf()
} catch (e: Exception) {
Log.d(TAG, "Service stopped without being started: ${e.message}")
}
isServiceStarted = false
saveServiceState(this, ServiceState.STOPPED)
}
private fun createNotificationChannel(): NotificationManager? { private fun createNotificationChannel(): NotificationManager? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -235,6 +237,9 @@ class SimplexService: Service() {
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE" private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce" private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
private var isServiceStarted = false
private var stopAfterStart = false
fun scheduleStart(context: Context) { fun scheduleStart(context: Context) {
Log.d(TAG, "Enqueuing work to start subscriber service") Log.d(TAG, "Enqueuing work to start subscriber service")
val workManager = WorkManager.getInstance(context) val workManager = WorkManager.getInstance(context)
@@ -244,7 +249,17 @@ class SimplexService: Service() {
suspend fun start(context: Context) = serviceAction(context, Action.START) suspend fun start(context: Context) = serviceAction(context, Action.START)
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java)) /**
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
* exception related to foreground services lifecycle
* */
fun safeStopService(context: Context) {
if (isServiceStarted) {
context.stopService(Intent(context, SimplexService::class.java))
} else {
stopAfterStart = true
}
}
private suspend fun serviceAction(context: Context, action: Action) { private suspend fun serviceAction(context: Context, action: Action) {
Log.d(TAG, "SimplexService serviceAction: ${action.name}") Log.d(TAG, "SimplexService serviceAction: ${action.name}")
@@ -20,7 +20,12 @@ import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.io.File
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
@Stable
class ChatModel(val controller: ChatController) { class ChatModel(val controller: ChatController) {
val onboardingStage = mutableStateOf<OnboardingStage?>(null) val onboardingStage = mutableStateOf<OnboardingStage?>(null)
val currentUser = mutableStateOf<User?>(null) val currentUser = mutableStateOf<User?>(null)
@@ -70,6 +75,8 @@ class ChatModel(val controller: ChatController) {
// working with external intents // working with external intents
val sharedContent = mutableStateOf(null as SharedContent?) val sharedContent = mutableStateOf(null as SharedContent?)
val filesToDelete = mutableSetOf<File>()
fun updateUserProfile(profile: LocalProfile) { fun updateUserProfile(profile: LocalProfile) {
val user = currentUser.value val user = currentUser.value
if (user != null) { if (user != null) {
@@ -218,6 +225,7 @@ class ChatModel(val controller: ChatController) {
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) { if (itemIndex >= 0) {
AudioPlayer.stop(chatItems[itemIndex])
chatItems.removeAt(itemIndex) chatItems.removeAt(itemIndex)
} }
} }
@@ -382,7 +390,7 @@ interface SomeChat {
val updatedAt: Instant val updatedAt: Instant
} }
@Serializable @Serializable @Stable
data class Chat ( data class Chat (
val chatInfo: ChatInfo, val chatInfo: ChatInfo,
val chatItems: List<ChatItem>, val chatItems: List<ChatItem>,
@@ -1015,7 +1023,7 @@ class AChatItem (
val chatItem: ChatItem val chatItem: ChatItem
) )
@Serializable @Serializable @Stable
data class ChatItem ( data class ChatItem (
val chatDir: CIDirection, val chatDir: CIDirection,
val meta: CIMeta, val meta: CIMeta,
@@ -1029,6 +1037,9 @@ data class ChatItem (
val text: String get() = val text: String get() =
when { when {
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
}
content.text == "" && file != null -> file.fileName content.text == "" && file != null -> file.fileName
else -> content.text else -> content.text
} }
@@ -1301,6 +1312,8 @@ class CIFile(
CIFileStatus.RcvComplete -> true CIFileStatus.RcvComplete -> true
} }
val audioInfo: MutableState<ProgressAndDuration> by lazy { mutableStateOf(ProgressAndDuration()) }
companion object { companion object {
fun getSample( fun getSample(
fileId: Long = 1, fileId: Long = 1,
@@ -1334,6 +1347,7 @@ sealed class MsgContent {
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
@@ -1341,11 +1355,17 @@ sealed class MsgContent {
is MCText -> "text $text" is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}" is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}" is MCImage -> "json ${json.encodeToString(this)}"
is MCVoice-> "json ${json.encodeToString(this)}"
is MCFile -> "json ${json.encodeToString(this)}" is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json" is MCUnknown -> "json $json"
} }
} }
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
val time = String.format("%02d:%02d", duration / 60, duration % 60)
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
}
@Serializable @Serializable
class CIGroupInvitation ( class CIGroupInvitation (
val groupId: Long, val groupId: Long,
@@ -1414,6 +1434,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
MsgContent.MCImage(text, image) MsgContent.MCImage(text, image)
} }
"voice" -> {
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
MsgContent.MCVoice(text, duration)
}
"file" -> MsgContent.MCFile(text) "file" -> MsgContent.MCFile(text)
else -> MsgContent.MCUnknown(t, text, json) else -> MsgContent.MCUnknown(t, text, json)
} }
@@ -1445,6 +1469,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
put("text", value.text) put("text", value.text)
put("image", value.image) put("image", value.image)
} }
is MsgContent.MCVoice ->
buildJsonObject {
put("type", "voice")
put("text", value.text)
put("duration", value.duration)
}
is MsgContent.MCFile -> is MsgContent.MCFile ->
buildJsonObject { buildJsonObject {
put("type", "file") put("type", "file")
@@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
if (cItem.content.text != "") { if (cItem.content.text != "") {
cItem.content.text cItem.content.text
} else { } else {
cItem.file?.fileName ?: "" if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
} }
} else { } else {
var res = "" var res = ""
@@ -1014,6 +1014,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val file = cItem.file val file = cItem.file
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) { if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) } withApi { receiveFile(file.fileId) }
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
} }
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem) ntfManager.notifyMessageReceived(cInfo, cItem)
@@ -1039,6 +1041,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.removeChatItem(cInfo, cItem) chatModel.removeChatItem(cInfo, cItem)
} else { } else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen // currently only broadcast deletion of rcv message can be received, and only this case should happen
AudioPlayer.stop(cItem)
chatModel.upsertChatItem(cInfo, cItem) chatModel.upsertChatItem(cInfo, cItem)
} }
} }
@@ -1220,7 +1223,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.notificationsMode.value = NotificationsMode.OFF chatModel.notificationsMode.value = NotificationsMode.OFF
SimplexService.StartReceiver.toggleReceiver(false) SimplexService.StartReceiver.toggleReceiver(false)
MessagesFetcherWorker.cancelAll() MessagesFetcherWorker.cancelAll()
SimplexService.stop(SimplexApp.context) SimplexService.safeStopService(SimplexApp.context)
} else { } else {
// show battery optimization notice // show battery optimization notice
showBGServiceNoticeIgnoreOptimization(mode) showBGServiceNoticeIgnoreOptimization(mode)
@@ -135,7 +135,7 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) }, topBar = { CloseSheetBar(close) },
bottomBar = { bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) { Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle) SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
} }
}, },
modifier = Modifier.navigationBarsWithImePadding() modifier = Modifier.navigationBarsWithImePadding()
@@ -42,8 +42,7 @@ import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.helpers.withApi import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.usersettings.NotificationsMode import chat.simplex.app.views.usersettings.NotificationsMode
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -63,7 +62,7 @@ fun ActiveCallView(chatModel: ChatModel) {
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
// Stop it when call ended // Stop it when call ended
if (!ntfModeService) SimplexService.stop(SimplexApp.context) if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
// Clear selected communication device to default value after we changed it in call // Clear selected communication device to default value after we changed it in call
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable @Composable
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) { fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) } val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
permissions = listOf( permissions = listOf(
@@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
Log.d(TAG, "WebRTCView: webview ready") Log.d(TAG, "WebRTCView: webview ready")
// for debugging // for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
withApi { scope.launch {
delay(2000L) delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv webView.value = wv
@@ -1,10 +1,8 @@
package chat.simplex.app.views.chat package chat.simplex.app.views.chat
import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.* import androidx.compose.foundation.gestures.*
@@ -52,8 +50,8 @@ import java.io.File
import kotlin.math.sign import kotlin.math.sign
@Composable @Composable
fun ChatView(chatModel: ChatModel) { fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) } val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") } val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
@@ -63,7 +61,6 @@ fun ChatView(chatModel: ChatModel) {
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) } val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value. // 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 // With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
@@ -97,17 +94,15 @@ fun ChatView(chatModel: ChatModel) {
chatModel.chatId.value = null chatModel.chatId.value = null
} else { } else {
val chat = activeChat.value!! val chat = activeChat.value!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button // 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) // Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember { val unreadCount = remember {
derivedStateOf { derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0 chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
} }
} }
ChatLayout( ChatLayout(
user,
chat, chat,
unreadCount, unreadCount,
composeState, composeState,
@@ -120,7 +115,6 @@ fun ChatView(chatModel: ChatModel) {
} }
}, },
attachmentOption, attachmentOption,
scope,
attachmentBottomSheetState, attachmentBottomSheetState,
chatModel.chatItems, chatModel.chatItems,
searchText, searchText,
@@ -128,6 +122,7 @@ fun ChatView(chatModel: ChatModel) {
chatModelIncognito = chatModel.incognito.value, chatModelIncognito = chatModel.incognito.value,
back = { back = {
hideKeyboard(view) hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null chatModel.chatId.value = null
}, },
info = { info = {
@@ -228,20 +223,19 @@ fun ChatView(chatModel: ChatModel) {
apiFindMessages(c.chatInfo, chatModel, value) apiFindMessages(c.chatInfo, chatModel, value)
searchText.value = value searchText.value = value
} }
} },
onComposed,
) )
} }
} }
@Composable @Composable
fun ChatLayout( fun ChatLayout(
user: User,
chat: Chat, chat: Chat,
unreadCount: State<Int>, unreadCount: State<Int>,
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit), composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>, attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState, attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>, chatItems: List<ChatItem>,
searchValue: State<String>, searchValue: State<String>,
@@ -260,8 +254,10 @@ fun ChatLayout(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit, onSearchValueChanged: (String) -> Unit,
onComposed: () -> Unit,
) { ) {
Surface( val scope = rememberCoroutineScope()
Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
@@ -292,9 +288,9 @@ fun ChatLayout(
) { contentPadding -> ) { contentPadding ->
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) { BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList( ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue, chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
) )
} }
} }
@@ -445,7 +441,6 @@ val CIListStateSaver = run {
@Composable @Composable
fun BoxWithConstraintsScope.ChatItemsList( fun BoxWithConstraintsScope.ChatItemsList(
user: User,
chat: Chat, chat: Chat,
unreadCount: State<Int>, unreadCount: State<Int>,
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
@@ -461,11 +456,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
acceptCall: (Contact) -> Unit, acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
ScrollToBottom(chat.id, listState) ScrollToBottom(chat.id, listState)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back // Scroll to bottom when search value changes from something to nothing and back
@@ -493,6 +487,16 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) } scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
} }
} }
LaunchedEffect(Unit) {
var stopListening = false
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.filter { !stopListening }
.collect {
onComposed()
stopListening = true
}
}
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem -> itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider( CompositionLocalProvider(
@@ -555,11 +559,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else { } else {
Spacer(Modifier.size(42.dp)) Spacer(Modifier.size(42.dp))
} }
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
} }
} else { } else {
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) { Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
} }
} }
} else { // direct message } else { // direct message
@@ -570,7 +574,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent) 12.dp else 76.dp, end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier) ).then(swipeableModifier)
) { ) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem) ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
} }
} }
@@ -717,7 +721,7 @@ fun PreloadItems(
.map { .map {
val totalItemsNumber = it.totalItemsCount val totalItemsNumber = it.totalItemsCount
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (lastVisibleItemIndex > (totalItemsNumber - remaining)) if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
totalItemsNumber totalItemsNumber
else else
0 0
@@ -931,7 +935,6 @@ fun PreviewChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") } val searchValue = remember { mutableStateOf("") }
ChatLayout( ChatLayout(
user = User.sampleData,
chat = Chat( chat = Chat(
chatInfo = ChatInfo.Direct.sampleData, chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems, chatItems = chatItems,
@@ -941,7 +944,6 @@ fun PreviewChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {}, composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }, attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems, chatItems = chatItems,
searchValue, searchValue,
@@ -960,6 +962,7 @@ fun PreviewChatLayout() {
markRead = { _, _ -> }, markRead = { _, _ -> },
changeNtfsState = { _, _ -> }, changeNtfsState = { _, _ -> },
onSearchValueChanged = {}, onSearchValueChanged = {},
onComposed = {},
) )
} }
} }
@@ -989,7 +992,6 @@ fun PreviewGroupChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") } val searchValue = remember { mutableStateOf("") }
ChatLayout( ChatLayout(
user = User.sampleData,
chat = Chat( chat = Chat(
chatInfo = ChatInfo.Group.sampleData, chatInfo = ChatInfo.Group.sampleData,
chatItems = chatItems, chatItems = chatItems,
@@ -999,7 +1001,6 @@ fun PreviewGroupChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {}, composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }, attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems, chatItems = chatItems,
searchValue, searchValue,
@@ -1018,6 +1019,7 @@ fun PreviewGroupChatLayout() {
markRead = { _, _ -> }, markRead = { _, _ -> },
changeNtfsState = { _, _ -> }, changeNtfsState = { _, _ -> },
onSearchValueChanged = {}, onSearchValueChanged = {},
onComposed = {},
) )
} }
} }
@@ -1,5 +1,6 @@
package chat.simplex.app.views.chat package chat.simplex.app.views.chat
import ComposeVoiceView
import ComposeFileView import ComposeFileView
import android.Manifest import android.Manifest
import android.content.* import android.content.*
@@ -14,11 +15,9 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@@ -29,26 +28,30 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.model.* import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import java.io.File
@Serializable @Serializable
sealed class ComposePreview { sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview() @Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val images: List<String>): ComposePreview() @Serializable class ImagePreview(val images: List<String>): ComposePreview()
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview() @Serializable class FilePreview(val fileName: String): ComposePreview()
} }
@@ -84,6 +87,7 @@ data class ComposeState(
get() = { get() = {
val hasContent = when (preview) { val hasContent = when (preview) {
is ComposePreview.ImagePreview -> true is ComposePreview.ImagePreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true is ComposePreview.FilePreview -> true
else -> message.isNotEmpty() else -> message.isNotEmpty()
} }
@@ -93,6 +97,7 @@ data class ComposeState(
get() = get() =
when (preview) { when (preview) {
is ComposePreview.ImagePreview -> false is ComposePreview.ImagePreview -> false
is ComposePreview.VoicePreview -> false
is ComposePreview.FilePreview -> false is ComposePreview.FilePreview -> false
else -> useLinkPreviews else -> useLinkPreviews
} }
@@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
is MsgContent.MCText -> ComposePreview.NoPreview is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image)) is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
is MsgContent.MCFile -> { is MsgContent.MCFile -> {
val fileName = chatItem.file?.fileName ?: "" val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName) ComposePreview.FilePreview(fileName)
} }
else -> ComposePreview.NoPreview is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
} }
} }
@@ -144,6 +150,11 @@ fun ComposeView(
val textStyle = remember { mutableStateOf(smallFont) } val textStyle = remember { mutableStateOf(smallFont) }
// attachments // attachments
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) } val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
)
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) } val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberCameraLauncher { uri: Uri? -> val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
if (uri != null) { if (uri != null) {
@@ -321,6 +332,7 @@ fun ComposeView(
is MsgContent.MCText -> checkLinkPreview() is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview() is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image) is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message) is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
} }
@@ -330,6 +342,7 @@ fun ComposeView(
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
textStyle.value = smallFont textStyle.value = smallFont
chosenContent.value = emptyList() chosenContent.value = emptyList()
chosenAudio.value = null
chosenFile.value = null chosenFile.value = null
linkUrl.value = null linkUrl.value = null
prevLinkUrl.value = null prevLinkUrl.value = null
@@ -376,6 +389,15 @@ fun ComposeView(
} }
} }
} }
is ComposePreview.VoicePreview -> {
val chosenAudioVal = chosenAudio.value
if (chosenAudioVal != null) {
val file = chosenAudioVal.first.toFile().name
files.add((file))
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
}
}
is ComposePreview.FilePreview -> { is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value val chosenFileVal = chosenFile.value
if (chosenFileVal != null) { if (chosenFileVal != null) {
@@ -426,6 +448,13 @@ fun ComposeView(
} }
} }
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
val file = File(filePath)
chosenAudio.value = file.toUri() to durationMs
chatModel.filesToDelete.add(file)
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
}
fun cancelLinkPreview() { fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri val uri = composeState.value.linkPreview?.uri
if (uri != null) { if (uri != null) {
@@ -440,6 +469,11 @@ fun ComposeView(
chosenContent.value = emptyList() chosenContent.value = emptyList()
} }
fun cancelVoice() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenContent.value = emptyList()
}
fun cancelFile() { fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenFile.value = null chosenFile.value = null
@@ -455,6 +489,13 @@ fun ComposeView(
::cancelImages, ::cancelImages,
cancelEnabled = !composeState.value.editing cancelEnabled = !composeState.value.editing
) )
is ComposePreview.VoicePreview -> ComposeVoiceView(
preview.voice,
preview.durationMs,
preview.finished,
cancelEnabled = !composeState.value.editing,
::cancelVoice
)
is ComposePreview.FilePreview -> ComposeFileView( is ComposePreview.FilePreview -> ComposeFileView(
preview.fileName, preview.fileName,
::cancelFile, ::cancelFile,
@@ -489,37 +530,34 @@ fun ComposeView(
Column { Column {
contextItemView() contextItemView()
when { when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView() else -> previewView()
} }
Row( Row(
modifier = Modifier.padding(start = 4.dp, end = 8.dp), modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
val attachEnabled = !composeState.value.editing val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
Box(Modifier.padding(bottom = 12.dp)) { IconButton(showChooseAttachment, enabled = attachEnabled) {
Icon( Icon(
Icons.Filled.AttachFile, Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach), contentDescription = stringResource(R.string.attach),
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray, tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier modifier = Modifier
.size(28.dp) .size(28.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable {
if (attachEnabled) {
showChooseAttachment()
}
}
) )
} }
SendMsgView( SendMsgView(
composeState, composeState,
allowVoiceRecord = true,
sendMessage = { sendMessage = {
sendMessage() sendMessage()
resetLinkPreview() resetLinkPreview()
}, },
::onMessageChange, ::onMessageChange,
::onAudioAdded,
textStyle textStyle
) )
} }
@@ -0,0 +1,124 @@
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Close
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.AudioInfoUpdater
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cancelEnabled: Boolean, cancelVoice: () -> Unit) {
BoxWithConstraints(Modifier
.fillMaxWidth()
) {
val audioPlaying = rememberSaveable { mutableStateOf(false) }
val audioInfo = rememberSaveable(saver = ProgressAndDuration.Saver) {
mutableStateOf(ProgressAndDuration(durationMs = durationMs))
}
LaunchedEffect(durationMs) {
audioInfo.value = audioInfo.value.copy(durationMs = durationMs)
}
val progressBarWidth = remember { Animatable(0f) }
LaunchedEffect(durationMs, finished) {
snapshotFlow { audioInfo.value }
.distinctUntilChanged()
.collect {
val number = if (audioPlaying.value) audioInfo.value.progressMs else if (!finished) durationMs else 0
val new = if (audioPlaying.value || finished)
((number.toDouble() / durationMs) * maxWidth.value).dp
else
(((number.toDouble()) / MAX_VOICE_MILLIS_FOR_SENDING) * maxWidth.value).dp
progressBarWidth.animateTo(new.value, audioProgressBarAnimationSpec())
}
}
Spacer(
Modifier
.requiredWidth(progressBarWidth.value.dp)
.padding(top = 58.dp)
.height(2.dp)
.background(MaterialTheme.colors.primary)
)
Row(
Modifier
.height(60.dp)
.fillMaxWidth()
.padding(top = 8.dp)
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath, audioInfo.value.progressMs) {
audioPlaying.value = false
}
}
val pause = {
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
IconButton({ if (!audioPlaying.value) play() else pause() }, enabled = finished) {
Icon(
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (finished) MaterialTheme.colors.primary else HighOrLowlight
)
}
val numberInText = remember(durationMs, audioInfo.value) {
derivedStateOf { if (audioPlaying.value) audioInfo.value.progressMs / 1000 else durationMs / 1000 }
}
val text = "%02d:%02d".format(numberInText.value / 60, numberInText.value % 60)
Text(
text,
fontSize = 18.sp,
color = HighOrLowlight,
)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(
onClick = {
AudioPlayer.stop(filePath)
cancelVoice()
},
modifier = Modifier.padding(0.dp)
) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
}
@Preview
@Composable
fun PreviewComposeAudioView() {
SimpleXTheme {
ComposeFileView(
"test.txt",
cancelFile = {},
cancelEnabled = true
)
}
}
@@ -1,7 +1,10 @@
package chat.simplex.app.views.chat package chat.simplex.app.views.chat
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.text.InputType import android.text.InputType
import android.view.ViewGroup import android.view.ViewGroup
@@ -12,38 +15,198 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.ArrowUpward import androidx.compose.material.icons.outlined.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.* import androidx.compose.ui.*
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.* import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doOnTextChanged import androidx.core.widget.*
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.SimplexApp import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatItem import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.SharedContent import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
import java.io.*
@Composable @Composable
fun SendMsgView( fun SendMsgView(
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
allowVoiceRecord: Boolean,
sendMessage: () -> Unit, sendMessage: () -> Unit,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
textStyle: MutableState<TextStyle> textStyle: MutableState<TextStyle>
) {
Column(Modifier.padding(vertical = 8.dp)) {
Box {
val cs = composeState.value
val attachEnabled = !composeState.value.editing
val filePath = rememberSaveable { mutableStateOf(null as String?) }
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && allowVoiceRecord && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
Box(if (recordingTimeRange.first == 0L)
Modifier
else
Modifier.clickable(false, onClick = {})
) {
NativeKeyboard(composeState, textStyle, onMessageChange)
}
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
} else if (!showVoiceButton) {
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
)
}
} else {
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.RECORD_AUDIO,
)
)
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (isActive) {
now = System.currentTimeMillis()
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
delay(100)
}
}
val stopRecordingAndAddAudio: () -> Unit = {
rec.stop()
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
}
val startStopRecording: () -> Unit = {
when {
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
recordingTimeRange = System.currentTimeMillis()..0L
filePath.value = rec.start(stopRecordingAndAddAudio)
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
}
}
var stopRecOnNextClick by remember { mutableStateOf(false) }
val context = LocalContext.current
DisposableEffect(stopRecOnNextClick) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
if (stopRecOnNextClick) {
// Lock orientation to current orientation because screen rotation will break the recording
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
val cleanUp = { remove: Boolean ->
rec.stop()
if (remove) filePath.value?.let { File(it).delete() }
filePath.value = null
stopRecOnNextClick = false
recordingTimeRange = 0L..0L
}
LaunchedEffect(cs.preview) {
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
// Pressed on X icon in preview
cleanUp(true)
}
}
val interactionSource = interactionSourceWithTapDetection(
onPress = {
if (filePath.value == null) startStopRecording()
},
onClick = {
if (!recordingInProgress.value && filePath.value != null) {
sendMessage()
cleanUp(false)
} else if (stopRecOnNextClick) {
stopRecordingAndAddAudio()
stopRecOnNextClick = false
} else {
// tapped and didn't hold a finger
stopRecOnNextClick = true
}
},
onCancel = startStopRecording,
onRelease = startStopRecording
)
val sendButtonModifier = if (recordingTimeRange.last != 0L)
Modifier.clip(CircleShape).background(color)
else
Modifier
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
Icon(
if (recordingTimeRange.last != 0L) Icons.Outlined.ArrowUpward else if (stopRecOnNextClick) Icons.Default.Stop else Icons.Default.Mic,
stringResource(R.string.icon_descr_record_voice_message),
tint = if (recordingTimeRange.last != 0L) Color.White else if (!cs.inProgress) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.then(sendButtonModifier)
)
}
DisposableEffect(Unit) {
onDispose {
rec.stop()
}
}
}
}
}
}
}
@Composable
private fun NativeKeyboard(
composeState: MutableState<ComposeState>,
textStyle: MutableState<TextStyle>,
onMessageChange: (String) -> Unit
) { ) {
val cs = composeState.value val cs = composeState.value
val textColor = MaterialTheme.colors.onBackground
val tintColor = MaterialTheme.colors.secondary
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
var showKeyboard by remember { mutableStateOf(false) } var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) { LaunchedEffect(cs.contextItem) {
when (cs.contextItem) { when (cs.contextItem) {
@@ -58,99 +221,69 @@ fun SendMsgView(
} }
} }
} }
val textColor = MaterialTheme.colors.onBackground
val tintColor = MaterialTheme.colors.secondary
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
Column(Modifier.padding(vertical = 8.dp)) { AndroidView(modifier = Modifier, factory = {
Box { val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
AndroidView(modifier = Modifier, factory = { override fun setOnReceiveContentListener(
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) { mimeTypes: Array<out String>?,
override fun setOnReceiveContentListener( listener: android.view.OnReceiveContentListener?
mimeTypes: Array<out String>?, ) {
listener: android.view.OnReceiveContentListener? super.setOnReceiveContentListener(mimeTypes, listener)
) {
super.setOnReceiveContentListener(mimeTypes, listener)
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
return@OnCommitContentListener false
}
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
true
}
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
}
}
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
editText.setText(cs.message)
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
DrawableCompat.setTint(it.background, tintColor.toArgb())
if (cs.message != it.text.toString()) {
it.setText(cs.message)
// Set cursor to the end of the text
it.setSelection(it.text.length)
}
if (showKeyboard) {
it.requestFocus()
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
} }
Box(Modifier.align(Alignment.BottomEnd)) { override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward val connection = super.onCreateInputConnection(editorInfo)
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
if (cs.inProgress val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) try {
) { inputContentInfo.requestPermission()
CircularProgressIndicator( } catch (e: Exception) {
Modifier return@OnCommitContentListener false
.size(36.dp) }
.padding(4.dp), SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
color = HighOrLowlight, true
strokeWidth = 3.dp
)
} else {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled()) {
sendMessage()
}
}
)
} }
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
} }
} }
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
editText.setText(cs.message)
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
DrawableCompat.setTint(it.background, tintColor.toArgb())
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
it.isFocusableInTouchMode = it.isFocusable
if (cs.message != it.text.toString()) {
it.setText(cs.message)
// Set cursor to the end of the text
it.setSelection(it.text.length)
}
if (showKeyboard) {
it.requestFocus()
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
}
if (composeState.value.preview is ComposePreview.VoicePreview) {
Text(
generalGetString(R.string.voice_message_send_text),
Modifier.padding(padding),
color = HighOrLowlight,
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
)
} }
} }
@@ -167,8 +300,10 @@ fun PreviewSendMsgView() {
SimpleXTheme { SimpleXTheme {
SendMsgView( SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
allowVoiceRecord = false,
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle textStyle = textStyle
) )
} }
@@ -188,8 +323,10 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme { SimpleXTheme {
SendMsgView( SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) }, composeState = remember { mutableStateOf(composeStateEditing) },
allowVoiceRecord = false,
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle textStyle = textStyle
) )
} }
@@ -209,8 +346,10 @@ fun PreviewSendMsgViewInProgress() {
SimpleXTheme { SimpleXTheme {
SendMsgView( SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) }, composeState = remember { mutableStateOf(composeStateInProgress) },
allowVoiceRecord = false,
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle textStyle = textStyle
) )
} }
@@ -0,0 +1,267 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@Composable
fun CIVoiceView(
durationSec: Int,
file: CIFile?,
edited: Boolean,
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
metaColor: Color
) {
Row(
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
val context = LocalContext.current
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioInfo = remember(file.filePath) {
file.audioInfo.value = file.audioInfo.value.copy(durationMs = durationSec * 1000)
file.audioInfo
}
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath ?: return@play, audioInfo.value.progressMs) {
// If you want to preserve the position after switching a track, remove this line
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
brokenAudio = !audioPlaying.value
}
val pause = {
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
val time = if (audioPlaying.value) audioInfo.value.progressMs else audioInfo.value.durationMs
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
val text = String.format("%02d:%02d", time / 1000 / 60, time / 1000 % 60)
if (hasText) {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Text(
text,
Modifier
.padding(start = 12.dp, end = 5.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
textAlign = TextAlign.Start,
maxLines = 1
)
} else {
if (sent) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
Text(
text,
Modifier
.padding(end = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
}
} else {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
Modifier
.padding(start = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
Spacer(Modifier.height(56.dp))
}
}
}
}
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, false, {}, {})
val metaReserve = if (edited)
" "
else
" "
Text(metaReserve)
}
}
}
@Composable
private fun PlayPauseButton(
audioPlaying: Boolean,
sent: Boolean,
angle: Float,
strokeWidth: Float,
strokeColor: Color,
enabled: Boolean,
error: Boolean,
play: () -> Unit,
pause: () -> Unit
) {
Surface(
onClick = { if (!audioPlaying) play() else pause() },
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
color = if (sent) SentColorLight else ReceivedColorLight,
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
) {
Box(
Modifier
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
Modifier.size(36.dp),
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun VoiceMsgIndicator(
file: CIFile?,
audioPlaying: Boolean,
sent: Boolean,
hasText: Boolean,
audioInfo: State<ProgressAndDuration>?,
error: Boolean,
play: () -> Unit,
pause: () -> Unit
) {
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
if (file != null && file.loaded && audioInfo != null) {
val angle = 360f * (audioInfo.value.progressMs.toDouble() / audioInfo.value.durationMs).toFloat()
if (hasText) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
Modifier.size(36.dp),
tint = MaterialTheme.colors.primary
)
}
} else {
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause)
}
} else {
if (file?.fileStatus == CIFileStatus.RcvInvitation
|| file?.fileStatus == CIFileStatus.RcvTransfer
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
Box(
Modifier
.size(56.dp)
.clip(RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
} else {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {})
}
}
}
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
val brush = Brush.linearGradient(
0f to Color.Transparent,
0f to color,
start = Offset(0f, 0f),
end = Offset(strokeWidth, strokeWidth),
tileMode = TileMode.Clamp
)
onDrawWithContent {
drawContent()
drawArc(
brush = brush,
startAngle = -90f,
sweepAngle = angle,
useCenter = false,
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
size = Size(size.width - strokeWidth, size.height - strokeWidth),
style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier.size(32.dp),
color = if (isInDarkTheme()) FileDark else FileLight,
strokeWidth = 4.dp
)
}
@Composable
fun AudioInfoUpdater(
filePath: String?,
audioPlaying: MutableState<Boolean>,
audioInfo: MutableState<ProgressAndDuration>
) {
LaunchedEffect(filePath) {
if (filePath != null && audioInfo.value.durationMs == 0) {
audioInfo.value = ProgressAndDuration(audioInfo.value.progressMs, AudioPlayer.duration(filePath))
}
}
LaunchedEffect(audioPlaying.value) {
while (isActive && audioPlaying.value) {
audioInfo.value = AudioPlayer.progressAndDurationOrEnded()
if (audioInfo.value.progressMs == audioInfo.value.durationMs) {
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
delay(50)
}
}
}
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item package chat.simplex.app.views.chat.item
import android.content.*
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -29,15 +28,11 @@ import kotlinx.datetime.Clock
@Composable @Composable
fun ChatItemView( fun ChatItemView(
user: User,
cInfo: ChatInfo, cInfo: ChatInfo,
cItem: ChatItem, cItem: ChatItem,
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null, imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false, showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean, useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit, receiveFile: (Long) -> Unit,
@@ -46,6 +41,7 @@ fun ChatItemView(
scrollToItem: (Long) -> Unit, scrollToItem: (Long) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) } val showMenu = remember { mutableStateOf(false) }
@@ -70,7 +66,7 @@ fun ChatItemView(
Column( Column(
Modifier Modifier
.clip(RoundedCornerShape(18.dp)) .clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick) .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) { ) {
@Composable fun ContentItem() { @Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
@@ -95,29 +91,30 @@ fun ChatItemView(
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file) val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when { when {
filePath != null -> shareFile(cxt, cItem.text, filePath) filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(cxt, cItem.content.text) else -> shareText(context, cItem.content.text)
} }
showMenu.value = false showMenu.value = false
}) })
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text) copyText(context, cItem.content.text)
showMenu.value = false showMenu.value = false
}) })
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) { if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
val filePath = getLoadedFilePath(context, cItem.file) val filePath = getLoadedFilePath(context, cItem.file)
if (filePath != null) { if (filePath != null) {
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = { ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
when (cItem.content.msgContent) { when (cItem.content.msgContent) {
is MsgContent.MCImage -> saveImage(context, cItem.file) is MsgContent.MCImage -> saveImage(context, cItem.file)
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName) is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {} else -> {}
} }
showMenu.value = false showMenu.value = false
}) })
} }
} }
if (cItem.meta.editable) { if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false showMenu.value = false
@@ -233,15 +230,12 @@ private fun showMsgDeliveryErrorAlert(description: String) {
fun PreviewChatItemView() { fun PreviewChatItemView() {
SimpleXTheme { SimpleXTheme {
ChatItemView( ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData, ChatInfo.Direct.sampleData,
ChatItem.getSampleData( ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello" 1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
), ),
useLinkPreviews = true, useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
receiveFile = {}, receiveFile = {},
joinGroup = {}, joinGroup = {},
@@ -256,13 +250,10 @@ fun PreviewChatItemView() {
fun PreviewChatItemViewDeletedContent() { fun PreviewChatItemViewDeletedContent() {
SimpleXTheme { SimpleXTheme {
ChatItemView( ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData, ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(), ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true, useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
receiveFile = {}, receiveFile = {},
joinGroup = {}, joinGroup = {},
@@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -56,8 +57,12 @@ fun FramedItemView(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp), Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart contentAlignment = Alignment.TopStart
) { ) {
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
qi.content.toTextWithDuration(true)
else
qi.text
MarkdownText( MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3, text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
) )
} }
@@ -87,13 +92,13 @@ fun FramedItemView(
modifier = Modifier.size(68.dp).clipToBounds() modifier = Modifier.size(68.dp).clipToBounds()
) )
} }
is MsgContent.MCFile -> { is MsgContent.MCFile, is MsgContent.MCVoice -> {
Box(Modifier.fillMaxWidth().weight(1f)) { Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi) ciQuotedMsgView(qi)
} }
Icon( Icon(
Icons.Filled.InsertDriveFile, if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
stringResource(R.string.icon_descr_file), if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
Modifier Modifier
.padding(top = 6.dp, end = 4.dp) .padding(top = 6.dp, end = 4.dp)
.size(22.dp), .size(22.dp),
@@ -105,7 +110,7 @@ fun FramedItemView(
} }
} }
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier Box(Modifier
.clip(RoundedCornerShape(18.dp)) .clip(RoundedCornerShape(18.dp))
.background( .background(
@@ -142,6 +147,12 @@ fun FramedItemView(
CIMarkdownText(ci, showMember, uriHandler) CIMarkdownText(ci, showMember, uriHandler)
} }
} }
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor)
if (mc.text != "") {
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCFile -> { is MsgContent.MCFile -> {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile) CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (mc.text != "") { if (mc.text != "") {
@@ -157,8 +168,10 @@ fun FramedItemView(
} }
} }
} }
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
CIMetaView(ci, metaColor) Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
} }
} }
} }
@@ -536,13 +536,11 @@ fun ChatListNavLinkLayout(
) { ) {
var modifier = Modifier.fillMaxWidth() var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) { Box(modifier) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp) .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
.padding(start = 8.dp)
.padding(end = 12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
chatLinkPreview() chatLinkPreview()
@@ -409,7 +409,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
m.controller.apiStopChat() m.controller.apiStopChat()
runChat.value = false runChat.value = false
m.chatRunning.value = false m.chatRunning.value = false
SimplexService.stop(context) SimplexService.safeStopService(context)
MessagesFetcherWorker.cancelAll() MessagesFetcherWorker.cancelAll()
} catch (e: Error) { } catch (e: Error) {
runChat.value = true runChat.value = true
@@ -2,4 +2,8 @@ package chat.simplex.app.views.helpers
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing) fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
@@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
return interactionSource return interactionSource
} }
@Composable
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(interactionSource) {
var firstTapTime = 0L
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
firstTapTime = System.currentTimeMillis(); onPress()
}
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
is PressInteraction.Cancel -> onCancel()
}
}
}
return interactionSource
}
suspend fun PointerInputScope.detectTransformGestures( suspend fun PointerInputScope.detectTransformGestures(
allowIntercept: () -> Boolean, allowIntercept: () -> Boolean,
panZoomLock: Boolean = false, panZoomLock: Boolean = false,
@@ -0,0 +1,199 @@
package chat.simplex.app.views.helpers
import android.media.*
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import android.util.Log
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
interface Recorder {
val recordingInProgress: MutableState<Boolean>
fun start(onStop: () -> Unit): String
fun stop()
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
}
data class ProgressAndDuration(
val progressMs: Int = 0,
val durationMs: Int = 0
) {
companion object {
val Saver
get() = Saver<MutableState<ProgressAndDuration>, Pair<Int, Int>>(
save = { it.value.progressMs to it.value.durationMs },
restore = { mutableStateOf(ProgressAndDuration(it.first, it.second)) }
)
}
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
companion object {
// Allows to stop the recorder from outside without having the recorder in a variable
var stopRecording: (() -> Unit)? = null
}
override val recordingInProgress = mutableStateOf(false)
private var recorder: MediaRecorder? = null
private fun initRecorder() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(SimplexApp.context)
} else {
MediaRecorder()
}
override fun start(onStop: () -> Unit): String {
AudioPlayer.stop()
recordingInProgress.value = true
val rec: MediaRecorder
recorder = initRecorder().also { rec = it }
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(-1)
rec.setMaxFileSize(recordedBytesLimit)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
rec.setOutputFile(filePath)
rec.prepare()
rec.start()
rec.setOnInfoListener { mr, what, extra ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
stop()
onStop()
}
}
stopRecording = { stop(); onStop() }
return filePath
}
override fun stop() {
if (!recordingInProgress.value) return
stopRecording = null
recordingInProgress.value = false
recorder?.metrics?.
runCatching {
recorder?.stop()
}
runCatching {
recorder?.reset()
}
runCatching {
// release all resources
recorder?.release()
}
recorder = null
}
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
stop()
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
}
}
object AudioPlayer {
private val player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
}
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
}
// Filepath: String, onStop: () -> Unit
private val currentlyPlaying: MutableState<Pair<String, () -> Unit>?> = mutableStateOf(null)
fun start(filePath: String, seek: Int? = null, onStop: () -> Unit): Boolean {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
return false
}
RecorderNative.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
player.reset()
// Notify prev audio listener about stop
current?.second?.invoke()
runCatching {
player.setDataSource(filePath)
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return false
}
runCatching { player.prepare() }.onFailure {
// Can happen when audio file is broken
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return false
}
}
if (seek != null) player.seekTo(seek)
player.start()
// Repeated calls to play/pause on the same track will not recompose all dependent views
if (currentlyPlaying.value?.first != filePath) {
currentlyPlaying.value = filePath to onStop
}
return true
}
fun pause(): Int {
player.pause()
return player.currentPosition
}
fun stop() {
if (!player.isPlaying) return
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke()
currentlyPlaying.value = null
player.stop()
}
fun stop(item: ChatItem) = stop(item.file?.fileName)
// FileName or filePath are ok
fun stop(fileName: String?) {
if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) {
stop()
}
}
/**
* If player starts playing at 2637 ms in a track 2816 ms long (these numbers are just an example),
* it will stop immediately after start but will not change currentPosition, so it will not be equal to duration.
* However, it sets isPlaying to false. Let's do it ourselves in order to prevent endless waiting loop
* */
fun progressAndDurationOrEnded(): ProgressAndDuration =
ProgressAndDuration(if (player.isPlaying) player.currentPosition else player.duration, player.duration)
fun duration(filePath: String): Int {
var res = 0
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.prepare()
helperPlayer.start()
helperPlayer.stop()
res = helperPlayer.duration
helperPlayer.reset()
}
return res
}
}
@@ -19,6 +19,7 @@ import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.* import androidx.compose.ui.platform.*
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.model.CIFile import chat.simplex.app.model.CIFile
@@ -220,6 +222,11 @@ private fun spannableStringToAnnotatedString(
// maximum image file size to be auto-accepted // maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE: Long = 236700 const val MAX_IMAGE_SIZE: Long = 236700
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
const val MAX_FILE_SIZE: Long = 8000000 const val MAX_FILE_SIZE: Long = 8000000
fun getFilesDirectory(context: Context): String { fun getFilesDirectory(context: Context): String {
@@ -449,3 +456,9 @@ fun Color.darker(factor: Float = 0.1f): Color =
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT) fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
val LongRange.Companion.saver
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
save = { it.value.first to it.value.last },
restore = { mutableStateOf(it.first..it.second) }
)
@@ -57,7 +57,7 @@ fun NotificationsSettingsView(
if (mode == NotificationsMode.SERVICE) if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context) SimplexService.start(SimplexApp.context)
else else
SimplexService.stop(SimplexApp.context) SimplexService.safeStopService(SimplexApp.context)
} }
if (mode != NotificationsMode.PERIODIC) { if (mode != NotificationsMode.PERIODIC) {
@@ -208,6 +208,10 @@
<string name="file_not_found">Datei nicht gefunden</string> <string name="file_not_found">Datei nicht gefunden</string>
<string name="error_saving_file">Fehler beim Speichern der Datei</string> <string name="error_saving_file">Fehler beim Speichern der Datei</string>
<!-- Voice messages -->
<string name="voice_message">***Voice message</string>
<string name="voice_message_send_text">***Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt --> <!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Benachrichtigungen</string> <string name="notifications">Benachrichtigungen</string>
@@ -221,10 +225,11 @@
<string name="icon_descr_server_status_error">Fehler</string> <string name="icon_descr_server_status_error">Fehler</string>
<string name="icon_descr_server_status_pending">Ausstehend</string> <string name="icon_descr_server_status_pending">Ausstehend</string>
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string> <string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string> <string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
<!-- Message Actions - SendMsgView.kt --> <!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Nachricht senden</string> <string name="icon_descr_send_message">Nachricht senden</string>
<string name="icon_descr_record_voice_message">***Record voice message</string>
<!-- General Actions / Responses --> <!-- General Actions / Responses -->
<string name="back">Zurück</string> <string name="back">Zurück</string>
@@ -364,9 +369,9 @@
<string name="chat_console">Chat Konsole</string> <string name="chat_console">Chat Konsole</string>
<string name="smp_servers">SMP-Server</string> <string name="smp_servers">SMP-Server</string>
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string> <string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
<string name="star_on_github">Stern auf GitHub</string> <string name="star_on_github">Stern auf GitHub vergeben</string>
<string name="contribute">Beitragen</string> <string name="contribute">Unterstützen Sie uns</string>
<string name="rate_the_app">Bewerte die App</string> <string name="rate_the_app">Bewerten Sie die App</string>
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string> <string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string> <string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
<string name="your_SMP_servers">Ihre SMP-Server</string> <string name="your_SMP_servers">Ihre SMP-Server</string>
@@ -567,7 +572,7 @@
<string name="settings_section_title_you">MEINE DATEN</string> <string name="settings_section_title_you">MEINE DATEN</string>
<string name="settings_section_title_settings">EINSTELLUNGEN</string> <string name="settings_section_title_settings">EINSTELLUNGEN</string>
<string name="settings_section_title_help">HILFE</string> <string name="settings_section_title_help">HILFE</string>
<string name="settings_section_title_support">UNTERSTÜTZEN SIMPLEX CHAT</string> <string name="settings_section_title_support">UNTERSTÜTZUNG VON SIMPLEX CHAT</string>
<string name="settings_section_title_develop">ENTWICKLUNG</string> <string name="settings_section_title_develop">ENTWICKLUNG</string>
<string name="settings_section_title_device">GERÄT</string> <string name="settings_section_title_device">GERÄT</string>
<string name="settings_section_title_chats">CHATS</string> <string name="settings_section_title_chats">CHATS</string>
@@ -208,6 +208,10 @@
<string name="file_not_found">Файл не найден</string> <string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string> <string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Voice messages -->
<string name="voice_message">Голосовое сообщение</string>
<string name="voice_message_send_text">Голосовое сообщение…</string>
<!-- Chat Info Settings - ChatInfoView.kt --> <!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Уведомления</string> <string name="notifications">Уведомления</string>
@@ -225,6 +229,7 @@
<!-- Message Actions - SendMsgView.kt --> <!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Отправить сообщение</string> <string name="icon_descr_send_message">Отправить сообщение</string>
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
<!-- General Actions / Responses --> <!-- General Actions / Responses -->
<string name="back">Назад</string> <string name="back">Назад</string>
@@ -208,6 +208,10 @@
<string name="file_not_found">File not found</string> <string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string> <string name="error_saving_file">Error saving file</string>
<!-- Voice messages -->
<string name="voice_message">Voice message</string>
<string name="voice_message_send_text">Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt --> <!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Notifications</string> <string name="notifications">Notifications</string>
@@ -225,6 +229,7 @@
<!-- Message Actions - SendMsgView.kt --> <!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Send Message</string> <string name="icon_descr_send_message">Send Message</string>
<string name="icon_descr_record_voice_message">Record voice message</string>
<!-- General Actions / Responses --> <!-- General Actions / Responses -->
<string name="back">Back</string> <string name="back">Back</string>
+18 -1
View File
@@ -311,7 +311,7 @@ func apiDeleteToken(token: DeviceToken) async throws {
func getUserSMPServers() throws -> [String] { func getUserSMPServers() throws -> [String] {
let r = chatSendCmdSync(.getUserSMPServers) let r = chatSendCmdSync(.getUserSMPServers)
if case let .userSMPServers(smpServers) = r { return smpServers } if case let .userSMPServers(smpServers, _) = r { return smpServers.map { $0.server } }
throw r throw r
} }
@@ -319,6 +319,17 @@ func setUserSMPServers(smpServers: [String]) async throws {
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers)) try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
} }
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
let r = await chatSendCmd(.testSMPServer(smpServer: smpServer))
if case let .sMPTestResult(testFailure) = r {
if let t = testFailure {
return .failure(t)
}
return .success(())
}
throw r
}
func getChatItemTTL() throws -> ChatItemTTL { func getChatItemTTL() throws -> ChatItemTTL {
let r = chatSendCmdSync(.apiGetChatItemTTL) let r = chatSendCmdSync(.apiGetChatItemTTL)
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
@@ -476,6 +487,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
} }
} }
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
if case let .contactPrefsUpdated(_, toContact) = r { return toContact }
throw r
}
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
if case let .contactAliasUpdated(toContact) = r { return toContact } if case let .contactAliasUpdated(toContact) = r { return toContact }
+19 -1
View File
@@ -53,7 +53,7 @@ struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var contact: Contact @State var contact: Contact
@Binding var connectionStats: ConnectionStats? @Binding var connectionStats: ConnectionStats?
var customUserProfile: Profile? var customUserProfile: Profile?
@State var localAlias: String @State var localAlias: String
@@ -99,6 +99,10 @@ struct ChatInfoView: View {
} }
} }
Section {
contactPreferencesButton()
}
Section("Servers") { Section("Servers") {
networkStatusRow() networkStatusRow()
.onTapGesture { .onTapGesture {
@@ -192,6 +196,20 @@ struct ChatInfoView: View {
} }
} }
func contactPreferencesButton() -> some View {
NavigationLink {
ContactPreferencesView(
contact: $contact,
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
)
.navigationBarTitle("Contact preferences")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Contact preferences", systemImage: "switch.2")
}
}
func networkStatusRow() -> some View { func networkStatusRow() -> some View {
HStack { HStack {
Text("Network status") Text("Network status")
@@ -0,0 +1,86 @@
//
// ContactPreferencesView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 13/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var contact: Contact
@State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed
var body: some View {
let user: User = chatModel.currentUser!
VStack {
List {
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
Section {
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
Button("Save (and notify contact)") { savePreferences() }
}
.disabled(currentFeaturesAllowed == featuresAllowed)
}
}
}
private func featureSection(_ feature: Feature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
let enabled = FeatureEnabled.enabled(
user: Preference(allow: allowFeature.wrappedValue.allowed),
contact: pref.contactPreference
)
return Section {
Picker("You allow", selection: allowFeature) {
ForEach(ContactFeatureAllowed.values(userDefault)) { allow in
Text(allow.text)
}
}
.frame(height: 36)
infoRow("Contact allows", pref.contactPreference.allow.text)
} header: {
HStack {
Image(systemName: "\(feature.icon).fill")
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
Text(feature.text)
}
} footer: {
Text(feature.enabledDescription(enabled))
.frame(height: 36, alignment: .topLeading)
}
}
private func savePreferences() {
Task {
do {
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
await MainActor.run {
contact = toContact
chatModel.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
}
} catch {
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
}
}
}
}
struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View {
ContactPreferencesView(
contact: Binding.constant(Contact.sampleData),
featuresAllowed: ContactFeaturesAllowed.sampleData,
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
)
}
}
@@ -13,13 +13,12 @@ struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var groupInfo: GroupInfo @State var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared @ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@State private var showAddMembersSheet: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil @State private var selectedMember: GroupMember? = nil
@State private var showGroupProfile: Bool = false
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -42,6 +41,17 @@ struct GroupChatInfoView: View {
groupInfoHeader() groupInfoHeader()
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
Section {
if groupInfo.canEdit {
editGroupButton()
}
groupPreferencesButton()
} header: {
Text("")
} footer: {
Text("Only group owners can change group preferences.")
}
Section("\(members.count + 1) members") { Section("\(members.count + 1) members") {
if groupInfo.canAddMembers { if groupInfo.canAddMembers {
groupLinkButton() groupLinkButton()
@@ -77,14 +87,8 @@ struct GroupChatInfoView: View {
}) { _ in }) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats) GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
} }
.sheet(isPresented: $showGroupProfile) {
GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile)
}
Section { Section {
if groupInfo.canEdit {
editGroupButton()
}
clearChatButton() clearChatButton()
if groupInfo.canDelete { if groupInfo.canDelete {
deleteGroupButton() deleteGroupButton()
@@ -189,16 +193,35 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink) GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
Label("Group link", systemImage: "link") Label("Group link", systemImage: "link")
.foregroundColor(.accentColor) }
}
func groupPreferencesButton() -> some View {
NavigationLink {
GroupPreferencesView(
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,
currentPreferences: groupInfo.fullGroupPreferences
)
.navigationBarTitle("Group preferences")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Group preferences", systemImage: "switch.2")
} }
} }
func editGroupButton() -> some View { func editGroupButton() -> some View {
Button { NavigationLink {
showGroupProfile = true GroupProfileView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile
)
.navigationBarTitle("Group profile")
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
Label("Edit group profile", systemImage: "pencil") Label("Edit group profile", systemImage: "pencil")
} }
@@ -29,10 +29,6 @@ struct GroupLinkView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack (alignment: .leading) { VStack (alignment: .leading) {
Text("Group link")
.font(.largeTitle)
.bold()
.padding(.bottom)
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
.padding(.bottom) .padding(.bottom)
if let groupLink = groupLink { if let groupLink = groupLink {
@@ -0,0 +1,84 @@
//
// GroupPreferencesView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 16.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct GroupPreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
var body: some View {
VStack {
List {
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.voice, $preferences.voice.enable)
if groupInfo.canEdit {
Section {
Button("Reset") { preferences = currentPreferences }
Button("Save (and notify group members)") { savePreferences() }
}
.disabled(currentPreferences == preferences)
}
}
}
}
private func featureSection(_ feature: Feature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
Section {
if (groupInfo.canEdit) {
settingsRow(feature.icon) {
Picker(feature.text, selection: enableFeature) {
ForEach(GroupFeatureEnabled.values) { enable in
Text(enable.text)
}
}
.frame(height: 36)
}
}
else {
settingsRow(feature.icon) {
infoRow(feature.text, enableFeature.wrappedValue.text)
}
}
} footer: {
Text(feature.enableGroupPrefDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.frame(height: 36, alignment: .topLeading)
}
}
private func savePreferences() {
Task {
do {
var gp = groupInfo.groupProfile
gp.groupPreferences = toGroupPreferences(preferences)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
chatModel.updateGroup(gInfo)
currentPreferences = preferences
}
} catch {
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
}
}
}
}
struct GroupPreferencesView_Previews: PreviewProvider {
static var previews: some View {
GroupPreferencesView(
groupInfo: Binding.constant(GroupInfo.sampleData),
preferences: FullGroupPreferences.sampleData,
currentPreferences: FullGroupPreferences.sampleData
)
}
}
@@ -12,7 +12,7 @@ import SimpleXChat
struct GroupProfileView: View { struct GroupProfileView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
var groupId: Int64 @Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile @State var groupProfile: GroupProfile
@State private var showChooseSource = false @State private var showChooseSource = false
@State private var showImagePicker = false @State private var showImagePicker = false
@@ -120,8 +120,9 @@ struct GroupProfileView: View {
func saveProfile() { func saveProfile() {
Task { Task {
do { do {
let gInfo = try await apiUpdateGroup(groupId, groupProfile) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run { await MainActor.run {
groupInfo = gInfo
chatModel.updateGroup(gInfo) chatModel.updateGroup(gInfo)
dismiss() dismiss()
} }
@@ -137,6 +138,6 @@ struct GroupProfileView: View {
struct GroupProfileView_Previews: PreviewProvider { struct GroupProfileView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupProfileView(groupId: 1, groupProfile: GroupProfile.sampleData) GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
} }
} }
@@ -158,7 +158,7 @@ struct DatabaseView: View {
Section { Section {
Picker("Delete messages after", selection: $chatItemTTL) { Picker("Delete messages after", selection: $chatItemTTL) {
ForEach([ChatItemTTL.none, ChatItemTTL.month, ChatItemTTL.week, ChatItemTTL.day]) { ttl in ForEach(ChatItemTTL.values) { ttl in
Text(ttl.deleteAfterText).tag(ttl) Text(ttl.deleteAfterText).tag(ttl)
} }
if case .seconds = chatItemTTL { if case .seconds = chatItemTTL {
@@ -40,6 +40,13 @@ struct NetworkAndServers: View {
Text("SMP servers") Text("SMP servers")
} }
NavigationLink {
SMPServersView()
.navigationTitle("Your SMP servers")
} label: {
Text("SMP servers (new)")
}
Picker("Use .onion hosts", selection: $onionHosts) { Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) } ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
} }
@@ -0,0 +1,78 @@
//
// PreferencesView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 13/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct PreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@State var profile: LocalProfile
@State var preferences: FullPreferences
@State var currentPreferences: FullPreferences
var body: some View {
VStack {
List {
featureSection(.fullDelete, $preferences.fullDelete.allow)
featureSection(.voice, $preferences.voice.allow)
Section {
Button("Reset") { preferences = currentPreferences }
Button("Save (and notify contacts)") { savePreferences() }
}
.disabled(currentPreferences == preferences)
}
}
}
private func featureSection(_ feature: Feature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
Section {
settingsRow(feature.icon) {
Picker(feature.text, selection: allowFeature) {
ForEach(FeatureAllowed.values) { allow in
Text(allow.text)
}
}
.frame(height: 36)
}
} footer: {
Text(feature.allowDescription(allowFeature.wrappedValue))
.frame(height: 36, alignment: .topLeading)
}
}
private func savePreferences() {
Task {
do {
var p = fromLocalProfile(profile)
p.preferences = toPreferences(preferences)
if let newProfile = try await apiUpdateProfile(profile: p) {
await MainActor.run {
if let profileId = chatModel.currentUser?.profile.profileId {
chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "")
chatModel.currentUser?.fullPreferences = preferences
}
currentPreferences = preferences
}
}
} catch {
logger.error("PreferencesView apiUpdateProfile error: \(responseError(error))")
}
}
}
}
struct PreferencesView_Previews: PreviewProvider {
static var previews: some View {
PreferencesView(
profile: LocalProfile(profileId: 1, displayName: "alice", fullName: "", localAlias: ""),
preferences: FullPreferences.sampleData,
currentPreferences: FullPreferences.sampleData
)
}
}
@@ -0,0 +1,105 @@
//
// SMPServerView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 15/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SMPServerView: View {
@State var server: ServerCfg
var body: some View {
if server.preset {
presetServer()
} else {
customServer()
}
}
private func presetServer() -> some View {
return VStack {
List {
Section("Preset server address") {
Text(server.server)
}
useServerSection()
}
}
}
private func customServer() -> some View {
VStack {
List {
Section("Your server address") {
TextEditor(text: $server.server)
.multilineTextAlignment(.leading)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.lineLimit(10)
.frame(height: 108)
.padding(-6)
}
useServerSection()
Section("Add to another device") {
QRCode(uri: server.server)
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
}
}
}
}
private func useServerSection() -> some View {
Section("Use server") {
HStack {
Button("Test server") {
Task { await testServerConnection(server: $server) }
}
Spacer()
showTestStatus(server: server)
}
Toggle("Enabled", isOn: $server.enabled)
Button("Remove server", role: .destructive) {
}
}
}
}
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
switch server.tested {
case .some(true):
Image(systemName: "checkmark")
.foregroundColor(.green)
case .some(false):
Image(systemName: "multiply")
.foregroundColor(.red)
case .none:
Color.clear
}
}
func testServerConnection(server: Binding<ServerCfg>) async {
do {
let r = try await testSMPServer(smpServer: server.wrappedValue.server)
await MainActor.run {
switch r {
case .success: server.wrappedValue.tested = true
case .failure: server.wrappedValue.tested = false
}
}
} catch let error {
await MainActor.run {
server.wrappedValue.tested = false
}
}
}
struct SMPServerView_Previews: PreviewProvider {
static var previews: some View {
SMPServerView(server: ServerCfg.sampleData.custom)
}
}
@@ -0,0 +1,106 @@
//
// SMPServersView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 15/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SMPServersView: View {
@Environment(\.editMode) var editMode
@State var servers: [ServerCfg] = [
ServerCfg.sampleData.preset,
ServerCfg.sampleData.custom,
ServerCfg.sampleData.untested,
]
@State var showAddServer = false
@State var showSaveAlert = false
var body: some View {
List {
Section("SMP servers") {
ForEach(servers) { srv in
smpServerView(srv)
}
.onMove { indexSet, offset in
servers.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
servers.remove(atOffsets: indexSet)
}
if isEditing {
Button("Add server…") {
showAddServer = true
}
}
}
}
.onChange(of: isEditing) { value in
if value == false {
showSaveAlert = true
}
}
.toolbar { EditButton() }
.confirmationDialog("Add server…", isPresented: $showAddServer, titleVisibility: .hidden) {
Button("Scan server QR code") {
}
Button("Add preset servers") {
}
Button("Enter server manually") {
servers.append(ServerCfg.empty)
}
}
.confirmationDialog("Save servers?", isPresented: $showSaveAlert, titleVisibility: .visible) {
Button("Test & save servers") {
for i in 0..<servers.count {
servers[i].tested = nil
}
Task {
for i in 0..<servers.count {
await testServerConnection(server: $servers[i])
}
}
}
Button("Save servers") {
}
Button("Revert changes") {
}
Button("Cancel", role: .cancel) {
editMode?.wrappedValue = .active
}
}
}
private var isEditing: Bool {
editMode?.wrappedValue.isEditing == true
}
private func smpServerView(_ srv: ServerCfg) -> some View {
NavigationLink {
SMPServerView(server: srv)
.navigationBarTitle("Server")
.navigationBarTitleDisplayMode(.large)
} label: {
let v = Text(srv.server)
HStack {
showTestStatus(server: srv)
.frame(width: 16, alignment: .center)
.padding(.trailing, 4)
if srv.enabled {
v
} else {
(v + Text(" (disabled)")).foregroundColor(.secondary)
}
}
}
}
}
struct SMPServersView_Previews: PreviewProvider {
static var previews: some View {
SMPServersView()
}
}
@@ -89,10 +89,8 @@ struct SettingsView: View {
ProfilePreview(profileOf: user) ProfilePreview(profileOf: user)
.padding(.leading, -8) .padding(.leading, -8)
} }
.disabled(chatModel.chatRunning != true)
incognitoRow() incognitoRow()
.disabled(chatModel.chatRunning != true)
NavigationLink { NavigationLink {
CreateLinkView(selection: .longTerm, viaNavLink: true) CreateLinkView(selection: .longTerm, viaNavLink: true)
@@ -100,24 +98,15 @@ struct SettingsView: View {
} label: { } label: {
settingsRow("qrcode") { Text("Your SimpleX contact address") } settingsRow("qrcode") { Text("Your SimpleX contact address") }
} }
.disabled(chatModel.chatRunning != true)
NavigationLink { NavigationLink {
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL) PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your chat database") .navigationTitle("Your preferences")
} label: { } label: {
let color: Color = chatModel.chatDbEncrypted == false ? .orange : .secondary settingsRow("switch.2") { Text("Chat preferences") }
settingsRow("internaldrive", color: color) {
HStack {
Text("Database passphrase & export")
Spacer()
if chatModel.chatRunning == false {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
}
}
}
} }
} }
.disabled(chatModel.chatRunning != true)
Section("Settings") { Section("Settings") {
NavigationLink { NavigationLink {
@@ -129,18 +118,32 @@ struct SettingsView: View {
Text("Notifications") Text("Notifications")
} }
} }
.disabled(chatModel.chatRunning != true)
NavigationLink {
NetworkAndServers()
.navigationTitle("Network & servers")
} label: {
settingsRow("externaldrive.connected.to.line.below") { Text("Network & servers") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink { NavigationLink {
CallSettings() CallSettings()
.navigationTitle("Your calls") .navigationTitle("Your calls")
} label: { } label: {
settingsRow("video") { Text("Audio & video calls") } settingsRow("video") { Text("Audio & video calls") }
} }
.disabled(chatModel.chatRunning != true)
NavigationLink { NavigationLink {
PrivacySettings() PrivacySettings()
.navigationTitle("Your privacy") .navigationTitle("Your privacy")
} label: { } label: {
settingsRow("lock") { Text("Privacy & security") } settingsRow("lock") { Text("Privacy & security") }
} }
.disabled(chatModel.chatRunning != true)
if UIApplication.shared.supportsAlternateIcons { if UIApplication.shared.supportsAlternateIcons {
NavigationLink { NavigationLink {
AppearanceSettings() AppearanceSettings()
@@ -148,15 +151,11 @@ struct SettingsView: View {
} label: { } label: {
settingsRow("sun.max") { Text("Appearance") } settingsRow("sun.max") { Text("Appearance") }
} }
.disabled(chatModel.chatRunning != true)
} }
NavigationLink {
NetworkAndServers() chatDatabaseRow()
.navigationTitle("Network & servers")
} label: {
settingsRow("externaldrive.connected.to.line.below") { Text("Network & servers") }
}
} }
.disabled(chatModel.chatRunning != true)
Section("Help") { Section("Help") {
NavigationLink { NavigationLink {
@@ -277,6 +276,24 @@ struct SettingsView: View {
} }
} }
private func chatDatabaseRow() -> some View {
NavigationLink {
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
.navigationTitle("Your chat database")
} label: {
let color: Color = chatModel.chatDbEncrypted == false ? .orange : .secondary
settingsRow("internaldrive", color: color) {
HStack {
Text("Database passphrase & export")
Spacer()
if chatModel.chatRunning == false {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
}
}
}
}
}
private enum SettingsSheet: Identifiable { private enum SettingsSheet: Identifiable {
case incognitoInfo case incognitoInfo
@@ -278,6 +278,36 @@
<target>Alle Ihre Kontakte bleiben verbunden.</target> <target>Alle Ihre Kontakte bleiben verbunden.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
<target>***Allow irreversible message deletion only if your contact allows it to you.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
<source>Allow to irreversibly delete sent messages.</source>
<target>***Allow to irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to send voice messages." xml:space="preserve">
<source>Allow to send voice messages.</source>
<target>***Allow to send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
<source>Allow voice messages only if your contact allows them.</source>
<target>***Allow voice messages only if your contact allows them.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
<source>Allow your contacts to irreversibly delete sent messages.</source>
<target>***Allow your contacts to irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
<source>Allow your contacts to send voice messages.</source>
<target>***Allow your contacts to send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Already connected?" xml:space="preserve"> <trans-unit id="Already connected?" xml:space="preserve">
<source>Already connected?</source> <source>Already connected?</source>
<target>Sind Sie bereits verbunden?</target> <target>Sind Sie bereits verbunden?</target>
@@ -328,6 +358,16 @@
<target>Automatisch</target> <target>Automatisch</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
<source>Both you and your contact can irreversibly delete sent messages.</source>
<target>***Both you and your contact can irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
<source>Both you and your contact can send voice messages.</source>
<target>***Both you and your contact can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Call already ended!" xml:space="preserve"> <trans-unit id="Call already ended!" xml:space="preserve">
<source>Call already ended!</source> <source>Call already ended!</source>
<target>Anruf ist bereits beendet!</target> <target>Anruf ist bereits beendet!</target>
@@ -428,6 +468,11 @@
<target>Der Chat ist beendet</target> <target>Der Chat ist beendet</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Chat preferences" xml:space="preserve">
<source>Chat preferences</source>
<target>***Chat preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Chats" xml:space="preserve"> <trans-unit id="Chats" xml:space="preserve">
<source>Chats</source> <source>Chats</source>
<target>Chats</target> <target>Chats</target>
@@ -558,6 +603,11 @@
<target>Verbindungszeitüberschreitung</target> <target>Verbindungszeitüberschreitung</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contact allows" xml:space="preserve">
<source>Contact allows</source>
<target>***Contact allows</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact already exists" xml:space="preserve"> <trans-unit id="Contact already exists" xml:space="preserve">
<source>Contact already exists</source> <source>Contact already exists</source>
<target>Der Kontakt ist bereits vorhanden</target> <target>Der Kontakt ist bereits vorhanden</target>
@@ -588,11 +638,21 @@
<target>Kontaktname</target> <target>Kontaktname</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contact preferences" xml:space="preserve">
<source>Contact preferences</source>
<target>***Contact preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact requests" xml:space="preserve"> <trans-unit id="Contact requests" xml:space="preserve">
<source>Contact requests</source> <source>Contact requests</source>
<target>Kontaktanfragen</target> <target>Kontaktanfragen</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
<target>***Contacts can mark messages for deletion; you will be able to view them.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Copy" xml:space="preserve"> <trans-unit id="Copy" xml:space="preserve">
<source>Copy</source> <source>Copy</source>
<target>Kopieren</target> <target>Kopieren</target>
@@ -1221,6 +1281,11 @@
<target>Für Konsole</target> <target>Für Konsole</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Full deletion" xml:space="preserve">
<source>Full deletion</source>
<target>***Full deletion</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Full name (optional)" xml:space="preserve"> <trans-unit id="Full name (optional)" xml:space="preserve">
<source>Full name (optional)</source> <source>Full name (optional)</source>
<target>Vollständiger Name (optional)</target> <target>Vollständiger Name (optional)</target>
@@ -1271,11 +1336,26 @@
<target>Gruppen-Link</target> <target>Gruppen-Link</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
<source>Group members can irreversibly delete sent messages.</source>
<target>***Group members can irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send voice messages." xml:space="preserve">
<source>Group members can send voice messages.</source>
<target>***Group members can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group message:" xml:space="preserve"> <trans-unit id="Group message:" xml:space="preserve">
<source>Group message:</source> <source>Group message:</source>
<target>Grppennachricht:</target> <target>Grppennachricht:</target>
<note>notification</note> <note>notification</note>
</trans-unit> </trans-unit>
<trans-unit id="Group preferences" xml:space="preserve">
<source>Group preferences</source>
<target>***Group preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve"> <trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
<source>Group profile is stored on members' devices, not on the servers.</source> <source>Group profile is stored on members' devices, not on the servers.</source>
<target>Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.</target> <target>Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.</target>
@@ -1453,6 +1533,11 @@
<target>In Gruppe einladen</target> <target>In Gruppe einladen</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
<source>Irreversible message deletion is prohibited in this chat.</source>
<target>***Irreversible message deletion is prohibited in this chat.</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"> <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> <source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.</target> <target>Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.</target>
@@ -1768,6 +1853,31 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.</target> <target>Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
<source>Only group owners can change group preferences.</source>
<target>***Only group owners can change group preferences.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
<target>***Only you can irreversibly delete messages (your contact can mark them for deletion).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only you can send voice messages." xml:space="preserve">
<source>Only you can send voice messages.</source>
<target>***Only you can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
<target>***Only your contact can irreversibly delete messages (you can mark them for deletion).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
<source>Only your contact can send voice messages.</source>
<target>***Only your contact can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open Settings" xml:space="preserve"> <trans-unit id="Open Settings" xml:space="preserve">
<source>Open Settings</source> <source>Open Settings</source>
<target>Geräte-Einstellungen öffnen</target> <target>Geräte-Einstellungen öffnen</target>
@@ -1858,6 +1968,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.</target> <target>Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Preferences" xml:space="preserve">
<source>Preferences</source>
<target>***Preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Privacy &amp; security" xml:space="preserve"> <trans-unit id="Privacy &amp; security" xml:space="preserve">
<source>Privacy &amp; security</source> <source>Privacy &amp; security</source>
<target>Datenschutz &amp; Sicherheit</target> <target>Datenschutz &amp; Sicherheit</target>
@@ -1873,6 +1988,16 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Profilbild</target> <target>Profilbild</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
<source>Prohibit irreversible message deletion.</source>
<target>***Prohibit irreversible message deletion.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
<source>Prohibit sending voice messages.</source>
<target>***Prohibit sending voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Protocol timeout" xml:space="preserve"> <trans-unit id="Protocol timeout" xml:space="preserve">
<source>Protocol timeout</source> <source>Protocol timeout</source>
<target>Protokollzeitüberschreitung</target> <target>Protokollzeitüberschreitung</target>
@@ -1885,7 +2010,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
</trans-unit> </trans-unit>
<trans-unit id="Rate the app" xml:space="preserve"> <trans-unit id="Rate the app" xml:space="preserve">
<source>Rate the app</source> <source>Rate the app</source>
<target>Bewerte die App</target> <target>Bewerten Sie die App</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Read" xml:space="preserve"> <trans-unit id="Read" xml:space="preserve">
@@ -1968,6 +2093,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Erforderlich</target> <target>Erforderlich</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Reset" xml:space="preserve">
<source>Reset</source>
<target>***Reset</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reset colors" xml:space="preserve"> <trans-unit id="Reset colors" xml:space="preserve">
<source>Reset colors</source> <source>Reset colors</source>
<target>Farben zurücksetzen</target> <target>Farben zurücksetzen</target>
@@ -2038,11 +2168,21 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Speichern</target> <target>Speichern</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Save (and notify contact)" xml:space="preserve">
<source>Save (and notify contact)</source>
<target>***Save (and notify contact)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve"> <trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source> <source>Save (and notify contacts)</source>
<target>Speichern (und Kontakte benachrichtigen)</target> <target>Speichern (und Kontakte benachrichtigen)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Save (and notify group members)" xml:space="preserve">
<source>Save (and notify group members)</source>
<target>***Save (and notify group members)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save archive" xml:space="preserve"> <trans-unit id="Save archive" xml:space="preserve">
<source>Save archive</source> <source>Save archive</source>
<target>Archiv speichern</target> <target>Archiv speichern</target>
@@ -2255,7 +2395,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
</trans-unit> </trans-unit>
<trans-unit id="Support SimpleX Chat" xml:space="preserve"> <trans-unit id="Support SimpleX Chat" xml:space="preserve">
<source>Support SimpleX Chat</source> <source>Support SimpleX Chat</source>
<target>Unterstützen SimpleX Chat</target> <target>Unterstützung von SimpleX Chat</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="System" xml:space="preserve"> <trans-unit id="System" xml:space="preserve">
@@ -2395,7 +2535,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
</trans-unit> </trans-unit>
<trans-unit id="This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member)." xml:space="preserve"> <trans-unit id="This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member)." xml:space="preserve">
<source>This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member).</source> <source>This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member).</source>
<target>Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</target> <target>Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="This group no longer exists." xml:space="preserve"> <trans-unit id="This group no longer exists." xml:space="preserve">
@@ -2572,6 +2712,16 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
<target>Videoanruf</target> <target>Videoanruf</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Voice messages" xml:space="preserve">
<source>Voice messages</source>
<target>***Voice messages</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
<source>Voice messages are prohibited in this chat.</source>
<target>***Voice messages are prohibited in this chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Waiting for file" xml:space="preserve"> <trans-unit id="Waiting for file" xml:space="preserve">
<source>Waiting for file</source> <source>Waiting for file</source>
<target>Warte auf Datei</target> <target>Warte auf Datei</target>
@@ -2627,6 +2777,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
<target>Sie haben die Verbindung akzeptiert</target> <target>Sie haben die Verbindung akzeptiert</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="You allow" xml:space="preserve">
<source>You allow</source>
<target>***You allow</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You are already connected to %@." xml:space="preserve"> <trans-unit id="You are already connected to %@." xml:space="preserve">
<source>You are already connected to %@.</source> <source>You are already connected to %@.</source>
<target>Sie sind bereits mit %@ verbunden.</target> <target>Sie sind bereits mit %@ verbunden.</target>
@@ -2844,6 +2999,11 @@ Sie können diese Verbindung abbrechen und den Kontakt entfernen (und es später
<target>Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die Importierte ERSETZT.</target> <target>Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die Importierte ERSETZT.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your preferences" xml:space="preserve">
<source>Your preferences</source>
<target>***Your preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your privacy" xml:space="preserve"> <trans-unit id="Your privacy" xml:space="preserve">
<source>Your privacy</source> <source>Your privacy</source>
<target>Meine Privatsphäre</target> <target>Meine Privatsphäre</target>
@@ -2878,7 +3038,7 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
</trans-unit> </trans-unit>
<trans-unit id="[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" xml:space="preserve"> <trans-unit id="[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" xml:space="preserve">
<source>[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)</source> <source>[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)</source>
<target>[Beitragen](https://github.com/simplex-chat/simplex-chat#contribute)</target> <target>[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="[Send us email](mailto:chat@simplex.chat)" xml:space="preserve"> <trans-unit id="[Send us email](mailto:chat@simplex.chat)" xml:space="preserve">
@@ -2888,7 +3048,7 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
</trans-unit> </trans-unit>
<trans-unit id="[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve"> <trans-unit id="[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
<source>[Star on GitHub](https://github.com/simplex-chat/simplex-chat)</source> <source>[Star on GitHub](https://github.com/simplex-chat/simplex-chat)</source>
<target>[Stern auf GitHub](https://github.com/simplex-chat/simplex-chat)</target> <target>[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="_italic_" xml:space="preserve"> <trans-unit id="_italic_" xml:space="preserve">
@@ -2916,6 +3076,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Admin</target> <target>Admin</target>
<note>member role</note> <note>member role</note>
</trans-unit> </trans-unit>
<trans-unit id="always" xml:space="preserve">
<source>always</source>
<target>***always</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve"> <trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
<source>audio call (not e2e encrypted)</source> <source>audio call (not e2e encrypted)</source>
<target>Audioanruf (nicht E2E verschlüsselt)</target> <target>Audioanruf (nicht E2E verschlüsselt)</target>
@@ -3056,6 +3221,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Ersteller</target> <target>Ersteller</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="default (%@)" xml:space="preserve">
<source>default (%@)</source>
<target>***default (%@)</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="deleted" xml:space="preserve"> <trans-unit id="deleted" xml:space="preserve">
<source>deleted</source> <source>deleted</source>
<target>Gelöscht</target> <target>Gelöscht</target>
@@ -3206,11 +3376,26 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Neue Nachricht</target> <target>Neue Nachricht</target>
<note>notification</note> <note>notification</note>
</trans-unit> </trans-unit>
<trans-unit id="no" xml:space="preserve">
<source>no</source>
<target>***no</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="no e2e encryption" xml:space="preserve"> <trans-unit id="no e2e encryption" xml:space="preserve">
<source>no e2e encryption</source> <source>no e2e encryption</source>
<target>Keine E2E-Verschlüsselung</target> <target>Keine E2E-Verschlüsselung</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="off" xml:space="preserve">
<source>off</source>
<target>***off</target>
<note>group pref value</note>
</trans-unit>
<trans-unit id="on" xml:space="preserve">
<source>on</source>
<target>***on</target>
<note>group pref value</note>
</trans-unit>
<trans-unit id="or chat with the developers" xml:space="preserve"> <trans-unit id="or chat with the developers" xml:space="preserve">
<source>or chat with the developers</source> <source>or chat with the developers</source>
<target>oder chatten Sie mit den Entwicklern</target> <target>oder chatten Sie mit den Entwicklern</target>
@@ -3336,6 +3521,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>möchte sich mit Ihnen verbinden!</target> <target>möchte sich mit Ihnen verbinden!</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="yes" xml:space="preserve">
<source>yes</source>
<target>***yes</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="you are invited to group" xml:space="preserve"> <trans-unit id="you are invited to group" xml:space="preserve">
<source>you are invited to group</source> <source>you are invited to group</source>
<target>Sie sind zur Gruppe eingeladen</target> <target>Sie sind zur Gruppe eingeladen</target>
@@ -278,6 +278,36 @@
<target>All your contacts will remain connected</target> <target>All your contacts will remain connected</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
<target>Allow irreversible message deletion only if your contact allows it to you.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
<source>Allow to irreversibly delete sent messages.</source>
<target>Allow to irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to send voice messages." xml:space="preserve">
<source>Allow to send voice messages.</source>
<target>Allow to send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
<source>Allow voice messages only if your contact allows them.</source>
<target>Allow voice messages only if your contact allows them.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
<source>Allow your contacts to irreversibly delete sent messages.</source>
<target>Allow your contacts to irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
<source>Allow your contacts to send voice messages.</source>
<target>Allow your contacts to send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Already connected?" xml:space="preserve"> <trans-unit id="Already connected?" xml:space="preserve">
<source>Already connected?</source> <source>Already connected?</source>
<target>Already connected?</target> <target>Already connected?</target>
@@ -328,6 +358,16 @@
<target>Automatically</target> <target>Automatically</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
<source>Both you and your contact can irreversibly delete sent messages.</source>
<target>Both you and your contact can irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
<source>Both you and your contact can send voice messages.</source>
<target>Both you and your contact can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Call already ended!" xml:space="preserve"> <trans-unit id="Call already ended!" xml:space="preserve">
<source>Call already ended!</source> <source>Call already ended!</source>
<target>Call already ended!</target> <target>Call already ended!</target>
@@ -428,6 +468,11 @@
<target>Chat is stopped</target> <target>Chat is stopped</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Chat preferences" xml:space="preserve">
<source>Chat preferences</source>
<target>Chat preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Chats" xml:space="preserve"> <trans-unit id="Chats" xml:space="preserve">
<source>Chats</source> <source>Chats</source>
<target>Chats</target> <target>Chats</target>
@@ -558,6 +603,11 @@
<target>Connection timeout</target> <target>Connection timeout</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contact allows" xml:space="preserve">
<source>Contact allows</source>
<target>Contact allows</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact already exists" xml:space="preserve"> <trans-unit id="Contact already exists" xml:space="preserve">
<source>Contact already exists</source> <source>Contact already exists</source>
<target>Contact already exists</target> <target>Contact already exists</target>
@@ -588,11 +638,21 @@
<target>Contact name</target> <target>Contact name</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contact preferences" xml:space="preserve">
<source>Contact preferences</source>
<target>Contact preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact requests" xml:space="preserve"> <trans-unit id="Contact requests" xml:space="preserve">
<source>Contact requests</source> <source>Contact requests</source>
<target>Contact requests</target> <target>Contact requests</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
<target>Contacts can mark messages for deletion; you will be able to view them.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Copy" xml:space="preserve"> <trans-unit id="Copy" xml:space="preserve">
<source>Copy</source> <source>Copy</source>
<target>Copy</target> <target>Copy</target>
@@ -1221,6 +1281,11 @@
<target>For console</target> <target>For console</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Full deletion" xml:space="preserve">
<source>Full deletion</source>
<target>Full deletion</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Full name (optional)" xml:space="preserve"> <trans-unit id="Full name (optional)" xml:space="preserve">
<source>Full name (optional)</source> <source>Full name (optional)</source>
<target>Full name (optional)</target> <target>Full name (optional)</target>
@@ -1271,11 +1336,26 @@
<target>Group link</target> <target>Group link</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
<source>Group members can irreversibly delete sent messages.</source>
<target>Group members can irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send voice messages." xml:space="preserve">
<source>Group members can send voice messages.</source>
<target>Group members can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group message:" xml:space="preserve"> <trans-unit id="Group message:" xml:space="preserve">
<source>Group message:</source> <source>Group message:</source>
<target>Group message:</target> <target>Group message:</target>
<note>notification</note> <note>notification</note>
</trans-unit> </trans-unit>
<trans-unit id="Group preferences" xml:space="preserve">
<source>Group preferences</source>
<target>Group preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve"> <trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
<source>Group profile is stored on members' devices, not on the servers.</source> <source>Group profile is stored on members' devices, not on the servers.</source>
<target>Group profile is stored on members' devices, not on the servers.</target> <target>Group profile is stored on members' devices, not on the servers.</target>
@@ -1453,6 +1533,11 @@
<target>Invite to group</target> <target>Invite to group</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
<source>Irreversible message deletion is prohibited in this chat.</source>
<target>Irreversible message deletion is prohibited in this chat.</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"> <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> <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> <target>It allows having many anonymous connections without any shared data between them in a single chat profile.</target>
@@ -1768,6 +1853,31 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</target> <target>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
<source>Only group owners can change group preferences.</source>
<target>Only group owners can change group preferences.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
<target>Only you can irreversibly delete messages (your contact can mark them for deletion).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only you can send voice messages." xml:space="preserve">
<source>Only you can send voice messages.</source>
<target>Only you can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
<target>Only your contact can irreversibly delete messages (you can mark them for deletion).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
<source>Only your contact can send voice messages.</source>
<target>Only your contact can send voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open Settings" xml:space="preserve"> <trans-unit id="Open Settings" xml:space="preserve">
<source>Open Settings</source> <source>Open Settings</source>
<target>Open Settings</target> <target>Open Settings</target>
@@ -1858,6 +1968,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Please store passphrase securely, you will NOT be able to change it if you lose it.</target> <target>Please store passphrase securely, you will NOT be able to change it if you lose it.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Preferences" xml:space="preserve">
<source>Preferences</source>
<target>Preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Privacy &amp; security" xml:space="preserve"> <trans-unit id="Privacy &amp; security" xml:space="preserve">
<source>Privacy &amp; security</source> <source>Privacy &amp; security</source>
<target>Privacy &amp; security</target> <target>Privacy &amp; security</target>
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Profile image</target> <target>Profile image</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
<source>Prohibit irreversible message deletion.</source>
<target>Prohibit irreversible message deletion.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
<source>Prohibit sending voice messages.</source>
<target>Prohibit sending voice messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Protocol timeout" xml:space="preserve"> <trans-unit id="Protocol timeout" xml:space="preserve">
<source>Protocol timeout</source> <source>Protocol timeout</source>
<target>Protocol timeout</target> <target>Protocol timeout</target>
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Required</target> <target>Required</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Reset" xml:space="preserve">
<source>Reset</source>
<target>Reset</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reset colors" xml:space="preserve"> <trans-unit id="Reset colors" xml:space="preserve">
<source>Reset colors</source> <source>Reset colors</source>
<target>Reset colors</target> <target>Reset colors</target>
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Save</target> <target>Save</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Save (and notify contact)" xml:space="preserve">
<source>Save (and notify contact)</source>
<target>Save (and notify contact)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve"> <trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source> <source>Save (and notify contacts)</source>
<target>Save (and notify contacts)</target> <target>Save (and notify contacts)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Save (and notify group members)" xml:space="preserve">
<source>Save (and notify group members)</source>
<target>Save (and notify group members)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save archive" xml:space="preserve"> <trans-unit id="Save archive" xml:space="preserve">
<source>Save archive</source> <source>Save archive</source>
<target>Save archive</target> <target>Save archive</target>
@@ -2572,6 +2712,16 @@ To connect, please ask your contact to create another connection link and check
<target>Video call</target> <target>Video call</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Voice messages" xml:space="preserve">
<source>Voice messages</source>
<target>Voice messages</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
<source>Voice messages are prohibited in this chat.</source>
<target>Voice messages are prohibited in this chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Waiting for file" xml:space="preserve"> <trans-unit id="Waiting for file" xml:space="preserve">
<source>Waiting for file</source> <source>Waiting for file</source>
<target>Waiting for file</target> <target>Waiting for file</target>
@@ -2627,6 +2777,11 @@ To connect, please ask your contact to create another connection link and check
<target>You accepted connection</target> <target>You accepted connection</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="You allow" xml:space="preserve">
<source>You allow</source>
<target>You allow</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You are already connected to %@." xml:space="preserve"> <trans-unit id="You are already connected to %@." xml:space="preserve">
<source>You are already connected to %@.</source> <source>You are already connected to %@.</source>
<target>You are already connected to %@.</target> <target>You are already connected to %@.</target>
@@ -2844,6 +2999,11 @@ You can cancel this connection and remove the contact (and try later with a new
<target>Your current chat database will be DELETED and REPLACED with the imported one.</target> <target>Your current chat database will be DELETED and REPLACED with the imported one.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your preferences" xml:space="preserve">
<source>Your preferences</source>
<target>Your preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your privacy" xml:space="preserve"> <trans-unit id="Your privacy" xml:space="preserve">
<source>Your privacy</source> <source>Your privacy</source>
<target>Your privacy</target> <target>Your privacy</target>
@@ -2916,6 +3076,11 @@ SimpleX servers cannot see your profile.</target>
<target>admin</target> <target>admin</target>
<note>member role</note> <note>member role</note>
</trans-unit> </trans-unit>
<trans-unit id="always" xml:space="preserve">
<source>always</source>
<target>always</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve"> <trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
<source>audio call (not e2e encrypted)</source> <source>audio call (not e2e encrypted)</source>
<target>audio call (not e2e encrypted)</target> <target>audio call (not e2e encrypted)</target>
@@ -3056,6 +3221,11 @@ SimpleX servers cannot see your profile.</target>
<target>creator</target> <target>creator</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="default (%@)" xml:space="preserve">
<source>default (%@)</source>
<target>default (%@)</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="deleted" xml:space="preserve"> <trans-unit id="deleted" xml:space="preserve">
<source>deleted</source> <source>deleted</source>
<target>deleted</target> <target>deleted</target>
@@ -3206,11 +3376,26 @@ SimpleX servers cannot see your profile.</target>
<target>new message</target> <target>new message</target>
<note>notification</note> <note>notification</note>
</trans-unit> </trans-unit>
<trans-unit id="no" xml:space="preserve">
<source>no</source>
<target>no</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="no e2e encryption" xml:space="preserve"> <trans-unit id="no e2e encryption" xml:space="preserve">
<source>no e2e encryption</source> <source>no e2e encryption</source>
<target>no e2e encryption</target> <target>no e2e encryption</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="off" xml:space="preserve">
<source>off</source>
<target>off</target>
<note>group pref value</note>
</trans-unit>
<trans-unit id="on" xml:space="preserve">
<source>on</source>
<target>on</target>
<note>group pref value</note>
</trans-unit>
<trans-unit id="or chat with the developers" xml:space="preserve"> <trans-unit id="or chat with the developers" xml:space="preserve">
<source>or chat with the developers</source> <source>or chat with the developers</source>
<target>or chat with the developers</target> <target>or chat with the developers</target>
@@ -3336,6 +3521,11 @@ SimpleX servers cannot see your profile.</target>
<target>wants to connect to you!</target> <target>wants to connect to you!</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="yes" xml:space="preserve">
<source>yes</source>
<target>yes</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="you are invited to group" xml:space="preserve"> <trans-unit id="you are invited to group" xml:space="preserve">
<source>you are invited to group</source> <source>you are invited to group</source>
<target>you are invited to group</target> <target>you are invited to group</target>
@@ -278,6 +278,36 @@
<target>Все контакты, которые соединились через этот адрес, сохранятся.</target> <target>Все контакты, которые соединились через этот адрес, сохранятся.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
<target>Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
<source>Allow to irreversibly delete sent messages.</source>
<target>Разрешить необратимо удалять отправленные сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to send voice messages." xml:space="preserve">
<source>Allow to send voice messages.</source>
<target>Разрешить отправлять голосовые сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
<source>Allow voice messages only if your contact allows them.</source>
<target>Разрешить голосовые сообщения, только если их разрешает ваш контакт.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
<source>Allow your contacts to irreversibly delete sent messages.</source>
<target>Разрешить вашим контактам необратимо удалять отправленные сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
<source>Allow your contacts to send voice messages.</source>
<target>Разрешить вашим контактам отправлять голосовые сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Already connected?" xml:space="preserve"> <trans-unit id="Already connected?" xml:space="preserve">
<source>Already connected?</source> <source>Already connected?</source>
<target>Соединение уже установлено?</target> <target>Соединение уже установлено?</target>
@@ -328,6 +358,16 @@
<target>Автоматически</target> <target>Автоматически</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
<source>Both you and your contact can irreversibly delete sent messages.</source>
<target>Вы и ваш контакт можете необратимо удалять отправленные сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
<source>Both you and your contact can send voice messages.</source>
<target>Вы и ваш контакт можете отправлять голосовые сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Call already ended!" xml:space="preserve"> <trans-unit id="Call already ended!" xml:space="preserve">
<source>Call already ended!</source> <source>Call already ended!</source>
<target>Звонок уже завершен!</target> <target>Звонок уже завершен!</target>
@@ -428,6 +468,11 @@
<target>Чат остановлен</target> <target>Чат остановлен</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Chat preferences" xml:space="preserve">
<source>Chat preferences</source>
<target>Предпочтения</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Chats" xml:space="preserve"> <trans-unit id="Chats" xml:space="preserve">
<source>Chats</source> <source>Chats</source>
<target>Чаты</target> <target>Чаты</target>
@@ -558,6 +603,11 @@
<target>Превышено время соединения</target> <target>Превышено время соединения</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contact allows" xml:space="preserve">
<source>Contact allows</source>
<target>Контакт разрешает</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact already exists" xml:space="preserve"> <trans-unit id="Contact already exists" xml:space="preserve">
<source>Contact already exists</source> <source>Contact already exists</source>
<target>Существующий контакт</target> <target>Существующий контакт</target>
@@ -588,11 +638,21 @@
<target>Имена контактов</target> <target>Имена контактов</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contact preferences" xml:space="preserve">
<source>Contact preferences</source>
<target>Предпочтения контакта</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact requests" xml:space="preserve"> <trans-unit id="Contact requests" xml:space="preserve">
<source>Contact requests</source> <source>Contact requests</source>
<target>Запросы контактов</target> <target>Запросы контактов</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
<target>Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Copy" xml:space="preserve"> <trans-unit id="Copy" xml:space="preserve">
<source>Copy</source> <source>Copy</source>
<target>Скопировать</target> <target>Скопировать</target>
@@ -1221,6 +1281,11 @@
<target>Для консоли</target> <target>Для консоли</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Full deletion" xml:space="preserve">
<source>Full deletion</source>
<target>Полное удаление</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Full name (optional)" xml:space="preserve"> <trans-unit id="Full name (optional)" xml:space="preserve">
<source>Full name (optional)</source> <source>Full name (optional)</source>
<target>Полное имя (не обязательно)</target> <target>Полное имя (не обязательно)</target>
@@ -1271,11 +1336,26 @@
<target>Ссылка группы</target> <target>Ссылка группы</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
<source>Group members can irreversibly delete sent messages.</source>
<target>Члены группы могут необратимо удалять отправленные сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send voice messages." xml:space="preserve">
<source>Group members can send voice messages.</source>
<target>Члены группы могут отправлять голосовые сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group message:" xml:space="preserve"> <trans-unit id="Group message:" xml:space="preserve">
<source>Group message:</source> <source>Group message:</source>
<target>Групповое сообщение:</target> <target>Групповое сообщение:</target>
<note>notification</note> <note>notification</note>
</trans-unit> </trans-unit>
<trans-unit id="Group preferences" xml:space="preserve">
<source>Group preferences</source>
<target>Предпочтения группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve"> <trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
<source>Group profile is stored on members' devices, not on the servers.</source> <source>Group profile is stored on members' devices, not on the servers.</source>
<target>Профиль группы хранится на устройствах членов, а не на серверах.</target> <target>Профиль группы хранится на устройствах членов, а не на серверах.</target>
@@ -1453,6 +1533,11 @@
<target>Пригласить в группу</target> <target>Пригласить в группу</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
<source>Irreversible message deletion is prohibited in this chat.</source>
<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"> <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> <source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target> <target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target>
@@ -1768,6 +1853,31 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**</target> <target>Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
<source>Only group owners can change group preferences.</source>
<target>Только владельцы группы могут изменять предпочтения группы.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
<target>Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only you can send voice messages." xml:space="preserve">
<source>Only you can send voice messages.</source>
<target>Только вы можете отправлять голосовые сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
<target>Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
<source>Only your contact can send voice messages.</source>
<target>Только ваш контакт может отправлять голосовые сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open Settings" xml:space="preserve"> <trans-unit id="Open Settings" xml:space="preserve">
<source>Open Settings</source> <source>Open Settings</source>
<target>Открыть Настройки</target> <target>Открыть Настройки</target>
@@ -1858,6 +1968,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</target> <target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Preferences" xml:space="preserve">
<source>Preferences</source>
<target>Предпочтения</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Privacy &amp; security" xml:space="preserve"> <trans-unit id="Privacy &amp; security" xml:space="preserve">
<source>Privacy &amp; security</source> <source>Privacy &amp; security</source>
<target>Конфиденциальность</target> <target>Конфиденциальность</target>
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Аватар</target> <target>Аватар</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
<source>Prohibit irreversible message deletion.</source>
<target>Запретить необратимое удаление сообщений.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
<source>Prohibit sending voice messages.</source>
<target>Запретить отправлять голосовые сообщений.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Protocol timeout" xml:space="preserve"> <trans-unit id="Protocol timeout" xml:space="preserve">
<source>Protocol timeout</source> <source>Protocol timeout</source>
<target>Таймаут протокола</target> <target>Таймаут протокола</target>
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Обязательно</target> <target>Обязательно</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Reset" xml:space="preserve">
<source>Reset</source>
<target>Сбросить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reset colors" xml:space="preserve"> <trans-unit id="Reset colors" xml:space="preserve">
<source>Reset colors</source> <source>Reset colors</source>
<target>Сбросить цвета</target> <target>Сбросить цвета</target>
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Сохранить</target> <target>Сохранить</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Save (and notify contact)" xml:space="preserve">
<source>Save (and notify contact)</source>
<target>Сохранить (и уведомить контакт)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve"> <trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source> <source>Save (and notify contacts)</source>
<target>Сохранить (и уведомить контакты)</target> <target>Сохранить (и уведомить контакты)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Save (and notify group members)" xml:space="preserve">
<source>Save (and notify group members)</source>
<target>Сохранить (и уведомить членов группы)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save archive" xml:space="preserve"> <trans-unit id="Save archive" xml:space="preserve">
<source>Save archive</source> <source>Save archive</source>
<target>Сохранить архив</target> <target>Сохранить архив</target>
@@ -2572,6 +2712,16 @@ To connect, please ask your contact to create another connection link and check
<target>Видеозвонок</target> <target>Видеозвонок</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Voice messages" xml:space="preserve">
<source>Voice messages</source>
<target>Голосовые сообщения</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
<source>Voice messages are prohibited in this chat.</source>
<target>Голосовые сообщения запрещены в этом чате.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Waiting for file" xml:space="preserve"> <trans-unit id="Waiting for file" xml:space="preserve">
<source>Waiting for file</source> <source>Waiting for file</source>
<target>Ожидается прием файла</target> <target>Ожидается прием файла</target>
@@ -2627,6 +2777,11 @@ To connect, please ask your contact to create another connection link and check
<target>Вы приняли приглашение соединиться</target> <target>Вы приняли приглашение соединиться</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="You allow" xml:space="preserve">
<source>You allow</source>
<target>Вы разрешаете</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You are already connected to %@." xml:space="preserve"> <trans-unit id="You are already connected to %@." xml:space="preserve">
<source>You are already connected to %@.</source> <source>You are already connected to %@.</source>
<target>Вы уже соединены с контактом %@.</target> <target>Вы уже соединены с контактом %@.</target>
@@ -2844,6 +2999,11 @@ You can cancel this connection and remove the contact (and try later with a new
<target>Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.</target> <target>Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your preferences" xml:space="preserve">
<source>Your preferences</source>
<target>Ваши предпочтения</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your privacy" xml:space="preserve"> <trans-unit id="Your privacy" xml:space="preserve">
<source>Your privacy</source> <source>Your privacy</source>
<target>Конфиденциальность</target> <target>Конфиденциальность</target>
@@ -2916,6 +3076,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>админ</target> <target>админ</target>
<note>member role</note> <note>member role</note>
</trans-unit> </trans-unit>
<trans-unit id="always" xml:space="preserve">
<source>always</source>
<target>всегда</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve"> <trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
<source>audio call (not e2e encrypted)</source> <source>audio call (not e2e encrypted)</source>
<target>аудиозвонок (не e2e зашифрованный)</target> <target>аудиозвонок (не e2e зашифрованный)</target>
@@ -3056,6 +3221,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>создатель</target> <target>создатель</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="default (%@)" xml:space="preserve">
<source>default (%@)</source>
<target>по умолчанию (%@)</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="deleted" xml:space="preserve"> <trans-unit id="deleted" xml:space="preserve">
<source>deleted</source> <source>deleted</source>
<target>удалено</target> <target>удалено</target>
@@ -3206,11 +3376,26 @@ SimpleX серверы не могут получить доступ к ваше
<target>новое сообщение</target> <target>новое сообщение</target>
<note>notification</note> <note>notification</note>
</trans-unit> </trans-unit>
<trans-unit id="no" xml:space="preserve">
<source>no</source>
<target>нет</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="no e2e encryption" xml:space="preserve"> <trans-unit id="no e2e encryption" xml:space="preserve">
<source>no e2e encryption</source> <source>no e2e encryption</source>
<target>нет e2e шифрования</target> <target>нет e2e шифрования</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="off" xml:space="preserve">
<source>off</source>
<target>нет</target>
<note>group pref value</note>
</trans-unit>
<trans-unit id="on" xml:space="preserve">
<source>on</source>
<target>да</target>
<note>group pref value</note>
</trans-unit>
<trans-unit id="or chat with the developers" xml:space="preserve"> <trans-unit id="or chat with the developers" xml:space="preserve">
<source>or chat with the developers</source> <source>or chat with the developers</source>
<target>или соединитесь с разработчиками</target> <target>или соединитесь с разработчиками</target>
@@ -3336,6 +3521,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>хочет соединиться с вами!</target> <target>хочет соединиться с вами!</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="yes" xml:space="preserve">
<source>yes</source>
<target>да</target>
<note>pref value</note>
</trans-unit>
<trans-unit id="you are invited to group" xml:space="preserve"> <trans-unit id="you are invited to group" xml:space="preserve">
<source>you are invited to group</source> <source>you are invited to group</source>
<target>вы приглашены в группу</target> <target>вы приглашены в группу</target>
+40 -20
View File
@@ -53,6 +53,13 @@
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* SMPServersView.swift */; };
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* SMPServerView.swift */; };
5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293229241CD90090FFF9 /* libffi.a */; };
5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */; };
5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293429241CD90090FFF9 /* libgmpxx.a */; };
5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */; };
5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293629241CDA0090FFF9 /* libgmp.a */; };
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; }; 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; };
@@ -68,6 +75,8 @@
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; };
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; };
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; };
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; };
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; };
5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; };
5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; }; 5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; };
5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8D2827126500B3292C /* OnboardingView.swift */; }; 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8D2827126500B3292C /* OnboardingView.swift */; };
@@ -120,11 +129,7 @@
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328565291CDEF200FBE5C8 /* libgmpxx.a */; };
6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */; };
6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328567291CDEF200FBE5C8 /* libffi.a */; };
6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328568291CDEF200FBE5C8 /* libgmp.a */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; }; 6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; }; 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; }; 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
@@ -248,6 +253,13 @@
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C93292E29239A170090FFF9 /* SMPServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServersView.swift; sourceTree = "<group>"; };
5C93293029239BED0090FFF9 /* SMPServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServerView.swift; sourceTree = "<group>"; };
5C93293229241CD90090FFF9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a"; sourceTree = "<group>"; };
5C93293429241CD90090FFF9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = "<group>"; };
5C93293629241CDA0090FFF9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = "<group>"; };
@@ -267,6 +279,8 @@
5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = "<group>"; }; 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = "<group>"; };
5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = "<group>"; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = "<group>"; };
5CADE79929211BB900072E13 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPreferencesView.swift; sourceTree = "<group>"; };
5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; }; 5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
5CB0BA8D2827126500B3292C /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 5CB0BA8D2827126500B3292C /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
@@ -319,11 +333,7 @@
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a"; sourceTree = "<group>"; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
64328565291CDEF200FBE5C8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a"; sourceTree = "<group>"; };
64328567291CDEF200FBE5C8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64328568291CDEF200FBE5C8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; }; 6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; }; 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; }; 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
@@ -374,13 +384,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */,
6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */,
6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */,
5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */,
5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */,
5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */,
5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */,
6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -428,6 +438,7 @@
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */, 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */,
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */,
5CE4407127ADB1D0007B033A /* Emoji.swift */, 5CE4407127ADB1D0007B033A /* Emoji.swift */,
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
); );
path = Chat; path = Chat;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -435,11 +446,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
64328567291CDEF200FBE5C8 /* libffi.a */, 5C93293229241CD90090FFF9 /* libffi.a */,
64328568291CDEF200FBE5C8 /* libgmp.a */, 5C93293629241CDA0090FFF9 /* libgmp.a */,
64328565291CDEF200FBE5C8 /* libgmpxx.a */, 5C93293429241CD90090FFF9 /* libgmpxx.a */,
64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */, 5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */,
64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */, 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -571,6 +582,7 @@
5CB346E62868D76D001FD2EF /* NotificationsView.swift */, 5CB346E62868D76D001FD2EF /* NotificationsView.swift */,
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
5CADE79929211BB900072E13 /* PreferencesView.swift */,
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */, 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */,
5C05DF522840AA1D00C683F9 /* CallSettings.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
@@ -579,6 +591,8 @@
5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
640F50E227CF991C001E05C2 /* SMPServers.swift */, 640F50E227CF991C001E05C2 /* SMPServers.swift */,
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
5CB2084E28DA4B4800D024EC /* RTCServers.swift */, 5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */, 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */, 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
@@ -680,6 +694,7 @@
6440CA01288AEC770062C672 /* Group */ = { 6440CA01288AEC770062C672 /* Group */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */,
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */, 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */,
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */, 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */,
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */, 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */,
@@ -885,12 +900,15 @@
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */,
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */,
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
@@ -956,6 +974,7 @@
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */,
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
@@ -978,6 +997,7 @@
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+132 -5
View File
@@ -48,6 +48,7 @@ public enum ChatCommand {
case apiGetGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64)
case getUserSMPServers case getUserSMPServers
case setUserSMPServers(smpServers: [String]) case setUserSMPServers(smpServers: [String])
case testSMPServer(smpServer: String)
case apiSetChatItemTTL(seconds: Int64?) case apiSetChatItemTTL(seconds: Int64?)
case apiGetChatItemTTL case apiGetChatItemTTL
case apiSetNetworkConfig(networkConfig: NetCfg) case apiSetNetworkConfig(networkConfig: NetCfg)
@@ -63,6 +64,7 @@ public enum ChatCommand {
case apiClearChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64)
case listContacts case listContacts
case apiUpdateProfile(profile: Profile) case apiUpdateProfile(profile: Profile)
case apiSetContactPrefs(contactId: Int64, preferences: Preferences)
case apiSetContactAlias(contactId: Int64, localAlias: String) case apiSetContactAlias(contactId: Int64, localAlias: String)
case apiSetConnectionAlias(connId: Int64, localAlias: String) case apiSetConnectionAlias(connId: Int64, localAlias: String)
case createMyAddress case createMyAddress
@@ -124,8 +126,9 @@ public enum ChatCommand {
case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)" case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)"
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case .getUserSMPServers: return "/smp_servers" case .getUserSMPServers: return "/smp"
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case let .setUserSMPServers(smpServers): return "/smp \(smpServersStr(smpServers: smpServers))"
case let .testSMPServer(smpServer): return "/smp test \(smpServer)"
case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))" case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))"
case .apiGetChatItemTTL: return "/ttl" case .apiGetChatItemTTL: return "/ttl"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
@@ -141,6 +144,7 @@ public enum ChatCommand {
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case .listContacts: return "/contacts" case .listContacts: return "/contacts"
case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
case .createMyAddress: return "/address" case .createMyAddress: return "/address"
@@ -203,6 +207,7 @@ public enum ChatCommand {
case .apiGetGroupLink: return "apiGetGroupLink" case .apiGetGroupLink: return "apiGetGroupLink"
case .getUserSMPServers: return "getUserSMPServers" case .getUserSMPServers: return "getUserSMPServers"
case .setUserSMPServers: return "setUserSMPServers" case .setUserSMPServers: return "setUserSMPServers"
case .testSMPServer: return "testSMPServer"
case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiSetChatItemTTL: return "apiSetChatItemTTL"
case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL"
case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiSetNetworkConfig: return "apiSetNetworkConfig"
@@ -218,6 +223,7 @@ public enum ChatCommand {
case .apiClearChat: return "apiClearChat" case .apiClearChat: return "apiClearChat"
case .listContacts: return "listContacts" case .listContacts: return "listContacts"
case .apiUpdateProfile: return "apiUpdateProfile" case .apiUpdateProfile: return "apiUpdateProfile"
case .apiSetContactPrefs: return "apiSetContactPrefs"
case .apiSetContactAlias: return "apiSetContactAlias" case .apiSetContactAlias: return "apiSetContactAlias"
case .apiSetConnectionAlias: return "apiSetConnectionAlias" case .apiSetConnectionAlias: return "apiSetConnectionAlias"
case .createMyAddress: return "createMyAddress" case .createMyAddress: return "createMyAddress"
@@ -288,7 +294,8 @@ public enum ChatResponse: Decodable, Error {
case chatSuspended case chatSuspended
case apiChats(chats: [ChatData]) case apiChats(chats: [ChatData])
case apiChat(chat: ChatData) case apiChat(chat: ChatData)
case userSMPServers(smpServers: [String]) case userSMPServers(smpServers: [ServerCfg], presetSMPServers: [String])
case sMPTestResult(smpTestFailure: SMPTestFailure?)
case chatItemTTL(chatItemTTL: Int64?) case chatItemTTL(chatItemTTL: Int64?)
case networkConfig(networkConfig: NetCfg) case networkConfig(networkConfig: NetCfg)
case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
@@ -303,6 +310,7 @@ public enum ChatResponse: Decodable, Error {
case userProfileUpdated(fromProfile: Profile, toProfile: Profile) case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
case contactAliasUpdated(toContact: Contact) case contactAliasUpdated(toContact: Contact)
case connectionAliasUpdated(toConnection: PendingContactConnection) case connectionAliasUpdated(toConnection: PendingContactConnection)
case contactPrefsUpdated(fromContact: Contact, toContact: Contact)
case userContactLink(contactLink: UserContactLink) case userContactLink(contactLink: UserContactLink)
case userContactLinkUpdated(contactLink: UserContactLink) case userContactLinkUpdated(contactLink: UserContactLink)
case userContactLinkCreated(connReqContact: String) case userContactLinkCreated(connReqContact: String)
@@ -390,6 +398,7 @@ public enum ChatResponse: Decodable, Error {
case .apiChats: return "apiChats" case .apiChats: return "apiChats"
case .apiChat: return "apiChat" case .apiChat: return "apiChat"
case .userSMPServers: return "userSMPServers" case .userSMPServers: return "userSMPServers"
case .sMPTestResult: return "smpTestResult"
case .chatItemTTL: return "chatItemTTL" case .chatItemTTL: return "chatItemTTL"
case .networkConfig: return "networkConfig" case .networkConfig: return "networkConfig"
case .contactInfo: return "contactInfo" case .contactInfo: return "contactInfo"
@@ -404,6 +413,7 @@ public enum ChatResponse: Decodable, Error {
case .userProfileUpdated: return "userProfileUpdated" case .userProfileUpdated: return "userProfileUpdated"
case .contactAliasUpdated: return "contactAliasUpdated" case .contactAliasUpdated: return "contactAliasUpdated"
case .connectionAliasUpdated: return "connectionAliasUpdated" case .connectionAliasUpdated: return "connectionAliasUpdated"
case .contactPrefsUpdated: return "contactPrefsUpdated"
case .userContactLink: return "userContactLink" case .userContactLink: return "userContactLink"
case .userContactLinkUpdated: return "userContactLinkUpdated" case .userContactLinkUpdated: return "userContactLinkUpdated"
case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkCreated: return "userContactLinkCreated"
@@ -490,7 +500,8 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return noDetails case .chatSuspended: return noDetails
case let .apiChats(chats): return String(describing: chats) case let .apiChats(chats): return String(describing: chats)
case let .apiChat(chat): return String(describing: chat) case let .apiChat(chat): return String(describing: chat)
case let .userSMPServers(smpServers): return String(describing: smpServers) case let .userSMPServers(smpServers, _): return String(describing: smpServers)
case let .sMPTestResult(smpTestFailure): return String(describing: smpTestFailure)
case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL) case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL)
case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .networkConfig(networkConfig): return String(describing: networkConfig)
case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))" case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))"
@@ -505,6 +516,7 @@ public enum ChatResponse: Decodable, Error {
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
case let .contactAliasUpdated(toContact): return String(describing: toContact) case let .contactAliasUpdated(toContact): return String(describing: toContact)
case let .connectionAliasUpdated(toConnection): return String(describing: toConnection) case let .connectionAliasUpdated(toConnection): return String(describing: toConnection)
case let .contactPrefsUpdated(fromContact, toContact): return "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))"
case let .userContactLink(contactLink): return contactLink.responseDetails case let .userContactLink(contactLink): return contactLink.responseDetails
case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails
case let .userContactLinkCreated(connReq): return connReq case let .userContactLinkCreated(connReq): return connReq
@@ -613,7 +625,7 @@ public struct ArchiveConfig: Encodable {
} }
} }
public struct DBEncryptionConfig: Encodable { public struct DBEncryptionConfig: Codable {
public init(currentKey: String, newKey: String) { public init(currentKey: String, newKey: String) {
self.currentKey = currentKey self.currentKey = currentKey
self.newKey = newKey self.newKey = newKey
@@ -623,6 +635,121 @@ public struct DBEncryptionConfig: Encodable {
public var newKey: String public var newKey: String
} }
public struct ServerCfg: Identifiable, Decodable {
public var server: String
public var preset: Bool
public var tested: Bool?
public var enabled: Bool
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
// Even if we don't see the use case, it's probably better to allow it in the model
// In any case, "trusted/known" servers are out of scope of this change
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
self.server = server
self.preset = preset
self.tested = tested
self.enabled = enabled
}
public var id: String { server }
public static var empty = ServerCfg(server: "", preset: false, tested: false, enabled: true)
public struct SampleData {
public var preset: ServerCfg
public var custom: ServerCfg
public var untested: ServerCfg
}
public static var sampleData = SampleData(
preset: ServerCfg(
server: "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
preset: true,
tested: true,
enabled: true
),
custom: ServerCfg(
server: "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
preset: false,
tested: false,
enabled: false
),
untested: ServerCfg(
server: "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
preset: false,
tested: nil,
enabled: true
)
)
}
public enum SMPTestStep: String, Decodable {
case connect
case createQueue
case secureQueue
case deleteQueue
case disconnect
var text: String {
switch self {
case .connect: return NSLocalizedString("Connect", comment: "server test step")
case .createQueue: return NSLocalizedString("Create queue", comment: "server test step")
case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step")
case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step")
case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step")
}
}
}
public struct SMPTestFailure: Decodable, Error {
var testStep: SMPTestStep
var testError: AgentErrorType
var localizedDescription: String {
let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@", comment: "server test failure"), testStep.text)
switch testError {
case .SMP(.AUTH):
return err + "," + NSLocalizedString("Server requires authentication to create queues, check password", comment: "server test error")
case .BROKER(.NETWORK):
return err + "," + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error")
default:
return err
}
}
}
public struct ServerAddress {
public var hostnames: [String]
public var port: String
public var keyHash: String
public var basicAuth: String
public init(hostnames: [String], port: String, keyHash: String, basicAuth: String = "") {
self.hostnames = hostnames
self.port = port
self.keyHash = keyHash
self.basicAuth = basicAuth
}
public var uri: String {
"smp://\(keyHash)\(basicAuth == "" ? "" : ":" + basicAuth)@\(hostnames.joined(separator: ","))"
}
static public var empty = ServerAddress(
hostnames: [],
port: "",
keyHash: "",
basicAuth: ""
)
static public var sampleData = ServerAddress(
hostnames: ["smp.simplex.im", "1234.onion"],
port: "",
keyHash: "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
basicAuth: "server_password"
)
}
public struct NetCfg: Codable, Equatable { public struct NetCfg: Codable, Equatable {
public var socksProxy: String? = nil public var socksProxy: String? = nil
public var hostMode: HostMode = .publicHost public var hostMode: HostMode = .publicHost
+358 -3
View File
@@ -14,6 +14,7 @@ public struct User: Decodable, NamedChat {
var userContactId: Int64 var userContactId: Int64
var localDisplayName: ContactName var localDisplayName: ContactName
public var profile: LocalProfile public var profile: LocalProfile
public var fullPreferences: FullPreferences
var activeUser: Bool var activeUser: Bool
public var displayName: String { get { profile.displayName } } public var displayName: String { get { profile.displayName } }
@@ -26,6 +27,7 @@ public struct User: Decodable, NamedChat {
userContactId: 1, userContactId: 1,
localDisplayName: "alice", localDisplayName: "alice",
profile: LocalProfile.sampleData, profile: LocalProfile.sampleData,
fullPreferences: FullPreferences.sampleData,
activeUser: true activeUser: true
) )
} }
@@ -35,15 +37,17 @@ public typealias ContactName = String
public typealias GroupName = String public typealias GroupName = String
public struct Profile: Codable, NamedChat { public struct Profile: Codable, NamedChat {
public init(displayName: String, fullName: String, image: String? = nil) { public init(displayName: String, fullName: String, image: String? = nil, preferences: Preferences? = nil) {
self.displayName = displayName self.displayName = displayName
self.fullName = fullName self.fullName = fullName
self.image = image self.image = image
self.preferences = preferences
} }
public var displayName: String public var displayName: String
public var fullName: String public var fullName: String
public var image: String? public var image: String?
public var preferences: Preferences?
public var localAlias: String { get { "" } } public var localAlias: String { get { "" } }
var profileViewName: String { var profileViewName: String {
@@ -57,11 +61,12 @@ public struct Profile: Codable, NamedChat {
} }
public struct LocalProfile: Codable, NamedChat { public struct LocalProfile: Codable, NamedChat {
public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, localAlias: String) { public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, preferences: Preferences? = nil, localAlias: String) {
self.profileId = profileId self.profileId = profileId
self.displayName = displayName self.displayName = displayName
self.fullName = fullName self.fullName = fullName
self.image = image self.image = image
self.preferences = preferences
self.localAlias = localAlias self.localAlias = localAlias
} }
@@ -69,6 +74,7 @@ public struct LocalProfile: Codable, NamedChat {
public var displayName: String public var displayName: String
public var fullName: String public var fullName: String
public var image: String? public var image: String?
public var preferences: Preferences?
public var localAlias: String public var localAlias: String
var profileViewName: String { var profileViewName: String {
@@ -81,6 +87,7 @@ public struct LocalProfile: Codable, NamedChat {
profileId: 1, profileId: 1,
displayName: "alice", displayName: "alice",
fullName: "Alice", fullName: "Alice",
preferences: Preferences.sampleData,
localAlias: "" localAlias: ""
) )
} }
@@ -117,6 +124,344 @@ extension NamedChat {
public typealias ChatId = String public typealias ChatId = String
public struct FullPreferences: Decodable, Equatable {
public var fullDelete: Preference
public var voice: Preference
public init(fullDelete: Preference, voice: Preference) {
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = FullPreferences(fullDelete: Preference(allow: .no), voice: Preference(allow: .yes))
}
public struct Preferences: Codable {
public var fullDelete: Preference?
public var voice: Preference?
public init(fullDelete: Preference?, voice: Preference?) {
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = Preferences(fullDelete: Preference(allow: .no), voice: Preference(allow: .yes))
}
public func toPreferences(_ fullPreferences: FullPreferences) -> Preferences {
Preferences(fullDelete: fullPreferences.fullDelete, voice: fullPreferences.voice)
}
public struct Preference: Codable, Equatable {
public var allow: FeatureAllowed
public init(allow: FeatureAllowed) {
self.allow = allow
}
}
public struct ContactUserPreferences: Decodable {
public var fullDelete: ContactUserPreference
public var voice: ContactUserPreference
public init(fullDelete: ContactUserPreference, voice: ContactUserPreference) {
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = ContactUserPreferences(
fullDelete: ContactUserPreference(
enabled: FeatureEnabled(forUser: false, forContact: false),
userPreference: .user(preference: Preference(allow: .no)),
contactPreference: Preference(allow: .no)
),
voice: ContactUserPreference(
enabled: FeatureEnabled(forUser: true, forContact: true),
userPreference: .user(preference: Preference(allow: .yes)),
contactPreference: Preference(allow: .yes)
)
)
}
public struct ContactUserPreference: Decodable {
public var enabled: FeatureEnabled
public var userPreference: ContactUserPref
public var contactPreference: Preference
public init(enabled: FeatureEnabled, userPreference: ContactUserPref, contactPreference: Preference) {
self.enabled = enabled
self.userPreference = userPreference
self.contactPreference = contactPreference
}
}
public struct FeatureEnabled: Decodable {
public var forUser: Bool
public var forContact: Bool
public init(forUser: Bool, forContact: Bool) {
self.forUser = forUser
self.forContact = forContact
}
public static func enabled(user: Preference, contact: Preference) -> FeatureEnabled {
switch (user.allow, contact.allow) {
case (.always, .no): return FeatureEnabled(forUser: false, forContact: true)
case (.no, .always): return FeatureEnabled(forUser: true, forContact: false)
case (_, .no): return FeatureEnabled(forUser: false, forContact: false)
case (.no, _): return FeatureEnabled(forUser: false, forContact: false)
default: return FeatureEnabled(forUser: true, forContact: true)
}
}
}
public enum ContactUserPref: Decodable {
case contact(preference: Preference) // contact override is set
case user(preference: Preference) // global user default is used
}
public enum Feature {
case fullDelete
case voice
public var values: [Feature] { [.fullDelete, .voice] }
public var id: Self { self }
public var text: LocalizedStringKey {
switch self {
case .fullDelete: return "Full deletion"
case .voice: return "Voice messages"
}
}
public var icon: String {
switch self {
case .fullDelete: return "trash.slash"
case .voice: return "speaker.wave.2"
}
}
public func allowDescription(_ allowed: FeatureAllowed) -> LocalizedStringKey {
switch self {
case .fullDelete:
switch allowed {
case .always: return "Allow your contacts to irreversibly delete sent messages."
case .yes: return "Allow irreversible message deletion only if your contact allows it to you."
case .no: return "Contacts can mark messages for deletion; you will be able to view them."
}
case .voice:
switch allowed {
case .always: return "Allow your contacts to send voice messages."
case .yes: return "Allow voice messages only if your contact allows them."
case .no: return "Prohibit sending voice messages."
}
}
}
public func enabledDescription(_ enabled: FeatureEnabled) -> LocalizedStringKey {
switch self {
case .fullDelete:
return enabled.forUser && enabled.forContact
? "Both you and your contact can irreversibly delete sent messages."
: enabled.forUser
? "Only you can irreversibly delete messages (your contact can mark them for deletion)."
: enabled.forContact
? "Only your contact can irreversibly delete messages (you can mark them for deletion)."
: "Irreversible message deletion is prohibited in this chat."
case .voice:
return enabled.forUser && enabled.forContact
? "Both you and your contact can send voice messages."
: enabled.forUser
? "Only you can send voice messages."
: enabled.forContact
? "Only your contact can send voice messages."
: "Voice messages are prohibited in this chat."
}
}
public func enableGroupPrefDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool) -> LocalizedStringKey {
if canEdit {
switch self {
case .fullDelete:
switch enabled {
case .on: return "Allow to irreversibly delete sent messages."
case .off: return "Prohibit irreversible message deletion."
}
case .voice:
switch enabled {
case .on: return "Allow to send voice messages."
case .off: return "Prohibit sending voice messages."
}
}
} else {
switch self {
case .fullDelete:
switch enabled {
case .on: return "Group members can irreversibly delete sent messages."
case .off: return "Irreversible message deletion is prohibited in this chat."
}
case .voice:
switch enabled {
case .on: return "Group members can send voice messages."
case .off: return "Voice messages are prohibited in this chat."
}
}
}
}
}
public enum ContactFeatureAllowed: Identifiable, Hashable {
case userDefault(FeatureAllowed)
case always
case yes
case no
public static func values(_ def: FeatureAllowed) -> [ContactFeatureAllowed] {
[.userDefault(def) , .always, .yes, .no]
}
public var id: Self { self }
public var allowed: FeatureAllowed {
switch self {
case let .userDefault(def): return def
case .always: return .always
case .yes: return .yes
case .no: return .no
}
}
public var text: String {
switch self {
case let .userDefault(def): return String.localizedStringWithFormat(NSLocalizedString("default (%@)", comment: "pref value"), def.text)
case .always: return NSLocalizedString("always", comment: "pref value")
case .yes: return NSLocalizedString("yes", comment: "pref value")
case .no: return NSLocalizedString("no", comment: "pref value")
}
}
}
public struct ContactFeaturesAllowed: Equatable {
public var fullDelete: ContactFeatureAllowed
public var voice: ContactFeatureAllowed
public init(fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) {
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = ContactFeaturesAllowed(
fullDelete: ContactFeatureAllowed.userDefault(.no),
voice: ContactFeatureAllowed.userDefault(.yes)
)
}
public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactUserPreferences) -> ContactFeaturesAllowed {
ContactFeaturesAllowed(
fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
)
}
public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference) -> ContactFeatureAllowed {
switch contactUserPreference.userPreference {
case let .user(preference): return .userDefault(preference.allow)
case let .contact(preference):
switch preference.allow {
case .always: return .always
case .yes: return .yes
case .no: return .no
}
}
}
public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeaturesAllowed) -> Preferences {
Preferences(
fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
)
}
public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> Preference? {
switch contactFeatureAllowed {
case .userDefault: return nil
case .always: return Preference(allow: .always)
case .yes: return Preference(allow: .yes)
case .no: return Preference(allow: .no)
}
}
public enum FeatureAllowed: String, Codable, Identifiable {
case always
case yes
case no
public static var values: [FeatureAllowed] { [.always, .yes, .no] }
public var id: Self { self }
public var text: String {
switch self {
case .always: return NSLocalizedString("always", comment: "pref value")
case .yes: return NSLocalizedString("yes", comment: "pref value")
case .no: return NSLocalizedString("no", comment: "pref value")
}
}
}
public struct FullGroupPreferences: Decodable, Equatable {
public var fullDelete: GroupPreference
public var voice: GroupPreference
public init(fullDelete: GroupPreference, voice: GroupPreference) {
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = FullGroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
}
public struct GroupPreferences: Codable {
public var fullDelete: GroupPreference?
public var voice: GroupPreference?
public init(fullDelete: GroupPreference?, voice: GroupPreference?) {
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = GroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
}
public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> GroupPreferences {
GroupPreferences(fullDelete: fullPreferences.fullDelete, voice: fullPreferences.voice)
}
public struct GroupPreference: Codable, Equatable {
public var enable: GroupFeatureEnabled
public init(enable: GroupFeatureEnabled) {
self.enable = enable
}
}
public enum GroupFeatureEnabled: String, Codable, Identifiable {
case on
case off
public static var values: [GroupFeatureEnabled] { [.on, .off] }
public var id: Self { self }
public var text: String {
switch self {
case .on: return NSLocalizedString("on", comment: "group pref value")
case .off: return NSLocalizedString("off", comment: "group pref value")
}
}
}
public enum ChatInfo: Identifiable, Decodable, NamedChat { public enum ChatInfo: Identifiable, Decodable, NamedChat {
case direct(contact: Contact) case direct(contact: Contact)
case group(groupInfo: GroupInfo) case group(groupInfo: GroupInfo)
@@ -321,6 +666,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var activeConn: Connection public var activeConn: Connection
public var viaGroup: Int64? public var viaGroup: Int64?
public var chatSettings: ChatSettings public var chatSettings: ChatSettings
public var userPreferences: Preferences
public var mergedPreferences: ContactUserPreferences
var createdAt: Date var createdAt: Date
var updatedAt: Date var updatedAt: Date
@@ -351,6 +698,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
profile: LocalProfile.sampleData, profile: LocalProfile.sampleData,
activeConn: Connection.sampleData, activeConn: Connection.sampleData,
chatSettings: ChatSettings.defaults, chatSettings: ChatSettings.defaults,
userPreferences: Preferences.sampleData,
mergedPreferences: ContactUserPreferences.sampleData,
createdAt: .now, createdAt: .now,
updatedAt: .now updatedAt: .now
) )
@@ -556,6 +905,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
public var groupId: Int64 public var groupId: Int64
var localDisplayName: GroupName var localDisplayName: GroupName
public var groupProfile: GroupProfile public var groupProfile: GroupProfile
public var fullGroupPreferences: FullGroupPreferences
public var membership: GroupMember public var membership: GroupMember
public var hostConnCustomUserProfileId: Int64? public var hostConnCustomUserProfileId: Int64?
public var chatSettings: ChatSettings public var chatSettings: ChatSettings
@@ -587,6 +937,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
groupId: 1, groupId: 1,
localDisplayName: "team", localDisplayName: "team",
groupProfile: GroupProfile.sampleData, groupProfile: GroupProfile.sampleData,
fullGroupPreferences: FullGroupPreferences.sampleData,
membership: GroupMember.sampleData, membership: GroupMember.sampleData,
hostConnCustomUserProfileId: nil, hostConnCustomUserProfileId: nil,
chatSettings: ChatSettings.defaults, chatSettings: ChatSettings.defaults,
@@ -596,15 +947,17 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
} }
public struct GroupProfile: Codable, NamedChat { public struct GroupProfile: Codable, NamedChat {
public init(displayName: String, fullName: String, image: String? = nil) { public init(displayName: String, fullName: String, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
self.displayName = displayName self.displayName = displayName
self.fullName = fullName self.fullName = fullName
self.image = image self.image = image
self.groupPreferences = groupPreferences
} }
public var displayName: String public var displayName: String
public var fullName: String public var fullName: String
public var image: String? public var image: String?
public var groupPreferences: GroupPreferences?
public var localAlias: String { "" } public var localAlias: String { "" }
public static let sampleData = GroupProfile( public static let sampleData = GroupProfile(
@@ -1561,6 +1914,8 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
case seconds(_ seconds: Int64) case seconds(_ seconds: Int64)
case none case none
public static var values: [ChatItemTTL] { [.none, .month, .week, .day] }
public var id: Self { self } public var id: Self { self }
public init(_ seconds: Int64?) { public init(_ seconds: Int64?) {
+119 -5
View File
@@ -29,13 +29,13 @@
")" = ")"; ")" = ")";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Beitragen](https://github.com/simplex-chat/simplex-chat#contribute)"; "[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"[Send us email](mailto:chat@simplex.chat)" = "[Senden Sie uns eine E-Mail](mailto:chat@simplex.chat)"; "[Send us email](mailto:chat@simplex.chat)" = "[Senden Sie uns eine E-Mail](mailto:chat@simplex.chat)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub](https://github.com/simplex-chat/simplex-chat)"; "[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"**Add new contact**: to create your one-time QR Code for your contact." = "**Fügen Sie einen neuen Kontakt hinzu**: Erzeugen Sie einen Einmal-QR-Code oder -Link für Ihren Kontakt."; "**Add new contact**: to create your one-time QR Code for your contact." = "**Fügen Sie einen neuen Kontakt hinzu**: Erzeugen Sie einen Einmal-QR-Code oder -Link für Ihren Kontakt.";
@@ -188,9 +188,30 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"All your contacts will remain connected" = "Alle Ihre Kontakte bleiben verbunden."; "All your contacts will remain connected" = "Alle Ihre Kontakte bleiben verbunden.";
/* No comment provided by engineer. */
"Allow irreversible message deletion only if your contact allows it to you." = "***Allow irreversible message deletion only if your contact allows it to you.";
/* No comment provided by engineer. */
"Allow to irreversibly delete sent messages." = "***Allow to irreversibly delete sent messages.";
/* No comment provided by engineer. */
"Allow to send voice messages." = "***Allow to send voice messages.";
/* No comment provided by engineer. */
"Allow voice messages only if your contact allows them." = "***Allow voice messages only if your contact allows them.";
/* No comment provided by engineer. */
"Allow your contacts to irreversibly delete sent messages." = "***Allow your contacts to irreversibly delete sent messages.";
/* No comment provided by engineer. */
"Allow your contacts to send voice messages." = "***Allow your contacts to send voice messages.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Already connected?" = "Sind Sie bereits verbunden?"; "Already connected?" = "Sind Sie bereits verbunden?";
/* pref value */
"always" = "***always";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Answer call" = "Anruf annehmen"; "Answer call" = "Anruf annehmen";
@@ -230,6 +251,12 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"bold" = "fett"; "bold" = "fett";
/* No comment provided by engineer. */
"Both you and your contact can irreversibly delete sent messages." = "***Both you and your contact can irreversibly delete sent messages.";
/* No comment provided by engineer. */
"Both you and your contact can send voice messages." = "***Both you and your contact can send voice messages.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Call already ended!" = "Anruf ist bereits beendet!"; "Call already ended!" = "Anruf ist bereits beendet!";
@@ -314,6 +341,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Chat is stopped" = "Der Chat ist beendet"; "Chat is stopped" = "Der Chat ist beendet";
/* No comment provided by engineer. */
"Chat preferences" = "***Chat preferences";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Chats" = "Chats"; "Chats" = "Chats";
@@ -431,6 +461,9 @@
/* connection information */ /* connection information */
"connection:%@" = "Verbindung:%@"; "connection:%@" = "Verbindung:%@";
/* No comment provided by engineer. */
"Contact allows" = "***Contact allows";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Contact already exists" = "Der Kontakt ist bereits vorhanden"; "Contact already exists" = "Der Kontakt ist bereits vorhanden";
@@ -455,9 +488,15 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Contact name" = "Kontaktname"; "Contact name" = "Kontaktname";
/* No comment provided by engineer. */
"Contact preferences" = "***Contact preferences";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Contact requests" = "Kontaktanfragen"; "Contact requests" = "Kontaktanfragen";
/* No comment provided by engineer. */
"Contacts can mark messages for deletion; you will be able to view them." = "***Contacts can mark messages for deletion; you will be able to view them.";
/* chat item action */ /* chat item action */
"Copy" = "Kopieren"; "Copy" = "Kopieren";
@@ -542,6 +581,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Decentralized" = "Dezentral"; "Decentralized" = "Dezentral";
/* pref value */
"default (%@)" = "***default (%@)";
/* chat item action */ /* chat item action */
"Delete" = "Löschen"; "Delete" = "Löschen";
@@ -857,6 +899,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"For console" = "Für Konsole"; "For console" = "Für Konsole";
/* No comment provided by engineer. */
"Full deletion" = "***Full deletion";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Full name (optional)" = "Vollständiger Name (optional)"; "Full name (optional)" = "Vollständiger Name (optional)";
@@ -890,9 +935,18 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Group link" = "Gruppen-Link"; "Group link" = "Gruppen-Link";
/* No comment provided by engineer. */
"Group members can irreversibly delete sent messages." = "***Group members can irreversibly delete sent messages.";
/* No comment provided by engineer. */
"Group members can send voice messages." = "***Group members can send voice messages.";
/* notification */ /* notification */
"Group message:" = "Grppennachricht:"; "Group message:" = "Grppennachricht:";
/* No comment provided by engineer. */
"Group preferences" = "***Group preferences";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Group profile is stored on members' devices, not on the servers." = "Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern."; "Group profile is stored on members' devices, not on the servers." = "Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.";
@@ -1034,6 +1088,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Für die sichere Speicherung des Passworts nach dem Neustart der App und dem Wechsel des Passworts wird der iOS Schlüsselbund verwendet - dies erlaubt den Empfang von Push-Benachrichtigungen."; "iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Für die sichere Speicherung des Passworts nach dem Neustart der App und dem Wechsel des Passworts wird der iOS Schlüsselbund verwendet - dies erlaubt den Empfang von Push-Benachrichtigungen.";
/* No comment provided by engineer. */
"Irreversible message deletion is prohibited in this chat." = "***Irreversible message deletion is prohibited in this chat.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen."; "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.";
@@ -1193,6 +1250,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"New passphrase…" = "Neues Passwort…"; "New passphrase…" = "Neues Passwort…";
/* pref value */
"no" = "***no";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"No" = "Nein"; "No" = "Nein";
@@ -1220,6 +1280,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!"; "Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!";
/* group pref value */
"off" = "***off";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Off (Local)" = "Aus (Lokal)"; "Off (Local)" = "Aus (Lokal)";
@@ -1232,6 +1295,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Old database archive" = "Altes Datenbankarchiv"; "Old database archive" = "Altes Datenbankarchiv";
/* group pref value */
"on" = "***on";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"One-time invitation link" = "Einmal-Einladungslink"; "One-time invitation link" = "Einmal-Einladungslink";
@@ -1247,6 +1313,21 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden."; "Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.";
/* No comment provided by engineer. */
"Only group owners can change group preferences." = "***Only group owners can change group preferences.";
/* No comment provided by engineer. */
"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "***Only you can irreversibly delete messages (your contact can mark them for deletion).";
/* No comment provided by engineer. */
"Only you can send voice messages." = "***Only you can send voice messages.";
/* No comment provided by engineer. */
"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "***Only your contact can irreversibly delete messages (you can mark them for deletion).";
/* No comment provided by engineer. */
"Only your contact can send voice messages." = "***Only your contact can send voice messages.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Open chat" = "Chat öffnen"; "Open chat" = "Chat öffnen";
@@ -1310,6 +1391,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren."; "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.";
/* No comment provided by engineer. */
"Preferences" = "***Preferences";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Privacy & security" = "Datenschutz & Sicherheit"; "Privacy & security" = "Datenschutz & Sicherheit";
@@ -1319,6 +1403,12 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Profile image" = "Profilbild"; "Profile image" = "Profilbild";
/* No comment provided by engineer. */
"Prohibit irreversible message deletion." = "***Prohibit irreversible message deletion.";
/* No comment provided by engineer. */
"Prohibit sending voice messages." = "***Prohibit sending voice messages.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Protocol timeout" = "Protokollzeitüberschreitung"; "Protocol timeout" = "Protokollzeitüberschreitung";
@@ -1326,7 +1416,7 @@
"Push notifications" = "Push-Benachrichtigungen"; "Push notifications" = "Push-Benachrichtigungen";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Rate the app" = "Bewerte die App"; "Rate the app" = "Bewerten Sie die App";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Read" = "Lesen"; "Read" = "Lesen";
@@ -1394,6 +1484,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Required" = "Erforderlich"; "Required" = "Erforderlich";
/* No comment provided by engineer. */
"Reset" = "***Reset";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Reset colors" = "Farben zurücksetzen"; "Reset colors" = "Farben zurücksetzen";
@@ -1430,9 +1523,15 @@
/* chat item action */ /* chat item action */
"Save" = "Speichern"; "Save" = "Speichern";
/* No comment provided by engineer. */
"Save (and notify contact)" = "***Save (and notify contact)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)"; "Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)";
/* No comment provided by engineer. */
"Save (and notify group members)" = "***Save (and notify group members)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Save archive" = "Archiv speichern"; "Save archive" = "Archiv speichern";
@@ -1578,7 +1677,7 @@
"strike" = "durchstreichen"; "strike" = "durchstreichen";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Support SimpleX Chat" = "Unterstützen SimpleX Chat"; "Support SimpleX Chat" = "Unterstützung von SimpleX Chat";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"System" = "System"; "System" = "System";
@@ -1665,7 +1764,7 @@
"this contact" = "Dieser Kontakt"; "this contact" = "Dieser Kontakt";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member)." = "Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können."; "This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed please check that you can still receive messages from this contact (or group member)." = "Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"This group no longer exists." = "Diese Gruppe existiert nicht mehr."; "This group no longer exists." = "Diese Gruppe existiert nicht mehr.";
@@ -1793,6 +1892,12 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"video call (not e2e encrypted)" = "Videoanruf (nicht E2E verschlüsselt)"; "video call (not e2e encrypted)" = "Videoanruf (nicht E2E verschlüsselt)";
/* No comment provided by engineer. */
"Voice messages" = "***Voice messages";
/* No comment provided by engineer. */
"Voice messages are prohibited in this chat." = "***Voice messages are prohibited in this chat.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"waiting for answer…" = "Warten auf Antwort…"; "waiting for answer…" = "Warten auf Antwort…";
@@ -1829,12 +1934,18 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Wrong passphrase!" = "Falsches Passwort!"; "Wrong passphrase!" = "Falsches Passwort!";
/* pref value */
"yes" = "***yes";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"You" = "Meine Daten"; "You" = "Meine Daten";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"You accepted connection" = "Sie haben die Verbindung akzeptiert"; "You accepted connection" = "Sie haben die Verbindung akzeptiert";
/* No comment provided by engineer. */
"You allow" = "***You allow";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"You are already connected to %@." = "Sie sind bereits mit %@ verbunden."; "You are already connected to %@." = "Sie sind bereits mit %@ verbunden.";
@@ -1988,6 +2099,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Your ICE servers" = "Ihre ICE-Server"; "Your ICE servers" = "Ihre ICE-Server";
/* No comment provided by engineer. */
"Your preferences" = "***Your preferences";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Your privacy" = "Meine Privatsphäre"; "Your privacy" = "Meine Privatsphäre";
+114
View File
@@ -188,9 +188,30 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся."; "All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся.";
/* No comment provided by engineer. */
"Allow irreversible message deletion only if your contact allows it to you." = "Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.";
/* No comment provided by engineer. */
"Allow to irreversibly delete sent messages." = "Разрешить необратимо удалять отправленные сообщения.";
/* No comment provided by engineer. */
"Allow to send voice messages." = "Разрешить отправлять голосовые сообщения.";
/* No comment provided by engineer. */
"Allow voice messages only if your contact allows them." = "Разрешить голосовые сообщения, только если их разрешает ваш контакт.";
/* No comment provided by engineer. */
"Allow your contacts to irreversibly delete sent messages." = "Разрешить вашим контактам необратимо удалять отправленные сообщения.";
/* No comment provided by engineer. */
"Allow your contacts to send voice messages." = "Разрешить вашим контактам отправлять голосовые сообщения.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Already connected?" = "Соединение уже установлено?"; "Already connected?" = "Соединение уже установлено?";
/* pref value */
"always" = "всегда";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Answer call" = "Принять звонок"; "Answer call" = "Принять звонок";
@@ -230,6 +251,12 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"bold" = "жирный"; "bold" = "жирный";
/* No comment provided by engineer. */
"Both you and your contact can irreversibly delete sent messages." = "Вы и ваш контакт можете необратимо удалять отправленные сообщения.";
/* No comment provided by engineer. */
"Both you and your contact can send voice messages." = "Вы и ваш контакт можете отправлять голосовые сообщения.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Call already ended!" = "Звонок уже завершен!"; "Call already ended!" = "Звонок уже завершен!";
@@ -314,6 +341,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Chat is stopped" = "Чат остановлен"; "Chat is stopped" = "Чат остановлен";
/* No comment provided by engineer. */
"Chat preferences" = "Предпочтения";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Chats" = "Чаты"; "Chats" = "Чаты";
@@ -431,6 +461,9 @@
/* connection information */ /* connection information */
"connection:%@" = "connection:%@"; "connection:%@" = "connection:%@";
/* No comment provided by engineer. */
"Contact allows" = "Контакт разрешает";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Contact already exists" = "Существующий контакт"; "Contact already exists" = "Существующий контакт";
@@ -455,9 +488,15 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Contact name" = "Имена контактов"; "Contact name" = "Имена контактов";
/* No comment provided by engineer. */
"Contact preferences" = "Предпочтения контакта";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Contact requests" = "Запросы контактов"; "Contact requests" = "Запросы контактов";
/* No comment provided by engineer. */
"Contacts can mark messages for deletion; you will be able to view them." = "Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.";
/* chat item action */ /* chat item action */
"Copy" = "Скопировать"; "Copy" = "Скопировать";
@@ -542,6 +581,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Decentralized" = "Децентрализованный"; "Decentralized" = "Децентрализованный";
/* pref value */
"default (%@)" = "по умолчанию (%@)";
/* chat item action */ /* chat item action */
"Delete" = "Удалить"; "Delete" = "Удалить";
@@ -857,6 +899,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"For console" = "Для консоли"; "For console" = "Для консоли";
/* No comment provided by engineer. */
"Full deletion" = "Полное удаление";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Full name (optional)" = "Полное имя (не обязательно)"; "Full name (optional)" = "Полное имя (не обязательно)";
@@ -890,9 +935,18 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Group link" = "Ссылка группы"; "Group link" = "Ссылка группы";
/* No comment provided by engineer. */
"Group members can irreversibly delete sent messages." = "Члены группы могут необратимо удалять отправленные сообщения.";
/* No comment provided by engineer. */
"Group members can send voice messages." = "Члены группы могут отправлять голосовые сообщения.";
/* notification */ /* notification */
"Group message:" = "Групповое сообщение:"; "Group message:" = "Групповое сообщение:";
/* No comment provided by engineer. */
"Group preferences" = "Предпочтения группы";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах."; "Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах.";
@@ -1034,6 +1088,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления."; "iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления.";
/* No comment provided by engineer. */
"Irreversible message deletion is prohibited in this chat." = "Необратимое удаление сообщений запрещено в этом чате.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя."; "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.";
@@ -1193,6 +1250,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"New passphrase…" = "Новый пароль…"; "New passphrase…" = "Новый пароль…";
/* pref value */
"no" = "нет";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"No" = "Нет"; "No" = "Нет";
@@ -1220,6 +1280,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Notifications are disabled!" = "Уведомления выключены"; "Notifications are disabled!" = "Уведомления выключены";
/* group pref value */
"off" = "нет";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Off (Local)" = "Выключить (Локальные)"; "Off (Local)" = "Выключить (Локальные)";
@@ -1232,6 +1295,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Old database archive" = "Старый архив чата"; "Old database archive" = "Старый архив чата";
/* group pref value */
"on" = "да";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"One-time invitation link" = "Одноразовая ссылка"; "One-time invitation link" = "Одноразовая ссылка";
@@ -1247,6 +1313,21 @@
/* No comment provided by engineer. */ /* 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 шифрованием**"; "Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**";
/* No comment provided by engineer. */
"Only group owners can change group preferences." = "Только владельцы группы могут изменять предпочтения группы.";
/* No comment provided by engineer. */
"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).";
/* No comment provided by engineer. */
"Only you can send voice messages." = "Только вы можете отправлять голосовые сообщения.";
/* No comment provided by engineer. */
"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).";
/* No comment provided by engineer. */
"Only your contact can send voice messages." = "Только ваш контакт может отправлять голосовые сообщения.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Open chat" = "Открыть чат"; "Open chat" = "Открыть чат";
@@ -1310,6 +1391,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете."; "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.";
/* No comment provided by engineer. */
"Preferences" = "Предпочтения";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Privacy & security" = "Конфиденциальность"; "Privacy & security" = "Конфиденциальность";
@@ -1319,6 +1403,12 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Profile image" = "Аватар"; "Profile image" = "Аватар";
/* No comment provided by engineer. */
"Prohibit irreversible message deletion." = "Запретить необратимое удаление сообщений.";
/* No comment provided by engineer. */
"Prohibit sending voice messages." = "Запретить отправлять голосовые сообщений.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Protocol timeout" = "Таймаут протокола"; "Protocol timeout" = "Таймаут протокола";
@@ -1394,6 +1484,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Required" = "Обязательно"; "Required" = "Обязательно";
/* No comment provided by engineer. */
"Reset" = "Сбросить";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Reset colors" = "Сбросить цвета"; "Reset colors" = "Сбросить цвета";
@@ -1430,9 +1523,15 @@
/* chat item action */ /* chat item action */
"Save" = "Сохранить"; "Save" = "Сохранить";
/* No comment provided by engineer. */
"Save (and notify contact)" = "Сохранить (и уведомить контакт)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Save (and notify contacts)" = "Сохранить (и уведомить контакты)"; "Save (and notify contacts)" = "Сохранить (и уведомить контакты)";
/* No comment provided by engineer. */
"Save (and notify group members)" = "Сохранить (и уведомить членов группы)";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Save archive" = "Сохранить архив"; "Save archive" = "Сохранить архив";
@@ -1793,6 +1892,12 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)"; "video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)";
/* No comment provided by engineer. */
"Voice messages" = "Голосовые сообщения";
/* No comment provided by engineer. */
"Voice messages are prohibited in this chat." = "Голосовые сообщения запрещены в этом чате.";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"waiting for answer…" = "ожидается ответ…"; "waiting for answer…" = "ожидается ответ…";
@@ -1829,12 +1934,18 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Wrong passphrase!" = "Неправильный пароль!"; "Wrong passphrase!" = "Неправильный пароль!";
/* pref value */
"yes" = "да";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"You" = "Вы"; "You" = "Вы";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"You accepted connection" = "Вы приняли приглашение соединиться"; "You accepted connection" = "Вы приняли приглашение соединиться";
/* No comment provided by engineer. */
"You allow" = "Вы разрешаете";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"You are already connected to %@." = "Вы уже соединены с контактом %@."; "You are already connected to %@." = "Вы уже соединены с контактом %@.";
@@ -1988,6 +2099,9 @@
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Your ICE servers" = "Ваши ICE серверы"; "Your ICE servers" = "Ваши ICE серверы";
/* No comment provided by engineer. */
"Your preferences" = "Ваши предпочтения";
/* No comment provided by engineer. */ /* No comment provided by engineer. */
"Your privacy" = "Конфиденциальность"; "Your privacy" = "Конфиденциальность";
+3 -1
View File
@@ -3,6 +3,7 @@
module Main where module Main where
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Data.Time.Clock (getCurrentTime)
import Server import Server
import Simplex.Chat.Controller (versionNumber) import Simplex.Chat.Controller (versionNumber)
import Simplex.Chat.Core import Simplex.Chat.Core
@@ -27,7 +28,8 @@ main = do
simplexChatTerminal terminalChatConfig opts t simplexChatTerminal terminalChatConfig opts t
else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do
r <- sendChatCmd cc chatCmd r <- sendChatCmd cc chatCmd
putStrLn $ serializeChatResponse (Just user) r ts <- getCurrentTime
putStrLn $ serializeChatResponse (Just user) ts r
threadDelay $ chatCmdDelay opts * 1000000 threadDelay $ chatCmdDelay opts * 1000000
welcome :: ChatOpts -> IO () welcome :: ChatOpts -> IO ()
+1 -1
View File
@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package source-repository-package
type: git type: git
location: https://github.com/simplex-chat/simplexmq.git location: https://github.com/simplex-chat/simplexmq.git
tag: d2b88a1baa390ec64b6535e32ce69f26f53f4d7a tag: c2342cba057fa2333b5936a2254507b5b62e8de2
source-repository-package source-repository-package
type: git type: git
+22 -14
View File
@@ -85,29 +85,37 @@ move <binary> %APPDATA%/local/bin/simplex-chat.exe
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs): On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
```shell ```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat cd simplex-chat
$ git checkout stable git checkout stable
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
``` ```
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)). > **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.7-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
#### Using Haskell stack #### In any OS
Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/): 1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 8.10.7 and cabal:
```shell ```shell
curl -sSL https://get.haskellstack.org/ | sh curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
``` ```
and build the project: 2. Build the project:
```shell ```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat cd simplex-chat
$ git checkout stable git checkout stable
$ stack install # on Linux
apt-get update && apt-get install -y build-essential libgmp3-dev zlib1g-dev
cp scripts/cabal.project.local.linux cabal.project.local
# or on MacOS:
# brew install openssl@1.1
# cp scripts/cabal.project.local.mac cabal.project.local
# you may need to amend cabal.project.local to point to the actual openssl location
cabal update
cabal install
``` ```
## Usage ## Usage
@@ -140,7 +148,7 @@ You can still talk to people using default or any other server - it only affects
Run `simplex-chat -h` to see all available options. Run `simplex-chat -h` to see all available options.
### Access messaging servers via Tor (BETA) ### Access messaging servers via Tor
Install Tor and run it as SOCKS5 proxy on port 9050, e.g. on Mac you can: Install Tor and run it as SOCKS5 proxy on port 9050, e.g. on Mac you can:
+10 -7
View File
@@ -5,12 +5,15 @@
Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode: Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode:
``` ```
ignore-project: False cp scripts/cabal.project.local.mac cabal.project.local
# or
package direct-sqlcipher # cp scripts/cabal.project.local.linux cabal.project.local
extra-include-dirs: /opt/homebrew/opt/openssl@3/include
extra-lib-dirs: /opt/homebrew/opt/openssl@3/lib
flags: +openssl
``` ```
OpenSSL can be installed with `brew install openssl` ## OpenSSL on MacOS
MacOS comes with LibreSSL as default, OpenSSL must be installed to compile SimpleX from source.
OpenSSL can be installed with `brew install openssl@1.1`
You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order to have things working properly
@@ -1,6 +1,6 @@
{ {
"name": "simplex-chat", "name": "simplex-chat",
"version": "0.1.0", "version": "0.1.1",
"description": "SimpleX Chat client", "description": "SimpleX Chat client",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -249,7 +249,7 @@ export class ChatClient {
const r = await this.sendChatCommand({type: "showMyAddress"}) const r = await this.sendChatCommand({type: "showMyAddress"})
switch (r.type) { switch (r.type) {
case "userContactLink": case "userContactLink":
return r.connReqContact return r.contactLink.connReqContact
default: default:
if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") { if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") {
return undefined return undefined
@@ -280,8 +280,7 @@ export interface CRCmdOk extends CR {
export interface CRUserContactLink extends CR { export interface CRUserContactLink extends CR {
type: "userContactLink" type: "userContactLink"
connReqContact: string contactLink: UserContactLink
autoAccept: boolean
} }
export interface CRUserContactLinkUpdated extends CR { export interface CRUserContactLinkUpdated extends CR {
@@ -913,6 +912,16 @@ interface FileTransferMeta {
cancelled: boolean cancelled: boolean
} }
interface UserContactLink {
connReqContact: string
autoAccept?: AutoAccept
}
interface AutoAccept {
acceptIncognito: boolean
autoReply?: MsgContent
}
export interface ChatStats { export interface ChatStats {
unreadCount: number unreadCount: number
minUnreadItemId: number minUnreadItemId: number
+9
View File
@@ -0,0 +1,9 @@
ignore-project: False
# amend to point to the actual openssl location
package direct-sqlcipher
extra-include-dirs: /usr/local/opt/openssl@1.1/include
extra-lib-dirs: /usr/local/opt/openssl@1.1/lib
flags: +openssl
test-show-details: direct
+9
View File
@@ -0,0 +1,9 @@
ignore-project: False
# amend to point to the actual openssl location
package direct-sqlcipher
extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include
extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib
flags: +openssl
test-show-details: direct
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"https://github.com/simplex-chat/simplexmq.git"."d2b88a1baa390ec64b6535e32ce69f26f53f4d7a" = "1ijmhi9srkyq43aflsgx38hfir3q3q5d9xlq13g1sdh43i4wmyvk"; "https://github.com/simplex-chat/simplexmq.git"."c2342cba057fa2333b5936a2254507b5b62e8de2" = "0fsi4lgq5x3dgy79g85s7isg3387ppwrqm4v8dndixlxn8cx3pyp";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
+2
View File
@@ -61,6 +61,8 @@ library
Simplex.Chat.Migrations.M20221024_contact_used Simplex.Chat.Migrations.M20221024_contact_used
Simplex.Chat.Migrations.M20221025_chat_settings Simplex.Chat.Migrations.M20221025_chat_settings
Simplex.Chat.Migrations.M20221029_group_link_id Simplex.Chat.Migrations.M20221029_group_link_id
Simplex.Chat.Migrations.M20221112_server_password
Simplex.Chat.Migrations.M20221115_server_cfg
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Options Simplex.Chat.Options
Simplex.Chat.ProfileGenerator Simplex.Chat.ProfileGenerator
+98 -72
View File
@@ -105,7 +105,7 @@ defaultChatConfig =
testView = False testView = False
} }
_defaultSMPServers :: NonEmpty SMPServer _defaultSMPServers :: NonEmpty SMPServerWithAuth
_defaultSMPServers = _defaultSMPServers =
L.fromList L.fromList
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
@@ -133,13 +133,14 @@ createChatDatabase filePrefix key yesToMigrations = do
newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController
newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {smpServers, networkConfig, logConnections, logServerHosts} sendToast = do newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {smpServers, networkConfig, logConnections, logServerHosts} sendToast = do
let config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts} servers <- resolveServers defaultServers
let servers' = servers {netCfg = networkConfig}
config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = servers'}
sendNotification = fromMaybe (const $ pure ()) sendToast sendNotification = fromMaybe (const $ pure ()) sendToast
firstTime = dbNew chatStore firstTime = dbNew chatStore
activeTo <- newTVarIO ActiveNone activeTo <- newTVarIO ActiveNone
currentUser <- newTVarIO user currentUser <- newTVarIO user
servers <- resolveServers defaultServers smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers'
smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers {netCfg = networkConfig}
agentAsync <- newTVarIO Nothing agentAsync <- newTVarIO Nothing
idsDrg <- newTVarIO =<< drgNew idsDrg <- newTVarIO =<< drgNew
inputQ <- newTBQueueIO tbqSize inputQ <- newTBQueueIO tbqSize
@@ -157,14 +158,21 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs} pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs}
where where
resolveServers :: InitialAgentServers -> IO InitialAgentServers resolveServers :: InitialAgentServers -> IO InitialAgentServers
resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of resolveServers ss = case nonEmpty smpServers of
Just smpServers' -> pure ss {smp = smpServers'} Just smpServers' -> pure ss {smp = L.map (\ServerCfg {server} -> server) smpServers'}
_ -> case user of _ -> case user of
Just usr -> do Just user' -> do
userSmpServers <- withTransaction chatStore (`getSMPServers` usr) userSmpServers <- withTransaction chatStore (`getSMPServers` user')
pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers} pure ss {smp = activeAgentServers cfg userSmpServers}
_ -> pure ss _ -> pure ss
activeAgentServers :: ChatConfig -> [ServerCfg] -> NonEmpty SMPServerWithAuth
activeAgentServers ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} =
fromMaybe defaultSMPServers
. nonEmpty
. map (\ServerCfg {server} -> server)
. filter (\ServerCfg {enabled} -> enabled)
startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ()) startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ())
startChatController user subConns enableExpireCIs = do startChatController user subConns enableExpireCIs = do
asks smpAgent >>= resumeAgentClient asks smpAgent >>= resumeAgentClient
@@ -276,10 +284,11 @@ processChatCommand = \case
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search) CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search)
CTContactRequest -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not implemented"
CTContactConnection -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported"
APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" APIGetChatItems pagination search -> withUser $ \user -> withStore $ \db ->
CRApiChatItems <$> getAllChatItems db user pagination search
APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
CTDirect -> do CTDirect -> do
ct@Contact {localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db userId chatId ct@Contact {localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct
(msgContainer, quotedItem_) <- prepareMsg fileInvitation_ (msgContainer, quotedItem_) <- prepareMsg fileInvitation_
@@ -378,6 +387,8 @@ processChatCommand = \case
| otherwise = case qmc of | otherwise = case qmc of
MCImage _ image -> MCImage qTextOrFile image MCImage _ image -> MCImage qTextOrFile image
MCFile _ -> MCFile qTextOrFile MCFile _ -> MCFile qTextOrFile
-- consider same for voice messages
-- MCVoice _ voice -> MCVoice qTextOrFile voice
_ -> qmc _ -> qmc
where where
-- if the message we're quoting with is one of the "large" MsgContents -- if the message we're quoting with is one of the "large" MsgContents
@@ -387,6 +398,7 @@ processChatCommand = \case
MCFile _ -> False MCFile _ -> False
MCLink {} -> True MCLink {} -> True
MCImage {} -> True MCImage {} -> True
MCVoice {} -> False
MCUnknown {} -> True MCUnknown {} -> True
qText = msgContentText qmc qText = msgContentText qmc
qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_ qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_
@@ -396,7 +408,7 @@ processChatCommand = \case
unzipMaybe3 _ = (Nothing, Nothing, Nothing) unzipMaybe3 _ = (Nothing, Nothing, Nothing)
APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock "updateChatItem" $ case cType of APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock "updateChatItem" $ case cType of
CTDirect -> do CTDirect -> do
(ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db userId chatId itemId
case ci of case ci of
CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do
case (ciContent, itemSharedMsgId) of case (ciContent, itemSharedMsgId) of
@@ -425,7 +437,7 @@ processChatCommand = \case
CTContactConnection -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported"
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock "deleteChatItem" $ case cType of APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock "deleteChatItem" $ case cType of
CTDirect -> do CTDirect -> do
(ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db userId chatId itemId
case (mode, msgDir, itemSharedMsgId) of case (mode, msgDir, itemSharedMsgId) of
(CIDMInternal, _, _) -> do (CIDMInternal, _, _) -> do
deleteCIFile user file deleteCIFile user file
@@ -468,10 +480,10 @@ processChatCommand = \case
CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk
CTContactRequest -> pure $ chatCmdError "not supported" CTContactRequest -> pure $ chatCmdError "not supported"
CTContactConnection -> pure $ chatCmdError "not supported" CTContactConnection -> pure $ chatCmdError "not supported"
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user@User {userId} -> case cType of APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of
CTDirect -> do CTDirect -> do
withStore $ \db -> do withStore $ \db -> do
ct <- getContact db userId chatId ct <- getContact db user chatId
liftIO $ updateContactUnreadChat db user ct unreadChat liftIO $ updateContactUnreadChat db user ct unreadChat
pure CRCmdOk pure CRCmdOk
CTGroup -> do CTGroup -> do
@@ -482,7 +494,7 @@ processChatCommand = \case
_ -> pure $ chatCmdError "not supported" _ -> pure $ chatCmdError "not supported"
APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
CTDirect -> do CTDirect -> do
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db userId chatId ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
conns <- withStore $ \db -> getContactConnections db userId ct conns <- withStore $ \db -> getContactConnections db userId ct
withChatLock "deleteChat direct" . procCmd $ do withChatLock "deleteChat direct" . procCmd $ do
@@ -516,9 +528,9 @@ processChatCommand = \case
withStore' $ \db -> deleteGroup db user gInfo withStore' $ \db -> deleteGroup db user gInfo
pure $ CRGroupDeletedUser gInfo pure $ CRGroupDeletedUser gInfo
CTContactRequest -> pure $ chatCmdError "not supported" CTContactRequest -> pure $ chatCmdError "not supported"
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of
CTDirect -> do CTDirect -> do
ct <- withStore $ \db -> getContact db userId chatId ct <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
@@ -561,7 +573,7 @@ processChatCommand = \case
pure $ CRContactRequestRejected cReq pure $ CRContactRequestRejected cReq
APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do
-- party initiating call -- party initiating call
ct <- withStore $ \db -> getContact db userId contactId ct <- withStore $ \db -> getContact db user contactId
calls <- asks currentCalls calls <- asks currentCalls
withChatLock "sendCallInvitation" $ do withChatLock "sendCallInvitation" $ do
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
@@ -629,27 +641,27 @@ processChatCommand = \case
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId) (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId)
updateCallItemStatus userId ct call WCSDisconnected $ Just msgId updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
pure Nothing pure Nothing
APIGetCallInvitations -> withUser $ \User {userId} -> do APIGetCallInvitations -> withUser $ \user -> do
calls <- asks currentCalls >>= readTVarIO calls <- asks currentCalls >>= readTVarIO
let invs = mapMaybe callInvitation $ M.elems calls let invs = mapMaybe callInvitation $ M.elems calls
CRCallInvitations <$> mapM (rcvCallInvitation userId) invs CRCallInvitations <$> mapM (rcvCallInvitation user) invs
where where
callInvitation Call {contactId, callState, callTs} = case callState of callInvitation Call {contactId, callState, callTs} = case callState of
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey) CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey)
_ -> Nothing _ -> Nothing
rcvCallInvitation userId (contactId, callTs, peerCallType, sharedKey) = do rcvCallInvitation user (contactId, callTs, peerCallType, sharedKey) = do
contact <- withStore (\db -> getContact db userId contactId) contact <- withStore (\db -> getContact db user contactId)
pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs} pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs}
APICallStatus contactId receivedStatus -> APICallStatus contactId receivedStatus ->
withCurrentCall contactId $ \userId ct call -> withCurrentCall contactId $ \userId ct call ->
updateCallItemStatus userId ct call receivedStatus Nothing $> Just call updateCallItemStatus userId ct call receivedStatus Nothing $> Just call
APIUpdateProfile profile -> withUser (`updateProfile` profile) APIUpdateProfile profile -> withUser (`updateProfile` profile)
APISetContactPrefs contactId prefs' -> withUser $ \user@User {userId} -> do APISetContactPrefs contactId prefs' -> withUser $ \user -> do
ct <- withStore $ \db -> getContact db userId contactId ct <- withStore $ \db -> getContact db user contactId
updateContactPrefs user ct prefs' updateContactPrefs user ct prefs'
APISetContactAlias contactId localAlias -> withUser $ \User {userId} -> do APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do
ct' <- withStore $ \db -> do ct' <- withStore $ \db -> do
ct <- getContact db userId contactId ct <- getContact db user contactId
liftIO $ updateContactAlias db userId ct localAlias liftIO $ updateContactAlias db userId ct localAlias
pure $ CRContactAliasUpdated ct' pure $ CRContactAliasUpdated ct'
APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do
@@ -668,12 +680,19 @@ processChatCommand = \case
msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta
connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing
pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages} pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages}
GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore' (`getSMPServers` user)) GetUserSMPServers -> do
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
withStore $ \db -> overwriteSMPServers db user smpServers
ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config
withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers)) smpServers <- withUser (\user -> withStore' (`getSMPServers` user))
let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers
pure $ CRUserSMPServers smpServers' defaultSMPServers
where
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True}
SetUserSMPServers (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
withStore $ \db -> overwriteSMPServers db user smpServers
cfg <- asks config
withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers
pure CRCmdOk pure CRCmdOk
TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer)
APISetChatItemTTL newTTL_ -> withUser' $ \user -> APISetChatItemTTL newTTL_ -> withUser' $ \user ->
checkStoreNotChanged $ checkStoreNotChanged $
withChatLock "setChatItemTTL" $ do withChatLock "setChatItemTTL" $ do
@@ -692,10 +711,10 @@ processChatCommand = \case
APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user)) APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user))
APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk
APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig) APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig)
APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user@User {userId} -> case cType of APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of
CTDirect -> do CTDirect -> do
ct <- withStore $ \db -> do ct <- withStore $ \db -> do
ct <- getContact db userId chatId ct <- getContact db user chatId
liftIO $ updateContactSettings db user chatId chatSettings liftIO $ updateContactSettings db user chatId chatSettings
pure ct pure ct
withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings)
@@ -709,9 +728,9 @@ processChatCommand = \case
withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError) withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError)
pure CRCmdOk pure CRCmdOk
_ -> pure $ chatCmdError "not supported" _ -> pure $ chatCmdError "not supported"
APIContactInfo contactId -> withUser $ \User {userId} -> do APIContactInfo contactId -> withUser $ \user@User {userId} -> do
-- [incognito] print user's incognito profile for this contact -- [incognito] print user's incognito profile for this contact
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db userId contactId ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
connectionStats <- withAgent (`getConnectionServers` contactConnId ct) connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile) pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile)
@@ -719,8 +738,8 @@ processChatCommand = \case
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
pure $ CRGroupMemberInfo g m connectionStats pure $ CRGroupMemberInfo g m connectionStats
APISwitchContact contactId -> withUser $ \User {userId} -> do APISwitchContact contactId -> withUser $ \user -> do
ct <- withStore $ \db -> getContact db userId contactId ct <- withStore $ \db -> getContact db user contactId
withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct
pure CRCmdOk pure CRCmdOk
APISwitchGroupMember gId gMemberId -> withUser $ \user -> do APISwitchGroupMember gId gMemberId -> withUser $ \user -> do
@@ -807,7 +826,7 @@ processChatCommand = \case
contacts <- withStore' (`getUserContacts` user) contacts <- withStore' (`getUserContacts` user)
withChatLock "sendMessageBroadcast" . procCmd $ do withChatLock "sendMessageBroadcast" . procCmd $ do
let mc = MCText $ safeDecodeUtf8 msg let mc = MCText $ safeDecodeUtf8 msg
cts = filter isReady contacts cts = filter (\ct -> isReady ct && isDirect ct) contacts
forM_ cts $ \ct -> forM_ cts $ \ct ->
void void
( do ( do
@@ -816,6 +835,9 @@ processChatCommand = \case
) )
`catchError` (toView . CRChatError) `catchError` (toView . CRChatError)
CRBroadcastSent mc (length cts) <$> liftIO getZonedTime CRBroadcastSent mc (length cts) <$> liftIO getZonedTime
where
isDirect Contact {contactUsed, activeConn = Connection {connLevel, viaGroupLink}} =
(connLevel == 0 && not viaGroupLink) || contactUsed
SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do
contactId <- withStore $ \db -> getContactIdByName db user cName contactId <- withStore $ \db -> getContactIdByName db user cName
quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg) quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg)
@@ -834,9 +856,9 @@ processChatCommand = \case
gVar <- asks idsDrg gVar <- asks idsDrg
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile) groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile)
pure $ CRGroupCreated groupInfo pure $ CRGroupCreated groupInfo
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock "addMember" $ do APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do
-- TODO for large groups: no need to load all members to determine if contact is a member -- TODO for large groups: no need to load all members to determine if contact is a member
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId
let Group gInfo@GroupInfo {membership} members = group let Group gInfo@GroupInfo {membership} members = group
GroupMember {memberRole = userRole} = membership GroupMember {memberRole = userRole} = membership
Contact {localDisplayName = cName} = contact Contact {localDisplayName = cName} = contact
@@ -888,7 +910,7 @@ processChatCommand = \case
Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole
_ -> throwChatError CEGroupMemberNotFound _ -> throwChatError CEGroupMemberNotFound
where where
changeMemberRole user@User {userId} gInfo@GroupInfo {membership} members m gEvent = do changeMemberRole user gInfo@GroupInfo {membership} members m gEvent = do
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m
GroupMember {memberRole = userRole} = membership GroupMember {memberRole = userRole} = membership
canChangeRole = userRole >= GRAdmin && userRole >= mRole && userRole >= memRole && memberCurrent membership canChangeRole = userRole >= GRAdmin && userRole >= mRole && userRole >= memRole && memberCurrent membership
@@ -898,7 +920,7 @@ processChatCommand = \case
withStore' $ \db -> updateGroupMemberRole db user m memRole withStore' $ \db -> updateGroupMemberRole db user m memRole
case mStatus of case mStatus of
GSMemInvited -> do GSMemInvited -> do
withStore (\db -> (,) <$> mapM (getContact db userId) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case withStore (\db -> (,) <$> mapM (getContact db user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case
(Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
_ -> do _ -> do
@@ -1012,11 +1034,11 @@ processChatCommand = \case
quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName (safeDecodeUtf8 quotedMsg) quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName (safeDecodeUtf8 quotedMsg)
let mc = MCText $ safeDecodeUtf8 msg let mc = MCText $ safeDecodeUtf8 msg
processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc
LastMessages (Just chatName) count -> withUser $ \user -> do LastMessages (Just chatName) count search -> withUser $ \user -> do
chatRef <- getChatRef user chatName chatRef <- getChatRef user chatName
CRLastMessages . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) Nothing) CRApiChatItems . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) search)
LastMessages Nothing count -> withUser $ \user -> withStore $ \db -> LastMessages Nothing count search ->
CRLastMessages <$> getAllChatItems db user (CPLast count) processChatCommand (APIGetChatItems (CPLast count) search)
SendFile chatName f -> withUser $ \user -> do SendFile chatName f -> withUser $ \user -> do
chatRef <- getChatRef user chatName chatRef <- getChatRef user chatName
processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "") processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "")
@@ -1048,7 +1070,7 @@ processChatCommand = \case
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
withStore (\db -> getChatRefByFileId db user fileId) >>= \case withStore (\db -> getChatRefByFileId db user fileId) >>= \case
ChatRef CTDirect contactId -> do ChatRef CTDirect contactId -> do
contact <- withStore $ \db -> getContact db userId contactId contact <- withStore $ \db -> getContact db user contactId
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
ChatRef CTGroup groupId -> do ChatRef CTGroup groupId -> do
Group gInfo ms <- withStore $ \db -> getGroup db user groupId Group gInfo ms <- withStore $ \db -> getGroup db user groupId
@@ -1111,7 +1133,7 @@ processChatCommand = \case
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
withStore' (\db -> getConnReqContactXContactId db userId cReqHash) >>= \case withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists contact (Just contact, _) -> pure $ CRContactAlreadyExists contact
(_, xContactId_) -> procCmd $ do (_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
@@ -1162,25 +1184,22 @@ processChatCommand = \case
void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError) void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError)
pure $ CRUserProfileUpdated (fromLocalProfile p) p' pure $ CRUserProfileUpdated (fromLocalProfile p) p'
updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse
updateContactPrefs user@User {userId} ct@Contact {contactId, activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct $ contactUserPreferences user ct -- nothing changed actually | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct
| otherwise = do | otherwise = do
withStore' $ \db -> updateContactUserPreferences db userId contactId contactUserPrefs' ct' <- withStore' $ \db -> updateContactUserPreferences db user ct contactUserPrefs'
-- [incognito] filter out contacts with whom user has incognito connections
let ct' = (ct :: Contact) {userPreferences = contactUserPrefs'}
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId
let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct')
withChatLock "updateProfile" . procCmd $ do withChatLock "updateProfile" . procCmd $ do
void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError) void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError)
pure $ CRContactPrefsUpdated ct ct' $ contactUserPreferences user ct' pure $ CRContactPrefsUpdated ct ct'
isReady :: Contact -> Bool isReady :: Contact -> Bool
isReady ct = isReady ct =
let s = connStatus $ activeConn (ct :: Contact) let s = connStatus $ activeConn (ct :: Contact)
in s == ConnReady || s == ConnSndReady in s == ConnReady || s == ConnSndReady
withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse
withCurrentCall ctId action = withUser $ \user@User {userId} -> do withCurrentCall ctId action = withUser $ \user@User {userId} -> do
ct <- withStore $ \db -> getContact db userId ctId ct <- withStore $ \db -> getContact db user ctId
calls <- asks currentCalls calls <- asks currentCalls
withChatLock "currentCall" $ withChatLock "currentCall" $
atomically (TM.lookup ctId calls) >>= \case atomically (TM.lookup ctId calls) >>= \case
@@ -1310,7 +1329,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F
chatRef <- withStore $ \db -> getChatRefByFileId db user fileId chatRef <- withStore $ \db -> getChatRefByFileId db user fileId
case (chatRef, grpMemberId) of case (chatRef, grpMemberId) of
(ChatRef CTDirect contactId, Nothing) -> do (ChatRef CTDirect contactId, Nothing) -> do
ct <- withStore $ \db -> getContact db userId contactId ct <- withStore $ \db -> getContact db user contactId
(msg, ci) <- acceptFile (msg, ci) <- acceptFile
void $ sendDirectContactMessage ct msg void $ sendDirectContactMessage ct msg
pure ci pure ci
@@ -2054,7 +2073,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
where where
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m () profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
profileContactRequest invId p xContactId_ = do profileContactRequest invId p xContactId_ = do
withStore (\db -> createOrUpdateContactRequest db userId userContactLinkId invId p xContactId_) >>= \case withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case
CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact
CORRequest cReq@UserContactRequest {localDisplayName} -> do CORRequest cReq@UserContactRequest {localDisplayName} -> do
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
@@ -2148,7 +2167,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
if connectedIncognito if connectedIncognito
then withStore' $ \db -> deleteSentProbe db userId probeId then withStore' $ \db -> deleteSentProbe db userId probeId
else do else do
cs <- withStore' $ \db -> getMatchingContacts db userId ct cs <- withStore' $ \db -> getMatchingContacts db user ct
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe) let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ()) forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
where where
@@ -2463,21 +2482,21 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
xInfo :: Contact -> Profile -> m () xInfo :: Contact -> Profile -> m ()
xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do
c' <- withStore $ \db -> updateContactProfile db userId c p' c' <- withStore $ \db -> updateContactProfile db user c p'
toView $ CRContactUpdated c c' $ contactUserPreferences user c' toView $ CRContactUpdated c c'
xInfoProbe :: Contact -> Probe -> m () xInfoProbe :: Contact -> Probe -> m ()
xInfoProbe c2 probe = xInfoProbe c2 probe =
-- [incognito] unless connected incognito -- [incognito] unless connected incognito
unless (contactConnIncognito c2) $ do unless (contactConnIncognito c2) $ do
r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe r <- withStore' $ \db -> matchReceivedProbe db user c2 probe
forM_ r $ \c1 -> probeMatch c1 c2 probe forM_ r $ \c1 -> probeMatch c1 c2 probe
xInfoProbeCheck :: Contact -> ProbeHash -> m () xInfoProbeCheck :: Contact -> ProbeHash -> m ()
xInfoProbeCheck c1 probeHash = xInfoProbeCheck c1 probeHash =
-- [incognito] unless connected incognito -- [incognito] unless connected incognito
unless (contactConnIncognito c1) $ do unless (contactConnIncognito c1) $ do
r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash r <- withStore' $ \db -> matchReceivedProbeHash db user c1 probeHash
forM_ r . uncurry $ probeMatch c1 forM_ r . uncurry $ probeMatch c1
probeMatch :: Contact -> Contact -> Probe -> m () probeMatch :: Contact -> Contact -> Probe -> m ()
@@ -2490,7 +2509,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
xInfoProbeOk :: Contact -> Probe -> m () xInfoProbeOk :: Contact -> Probe -> m ()
xInfoProbeOk c1@Contact {contactId = cId1} probe = do xInfoProbeOk c1@Contact {contactId = cId1} probe = do
r <- withStore' $ \db -> matchSentProbe db userId c1 probe r <- withStore' $ \db -> matchSentProbe db user c1 probe
forM_ r $ \c2@Contact {contactId = cId2} -> forM_ r $ \c2@Contact {contactId = cId2} ->
if cId1 /= cId2 if cId1 /= cId2
then mergeContacts c1 c2 then mergeContacts c1 c2
@@ -2605,7 +2624,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
ChatMessage {chatMsgEvent} <- parseChatMessage connInfo ChatMessage {chatMsgEvent} <- parseChatMessage connInfo
case chatMsgEvent of case chatMsgEvent of
XInfo p -> do XInfo p -> do
ct <- withStore $ \db -> createDirectContact db userId activeConn p ct <- withStore $ \db -> createDirectContact db user activeConn p
toView $ CRContactConnecting ct toView $ CRContactConnecting ct
-- TODO show/log error, other events in SMP confirmation -- TODO show/log error, other events in SMP confirmation
_ -> pure () _ -> pure ()
@@ -3163,8 +3182,8 @@ chatCommandP =
"/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql chat " *> (ExecChatStoreSQL <$> textP),
"/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP),
"/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional searchP), "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get items count=" *> (APIGetChatItems <$> A.decimal), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
"/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))), "/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
@@ -3199,9 +3218,15 @@ chatCommandP =
"/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal), "/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal),
"/_leave #" *> (APILeaveGroup <$> A.decimal), "/_leave #" *> (APILeaveGroup <$> A.decimal),
"/_members #" *> (APIListMembers <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal),
"/smp_servers default" $> SetUserSMPServers [], -- /smp_servers is deprecated, use /smp and /_smp
"/smp_servers " *> (SetUserSMPServers <$> smpServersP), "/smp_servers default" $> SetUserSMPServers (SMPServersConfig []),
"/smp_servers " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP),
"/smp_servers" $> GetUserSMPServers, "/smp_servers" $> GetUserSMPServers,
"/smp default" $> SetUserSMPServers (SMPServersConfig []),
"/smp test " *> (TestSMPServer <$> strP),
"/_smp " *> (SetUserSMPServers <$> jsonP),
"/smp " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP),
"/smp" $> GetUserSMPServers,
"/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal), "/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal),
"/ttl " *> (APISetChatItemTTL <$> ciTTL), "/ttl " *> (APISetChatItemTTL <$> ciTTL),
"/ttl" $> APIGetChatItemTTL, "/ttl" $> APIGetChatItemTTL,
@@ -3256,7 +3281,8 @@ chatCommandP =
("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString), ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString),
("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString), ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString),
"/feed " *> (SendMessageBroadcast <$> A.takeByteString), "/feed " *> (SendMessageBroadcast <$> A.takeByteString),
("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP), ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing),
("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> stringP))),
("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath), ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath),
("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath), ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath),
("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
@@ -3316,8 +3342,8 @@ chatCommandP =
n <- (A.space *> A.takeByteString) <|> pure "" n <- (A.space *> A.takeByteString) <|> pure ""
pure $ if B.null n then name else safeDecodeUtf8 n pure $ if B.null n then name else safeDecodeUtf8 n
textP = safeDecodeUtf8 <$> A.takeByteString textP = safeDecodeUtf8 <$> A.takeByteString
filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
searchP = T.unpack . safeDecodeUtf8 <$> (" search=" *> A.takeByteString) filePath = stringP
memberRole = memberRole =
A.choice A.choice
[ " owner" $> GROwner, [ " owner" $> GROwner,
+33 -9
View File
@@ -23,6 +23,7 @@ import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import Data.Char (ord) import Data.Char (ord)
import Data.Int (Int64) import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
import Data.String import Data.String
import Data.Text (Text) import Data.Text (Text)
@@ -39,7 +40,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink) import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent (AgentClient)
import Simplex.Messaging.Agent.Client (AgentLocks) import Simplex.Messaging.Agent.Client (AgentLocks, SMPTestFailure)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig)
import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Protocol
@@ -146,7 +147,7 @@ data ChatCommand
| ExecAgentStoreSQL Text | ExecAgentStoreSQL Text
| APIGetChats {pendingConnections :: Bool} | APIGetChats {pendingConnections :: Bool}
| APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems Int | APIGetChatItems ChatPagination (Maybe String)
| APISendMessage ChatRef ComposedMessage | APISendMessage ChatRef ComposedMessage
| APIUpdateChatItem ChatRef ChatItemId MsgContent | APIUpdateChatItem ChatRef ChatItemId MsgContent
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
@@ -166,7 +167,7 @@ data ChatCommand
| APIGetCallInvitations | APIGetCallInvitations
| APICallStatus ContactId WebRTCCallStatus | APICallStatus ContactId WebRTCCallStatus
| APIUpdateProfile Profile | APIUpdateProfile Profile
| APISetContactPrefs Int64 Preferences | APISetContactPrefs ContactId Preferences
| APISetContactAlias ContactId LocalAlias | APISetContactAlias ContactId LocalAlias
| APISetConnectionAlias Int64 LocalAlias | APISetConnectionAlias Int64 LocalAlias
| APIParseMarkdown Text | APIParseMarkdown Text
@@ -186,7 +187,8 @@ data ChatCommand
| APIDeleteGroupLink GroupId | APIDeleteGroupLink GroupId
| APIGetGroupLink GroupId | APIGetGroupLink GroupId
| GetUserSMPServers | GetUserSMPServers
| SetUserSMPServers [SMPServer] | SetUserSMPServers SMPServersConfig
| TestSMPServer SMPServerWithAuth
| APISetChatItemTTL (Maybe Int64) | APISetChatItemTTL (Maybe Int64)
| APIGetChatItemTTL | APIGetChatItemTTL
| APISetNetworkConfig NetworkConfig | APISetNetworkConfig NetworkConfig
@@ -235,7 +237,7 @@ data ChatCommand
| DeleteGroupLink GroupName | DeleteGroupLink GroupName
| ShowGroupLink GroupName | ShowGroupLink GroupName
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString}
| LastMessages (Maybe ChatName) Int | LastMessages (Maybe ChatName) Int (Maybe String)
| SendFile ChatName FilePath | SendFile ChatName FilePath
| SendImage ChatName FilePath | SendImage ChatName FilePath
| ForwardFile ChatName FileTransferId | ForwardFile ChatName FileTransferId
@@ -259,9 +261,10 @@ data ChatResponse
| CRChatSuspended | CRChatSuspended
| CRApiChats {chats :: [AChat]} | CRApiChats {chats :: [AChat]}
| CRApiChat {chat :: AChat} | CRApiChat {chat :: AChat}
| CRLastMessages {chatItems :: [AChatItem]} | CRApiChatItems {chatItems :: [AChatItem]}
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
| CRUserSMPServers {smpServers :: [SMPServer]} | CRUserSMPServers {smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth}
| CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure}
| CRChatItemTTL {chatItemTTL :: Maybe Int64} | CRChatItemTTL {chatItemTTL :: Maybe Int64}
| CRNetworkConfig {networkConfig :: NetworkConfig} | CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} | CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
@@ -296,7 +299,7 @@ data ChatResponse
| CRInvitation {connReqInvitation :: ConnReqInvitation} | CRInvitation {connReqInvitation :: ConnReqInvitation}
| CRSentConfirmation | CRSentConfirmation
| CRSentInvitation {customUserProfile :: Maybe Profile} | CRSentInvitation {customUserProfile :: Maybe Profile}
| CRContactUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences} | CRContactUpdated {fromContact :: Contact, toContact :: Contact}
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
| CRContactDeleted {contact :: Contact} | CRContactDeleted {contact :: Contact}
| CRChatCleared {chatInfo :: AChatInfo} | CRChatCleared {chatInfo :: AChatInfo}
@@ -322,7 +325,7 @@ data ChatResponse
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
| CRContactAliasUpdated {toContact :: Contact} | CRContactAliasUpdated {toContact :: Contact}
| CRConnectionAliasUpdated {toConnection :: PendingContactConnection} | CRConnectionAliasUpdated {toConnection :: PendingContactConnection}
| CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences} | CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact}
| CRContactConnecting {contact :: Contact} | CRContactConnecting {contact :: Contact}
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile} | CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
| CRContactAnotherClient {contact :: Contact} | CRContactAnotherClient {contact :: Contact}
@@ -383,6 +386,9 @@ instance ToJSON ChatResponse where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
data SMPServersConfig = SMPServersConfig {smpServers :: [ServerCfg]}
deriving (Show, Generic, FromJSON)
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
deriving (Show, Generic, FromJSON) deriving (Show, Generic, FromJSON)
@@ -465,6 +471,24 @@ data SwitchProgress = SwitchProgress
instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions
data ParsedServerAddress = ParsedServerAddress
{ serverAddress :: Maybe ServerAddress,
parseError :: String
}
deriving (Show, Generic)
instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions
data ServerAddress = ServerAddress
{ hostnames :: NonEmpty String,
port :: String,
keyHash :: String,
basicAuth :: String
}
deriving (Show, Generic)
instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions
data ChatError data ChatError
= ChatError {errorType :: ChatErrorType} = ChatError {errorType :: ChatErrorType}
| ChatErrorAgent {agentError :: AgentErrorType} | ChatErrorAgent {agentError :: AgentErrorType}
+9 -3
View File
@@ -155,6 +155,12 @@ messagesHelpInfo =
indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team", indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team",
indent <> highlight "/tail [N] " <> " - the last N messages in all chats", indent <> highlight "/tail [N] " <> " - the last N messages in all chats",
"", "",
green "Search for messages",
indent <> highlight "/search @alice [N] <text>" <> " - the last N messages with alice containing <text> (10 by default)",
indent <> highlight "/search #team [N] <text> " <> " - the last N messages in the group team containing <text>",
indent <> highlight "/search [N] <text> " <> " - the last N messages in all chats containing <text>",
indent <> highlight "/?" <> " can be used instead of /search",
"",
green "Sending replies to messages", green "Sending replies to messages",
"To quote a message that starts with \"hi\":", "To quote a message that starts with \"hi\":",
indent <> highlight "> @alice (hi) <msg> " <> " - to reply to alice's most recent message", indent <> highlight "> @alice (hi) <msg> " <> " - to reply to alice's most recent message",
@@ -164,8 +170,8 @@ messagesHelpInfo =
"", "",
green "Deleting sent messages (for everyone)", green "Deleting sent messages (for everyone)",
"To delete a message that starts with \"hi\":", "To delete a message that starts with \"hi\":",
indent <> highlight "\\ @alice hi " <> " - to delete your message to alice", indent <> highlight "\\ @alice hi " <> " - to delete your message to alice",
indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team", indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team",
"", "",
green "Editing sent messages", green "Editing sent messages",
"To edit your last message press up arrow, edit (keep the initial ! symbol) and press enter.", "To edit your last message press up arrow, edit (keep the initial ! symbol) and press enter.",
@@ -193,7 +199,7 @@ settingsInfo =
styleMarkdown styleMarkdown
[ green "Chat settings:", [ green "Chat settings:",
indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/network " <> " - show / set network access options",
indent <> highlight "/smp_servers " <> " - show / set custom SMP servers", indent <> highlight "/smp " <> " - show / set custom SMP servers",
indent <> highlight "/info <contact> " <> " - information about contact connection", indent <> highlight "/info <contact> " <> " - information about contact connection",
indent <> highlight "/info #<group> <member> " <> " - information about member connection", indent <> highlight "/info #<group> <member> " <> " - information about member connection",
indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command", indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command",
+2 -7
View File
@@ -881,14 +881,9 @@ ciCallInfoText status duration = case status of
CISCallRejected -> "rejected" CISCallRejected -> "rejected"
CISCallAccepted -> "accepted" CISCallAccepted -> "accepted"
CISCallNegotiated -> "connecting..." CISCallNegotiated -> "connecting..."
CISCallProgress -> "in progress " <> d CISCallProgress -> "in progress " <> durationText duration
CISCallEnded -> "ended " <> d CISCallEnded -> "ended " <> durationText duration
CISCallError -> "error" CISCallError -> "error"
where
d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")"
with0 n
| n < 9 = '0' : show n
| otherwise = show n
data SChatType (c :: ChatType) where data SChatType (c :: ChatType) where
SCTDirect :: SChatType 'CTDirect SCTDirect :: SChatType 'CTDirect
@@ -0,0 +1,12 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20221112_server_password where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20221112_server_password :: Query
m20221112_server_password =
[sql|
ALTER TABLE smp_servers ADD COLUMN basic_auth TEXT;
|]
@@ -0,0 +1,19 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20221115_server_cfg where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20221115_server_cfg :: Query
m20221115_server_cfg =
[sql|
PRAGMA ignore_check_constraints=ON;
ALTER TABLE smp_servers ADD COLUMN preset INTEGER DEFAULT 0 CHECK (preset NOT NULL);
ALTER TABLE smp_servers ADD COLUMN tested INTEGER;
ALTER TABLE smp_servers ADD COLUMN enabled INTEGER DEFAULT 1 CHECK (enabled NOT NULL);
UPDATE smp_servers SET preset = 0, enabled = 1;
PRAGMA ignore_check_constraints=OFF;
|]
@@ -384,6 +384,10 @@ CREATE TABLE smp_servers(
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT(datetime('now')), created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')),
basic_auth TEXT,
preset INTEGER DEFAULT 0 CHECK(preset NOT NULL),
tested INTEGER,
enabled INTEGER DEFAULT 1 CHECK(enabled NOT NULL),
UNIQUE(host, port) UNIQUE(host, port)
); );
CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id);
+22 -1
View File
@@ -16,6 +16,7 @@ import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Functor (($>)) import Data.Functor (($>))
import Data.List (find) import Data.List (find)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Database.SQLite.Simple (SQLError (..)) import Database.SQLite.Simple (SQLError (..))
import qualified Database.SQLite.Simple as DB import qualified Database.SQLite.Simple as DB
@@ -34,8 +35,10 @@ import Simplex.Chat.Types
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore)
import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore) import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore)
import Simplex.Messaging.Client (defaultNetworkConfig) import Simplex.Messaging.Client (defaultNetworkConfig)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
import Simplex.Messaging.Protocol (CorrId (..)) import Simplex.Messaging.Protocol (BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth)
import Simplex.Messaging.Util (catchAll, safeDecodeUtf8) import Simplex.Messaging.Util (catchAll, safeDecodeUtf8)
import System.Timeout (timeout) import System.Timeout (timeout)
@@ -58,6 +61,8 @@ foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatCont
foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString
foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString
-- | check / migrate database and initialize chat controller on success -- | check / migrate database and initialize chat controller on success
cChatMigrateInit :: CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit :: CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
cChatMigrateInit fp key ctrl = do cChatMigrateInit fp key ctrl = do
@@ -107,6 +112,10 @@ cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t)
cChatParseMarkdown :: CString -> IO CJSONString cChatParseMarkdown :: CString -> IO CJSONString
cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s
-- | parse server address - returns ParsedServerAddress JSON
cChatParseServer :: CString -> IO CJSONString
cChatParseServer s = newCAString . chatParseServer =<< peekCAString s
mobileChatOpts :: ChatOpts mobileChatOpts :: ChatOpts
mobileChatOpts = mobileChatOpts =
ChatOpts ChatOpts
@@ -206,6 +215,18 @@ chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc)
chatParseMarkdown :: String -> JSONString chatParseMarkdown :: String -> JSONString
chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack
chatParseServer :: String -> JSONString
chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack
where
toServerAddress :: Either String SMPServerWithAuth -> ParsedServerAddress
toServerAddress = \case
Right (ProtoServerWithAuth ProtocolServer {host, port, keyHash = C.KeyHash kh} auth) ->
let basicAuth = maybe "" (\(BasicAuth a) -> enc a) auth
in ParsedServerAddress (Just ServerAddress {hostnames = L.map enc host, port, keyHash = enc kh, basicAuth}) ""
Left e -> ParsedServerAddress Nothing e
enc :: StrEncoding a => a -> String
enc = B.unpack . strEncode
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
deriving (Generic) deriving (Generic)
+7 -5
View File
@@ -16,7 +16,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import Options.Applicative import Options.Applicative
import Simplex.Chat.Controller (updateStr, versionStr) import Simplex.Chat.Controller (updateStr, versionStr)
import Simplex.Messaging.Agent.Protocol (SMPServer) import Simplex.Chat.Types (ServerCfg (..))
import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig) import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig)
import Simplex.Messaging.Encoding.String import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Parsers (parseAll)
@@ -26,7 +26,7 @@ import System.FilePath (combine)
data ChatOpts = ChatOpts data ChatOpts = ChatOpts
{ dbFilePrefix :: String, { dbFilePrefix :: String,
dbKey :: String, dbKey :: String,
smpServers :: [SMPServer], smpServers :: [ServerCfg],
networkConfig :: NetworkConfig, networkConfig :: NetworkConfig,
logConnections :: Bool, logConnections :: Bool,
logServerHosts :: Bool, logServerHosts :: Bool,
@@ -155,7 +155,7 @@ fullNetworkConfig socksProxy tcpTimeout =
let tcpConnectTimeout = (tcpTimeout * 3) `div` 2 let tcpConnectTimeout = (tcpTimeout * 3) `div` 2
in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout} in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout}
parseSMPServers :: ReadM [SMPServer] parseSMPServers :: ReadM [ServerCfg]
parseSMPServers = eitherReader $ parseAll smpServersP . B.pack parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
parseSocksProxy :: ReadM (Maybe SocksProxy) parseSocksProxy :: ReadM (Maybe SocksProxy)
@@ -167,8 +167,10 @@ parseServerPort = eitherReader $ parseAll serverPortP . B.pack
serverPortP :: A.Parser (Maybe String) serverPortP :: A.Parser (Maybe String)
serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit
smpServersP :: A.Parser [SMPServer] smpServersP :: A.Parser [ServerCfg]
smpServersP = strP `A.sepBy1` A.char ';' smpServersP = (toServerCfg <$> strP) `A.sepBy1` A.char ';'
where
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
getChatOpts :: FilePath -> FilePath -> IO ChatOpts getChatOpts :: FilePath -> FilePath -> IO ChatOpts
getChatOpts appDir defaultDbFileName = getChatOpts appDir defaultDbFileName =
+24 -1
View File
@@ -29,6 +29,7 @@ import Data.ByteString.Internal (c2w, w2c)
import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
import Data.Type.Equality import Data.Type.Equality
@@ -263,7 +264,7 @@ cmToQuotedMsg = \case
ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg
_ -> Nothing _ -> Nothing
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCFile_ | MCUnknown_ Text data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVoice_ | MCFile_ | MCUnknown_ Text
instance StrEncoding MsgContentTag where instance StrEncoding MsgContentTag where
strEncode = \case strEncode = \case
@@ -271,11 +272,13 @@ instance StrEncoding MsgContentTag where
MCLink_ -> "link" MCLink_ -> "link"
MCImage_ -> "image" MCImage_ -> "image"
MCFile_ -> "file" MCFile_ -> "file"
MCVoice_ -> "voice"
MCUnknown_ t -> encodeUtf8 t MCUnknown_ t -> encodeUtf8 t
strDecode = \case strDecode = \case
"text" -> Right MCText_ "text" -> Right MCText_
"link" -> Right MCLink_ "link" -> Right MCLink_
"image" -> Right MCImage_ "image" -> Right MCImage_
"voice" -> Right MCVoice_
"file" -> Right MCFile_ "file" -> Right MCFile_
t -> Right . MCUnknown_ $ safeDecodeUtf8 t t -> Right . MCUnknown_ $ safeDecodeUtf8 t
strP = strDecode <$?> A.takeTill (== ' ') strP = strDecode <$?> A.takeTill (== ' ')
@@ -313,6 +316,7 @@ data MsgContent
= MCText Text = MCText Text
| MCLink {text :: Text, preview :: LinkPreview} | MCLink {text :: Text, preview :: LinkPreview}
| MCImage {text :: Text, image :: ImageData} | MCImage {text :: Text, image :: ImageData}
| MCVoice {text :: Text, duration :: Int}
| MCFile Text | MCFile Text
| MCUnknown {tag :: Text, text :: Text, json :: J.Object} | MCUnknown {tag :: Text, text :: Text, json :: J.Object}
deriving (Eq, Show) deriving (Eq, Show)
@@ -322,14 +326,27 @@ msgContentText = \case
MCText t -> t MCText t -> t
MCLink {text} -> text MCLink {text} -> text
MCImage {text} -> text MCImage {text} -> text
MCVoice {text, duration} ->
if T.null text then msg else msg <> "; " <> text
where
msg = "voice message " <> durationText duration
MCFile t -> t MCFile t -> t
MCUnknown {text} -> text MCUnknown {text} -> text
durationText :: Int -> Text
durationText duration =
let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")"
where
with0 n
| n <= 9 = '0' : show n
| otherwise = show n
msgContentTag :: MsgContent -> MsgContentTag msgContentTag :: MsgContent -> MsgContentTag
msgContentTag = \case msgContentTag = \case
MCText _ -> MCText_ MCText _ -> MCText_
MCLink {} -> MCLink_ MCLink {} -> MCLink_
MCImage {} -> MCImage_ MCImage {} -> MCImage_
MCVoice {} -> MCVoice_
MCFile {} -> MCFile_ MCFile {} -> MCFile_
MCUnknown {tag} -> MCUnknown_ tag MCUnknown {tag} -> MCUnknown_ tag
@@ -356,6 +373,10 @@ instance FromJSON MsgContent where
text <- v .: "text" text <- v .: "text"
image <- v .: "image" image <- v .: "image"
pure MCImage {image, text} pure MCImage {image, text}
MCVoice_ -> do
text <- v .: "text"
duration <- v .: "duration"
pure MCVoice {text, duration}
MCFile_ -> MCFile <$> v .: "text" MCFile_ -> MCFile <$> v .: "text"
MCUnknown_ tag -> do MCUnknown_ tag -> do
text <- fromMaybe unknownMsgType <$> v .:? "text" text <- fromMaybe unknownMsgType <$> v .:? "text"
@@ -382,12 +403,14 @@ instance ToJSON MsgContent where
MCText t -> J.object ["type" .= MCText_, "text" .= t] MCText t -> J.object ["type" .= MCText_, "text" .= t]
MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview] MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview]
MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image] MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image]
MCVoice {text, duration} -> J.object ["type" .= MCVoice_, "text" .= text, "duration" .= duration]
MCFile t -> J.object ["type" .= MCFile_, "text" .= t] MCFile t -> J.object ["type" .= MCFile_, "text" .= t]
toEncoding = \case toEncoding = \case
MCUnknown {json} -> JE.value $ J.Object json MCUnknown {json} -> JE.value $ J.Object json
MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t
MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview
MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image
MCVoice {text, duration} -> J.pairs $ "type" .= MCVoice_ <> "text" .= text <> "duration" .= duration
MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t
instance ToField MsgContent where instance ToField MsgContent where
+174 -99
View File
@@ -252,6 +252,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe)
import Data.Ord (Down (..)) import Data.Ord (Down (..))
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) import Data.Time.LocalTime (TimeZone, getCurrentTimeZone)
import Data.Type.Equality import Data.Type.Equality
@@ -295,6 +296,8 @@ import Simplex.Chat.Migrations.M20221021_auto_accept__group_links
import Simplex.Chat.Migrations.M20221024_contact_used import Simplex.Chat.Migrations.M20221024_contact_used
import Simplex.Chat.Migrations.M20221025_chat_settings import Simplex.Chat.Migrations.M20221025_chat_settings
import Simplex.Chat.Migrations.M20221029_group_link_id import Simplex.Chat.Migrations.M20221029_group_link_id
import Simplex.Chat.Migrations.M20221112_server_password
import Simplex.Chat.Migrations.M20221115_server_cfg
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..)) import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..))
@@ -302,9 +305,9 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
import Simplex.Messaging.Protocol (ProtocolServer (..), SMPServer, pattern SMPServer) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), pattern SMPServer)
import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Transport.Client (TransportHost)
import Simplex.Messaging.Util (eitherToMaybe) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
import UnliftIO.STM import UnliftIO.STM
schemaMigrations :: [(String, Query)] schemaMigrations :: [(String, Query)]
@@ -341,7 +344,9 @@ schemaMigrations =
("20221021_auto_accept__group_links", m20221021_auto_accept__group_links), ("20221021_auto_accept__group_links", m20221021_auto_accept__group_links),
("20221024_contact_used", m20221024_contact_used), ("20221024_contact_used", m20221024_contact_used),
("20221025_chat_settings", m20221025_chat_settings), ("20221025_chat_settings", m20221025_chat_settings),
("20221029_group_link_id", m20221029_group_link_id) ("20221029_group_link_id", m20221029_group_link_id),
("20221112_server_password", m20221112_server_password),
("20221115_server_cfg", m20221115_server_cfg)
] ]
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date
@@ -411,7 +416,7 @@ getUsers db =
toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User
toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) = toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""} let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences}
setActiveUser :: DB.Connection -> UserId -> IO () setActiveUser :: DB.Connection -> UserId -> IO ()
setActiveUser db userId = do setActiveUser db userId = do
@@ -435,15 +440,15 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou
pccConnId <- insertedRowId db pccConnId <- insertedRowId db
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt}
getConnReqContactXContactId :: DB.Connection -> UserId -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId)
getConnReqContactXContactId db userId cReqHash = do getConnReqContactXContactId db user@User {userId} cReqHash = do
getContact' >>= \case getContact' >>= \case
c@(Just _) -> pure (c, Nothing) c@(Just _) -> pure (c, Nothing)
Nothing -> (Nothing,) <$> getXContactId Nothing -> (Nothing,) <$> getXContactId
where where
getContact' :: IO (Maybe Contact) getContact' :: IO (Maybe Contact)
getContact' = getContact' =
maybeFirstRow toContact $ maybeFirstRow (toContact user) $
DB.query DB.query
db db
[sql| [sql|
@@ -531,11 +536,14 @@ createConnection_ db userId connType entityId acId viaContact viaUserContactLink
where where
ent ct = if connType == ct then entityId else Nothing ent ct = if connType == ct then entityId else Nothing
createDirectContact :: DB.Connection -> UserId -> Connection -> Profile -> ExceptT StoreError IO Contact createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
createDirectContact db userId activeConn@Connection {connId, localAlias} profile = do createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do
createdAt <- liftIO getCurrentTime createdAt <- liftIO getCurrentTime
(localDisplayName, contactId, profileId) <- createContact_ db userId connId profile localAlias Nothing createdAt (localDisplayName, contactId, profileId) <- createContact_ db userId connId p localAlias Nothing createdAt
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile localAlias, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences = emptyChatPrefs, createdAt, updatedAt = createdAt} let profile = toLocalProfile profileId p localAlias
userPreferences = emptyChatPrefs
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt}
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId) createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId)
createContact_ db userId connId Profile {displayName, fullName, image, preferences} localAlias viaGroup currentTs = createContact_ db userId connId Profile {displayName, fullName, image, preferences} localAlias viaGroup currentTs =
@@ -629,24 +637,32 @@ updateUserProfile db User {userId, userContactId, localDisplayName, profile = Lo
updateContactProfile_' db userId profileId p' currentTs updateContactProfile_' db userId profileId p' currentTs
updateContact_ db userId userContactId localDisplayName newName currentTs updateContact_ db userId userContactId localDisplayName newName currentTs
updateContactProfile :: DB.Connection -> UserId -> Contact -> Profile -> ExceptT StoreError IO Contact updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact
updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} p'@Profile {displayName = newName} updateContactProfile db user@User {userId} c p'
| displayName == newName = | displayName == newName = do
liftIO $ updateContactProfile_ db userId profileId p' $> (c :: Contact) {profile = toLocalProfile profileId p' localAlias} liftIO $ updateContactProfile_ db userId profileId p'
pure $ c {profile, mergedPreferences}
| otherwise = | otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime currentTs <- getCurrentTime
updateContactProfile_' db userId profileId p' currentTs updateContactProfile_' db userId profileId p' currentTs
updateContact_ db userId contactId localDisplayName ldn currentTs updateContact_ db userId contactId localDisplayName ldn currentTs
pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p' localAlias} pure . Right $ c {localDisplayName = ldn, profile, mergedPreferences}
where
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, activeConn, userPreferences} = c
Profile {displayName = newName, preferences} = p'
profile = toLocalProfile profileId p' localAlias
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
updateContactUserPreferences :: DB.Connection -> UserId -> Int64 -> Preferences -> IO () updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact
updateContactUserPreferences db userId contactId userPreferences = do updateContactUserPreferences db user@User {userId} c@Contact {contactId, activeConn} userPreferences = do
updatedAt <- getCurrentTime updatedAt <- getCurrentTime
DB.execute DB.execute
db db
"UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" "UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
(userPreferences, updatedAt, userId, contactId) (userPreferences, updatedAt, userId, contactId)
let mergedPreferences = contactUserPreferences user userPreferences (preferences' c) $ connIncognito activeConn
pure $ c {mergedPreferences, userPreferences}
updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact
updateContactAlias db userId c@Contact {profile = lp@LocalProfile {profileId}} localAlias = do updateContactAlias db userId c@Contact {profile = lp@LocalProfile {profileId}} localAlias = do
@@ -719,33 +735,35 @@ updateContact_ db userId contactId displayName newName updatedAt = do
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime) type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime)
toContact :: ContactRow :. ConnectionRow -> Contact toContact :: User -> ContactRow :. ConnectionRow -> Contact
toContact (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) = toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias} let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
activeConn = toConnection connRow activeConn = toConnection connRow
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact
toContactOrError (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) = toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias} let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in case toMaybeConnection connRow of in case toMaybeConnection connRow of
Just activeConn -> Just activeConn ->
Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt} let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
_ -> Left $ SEContactNotReady localDisplayName _ -> Left $ SEContactNotReady localDisplayName
-- TODO return the last connection that is ready, not any last connection -- TODO return the last connection that is ready, not any last connection
-- requires updating connection status -- requires updating connection status
getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact
getContactByName db user@User {userId} localDisplayName = do getContactByName db user localDisplayName = do
cId <- getContactIdByName db user localDisplayName cId <- getContactIdByName db user localDisplayName
getContact db userId cId getContact db user cId
getUserContacts :: DB.Connection -> User -> IO [Contact] getUserContacts :: DB.Connection -> User -> IO [Contact]
getUserContacts db User {userId} = do getUserContacts db user@User {userId} = do
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId) contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId)
rights <$> mapM (runExceptT . getContact db userId) contactIds rights <$> mapM (runExceptT . getContact db user) contactIds
createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO () createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO ()
createUserContactLink db userId agentConnId cReq = createUserContactLink db userId agentConnId cReq =
@@ -974,8 +992,8 @@ getGroupLinkId db User {userId} GroupInfo {groupId} =
fmap join . maybeFirstRow fromOnly $ fmap join . maybeFirstRow fromOnly $
DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId)
createOrUpdateContactRequest :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ = createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
liftIO (maybeM getContact' xContactId_) >>= \case liftIO (maybeM getContact' xContactId_) >>= \case
Just contact -> pure $ CORContact contact Just contact -> pure $ CORContact contact
Nothing -> CORRequest <$> createOrUpdate_ Nothing -> CORRequest <$> createOrUpdate_
@@ -1011,7 +1029,7 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN
insertedRowId db insertedRowId db
getContact' :: XContactId -> IO (Maybe Contact) getContact' :: XContactId -> IO (Maybe Contact)
getContact' xContactId = getContact' xContactId =
maybeFirstRow toContact $ maybeFirstRow (toContact user) $
DB.query DB.query
db db
[sql| [sql|
@@ -1130,20 +1148,21 @@ deleteContactRequest db userId contactRequestId = do
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
createAcceptedContact :: DB.Connection -> User -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact createAcceptedContact :: DB.Connection -> User -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact
createAcceptedContact db User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
createdAt <- getCurrentTime createdAt <- getCurrentTime
customUserProfileId <- forM incognitoProfile $ \case customUserProfileId <- forM incognitoProfile $ \case
NewIncognito p -> createIncognitoProfile_ db userId createdAt p NewIncognito p -> createIncognitoProfile_ db userId createdAt p
ExistingIncognito LocalProfile {profileId = pId} -> pure pId ExistingIncognito LocalProfile {profileId = pId} -> pure pId
let contactUserPrefs = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
DB.execute DB.execute
db db
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?,?,?)" "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?,?,?)"
(userId, localDisplayName, profileId, True, contactUserPrefs, createdAt, createdAt, xContactId) (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, xContactId)
contactId <- insertedRowId db contactId <- insertedRowId db
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences = contactUserPrefs, createdAt = createdAt, updatedAt = createdAt} let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt}
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
getLiveSndFileTransfers db User {userId} = do getLiveSndFileTransfers db User {userId} = do
@@ -1246,8 +1265,8 @@ toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, v
Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt)
toMaybeConnection _ = Nothing toMaybeConnection _ = Nothing
getMatchingContacts :: DB.Connection -> UserId -> Contact -> IO [Contact] getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact]
getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
contactIds <- contactIds <-
map fromOnly map fromOnly
<$> DB.query <$> DB.query
@@ -1261,7 +1280,7 @@ getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displa
AND ((p.image IS NULL AND ? IS NULL) OR p.image = ?) AND ((p.image IS NULL AND ? IS NULL) OR p.image = ?)
|] |]
(userId, contactId, displayName, fullName, image, image) (userId, contactId, displayName, fullName, image, image)
rights <$> mapM (runExceptT . getContact db userId) contactIds rights <$> mapM (runExceptT . getContact db user) contactIds
createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> Contact -> ExceptT StoreError IO (Probe, Int64) createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> Contact -> ExceptT StoreError IO (Probe, Int64)
createSentProbe db gVar userId _to@Contact {contactId} = createSentProbe db gVar userId _to@Contact {contactId} =
@@ -1288,8 +1307,8 @@ deleteSentProbe db userId probeId =
"DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?" "DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?"
(userId, probeId) (userId, probeId)
matchReceivedProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact) matchReceivedProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do matchReceivedProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
let probeHash = C.sha256Hash probe let probeHash = C.sha256Hash probe
contactIds <- contactIds <-
map fromOnly map fromOnly
@@ -1309,10 +1328,10 @@ matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
(contactId, probe, probeHash, userId, currentTs, currentTs) (contactId, probe, probeHash, userId, currentTs, currentTs)
case contactIds of case contactIds of
[] -> pure Nothing [] -> pure Nothing
cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId) cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
matchReceivedProbeHash :: DB.Connection -> UserId -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe)) matchReceivedProbeHash :: DB.Connection -> User -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe))
matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash) = do matchReceivedProbeHash db user@User {userId} _from@Contact {contactId} (ProbeHash probeHash) = do
namesAndProbes <- namesAndProbes <-
DB.query DB.query
db db
@@ -1332,10 +1351,10 @@ matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash)
[] -> pure Nothing [] -> pure Nothing
(cId, probe) : _ -> (cId, probe) : _ ->
either (const Nothing) (Just . (,Probe probe)) either (const Nothing) (Just . (,Probe probe))
<$> runExceptT (getContact db userId cId) <$> runExceptT (getContact db user cId)
matchSentProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact) matchSentProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
contactIds <- contactIds <-
map fromOnly map fromOnly
<$> DB.query <$> DB.query
@@ -1350,7 +1369,7 @@ matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do
(userId, probe, contactId) (userId, probe, contactId)
case contactIds of case contactIds of
[] -> pure Nothing [] -> pure Nothing
cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId) cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO () mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO ()
mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do
@@ -1435,7 +1454,8 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)] = toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)] =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias} let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember_ groupMemberId c = ExceptT $ do getGroupAndMember_ groupMemberId c = ExceptT $ do
@@ -1587,6 +1607,7 @@ updateConnectionStatus db Connection {connId} connStatus = do
createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo
createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
fullGroupPreferences = mergeGroupPreferences groupPreferences
currentTs <- getCurrentTime currentTs <- getCurrentTime
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
groupId <- liftIO $ do groupId <- liftIO $ do
@@ -1603,7 +1624,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
memberId <- liftIO $ encodedRandomBytes gVar 12 memberId <- liftIO $ encodedRandomBytes gVar 12
membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs
let chatSettings = ChatSettings {enableNtfs = True} let chatSettings = ChatSettings {enableNtfs = True}
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs}
-- | creates a new group record for the group the current user was invited to, or returns an existing one -- | creates a new group record for the group the current user was invited to, or returns an existing one
createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId)
@@ -1630,6 +1651,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId)
createGroupInvitation_ = do createGroupInvitation_ = do
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
fullGroupPreferences = mergeGroupPreferences groupPreferences
ExceptT $ ExceptT $
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
currentTs <- liftIO getCurrentTime currentTs <- liftIO getCurrentTime
@@ -1647,7 +1669,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs
membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs
let chatSettings = ChatSettings {enableNtfs = True} let chatSettings = ChatSettings {enableNtfs = True}
pure (GroupInfo {groupId, localDisplayName, groupProfile, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}, groupMemberId) pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}, groupMemberId)
getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId
getHostMemberId_ db User {userId} groupId = getHostMemberId_ db User {userId} groupId =
@@ -1802,7 +1824,8 @@ toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) = toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) =
let membership = toGroupMember userContactId userMemberRow let membership = toGroupMember userContactId userMemberRow
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt} fullGroupPreferences = mergeGroupPreferences groupPreferences
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember
getGroupMember db user@User {userId} groupId groupMemberId = getGroupMember db user@User {userId} groupId groupMemberId =
@@ -1973,8 +1996,8 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co
) )
getContactViaMember :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) getContactViaMember :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
getContactViaMember db User {userId} GroupMember {groupMemberId} = getContactViaMember db user@User {userId} GroupMember {groupMemberId} =
maybeFirstRow toContact $ maybeFirstRow (toContact user) $
DB.query DB.query
db db
[sql| [sql|
@@ -2099,7 +2122,7 @@ cleanupMemberContactAndProfile_ :: DB.Connection -> User -> GroupMember -> IO ()
cleanupMemberContactAndProfile_ db user@User {userId} m@GroupMember {groupMemberId, localDisplayName, memberContactId, memberContactProfileId, memberProfile = LocalProfile {profileId}} = cleanupMemberContactAndProfile_ db user@User {userId} m@GroupMember {groupMemberId, localDisplayName, memberContactId, memberContactProfileId, memberProfile = LocalProfile {profileId}} =
case memberContactId of case memberContactId of
Just contactId -> Just contactId ->
runExceptT (getContact db userId contactId) >>= \case runExceptT (getContact db user contactId) >>= \case
Right ct@Contact {activeConn = Connection {connLevel, viaGroupLink}, contactUsed} -> Right ct@Contact {activeConn = Connection {connLevel, viaGroupLink}, contactUsed} ->
unless ((connLevel == 0 && not viaGroupLink) || contactUsed) $ deleteContact db user ct unless ((connLevel == 0 && not viaGroupLink) || contactUsed) $ deleteContact db user ct
_ -> pure () _ -> pure ()
@@ -2317,7 +2340,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow})
getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
getViaGroupContact db User {userId} GroupMember {groupMemberId} = getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
maybeFirstRow toContact' $ maybeFirstRow toContact' $
DB.query DB.query
db db
@@ -2344,7 +2367,8 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias} let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
activeConn = toConnection connRow activeConn = toConnection connRow
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta
createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do
@@ -2656,7 +2680,7 @@ getRcvFileTransfer db user@User {userId} fileId = do
rfi_ = \case rfi_ = \case
(Just filePath, Just connId, Just agentConnId, _, _, _, _) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId} (Just filePath, Just connId, Just agentConnId, _, _, _, _) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId}
(Just filePath, Nothing, Nothing, Just contactId, _, _, True) -> do (Just filePath, Nothing, Nothing, Just contactId, _, _, True) -> do
Contact {activeConn = Connection {connId, agentConnId}} <- getContact db userId contactId Contact {activeConn = Connection {connId, agentConnId}} <- getContact db user contactId
pure $ Just RcvFileInfo {filePath, connId, agentConnId} pure $ Just RcvFileInfo {filePath, connId, agentConnId}
(Just filePath, Nothing, Nothing, _, Just groupId, Just groupMemberId, True) -> do (Just filePath, Nothing, Nothing, _, Just groupId, Just groupMemberId, True) -> do
getGroupMember db user groupId groupMemberId >>= \case getGroupMember db user groupId groupMemberId >>= \case
@@ -3191,7 +3215,7 @@ getChatPreviews db user withPCC = do
ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo
getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat]
getDirectChatPreviews_ db User {userId} = do getDirectChatPreviews_ db user@User {userId} = do
tz <- getCurrentTimeZone tz <- getCurrentTimeZone
currentTs <- getCurrentTime currentTs <- getCurrentTime
map (toDirectChatPreview tz currentTs) map (toDirectChatPreview tz currentTs)
@@ -3230,7 +3254,7 @@ getDirectChatPreviews_ db User {userId} = do
WHERE item_status = ? AND item_deleted != 1 WHERE item_status = ? AND item_deleted != 1
GROUP BY contact_id GROUP BY contact_id
) ChatStats ON ChatStats.contact_id = ct.contact_id ) ChatStats ON ChatStats.contact_id = ct.contact_id
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
WHERE ct.user_id = ? WHERE ct.user_id = ?
AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1) AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1)
AND c.connection_id = ( AND c.connection_id = (
@@ -3250,7 +3274,7 @@ getDirectChatPreviews_ db User {userId} = do
where where
toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) = toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) =
let contact = toContact $ contactRow :. connRow let contact = toContact user $ contactRow :. connRow
ci_ = toDirectChatItemList tz currentTs ciRow_ ci_ = toDirectChatItemList tz currentTs ciRow_
stats = toChatStats statsRow stats = toChatStats statsRow
in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats
@@ -3307,7 +3331,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
) ChatStats ON ChatStats.group_id = g.group_id ) ChatStats ON ChatStats.group_id = g.group_id
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
WHERE g.user_id = ? AND mu.contact_id = ? WHERE g.user_id = ? AND mu.contact_id = ?
@@ -3417,8 +3441,8 @@ getDirectChat db user contactId pagination search_ = do
CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search
getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatLast_ db User {userId} contactId count search = do getDirectChatLast_ db user@User {userId} contactId count search = do
contact <- getContact db userId contactId contact <- getContact db user contactId
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsLast_ chatItems <- ExceptT getDirectChatItemsLast_
pure $ Chat (DirectChat contact) (reverse chatItems) stats pure $ Chat (DirectChat contact) (reverse chatItems) stats
@@ -3440,7 +3464,7 @@ getDirectChatLast_ db User {userId} contactId count search = do
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%' WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
ORDER BY i.chat_item_id DESC ORDER BY i.chat_item_id DESC
LIMIT ? LIMIT ?
@@ -3448,8 +3472,8 @@ getDirectChatLast_ db User {userId} contactId count search = do
(userId, contactId, search, count) (userId, contactId, search, count)
getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search = do
contact <- getContact db userId contactId contact <- getContact db user contactId
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsAfter_ chatItems <- ExceptT getDirectChatItemsAfter_
pure $ Chat (DirectChat contact) chatItems stats pure $ Chat (DirectChat contact) chatItems stats
@@ -3471,7 +3495,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%' WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
AND i.chat_item_id > ? AND i.chat_item_id > ?
ORDER BY i.chat_item_id ASC ORDER BY i.chat_item_id ASC
@@ -3480,8 +3504,8 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
(userId, contactId, search, afterChatItemId, count) (userId, contactId, search, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search = do getDirectChatBefore_ db user@User {userId} contactId beforeChatItemId count search = do
contact <- getContact db userId contactId contact <- getContact db user contactId
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsBefore_ chatItems <- ExceptT getDirectChatItemsBefore_
pure $ Chat (DirectChat contact) (reverse chatItems) stats pure $ Chat (DirectChat contact) (reverse chatItems) stats
@@ -3503,7 +3527,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search =
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%' WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
AND i.chat_item_id < ? AND i.chat_item_id < ?
ORDER BY i.chat_item_id DESC ORDER BY i.chat_item_id DESC
@@ -3516,9 +3540,9 @@ getContactIdByName db User {userId} cName =
ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $
DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName) DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName)
getContact :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO Contact getContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact
getContact db userId contactId = getContact db user@User {userId} contactId =
ExceptT . fmap join . firstRow toContactOrError (SEContactNotFound contactId) $ ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $
DB.query DB.query
db db
[sql| [sql|
@@ -3646,17 +3670,18 @@ getGroupInfo db User {userId, userContactId} groupId =
(groupId, userId, userContactId) (groupId, userId, userContactId)
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, groupPreferences}} p'@GroupProfile {displayName = newName, fullName, image} updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, image, groupPreferences}
| displayName == newName = liftIO $ do | displayName == newName = liftIO $ do
currentTs <- getCurrentTime currentTs <- getCurrentTime
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p'} updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p', fullGroupPreferences}
| otherwise = | otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime currentTs <- getCurrentTime
updateGroupProfile_ currentTs updateGroupProfile_ currentTs
updateGroup_ ldn currentTs updateGroup_ ldn currentTs
pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p'} pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences}
where where
fullGroupPreferences = mergeGroupPreferences groupPreferences
updateGroupProfile_ currentTs = updateGroupProfile_ currentTs =
DB.execute DB.execute
db db
@@ -3677,15 +3702,16 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou
(ldn, currentTs, userId, groupId) (ldn, currentTs, userId, groupId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
getAllChatItems :: DB.Connection -> User -> ChatPagination -> ExceptT StoreError IO [AChatItem] getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem]
getAllChatItems db user pagination = do getAllChatItems db user pagination search_ = do
let search = fromMaybe "" search_
case pagination of case pagination of
CPLast count -> getAllChatItemsLast_ db user count CPLast count -> getAllChatItemsLast_ db user count search
CPAfter _afterId _count -> throwError $ SEInternalError "not implemented" CPAfter afterId count -> getAllChatItemsAfter_ db user afterId count search
CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented" CPBefore beforeId count -> getAllChatItemsBefore_ db user beforeId count search
getAllChatItemsLast_ :: DB.Connection -> User -> Int -> ExceptT StoreError IO [AChatItem] getAllChatItemsLast_ :: DB.Connection -> User -> Int -> String -> ExceptT StoreError IO [AChatItem]
getAllChatItemsLast_ db user@User {userId} count = do getAllChatItemsLast_ db user@User {userId} count search = do
itemRefs <- itemRefs <-
liftIO $ liftIO $
reverse . rights . map toChatItemRef reverse . rights . map toChatItemRef
@@ -3694,13 +3720,59 @@ getAllChatItemsLast_ db user@User {userId} count = do
[sql| [sql|
SELECT chat_item_id, contact_id, group_id SELECT chat_item_id, contact_id, group_id
FROM chat_items FROM chat_items
WHERE user_id = ? WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
ORDER BY item_ts DESC, chat_item_id DESC ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, count) (userId, search, count)
mapM (uncurry $ getAChatItem_ db user) itemRefs mapM (uncurry $ getAChatItem_ db user) itemRefs
getAllChatItemsAfter_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO [AChatItem]
getAllChatItemsAfter_ db user@User {userId} afterItemId count search = do
AChatItem _ _ _ afterItem <- getAChatItem db user afterItemId
itemRefs <- liftIO $ getChatItemRefsAfter_ (chatItemTs' afterItem)
mapM (uncurry $ getAChatItem_ db user) itemRefs
where
getChatItemRefsAfter_ afterItemTs =
rights . map toChatItemRef
<$> DB.query
db
[sql|
SELECT chat_item_id, contact_id, group_id
FROM chat_items
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?))
ORDER BY item_ts ASC, chat_item_id ASC
LIMIT ?
|]
(userId, search, afterItemTs, afterItemTs, afterItemId, count)
getAllChatItemsBefore_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO [AChatItem]
getAllChatItemsBefore_ db user@User {userId} beforeItemId count search = do
AChatItem _ _ _ beforeItem <- getAChatItem db user beforeItemId
itemRefs <- liftIO $ getChatItemRefsBefore_ (chatItemTs' beforeItem)
mapM (uncurry $ getAChatItem_ db user) itemRefs
where
getChatItemRefsBefore_ beforeItemTs =
reverse . rights . map toChatItemRef
<$> DB.query
db
[sql|
SELECT chat_item_id, contact_id, group_id
FROM chat_items
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?))
ORDER BY item_ts DESC, chat_item_id DESC
LIMIT ?
|]
(userId, search, beforeItemTs, beforeItemTs, beforeItemId, count)
getAChatItem :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO AChatItem
getAChatItem db user@User {userId} itemId = do
afterItemRef <- ExceptT $ firstRow' toChatItemRef (SEChatItemNotFound itemId) $
DB.query db "SELECT chat_item_id, contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId)
uncurry (getAChatItem_ db user) afterItemRef
getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId
getGroupIdByName db User {userId} gName = getGroupIdByName db User {userId} gName =
ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $ ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $
@@ -3861,7 +3933,7 @@ getDirectChatItem db userId contactId itemId = ExceptT $ do
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ? WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ?
|] |]
(userId, contactId, itemId) (userId, contactId, itemId)
@@ -3986,7 +4058,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ? WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ?
@@ -4072,7 +4144,7 @@ getChatItemByGroupId db user@User {userId} groupId = do
getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem
getAChatItem_ db user@User {userId} itemId = \case getAChatItem_ db user@User {userId} itemId = \case
ChatRef CTDirect contactId -> do ChatRef CTDirect contactId -> do
ct <- getContact db userId contactId ct <- getContact db user contactId
(CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId (CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId
pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci
ChatRef CTGroup groupId -> do ChatRef CTGroup groupId -> do
@@ -4234,35 +4306,38 @@ toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just
either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_)
toGroupChatItemList _ _ _ _ = [] toGroupChatItemList _ _ _ _ = []
getSMPServers :: DB.Connection -> User -> IO [SMPServer] getSMPServers :: DB.Connection -> User -> IO [ServerCfg]
getSMPServers db User {userId} = getSMPServers db User {userId} =
map toSmpServer map toServerCfg
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT host, port, key_hash SELECT host, port, key_hash, basic_auth, preset, tested, enabled
FROM smp_servers FROM smp_servers
WHERE user_id = ?; WHERE user_id = ?;
|] |]
(Only userId) (Only userId)
where where
toSmpServer :: (NonEmpty TransportHost, String, C.KeyHash) -> SMPServer toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg
toSmpServer (host, port, keyHash) = SMPServer host port keyHash toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
let server = ProtoServerWithAuth (SMPServer host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
in ServerCfg {server, preset, tested, enabled}
overwriteSMPServers :: DB.Connection -> User -> [SMPServer] -> ExceptT StoreError IO () overwriteSMPServers :: DB.Connection -> User -> [ServerCfg] -> ExceptT StoreError IO ()
overwriteSMPServers db User {userId} smpServers = overwriteSMPServers db User {userId} servers =
checkConstraint SEUniqueID . ExceptT $ do checkConstraint SEUniqueID . ExceptT $ do
currentTs <- getCurrentTime currentTs <- getCurrentTime
DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId) DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId)
forM_ smpServers $ \ProtocolServer {host, port, keyHash} -> forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do
let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server
DB.execute DB.execute
db db
[sql| [sql|
INSERT INTO smp_servers INSERT INTO smp_servers
(host, port, key_hash, user_id, created_at, updated_at) (host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?)
|] |]
(host, port, keyHash, userId, currentTs, currentTs) (host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, preset, tested, enabled, userId, currentTs, currentTs)
pure $ Right () pure $ Right ()
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
+3 -1
View File
@@ -12,6 +12,7 @@ import Control.Monad.Reader
import Data.List (dropWhileEnd) import Data.List (dropWhileEnd)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8) import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (getCurrentTime)
import Simplex.Chat import Simplex.Chat
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Chat.Styled import Simplex.Chat.Styled
@@ -41,7 +42,8 @@ runInputLoop ct cc = forever $ do
_ -> pure () _ -> pure ()
let testV = testView $ config cc let testV = testView $ config cc
user <- readTVarIO $ currentUser cc user <- readTVarIO $ currentUser cc
printToTerminal ct $ responseToView user testV r ts <- getCurrentTime
printToTerminal ct $ responseToView user testV ts r
where where
echo s = printToTerminal ct [plain s] echo s = printToTerminal ct [plain s]
isMessage = \case isMessage = \case
+3 -1
View File
@@ -10,6 +10,7 @@ module Simplex.Chat.Terminal.Output where
import Control.Monad.Catch (MonadMask) import Control.Monad.Catch (MonadMask)
import Control.Monad.IO.Unlift import Control.Monad.IO.Unlift
import Control.Monad.Reader import Control.Monad.Reader
import Data.Time.Clock (getCurrentTime)
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Chat.Styled import Simplex.Chat.Styled
import Simplex.Chat.View import Simplex.Chat.View
@@ -78,7 +79,8 @@ runTerminalOutput ct cc = do
forever $ do forever $ do
(_, r) <- atomically . readTBQueue $ outputQ cc (_, r) <- atomically . readTBQueue $ outputQ cc
user <- readTVarIO $ currentUser cc user <- readTVarIO $ currentUser cc
printToTerminal ct $ responseToView user testV r ts <- getCurrentTime
printToTerminal ct $ responseToView user testV ts r
printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal :: ChatTerminal -> [StyledString] -> IO ()
printToTerminal ct s = printToTerminal ct s =
+112 -36
View File
@@ -44,6 +44,7 @@ import GHC.Generics (Generic)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId)
import Simplex.Messaging.Encoding.String import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Protocol (SMPServerWithAuth)
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
class IsContact a where class IsContact a where
@@ -66,6 +67,7 @@ data User = User
userContactId :: ContactId, userContactId :: ContactId,
localDisplayName :: ContactName, localDisplayName :: ContactName,
profile :: LocalProfile, profile :: LocalProfile,
fullPreferences :: FullPreferences,
activeUser :: Bool activeUser :: Bool
} }
deriving (Show, Generic, FromJSON) deriving (Show, Generic, FromJSON)
@@ -87,6 +89,7 @@ data Contact = Contact
contactUsed :: Bool, contactUsed :: Bool,
chatSettings :: ChatSettings, chatSettings :: ChatSettings,
userPreferences :: Preferences, userPreferences :: Preferences,
mergedPreferences :: ContactUserPreferences,
createdAt :: UTCTime, createdAt :: UTCTime,
updatedAt :: UTCTime updatedAt :: UTCTime
} }
@@ -100,13 +103,10 @@ contactConn :: Contact -> Connection
contactConn = activeConn contactConn = activeConn
contactConnId :: Contact -> ConnId contactConnId :: Contact -> ConnId
contactConnId Contact {activeConn} = aConnId activeConn contactConnId = aConnId . contactConn
contactConnIncognito :: Contact -> Bool contactConnIncognito :: Contact -> Bool
contactConnIncognito = isJust . customUserProfileId' contactConnIncognito = connIncognito . contactConn
customUserProfileId' :: Contact -> Maybe Int64
customUserProfileId' Contact {activeConn} = customUserProfileId (activeConn :: Connection)
data ContactRef = ContactRef data ContactRef = ContactRef
{ contactId :: ContactId, { contactId :: ContactId,
@@ -207,6 +207,7 @@ data GroupInfo = GroupInfo
{ groupId :: GroupId, { groupId :: GroupId,
localDisplayName :: GroupName, localDisplayName :: GroupName,
groupProfile :: GroupProfile, groupProfile :: GroupProfile,
fullGroupPreferences :: FullGroupPreferences,
membership :: GroupMember, membership :: GroupMember,
hostConnCustomUserProfileId :: Maybe ProfileId, hostConnCustomUserProfileId :: Maybe ProfileId,
chatSettings :: ChatSettings, chatSettings :: ChatSettings,
@@ -293,6 +294,39 @@ data Preferences = Preferences
} }
deriving (Eq, Show, Generic, FromJSON) deriving (Eq, Show, Generic, FromJSON)
instance ToJSON Preferences where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
instance ToField Preferences where
toField = toField . encodeJSON
instance FromField Preferences where
fromField = fromTextField_ decodeJSON
groupPrefSel :: ChatFeature -> GroupPreferences -> Maybe GroupPreference
groupPrefSel = \case
CFFullDelete -> fullDelete
-- CFReceipts -> receipts
CFVoice -> voice
class GroupPreferenceI p where
getGroupPreference :: ChatFeature -> p -> GroupPreference
instance GroupPreferenceI GroupPreferences where
getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt prefs)
instance GroupPreferenceI (Maybe GroupPreferences) where
getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs)
instance GroupPreferenceI FullGroupPreferences where
getGroupPreference = \case
CFFullDelete -> fullDelete
-- CFReceipts -> receipts
CFVoice -> voice
{-# INLINE getGroupPreference #-}
-- collection of optional group preferences
data GroupPreferences = GroupPreferences data GroupPreferences = GroupPreferences
{ fullDelete :: Maybe GroupPreference, { fullDelete :: Maybe GroupPreference,
-- receipts :: Maybe GroupPreference, -- receipts :: Maybe GroupPreference,
@@ -317,7 +351,20 @@ data FullPreferences = FullPreferences
-- receipts :: Preference, -- receipts :: Preference,
voice :: Preference voice :: Preference
} }
deriving (Eq) deriving (Eq, Show, Generic, FromJSON)
instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions
-- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing
-- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here.
data FullGroupPreferences = FullGroupPreferences
{ fullDelete :: GroupPreference,
-- receipts :: GroupPreference,
voice :: GroupPreference
}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions
-- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences -- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences
data ContactUserPreferences = ContactUserPreferences data ContactUserPreferences = ContactUserPreferences
@@ -325,17 +372,17 @@ data ContactUserPreferences = ContactUserPreferences
-- receipts :: ContactUserPreference, -- receipts :: ContactUserPreference,
voice :: ContactUserPreference voice :: ContactUserPreference
} }
deriving (Show, Generic) deriving (Eq, Show, Generic)
data ContactUserPreference = ContactUserPreference data ContactUserPreference = ContactUserPreference
{ enabled :: PrefEnabled, { enabled :: PrefEnabled,
userPreference :: ContactUserPref, userPreference :: ContactUserPref,
contactPreference :: Preference contactPreference :: Preference
} }
deriving (Show, Generic) deriving (Eq, Show, Generic)
data ContactUserPref = CUPContact {preference :: Preference} | CUPUser {preference :: Preference} data ContactUserPref = CUPContact {preference :: Preference} | CUPUser {preference :: Preference}
deriving (Show, Generic) deriving (Eq, Show, Generic)
instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions
@@ -364,26 +411,24 @@ defaultChatPrefs =
emptyChatPrefs :: Preferences emptyChatPrefs :: Preferences
emptyChatPrefs = Preferences Nothing Nothing emptyChatPrefs = Preferences Nothing Nothing
instance ToJSON Preferences where defaultGroupPrefs :: FullGroupPreferences
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} defaultGroupPrefs =
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} FullGroupPreferences
{ fullDelete = GroupPreference {enable = FEOff},
instance ToField Preferences where -- receipts = GroupPreference {enable = FEOff},
toField = toField . encodeJSON voice = GroupPreference {enable = FEOn}
}
instance FromField Preferences where
fromField = fromTextField_ decodeJSON
data Preference = Preference data Preference = Preference
{allow :: FeatureAllowed} {allow :: FeatureAllowed}
deriving (Eq, Show, Generic, FromJSON) deriving (Eq, Show, Generic, FromJSON)
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
data GroupPreference = GroupPreference data GroupPreference = GroupPreference
{enable :: GroupFeatureEnabled} {enable :: GroupFeatureEnabled}
deriving (Eq, Show, Generic, FromJSON) deriving (Eq, Show, Generic, FromJSON)
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
data FeatureAllowed data FeatureAllowed
@@ -392,9 +437,6 @@ data FeatureAllowed
| FANo -- do not allow | FANo -- do not allow
deriving (Eq, Show, Generic) deriving (Eq, Show, Generic)
data GroupFeatureEnabled = FEOn | FEOff
deriving (Eq, Show, Generic)
instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode
instance ToField FeatureAllowed where toField = toField . strEncode instance ToField FeatureAllowed where toField = toField . strEncode
@@ -418,6 +460,9 @@ instance ToJSON FeatureAllowed where
toJSON = strToJSON toJSON = strToJSON
toEncoding = strToJEncoding toEncoding = strToJEncoding
data GroupFeatureEnabled = FEOn | FEOff
deriving (Eq, Show, Generic)
instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode
instance ToField GroupFeatureEnabled where toField = toField . strEncode instance ToField GroupFeatureEnabled where toField = toField . strEncode
@@ -452,12 +497,25 @@ mergePreferences contactPrefs userPreferences =
in fromMaybe (getPreference pt defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel) in fromMaybe (getPreference pt defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel)
mergeUserChatPrefs :: User -> Contact -> FullPreferences mergeUserChatPrefs :: User -> Contact -> FullPreferences
mergeUserChatPrefs user ct = mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct)
let userPrefs = if contactConnIncognito ct then Nothing else preferences' user
in mergePreferences (Just $ userPreferences ct) userPrefs mergeUserChatPrefs' :: User -> Bool -> Preferences -> FullPreferences
mergeUserChatPrefs' user connectedIncognito userPreferences =
let userPrefs = if connectedIncognito then Nothing else preferences' user
in mergePreferences (Just userPreferences) userPrefs
mergeGroupPreferences :: Maybe GroupPreferences -> FullGroupPreferences
mergeGroupPreferences groupPreferences =
FullGroupPreferences
{ fullDelete = pref CFFullDelete,
-- receipts = pref CFReceipts,
voice = pref CFVoice
}
where
pref pt = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPreferences >>= groupPrefSel pt)
data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool} data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool}
deriving (Show, Generic) deriving (Eq, Show, Generic)
instance ToJSON PrefEnabled where instance ToJSON PrefEnabled where
toJSON = J.genericToJSON J.defaultOptions toJSON = J.genericToJSON J.defaultOptions
@@ -471,8 +529,8 @@ prefEnabled Preference {allow = user} Preference {allow = contact} = case (user,
(FANo, _) -> PrefEnabled False False (FANo, _) -> PrefEnabled False False
_ -> PrefEnabled True True _ -> PrefEnabled True True
contactUserPreferences :: User -> Contact -> ContactUserPreferences contactUserPreferences :: User -> Preferences -> Maybe Preferences -> Bool -> ContactUserPreferences
contactUserPreferences user ct = contactUserPreferences user userPreferences contactPreferences connectedIncognito =
ContactUserPreferences ContactUserPreferences
{ fullDelete = pref CFFullDelete, { fullDelete = pref CFFullDelete,
-- receipts = pref CFReceipts, -- receipts = pref CFReceipts,
@@ -483,19 +541,19 @@ contactUserPreferences user ct =
ContactUserPreference ContactUserPreference
{ enabled = prefEnabled userPref ctPref, { enabled = prefEnabled userPref ctPref,
-- incognito contact cannot have default user preference used -- incognito contact cannot have default user preference used
userPreference = if contactConnIncognito ct then CUPContact ctUserPref else maybe (CUPUser userPref) CUPContact ctUserPref_, userPreference = if connectedIncognito then CUPContact ctUserPref else maybe (CUPUser userPref) CUPContact ctUserPref_,
contactPreference = ctPref contactPreference = ctPref
} }
where where
ctUserPref = getPreference pt $ userPreferences ct ctUserPref = getPreference pt userPreferences
ctUserPref_ = chatPrefSel pt $ userPreferences ct ctUserPref_ = chatPrefSel pt userPreferences
userPref = getPreference pt ctUserPrefs userPref = getPreference pt ctUserPrefs
ctPref = getPreference pt ctPrefs ctPref = getPreference pt ctPrefs
ctUserPrefs = mergeUserChatPrefs user ct ctUserPrefs = mergeUserChatPrefs' user connectedIncognito userPreferences
ctPrefs = mergePreferences (preferences' ct) Nothing ctPrefs = mergePreferences contactPreferences Nothing
getContactUserPrefefence :: ChatFeature -> ContactUserPreferences -> ContactUserPreference getContactUserPreference :: ChatFeature -> ContactUserPreferences -> ContactUserPreference
getContactUserPrefefence = \case getContactUserPreference = \case
CFFullDelete -> fullDelete CFFullDelete -> fullDelete
-- CFReceipts -> receipts -- CFReceipts -> receipts
CFVoice -> voice CFVoice -> voice
@@ -1144,6 +1202,9 @@ data Connection = Connection
aConnId :: Connection -> ConnId aConnId :: Connection -> ConnId
aConnId Connection {agentConnId = AgentConnId cId} = cId aConnId Connection {agentConnId = AgentConnId cId} = cId
connIncognito :: Connection -> Bool
connIncognito Connection {customUserProfileId} = isJust customUserProfileId
instance ToJSON Connection where instance ToJSON Connection where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
@@ -1389,3 +1450,18 @@ encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode
decodeJSON :: FromJSON a => Text -> Maybe a decodeJSON :: FromJSON a => Text -> Maybe a
decodeJSON = J.decode . LB.fromStrict . encodeUtf8 decodeJSON = J.decode . LB.fromStrict . encodeUtf8
data ServerCfg = ServerCfg
{ server :: SMPServerWithAuth,
preset :: Bool,
tested :: Maybe Bool,
enabled :: Bool
}
deriving (Show, Generic)
instance ToJSON ServerCfg where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
instance FromJSON ServerCfg where
parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
+123 -88
View File
@@ -18,10 +18,11 @@ import Data.Char (toUpper)
import Data.Function (on) import Data.Function (on)
import Data.Int (Int64) import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn) import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
import qualified Data.List.NonEmpty as L
import Data.Maybe (isJust, isNothing, mapMaybe) import Data.Maybe (isJust, isNothing, mapMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time.Clock (DiffTime) import Data.Time.Clock (DiffTime, UTCTime)
import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime) import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime)
import GHC.Generics (Generic) import GHC.Generics (Generic)
@@ -37,6 +38,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
import Simplex.Chat.Styled import Simplex.Chat.Styled
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Messaging.Agent.Client (SMPTestFailure (..), SMPTestStep (..))
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
@@ -49,11 +51,13 @@ import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (bshow) import Simplex.Messaging.Util (bshow)
import System.Console.ANSI.Types import System.Console.ANSI.Types
serializeChatResponse :: Maybe User -> ChatResponse -> String type CurrentTime = UTCTime
serializeChatResponse user_ = unlines . map unStyle . responseToView user_ False
responseToView :: Maybe User -> Bool -> ChatResponse -> [StyledString] serializeChatResponse :: Maybe User -> CurrentTime -> ChatResponse -> String
responseToView user_ testView = \case serializeChatResponse user_ ts = unlines . map unStyle . responseToView user_ False ts
responseToView :: Maybe User -> Bool -> CurrentTime -> ChatResponse -> [StyledString]
responseToView user_ testView ts = \case
CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
CRChatStarted -> ["chat started"] CRChatStarted -> ["chat started"]
CRChatRunning -> ["chat is running"] CRChatRunning -> ["chat is running"]
@@ -62,20 +66,21 @@ responseToView user_ testView = \case
CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats]
CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat]
CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft]
CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRUserSMPServers smpServers _ -> viewSMPServers (L.toList smpServers) testView
CRSmpTestResult testFailure -> viewSMPTestResult testFailure
CRChatItemTTL ttl -> viewChatItemTTL ttl CRChatItemTTL ttl -> viewChatItemTTL ttl
CRNetworkConfig cfg -> viewNetworkConfig cfg CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats
CRContactSwitch ct progress -> viewContactSwitch ct progress CRContactSwitch ct progress -> viewContactSwitch ct progress
CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress
CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems CRApiChatItems chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems
CRChatItemStatusUpdated _ -> [] CRChatItemStatusUpdated _ -> []
CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item ts
CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem ts
CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"] CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"]
CRBroadcastSent mc n ts -> viewSentBroadcast mc n ts CRBroadcastSent mc n t -> viewSentBroadcast mc n ts t
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
CRCmdAccepted _ -> [] CRCmdAccepted _ -> []
CRCmdOk -> ["ok"] CRCmdOk -> ["ok"]
@@ -123,13 +128,13 @@ responseToView user_ testView = \case
CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts
CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft
CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' CRUserProfileUpdated p p' -> viewUserProfileUpdated p p'
CRContactPrefsUpdated {fromContact, toContact, preferences} -> case user_ of CRContactPrefsUpdated {fromContact, toContact} -> case user_ of
Just user -> viewUserContactPrefsUpdated user fromContact toContact preferences Just user -> viewUserContactPrefsUpdated user fromContact toContact
_ -> ["unexpected chat event CRContactPrefsUpdated without current user"] _ -> ["unexpected chat event CRContactPrefsUpdated without current user"]
CRContactAliasUpdated c -> viewContactAliasUpdated c CRContactAliasUpdated c -> viewContactAliasUpdated c
CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c
CRContactUpdated {fromContact = c, toContact = c', preferences} -> case user_ of CRContactUpdated {fromContact = c, toContact = c'} -> case user_ of
Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' preferences Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c'
_ -> ["unexpected chat event CRContactUpdated without current user"] _ -> ["unexpected chat event CRContactUpdated without current user"]
CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt
CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile
@@ -256,8 +261,8 @@ showSMPServer = B.unpack . strEncode . host
viewHostEvent :: AProtocolType -> TransportHost -> String viewHostEvent :: AProtocolType -> TransportHost -> String
viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h)
viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> [StyledString] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> [StyledString]
viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = case chat of viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow ts = case chat of
DirectChat c -> case chatDir of DirectChat c -> case chatDir of
CIDirectSnd -> case content of CIDirectSnd -> case content of
CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc
@@ -267,7 +272,7 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
to = ttyToContact' c to = ttyToContact' c
CIDirectRcv -> case content of CIDirectRcv -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err meta CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta
CIRcvGroupEvent {} -> showRcvItemProhibited from CIRcvGroupEvent {} -> showRcvItemProhibited from
_ -> showRcvItem from _ -> showRcvItem from
where where
@@ -283,7 +288,7 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
to = ttyToGroup g to = ttyToGroup g
CIGroupRcv m -> case content of CIGroupRcv m -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err meta CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta
CIRcvGroupInvitation {} -> showRcvItemProhibited from CIRcvGroupInvitation {} -> showRcvItemProhibited from
_ -> showRcvItem from _ -> showRcvItem from
where where
@@ -294,26 +299,26 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
where where
withSndFile = withFile viewSentFileInvitation withSndFile = withFile viewSentFileInvitation
withRcvFile = withFile viewReceivedFileInvitation withRcvFile = withFile viewReceivedFileInvitation
withFile view dir l = maybe l (\f -> l <> view dir f meta) file withFile view dir l = maybe l (\f -> l <> view dir f ts meta) file
sndMsg = msg viewSentMessage sndMsg = msg viewSentMessage
rcvMsg = msg viewReceivedMessage rcvMsg = msg viewReceivedMessage
msg view dir quote mc = case (msgContentText mc, file, quote) of msg view dir quote mc = case (msgContentText mc, file, quote) of
("", Just _, []) -> [] ("", Just _, []) -> []
("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) meta ("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) ts meta
_ -> view dir quote mc meta _ -> view dir quote mc ts meta
showSndItem to = showItem $ sentWithTime_ [to <> plainContent content] meta showSndItem to = showItem $ sentWithTime_ ts [to <> plainContent content] meta
showRcvItem from = showItem $ receivedWithTime_ from [] meta [plainContent content] showRcvItem from = showItem $ receivedWithTime_ ts from [] meta [plainContent content]
showSndItemProhibited to = showItem $ sentWithTime_ [to <> plainContent content <> " " <> prohibited] meta showSndItemProhibited to = showItem $ sentWithTime_ ts [to <> plainContent content <> " " <> prohibited] meta
showRcvItemProhibited from = showItem $ receivedWithTime_ from [] meta [plainContent content <> " " <> prohibited] showRcvItemProhibited from = showItem $ receivedWithTime_ ts from [] meta [plainContent content <> " " <> prohibited]
showItem ss = if doShow then ss else [] showItem ss = if doShow then ss else []
plainContent = plain . ciContentToText plainContent = plain . ciContentToText
prohibited = styled (colored Red) ("[prohibited - it's a bug if this chat item was created in this context, please report it to dev team]" :: String) prohibited = styled (colored Red) ("[prohibited - it's a bug if this chat item was created in this context, please report it to dev team]" :: String)
viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> CurrentTime -> [StyledString]
viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} ts = case chat of
DirectChat Contact {localDisplayName = c} -> case chatDir of DirectChat Contact {localDisplayName = c} -> case chatDir of
CIDirectRcv -> case content of CIDirectRcv -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta CIRcvMsgContent mc -> viewReceivedMessage from quote mc ts meta
_ -> [] _ -> []
where where
from = ttyFromContactEdited c from = ttyFromContactEdited c
@@ -321,7 +326,7 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
CIDirectSnd -> ["message updated"] CIDirectSnd -> ["message updated"]
GroupChat g -> case chatDir of GroupChat g -> case chatDir of
CIGroupRcv GroupMember {localDisplayName = m} -> case content of CIGroupRcv GroupMember {localDisplayName = m} -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta CIRcvMsgContent mc -> viewReceivedMessage from quote mc ts meta
_ -> [] _ -> []
where where
from = ttyFromGroupEdited g m from = ttyFromGroupEdited g m
@@ -329,16 +334,16 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
CIGroupSnd -> ["message updated"] CIGroupSnd -> ["message updated"]
_ -> [] _ -> []
viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> [StyledString] viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> CurrentTime -> [StyledString]
viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} = case chat of viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} ts = case chat of
DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) of DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) of
(CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of (CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc meta CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc ts meta
CIDMInternal -> ["message deleted"] CIDMInternal -> ["message deleted"]
_ -> ["message deleted"] _ -> ["message deleted"]
GroupChat g -> case (chatDir, deletedContent, toContent) of GroupChat g -> case (chatDir, deletedContent, toContent) of
(CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of (CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] mc meta CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] mc ts meta
CIDMInternal -> ["message deleted"] CIDMInternal -> ["message deleted"]
_ -> ["message deleted"] _ -> ["message deleted"]
_ -> [] _ -> []
@@ -365,8 +370,8 @@ msgPreview = msgPlain . preview . msgContentText
| T.length t <= 120 = t | T.length t <= 120 = t
| otherwise = T.take 120 t <> "..." | otherwise = T.take 120 t <> "..."
viewRcvIntegrityError :: StyledString -> MsgErrorType -> CIMeta 'MDRcv -> [StyledString] viewRcvIntegrityError :: StyledString -> MsgErrorType -> CurrentTime -> CIMeta 'MDRcv -> [StyledString]
viewRcvIntegrityError from msgErr meta = receivedWithTime_ from [] meta $ viewMsgIntegrityError msgErr viewRcvIntegrityError from msgErr ts meta = receivedWithTime_ ts from [] meta $ viewMsgIntegrityError msgErr
viewMsgIntegrityError :: MsgErrorType -> [StyledString] viewMsgIntegrityError :: MsgErrorType -> [StyledString]
viewMsgIntegrityError err = msgError $ case err of viewMsgIntegrityError err = msgError $ case err of
@@ -618,15 +623,16 @@ viewUserProfile Profile {displayName, fullName} =
"(the updated profile will be sent to all your contacts)" "(the updated profile will be sent to all your contacts)"
] ]
viewSMPServers :: [SMPServer] -> Bool -> [StyledString] viewSMPServers :: [ServerCfg] -> Bool -> [StyledString]
viewSMPServers smpServers testView = viewSMPServers smpServers testView =
if testView if testView
then [customSMPServers] then [customSMPServers]
else else
[ customSMPServers, [ customSMPServers,
"", "",
"use " <> highlight' "/smp_servers <srv1[,srv2,...]>" <> " to switch to custom SMP servers", "use " <> highlight' "/smp test <srv>" <> " to test SMP server connection",
"use " <> highlight' "/smp_servers default" <> " to remove custom SMP servers and use default", "use " <> highlight' "/smp set <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
"use " <> highlight' "/smp default" <> " to remove custom SMP servers and use default",
"(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)" "(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"
] ]
where where
@@ -635,6 +641,16 @@ viewSMPServers smpServers testView =
then "no custom SMP servers saved" then "no custom SMP servers saved"
else viewServers smpServers else viewServers smpServers
viewSMPTestResult :: Maybe SMPTestFailure -> [StyledString]
viewSMPTestResult = \case
Just SMPTestFailure {testStep, testError} ->
result
<> ["Server requires authentication to create queues, check password" | testStep == TSCreateQueue && testError == SMP SMP.AUTH]
<> ["Possibly, certificate fingerprint in server address is incorrect" | testStep == TSConnect && testError == BROKER NETWORK]
where
result = ["SMP server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> plain (strEncode testError)]
_ -> ["SMP server test passed"]
viewChatItemTTL :: Maybe Int64 -> [StyledString] viewChatItemTTL :: Maybe Int64 -> [StyledString]
viewChatItemTTL = \case viewChatItemTTL = \case
Nothing -> ["old messages are not being deleted"] Nothing -> ["old messages are not being deleted"]
@@ -650,7 +666,7 @@ viewNetworkConfig :: NetworkConfig -> [StyledString]
viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} = viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
[ plain $ maybe "direct network connection" (("using SOCKS5 proxy " <>) . show) socksProxy, [ plain $ maybe "direct network connection" (("using SOCKS5 proxy " <>) . show) socksProxy,
"TCP timeout: " <> sShow tcpTimeout, "TCP timeout: " <> sShow tcpTimeout,
"use `/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]` to change settings" "use " <> highlight' "/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]" <> " to change settings"
] ]
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
@@ -675,8 +691,8 @@ viewConnectionStats ConnectionStats {rcvServers, sndServers} =
["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers] ["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers]
<> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers] <> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers]
viewServers :: [SMPServer] -> StyledString viewServers :: [ServerCfg] -> StyledString
viewServers = plain . intercalate ", " . map (B.unpack . strEncode) viewServers = plain . intercalate ", " . map (B.unpack . strEncode . (\ServerCfg {server} -> server))
viewServerHosts :: [SMPServer] -> StyledString viewServerHosts :: [SMPServer] -> StyledString
viewServerHosts = plain . intercalate ", " . map showSMPServer viewServerHosts = plain . intercalate ", " . map showSMPServer
@@ -708,15 +724,15 @@ viewUserProfileUpdated Profile {displayName = n, fullName, image, preferences} P
| otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified] | otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified]
notified = " (your contacts are notified)" notified = " (your contacts are notified)"
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString] viewUserContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
viewUserContactPrefsUpdated user ct ct' cups viewUserContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
| null prefs = ["your preferences for " <> ttyContact' ct' <> " did not change"] | null prefs = ["your preferences for " <> ttyContact' ct' <> " did not change"]
| otherwise = ("you updated preferences for " <> ttyContact' ct' <> ":") : prefs | otherwise = ("you updated preferences for " <> ttyContact' ct' <> ":") : prefs
where where
prefs = viewContactPreferences user ct ct' cups prefs = viewContactPreferences user ct ct' cups
viewContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString] viewContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
viewContactPrefsUpdated user ct ct' cups viewContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
| null prefs = [] | null prefs = []
| otherwise = (ttyContact' ct' <> " updated preferences for you:") : prefs | otherwise = (ttyContact' ct' <> " updated preferences for you:") : prefs
where where
@@ -734,7 +750,7 @@ viewContactPref userPrefs userPrefs' ctPrefs cups pt
userPref = getPreference pt userPrefs userPref = getPreference pt userPrefs
userPref' = getPreference pt userPrefs' userPref' = getPreference pt userPrefs'
ctPref = getPreference pt ctPrefs ctPref = getPreference pt ctPrefs
ContactUserPreference {enabled, userPreference, contactPreference} = getContactUserPrefefence pt cups ContactUserPreference {enabled, userPreference, contactPreference} = getContactUserPreference pt cups
viewPrefsUpdated :: Maybe Preferences -> Maybe Preferences -> [StyledString] viewPrefsUpdated :: Maybe Preferences -> Maybe Preferences -> [StyledString]
viewPrefsUpdated ps ps' viewPrefsUpdated ps ps'
@@ -769,15 +785,36 @@ viewPrefEnabled = \case
viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString] viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString]
viewGroupUpdated viewGroupUpdated
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image}} GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image, groupPreferences = gps}}
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image'}} g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image', groupPreferences = gps'}}
m m = do
| n == n' && fullName == fullName' && image == image' = [] let update = groupProfileUpdated <> groupPrefsUpdated
| n == n' && fullName == fullName' = ["group " <> ttyGroup n <> ": profile image " <> (if isNothing image' then "removed" else "updated") <> byMember] if null update
| n == n' = ["group " <> ttyGroup n <> ": full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName' <> byMember] then []
| otherwise = ["group " <> ttyGroup n <> " is changed to " <> ttyFullGroup g' <> byMember] else memberUpdated <> update
where where
byMember = maybe "" ((" by " <>) . ttyMember) m memberUpdated = maybe [] (\m' -> [ttyMember m' <> " updated group " <> ttyGroup n <> ":"]) m
groupProfileUpdated
| n == n' && fullName == fullName' && image == image' = []
| n == n' && fullName == fullName' = ["profile image " <> (if isNothing image' then "removed" else "updated")]
| n == n' = ["full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName']
| otherwise = ["changed to " <> ttyFullGroup g']
groupPrefsUpdated
| null prefs = []
| otherwise = "updated group preferences:" : prefs
where
prefs = mapMaybe viewPref allChatFeatures
viewPref pt
| pref gps == pref gps' = Nothing
| otherwise = Just $ plain (chatPrefName pt) <> " enabled: " <> viewGroupPreference (pref gps')
where
pref pss = getGroupPreference pt $ mergeGroupPreferences pss
viewGroupPreference :: GroupPreference -> StyledString
viewGroupPreference = \case
GroupPreference {enable} -> case enable of
FEOn -> "on"
FEOff -> "off"
viewContactAliasUpdated :: Contact -> [StyledString] viewContactAliasUpdated :: Contact -> [StyledString]
viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}} viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}}
@@ -802,36 +839,37 @@ viewContactUpdated
where where
fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName'
viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString] viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta d -> [StyledString]
viewReceivedMessage from quote mc meta = receivedWithTime_ from quote meta (ttyMsgContent mc) viewReceivedMessage from quote mc ts meta = receivedWithTime_ ts from quote meta (ttyMsgContent mc)
receivedWithTime_ :: StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString] receivedWithTime_ :: CurrentTime -> StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString]
receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do receivedWithTime_ ts from quote CIMeta {localItemTs} styledMsg = do
prependFirst (formattedTime <> " " <> from) (quote <> prependFirst indent styledMsg) prependFirst (ttyMsgTime ts localItemTs <> " " <> from) (quote <> prependFirst indent styledMsg)
where
indent = if null quote then "" else " "
formattedTime :: StyledString
formattedTime =
let localTime = zonedTimeToLocalTime localItemTs
tz = zonedTimeZone localItemTs
format =
if (localDay localTime < localDay (zonedTimeToLocalTime $ utcToZonedTime tz createdAt))
&& (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime))
then "%m-%d" -- if message is from yesterday or before and 6 hours has passed since midnight
else "%H:%M"
in styleTime $ formatTime defaultTimeLocale format localTime
viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString]
viewSentMessage to quote mc = sentWithTime_ (prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc))
where where
indent = if null quote then "" else " " indent = if null quote then "" else " "
viewSentBroadcast :: MsgContent -> Int -> ZonedTime -> [StyledString] ttyMsgTime :: CurrentTime -> ZonedTime -> StyledString
viewSentBroadcast mc n ts = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts <> " ") (ttyMsgContent mc) ttyMsgTime ts t =
let localTime = zonedTimeToLocalTime t
tz = zonedTimeZone t
fmt =
if (localDay localTime < localDay (zonedTimeToLocalTime $ utcToZonedTime tz ts))
&& (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime))
then "%m-%d" -- if message is from yesterday or before and 6 hours has passed since midnight
else "%H:%M"
in styleTime $ formatTime defaultTimeLocale fmt localTime
viewSentFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString] viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta d -> [StyledString]
viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} = case filePath of viewSentMessage to quote mc ts = sentWithTime_ ts (prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc))
Just fPath -> sentWithTime_ $ ttySentFile fPath where
indent = if null quote then "" else " "
viewSentBroadcast :: MsgContent -> Int -> CurrentTime -> ZonedTime -> [StyledString]
viewSentBroadcast mc n ts t = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts t <> " ") (ttyMsgContent mc)
viewSentFileInvitation :: StyledString -> CIFile d -> CurrentTime -> CIMeta d -> [StyledString]
viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} ts = case filePath of
Just fPath -> sentWithTime_ ts $ ttySentFile fPath
_ -> const [] _ -> const []
where where
ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending
@@ -839,12 +877,9 @@ viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} = case filePath
CIFSSndTransfer -> [] CIFSSndTransfer -> []
_ -> ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"] _ -> ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"]
sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString] sentWithTime_ :: CurrentTime -> [StyledString] -> CIMeta d -> [StyledString]
sentWithTime_ styledMsg CIMeta {localItemTs} = sentWithTime_ ts styledMsg CIMeta {localItemTs} =
prependFirst (ttyMsgTime localItemTs <> " ") styledMsg prependFirst (ttyMsgTime ts localItemTs <> " ") styledMsg
ttyMsgTime :: ZonedTime -> StyledString
ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M"
ttyMsgContent :: MsgContent -> [StyledString] ttyMsgContent :: MsgContent -> [StyledString]
ttyMsgContent = msgPlain . msgContentText ttyMsgContent = msgPlain . msgContentText
@@ -873,8 +908,8 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
sndFile :: SndFileTransfer -> StyledString sndFile :: SndFileTransfer -> StyledString
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
viewReceivedFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString] viewReceivedFileInvitation :: StyledString -> CIFile d -> CurrentTime -> CIMeta d -> [StyledString]
viewReceivedFileInvitation from file meta = receivedWithTime_ from [] meta (receivedFileInvitation_ file) viewReceivedFileInvitation from file ts meta = receivedWithTime_ ts from [] meta (receivedFileInvitation_ file)
receivedFileInvitation_ :: CIFile d -> [StyledString] receivedFileInvitation_ :: CIFile d -> [StyledString]
receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} = receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} =
+1 -1
View File
@@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq # - ../simplexmq
- github: simplex-chat/simplexmq - github: simplex-chat/simplexmq
commit: d2b88a1baa390ec64b6535e32ce69f26f53f4d7a commit: c2342cba057fa2333b5936a2254507b5b62e8de2
# - ../direct-sqlcipher # - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher - github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294 commit: 34309410eb2069b029b8fc1872deb1e0db123294
+3 -2
View File
@@ -25,7 +25,7 @@ import Simplex.Chat.Options
import Simplex.Chat.Store import Simplex.Chat.Store
import Simplex.Chat.Terminal import Simplex.Chat.Terminal
import Simplex.Chat.Terminal.Output (newChatTerminal) import Simplex.Chat.Terminal.Output (newChatTerminal)
import Simplex.Chat.Types (Profile, User (..)) import Simplex.Chat.Types (Profile, ServerCfg (..), User (..))
import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.RetryInterval
import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig)
@@ -51,7 +51,7 @@ testOpts =
{ dbFilePrefix = undefined, { dbFilePrefix = undefined,
dbKey = "", dbKey = "",
-- dbKey = "this is a pass-phrase to encrypt the database", -- dbKey = "this is a pass-phrase to encrypt the database",
smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], smpServers = [ServerCfg "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" False Nothing True],
networkConfig = defaultNetworkConfig, networkConfig = defaultNetworkConfig,
logConnections = False, logConnections = False,
logServerHosts = False, logServerHosts = False,
@@ -275,6 +275,7 @@ serverCfg =
storeLogFile = Nothing, storeLogFile = Nothing,
storeMsgsFile = Nothing, storeMsgsFile = Nothing,
allowNewQueues = True, allowNewQueues = True,
newQueueBasicAuth = Nothing, -- Just "server_password",
messageExpiration = Just defaultMessageExpiration, messageExpiration = Just defaultMessageExpiration,
inactiveClientExpiration = Just defaultInactiveClientExpiration, inactiveClientExpiration = Just defaultInactiveClientExpiration,
caCertificateFile = "tests/fixtures/tls/ca.crt", caCertificateFile = "tests/fixtures/tls/ca.crt",
+69 -15
View File
@@ -74,7 +74,7 @@ chatTests = do
describe "async group connections" $ do describe "async group connections" $ do
xit "create and join group when clients go offline" testGroupAsync xit "create and join group when clients go offline" testGroupAsync
describe "user profiles" $ do describe "user profiles" $ do
it "update user profiles and notify contacts" testUpdateProfile it "update user profile and notify contacts" testUpdateProfile
it "update user profile with image" testUpdateProfileImage it "update user profile with image" testUpdateProfileImage
describe "sending and receiving files" $ do describe "sending and receiving files" $ do
describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer
@@ -112,12 +112,15 @@ chatTests = do
it "join group incognito" testJoinGroupIncognito it "join group incognito" testJoinGroupIncognito
it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito
it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito
describe "contact aliases and prefs" $ do describe "contact aliases" $ do
it "set contact alias" testSetAlias it "set contact alias" testSetAlias
it "set connection alias" testSetConnectionAlias it "set connection alias" testSetConnectionAlias
it "set contact prefs" testSetContactPrefs describe "preferences" $ do
describe "SMP servers" $ it "set contact preferences" testSetContactPrefs
it "update group preferences" testUpdateGroupPrefs
describe "SMP servers" $ do
it "get and set SMP servers" testGetSetSMPServers it "get and set SMP servers" testGetSetSMPServers
it "test SMP server connection" testTestSMPServerConnection
describe "async connection handshake" $ do describe "async connection handshake" $ do
it "connect when initiating client goes offline" testAsyncInitiatingOffline it "connect when initiating client goes offline" testAsyncInitiatingOffline
it "connect when accepting client goes offline" testAsyncAcceptingOffline it "connect when accepting client goes offline" testAsyncAcceptingOffline
@@ -1297,10 +1300,15 @@ testUpdateGroupProfile =
bob ##> "/gp team my_team" bob ##> "/gp team my_team"
bob <## "you have insufficient permissions for this group command" bob <## "you have insufficient permissions for this group command"
alice ##> "/gp team my_team" alice ##> "/gp team my_team"
alice <## "group #team is changed to #my_team" alice <## "changed to #my_team"
concurrently_ concurrentlyN_
(bob <## "group #team is changed to #my_team by alice") [ do
(cath <## "group #team is changed to #my_team by alice") bob <## "alice updated group #team:"
bob <## "changed to #my_team",
do
cath <## "alice updated group #team:"
cath <## "changed to #my_team"
]
bob #> "#my_team hi" bob #> "#my_team hi"
concurrently_ concurrently_
(alice <# "#my_team bob> hi") (alice <# "#my_team bob> hi")
@@ -2862,17 +2870,63 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $
bob <## "alice updated preferences for you:" bob <## "alice updated preferences for you:"
bob <## "full message deletion: off (you allow: default (yes), contact allows: no)" bob <## "full message deletion: off (you allow: default (yes), contact allows: no)"
testUpdateGroupPrefs :: IO ()
testUpdateGroupPrefs =
testChat2 aliceProfile bobProfile $
\alice bob -> do
createGroup2 "team" alice bob
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"on\"}}}"
alice <## "updated group preferences:"
alice <## "full message deletion enabled: on"
bob <## "alice updated group #team:"
bob <## "updated group preferences:"
bob <## "full message deletion enabled: on"
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"off\"}}}"
alice <## "updated group preferences:"
alice <## "full message deletion enabled: off"
alice <## "voice messages enabled: off"
bob <## "alice updated group #team:"
bob <## "updated group preferences:"
bob <## "full message deletion enabled: off"
bob <## "voice messages enabled: off"
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}"
alice <## "updated group preferences:"
alice <## "voice messages enabled: on"
bob <## "alice updated group #team:"
bob <## "updated group preferences:"
bob <## "voice messages enabled: on"
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}"
-- no update
alice #> "#team hey"
bob <# "#team alice> hey"
bob #> "#team hi"
alice <# "#team bob> hi"
testGetSetSMPServers :: IO () testGetSetSMPServers :: IO ()
testGetSetSMPServers = testGetSetSMPServers =
testChat2 aliceProfile bobProfile $ testChat2 aliceProfile bobProfile $
\alice _ -> do \alice _ -> do
alice #$> ("/smp_servers", id, "no custom SMP servers saved") alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001")
alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok") alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok")
alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im") alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im")
alice #$> ("/smp_servers smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok")
alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
alice #$> ("/smp_servers default", id, "ok") alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok")
alice #$> ("/smp_servers", id, "no custom SMP servers saved") alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
alice #$> ("/smp default", id, "ok")
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001")
testTestSMPServerConnection :: IO ()
testTestSMPServerConnection =
testChat2 aliceProfile bobProfile $
\alice _ -> do
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"
alice <## "SMP server test passed"
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001"
alice <## "SMP server test passed"
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZwjI=@localhost:5001"
alice <## "SMP server test failed at Connect, error: BROKER NETWORK"
alice <## "Possibly, certificate fingerprint in server address is incorrect"
testAsyncInitiatingOffline :: IO () testAsyncInitiatingOffline :: IO ()
testAsyncInitiatingOffline = withTmpFiles $ do testAsyncInitiatingOffline = withTmpFiles $ do
+2 -2
View File
@@ -32,9 +32,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\"
activeUser :: String activeUser :: String
#if defined(darwin_HOST_OS) && defined(swiftJSON) #if defined(darwin_HOST_OS) && defined(swiftJSON)
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}}" activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}"
#else #else
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}" activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}"
#endif #endif
chatStarted :: String chatStarted :: String