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

View File

@@ -1,10 +1,29 @@
FROM haskell:8.10.4 AS build-stage
# if you encounter "version `GLIBC_2.28' not found" error when running
# chat client executable, build with the following base image instead:
# FROM haskell:8.10.4-stretch AS build-stage
FROM ubuntu:focal AS build
# Install curl and simplex-chat-related dependencies
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
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
COPY --from=build-stage /root/.local/bin/simplex-chat /
COPY --from=build /root/.cabal/bin/simplex-chat /

View File

@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 68
versionName "4.2.1"
versionCode 69
versionName "4.3-beta.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {

View File

@@ -9,6 +9,7 @@ import android.os.SystemClock.elapsedRealtime
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
@@ -19,7 +20,9 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
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.onboarding.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class MainActivity: FragmentActivity() {
companion object {
@@ -317,14 +323,65 @@ fun MainPage(
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else {
showAdvertiseLAAlert = true
val stopped = chatModel.chatRunning.value == false
if (chatModel.chatId.value == null) {
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
BoxWithConstraints {
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box(
Modifier
.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)
}
}
}

View File

@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -20,7 +19,6 @@ import kotlinx.coroutines.withContext
class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private var isStartingService = false
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
@@ -48,11 +46,32 @@ class SimplexService: Service() {
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
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() {
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 (SimplexApp.context.allowToStartServiceAfterAppExit())
@@ -62,7 +81,7 @@ class SimplexService: Service() {
private fun startService() {
Log.d(TAG, "SimplexService startService")
if (isServiceStarted || isStartingService) return
if (wakeLock != null || isStartingService) return
val self = this
isStartingService = true
withApi {
@@ -73,10 +92,9 @@ class SimplexService: Service() {
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
stopService()
safeStopService(self)
return@withApi
}
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
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? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
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 WORK_NAME_ONCE = "ServiceStartWorkerOnce"
private var isServiceStarted = false
private var stopAfterStart = false
fun scheduleStart(context: Context) {
Log.d(TAG, "Enqueuing work to start subscriber service")
val workManager = WorkManager.getInstance(context)
@@ -244,7 +249,17 @@ class SimplexService: Service() {
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) {
Log.d(TAG, "SimplexService serviceAction: ${action.name}")

View File

@@ -20,7 +20,12 @@ import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
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) {
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
val currentUser = mutableStateOf<User?>(null)
@@ -70,6 +75,8 @@ class ChatModel(val controller: ChatController) {
// working with external intents
val sharedContent = mutableStateOf(null as SharedContent?)
val filesToDelete = mutableSetOf<File>()
fun updateUserProfile(profile: LocalProfile) {
val user = currentUser.value
if (user != null) {
@@ -218,6 +225,7 @@ class ChatModel(val controller: ChatController) {
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
AudioPlayer.stop(chatItems[itemIndex])
chatItems.removeAt(itemIndex)
}
}
@@ -382,7 +390,7 @@ interface SomeChat {
val updatedAt: Instant
}
@Serializable
@Serializable @Stable
data class Chat (
val chatInfo: ChatInfo,
val chatItems: List<ChatItem>,
@@ -1015,7 +1023,7 @@ class AChatItem (
val chatItem: ChatItem
)
@Serializable
@Serializable @Stable
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
@@ -1029,6 +1037,9 @@ data class ChatItem (
val text: String get() =
when {
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
}
content.text == "" && file != null -> file.fileName
else -> content.text
}
@@ -1301,6 +1312,8 @@ class CIFile(
CIFileStatus.RcvComplete -> true
}
val audioInfo: MutableState<ProgressAndDuration> by lazy { mutableStateOf(ProgressAndDuration()) }
companion object {
fun getSample(
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 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 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 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 MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}"
is MCVoice-> "json ${json.encodeToString(this)}"
is MCFile -> "json ${json.encodeToString(this)}"
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
class CIGroupInvitation (
val groupId: Long,
@@ -1414,6 +1434,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
MsgContent.MCImage(text, image)
}
"voice" -> {
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
MsgContent.MCVoice(text, duration)
}
"file" -> MsgContent.MCFile(text)
else -> MsgContent.MCUnknown(t, text, json)
}
@@ -1445,6 +1469,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
put("text", value.text)
put("image", value.image)
}
is MsgContent.MCVoice ->
buildJsonObject {
put("type", "voice")
put("text", value.text)
put("duration", value.duration)
}
is MsgContent.MCFile ->
buildJsonObject {
put("type", "file")

View File

@@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
if (cItem.content.text != "") {
cItem.content.text
} else {
cItem.file?.fileName ?: ""
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
}
} else {
var res = ""

View File

@@ -1014,6 +1014,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val file = cItem.file
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
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)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
@@ -1039,6 +1041,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.removeChatItem(cInfo, cItem)
} else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
AudioPlayer.stop(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
SimplexService.StartReceiver.toggleReceiver(false)
MessagesFetcherWorker.cancelAll()
SimplexService.stop(SimplexApp.context)
SimplexService.safeStopService(SimplexApp.context)
} else {
// show battery optimization notice
showBGServiceNoticeIgnoreOptimization(mode)

View File

@@ -135,7 +135,7 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()

View File

@@ -42,8 +42,7 @@ import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.usersettings.NotificationsMode
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@@ -63,7 +62,7 @@ fun ActiveCallView(chatModel: ChatModel) {
DisposableEffect(Unit) {
onDispose {
// 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
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
@@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
withApi {
scope.launch {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv

View File

@@ -1,10 +1,8 @@
package chat.simplex.app.views.chat
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
@@ -52,8 +50,8 @@ import java.io.File
import kotlin.math.sign
@Composable
fun ChatView(chatModel: ChatModel) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
@@ -63,7 +61,6 @@ fun ChatView(chatModel: ChatModel) {
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
@@ -97,17 +94,15 @@ fun ChatView(chatModel: ChatModel) {
chatModel.chatId.value = null
} else {
val chat = activeChat.value!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
}
}
ChatLayout(
user,
chat,
unreadCount,
composeState,
@@ -120,7 +115,6 @@ fun ChatView(chatModel: ChatModel) {
}
},
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
searchText,
@@ -128,6 +122,7 @@ fun ChatView(chatModel: ChatModel) {
chatModelIncognito = chatModel.incognito.value,
back = {
hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null
},
info = {
@@ -228,20 +223,19 @@ fun ChatView(chatModel: ChatModel) {
apiFindMessages(c.chatInfo, chatModel, value)
searchText.value = value
}
}
},
onComposed,
)
}
}
@Composable
fun ChatLayout(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
searchValue: State<String>,
@@ -260,8 +254,10 @@ fun ChatLayout(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
onComposed: () -> Unit,
) {
Surface(
val scope = rememberCoroutineScope()
Box(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)
@@ -292,9 +288,9 @@ fun ChatLayout(
) { contentPadding ->
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue,
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
)
}
}
@@ -445,7 +441,6 @@ val CIListStateSaver = run {
@Composable
fun BoxWithConstraintsScope.ChatItemsList(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
@@ -461,11 +456,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
ScrollToBottom(chat.id, listState)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// 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) }
}
}
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) {
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
@@ -555,11 +559,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else {
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 {
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
@@ -570,7 +574,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent) 12.dp else 76.dp,
).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 {
val totalItemsNumber = it.totalItemsCount
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
totalItemsNumber
else
0
@@ -931,7 +935,6 @@ fun PreviewChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
@@ -941,7 +944,6 @@ fun PreviewChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
@@ -960,6 +962,7 @@ fun PreviewChatLayout() {
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}
@@ -989,7 +992,6 @@ fun PreviewGroupChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = chatItems,
@@ -999,7 +1001,6 @@ fun PreviewGroupChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
@@ -1018,6 +1019,7 @@ fun PreviewGroupChatLayout() {
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}

View File

@@ -1,5 +1,6 @@
package chat.simplex.app.views.chat
import ComposeVoiceView
import ComposeFileView
import android.Manifest
import android.content.*
@@ -14,11 +15,9 @@ import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import java.io.File
@Serializable
sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): 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()
}
@@ -84,6 +87,7 @@ data class ComposeState(
get() = {
val hasContent = when (preview) {
is ComposePreview.ImagePreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty()
}
@@ -93,6 +97,7 @@ data class ComposeState(
get() =
when (preview) {
is ComposePreview.ImagePreview -> false
is ComposePreview.VoicePreview -> false
is ComposePreview.FilePreview -> false
else -> useLinkPreviews
}
@@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
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 -> {
val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName)
}
else -> ComposePreview.NoPreview
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
}
}
@@ -144,6 +150,11 @@ fun ComposeView(
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
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 cameraLauncher = rememberCameraLauncher { uri: Uri? ->
if (uri != null) {
@@ -321,6 +332,7 @@ fun ComposeView(
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
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.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
}
@@ -330,6 +342,7 @@ fun ComposeView(
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
textStyle.value = smallFont
chosenContent.value = emptyList()
chosenAudio.value = null
chosenFile.value = null
linkUrl.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 -> {
val chosenFileVal = chosenFile.value
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() {
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
@@ -440,6 +469,11 @@ fun ComposeView(
chosenContent.value = emptyList()
}
fun cancelVoice() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenContent.value = emptyList()
}
fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenFile.value = null
@@ -455,6 +489,13 @@ fun ComposeView(
::cancelImages,
cancelEnabled = !composeState.value.editing
)
is ComposePreview.VoicePreview -> ComposeVoiceView(
preview.voice,
preview.durationMs,
preview.finished,
cancelEnabled = !composeState.value.editing,
::cancelVoice
)
is ComposePreview.FilePreview -> ComposeFileView(
preview.fileName,
::cancelFile,
@@ -489,37 +530,34 @@ fun ComposeView(
Column {
contextItemView()
when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView()
}
Row(
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
val attachEnabled = !composeState.value.editing
Box(Modifier.padding(bottom = 12.dp)) {
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
IconButton(showChooseAttachment, enabled = attachEnabled) {
Icon(
Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach),
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable {
if (attachEnabled) {
showChooseAttachment()
}
}
)
}
SendMsgView(
composeState,
allowVoiceRecord = true,
sendMessage = {
sendMessage()
resetLinkPreview()
},
::onMessageChange,
::onAudioAdded,
textStyle
)
}

View File

@@ -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
)
}
}

View File

@@ -1,7 +1,10 @@
package chat.simplex.app.views.chat
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.text.InputType
import android.view.ViewGroup
@@ -12,38 +15,198 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doOnTextChanged
import androidx.core.widget.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.SharedContent
import kotlinx.coroutines.delay
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
import java.io.*
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
allowVoiceRecord: Boolean,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
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 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) }
LaunchedEffect(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)) {
Box {
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: android.view.OnReceiveContentListener?
) {
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
}
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: android.view.OnReceiveContentListener?
) {
super.setOnReceiveContentListener(mimeTypes, listener)
}
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.FilePreview)
) {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp),
color = HighOrLowlight,
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()
}
}
)
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.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 {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle
)
}
@@ -188,8 +323,10 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle
)
}
@@ -209,8 +346,10 @@ fun PreviewSendMsgViewInProgress() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle
)
}

View File

@@ -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)
}
}
}

View File

@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import android.content.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -29,15 +28,11 @@ import kotlinx.datetime.Clock
@Composable
fun ChatItemView(
user: User,
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
@@ -46,6 +41,7 @@ fun ChatItemView(
scrollToItem: (Long) -> Unit,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
@@ -70,7 +66,7 @@ fun ChatItemView(
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable fun ContentItem() {
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 = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(cxt, cItem.text, filePath)
else -> shareText(cxt, cItem.content.text)
filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(context, cItem.content.text)
}
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text)
copyText(context, cItem.content.text)
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)
if (filePath != null) {
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> saveImage(context, cItem.file)
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {}
}
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 = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
@@ -233,15 +230,12 @@ private fun showMsgDeliveryErrorAlert(description: String) {
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
@@ -256,13 +250,10 @@ fun PreviewChatItemView() {
fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -56,8 +57,12 @@ fun FramedItemView(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart
) {
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
qi.content.toTextWithDuration(true)
else
qi.text
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)
)
}
@@ -87,13 +92,13 @@ fun FramedItemView(
modifier = Modifier.size(68.dp).clipToBounds()
)
}
is MsgContent.MCFile -> {
is MsgContent.MCFile, is MsgContent.MCVoice -> {
Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi)
}
Icon(
Icons.Filled.InsertDriveFile,
stringResource(R.string.icon_descr_file),
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
Modifier
.padding(top = 6.dp, end = 4.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
.clip(RoundedCornerShape(18.dp))
.background(
@@ -142,6 +147,12 @@ fun FramedItemView(
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 -> {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (mc.text != "") {
@@ -157,8 +168,10 @@ fun FramedItemView(
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
}
}
}

View File

@@ -536,13 +536,11 @@ fun ChatListNavLinkLayout(
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) {
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()

View File

@@ -409,7 +409,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
m.controller.apiStopChat()
runChat.value = false
m.chatRunning.value = false
SimplexService.stop(context)
SimplexService.safeStopService(context)
MessagesFetcherWorker.cancelAll()
} catch (e: Error) {
runChat.value = true

View File

@@ -2,4 +2,8 @@ package chat.simplex.app.views.helpers
import androidx.compose.animation.core.*
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)

View File

@@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
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(
allowIntercept: () -> Boolean,
panZoomLock: Boolean = false,

View File

@@ -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
}
}

View File

@@ -19,6 +19,7 @@ import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager
import androidx.annotation.StringRes
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
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.unit.*
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.text.HtmlCompat
import chat.simplex.app.*
import chat.simplex.app.model.CIFile
@@ -220,6 +222,11 @@ private fun spannableStringToAnnotatedString(
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE: Long = 236700
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
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 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) }
)

View File

@@ -57,7 +57,7 @@ fun NotificationsSettingsView(
if (mode == NotificationsMode.SERVICE)
SimplexService.start(SimplexApp.context)
else
SimplexService.stop(SimplexApp.context)
SimplexService.safeStopService(SimplexApp.context)
}
if (mode != NotificationsMode.PERIODIC) {

View File

@@ -208,6 +208,10 @@
<string name="file_not_found">Datei nicht gefunden</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 -->
<string name="notifications">Benachrichtigungen</string>
@@ -221,10 +225,11 @@
<string name="icon_descr_server_status_error">Fehler</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_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 -->
<string name="icon_descr_send_message">Nachricht senden</string>
<string name="icon_descr_record_voice_message">***Record voice message</string>
<!-- General Actions / Responses -->
<string name="back">Zurück</string>
@@ -364,9 +369,9 @@
<string name="chat_console">Chat Konsole</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="star_on_github">Stern auf GitHub</string>
<string name="contribute">Beitragen</string>
<string name="rate_the_app">Bewerte die App</string>
<string name="star_on_github">Stern auf GitHub vergeben</string>
<string name="contribute">Unterstützen Sie uns</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="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</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_settings">EINSTELLUNGEN</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_device">GERÄT</string>
<string name="settings_section_title_chats">CHATS</string>

View File

@@ -208,6 +208,10 @@
<string name="file_not_found">Файл не найден</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 -->
<string name="notifications">Уведомления</string>
@@ -225,6 +229,7 @@
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Отправить сообщение</string>
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
<!-- General Actions / Responses -->
<string name="back">Назад</string>

View File

@@ -208,6 +208,10 @@
<string name="file_not_found">File not found</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 -->
<string name="notifications">Notifications</string>
@@ -225,6 +229,7 @@
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Send Message</string>
<string name="icon_descr_record_voice_message">Record voice message</string>
<!-- General Actions / Responses -->
<string name="back">Back</string>

View File

@@ -311,7 +311,7 @@ func apiDeleteToken(token: DeviceToken) async throws {
func getUserSMPServers() throws -> [String] {
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
}
@@ -319,6 +319,17 @@ func setUserSMPServers(smpServers: [String]) async throws {
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 {
let r = chatSendCmdSync(.apiGetChatItemTTL)
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? {
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
if case let .contactAliasUpdated(toContact) = r { return toContact }

View File

@@ -53,7 +53,7 @@ struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
var contact: Contact
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
var customUserProfile: Profile?
@State var localAlias: String
@@ -99,6 +99,10 @@ struct ChatInfoView: View {
}
}
Section {
contactPreferencesButton()
}
Section("Servers") {
networkStatusRow()
.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 {
HStack {
Text("Network status")

View File

@@ -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
)
}
}

View File

@@ -13,13 +13,12 @@ struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
var groupInfo: GroupInfo
@State var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String?
@State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil
@State private var showGroupProfile: Bool = false
@State private var connectionStats: ConnectionStats?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -42,6 +41,17 @@ struct GroupChatInfoView: View {
groupInfoHeader()
.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") {
if groupInfo.canAddMembers {
groupLinkButton()
@@ -77,14 +87,8 @@ struct GroupChatInfoView: View {
}) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
}
.sheet(isPresented: $showGroupProfile) {
GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile)
}
Section {
if groupInfo.canEdit {
editGroupButton()
}
clearChatButton()
if groupInfo.canDelete {
deleteGroupButton()
@@ -189,16 +193,35 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View {
NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: {
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 {
Button {
showGroupProfile = true
NavigationLink {
GroupProfileView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile
)
.navigationBarTitle("Group profile")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Edit group profile", systemImage: "pencil")
}

View File

@@ -29,10 +29,6 @@ struct GroupLinkView: View {
var body: some View {
ScrollView {
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.")
.padding(.bottom)
if let groupLink = groupLink {

View File

@@ -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
)
}
}

View File

@@ -12,7 +12,7 @@ import SimpleXChat
struct GroupProfileView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var groupId: Int64
@Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile
@State private var showChooseSource = false
@State private var showImagePicker = false
@@ -120,8 +120,9 @@ struct GroupProfileView: View {
func saveProfile() {
Task {
do {
let gInfo = try await apiUpdateGroup(groupId, groupProfile)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run {
groupInfo = gInfo
chatModel.updateGroup(gInfo)
dismiss()
}
@@ -137,6 +138,6 @@ struct GroupProfileView: View {
struct GroupProfileView_Previews: PreviewProvider {
static var previews: some View {
GroupProfileView(groupId: 1, groupProfile: GroupProfile.sampleData)
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
}
}

View File

@@ -158,7 +158,7 @@ struct DatabaseView: View {
Section {
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)
}
if case .seconds = chatItemTTL {

View File

@@ -40,6 +40,13 @@ struct NetworkAndServers: View {
Text("SMP servers")
}
NavigationLink {
SMPServersView()
.navigationTitle("Your SMP servers")
} label: {
Text("SMP servers (new)")
}
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}

View File

@@ -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
)
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -89,10 +89,8 @@ struct SettingsView: View {
ProfilePreview(profileOf: user)
.padding(.leading, -8)
}
.disabled(chatModel.chatRunning != true)
incognitoRow()
.disabled(chatModel.chatRunning != true)
NavigationLink {
CreateLinkView(selection: .longTerm, viaNavLink: true)
@@ -100,24 +98,15 @@ struct SettingsView: View {
} label: {
settingsRow("qrcode") { Text("Your SimpleX contact address") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
.navigationTitle("Your chat database")
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your preferences")
} 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)
}
}
}
settingsRow("switch.2") { Text("Chat preferences") }
}
}
.disabled(chatModel.chatRunning != true)
Section("Settings") {
NavigationLink {
@@ -129,18 +118,32 @@ struct SettingsView: View {
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 {
CallSettings()
.navigationTitle("Your calls")
} label: {
settingsRow("video") { Text("Audio & video calls") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
PrivacySettings()
.navigationTitle("Your privacy")
} label: {
settingsRow("lock") { Text("Privacy & security") }
}
.disabled(chatModel.chatRunning != true)
if UIApplication.shared.supportsAlternateIcons {
NavigationLink {
AppearanceSettings()
@@ -148,15 +151,11 @@ struct SettingsView: View {
} label: {
settingsRow("sun.max") { Text("Appearance") }
}
.disabled(chatModel.chatRunning != true)
}
NavigationLink {
NetworkAndServers()
.navigationTitle("Network & servers")
} label: {
settingsRow("externaldrive.connected.to.line.below") { Text("Network & servers") }
}
chatDatabaseRow()
}
.disabled(chatModel.chatRunning != true)
Section("Help") {
NavigationLink {
@@ -276,6 +275,24 @@ struct SettingsView: View {
.padding(.leading, indent)
}
}
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 {
case incognitoInfo

View File

@@ -278,6 +278,36 @@
<target>Alle Ihre Kontakte bleiben verbunden.</target>
<note>No comment provided by engineer.</note>
</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">
<source>Already connected?</source>
<target>Sind Sie bereits verbunden?</target>
@@ -328,6 +358,16 @@
<target>Automatisch</target>
<note>No comment provided by engineer.</note>
</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">
<source>Call already ended!</source>
<target>Anruf ist bereits beendet!</target>
@@ -428,6 +468,11 @@
<target>Der Chat ist beendet</target>
<note>No comment provided by engineer.</note>
</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">
<source>Chats</source>
<target>Chats</target>
@@ -558,6 +603,11 @@
<target>Verbindungszeitüberschreitung</target>
<note>No comment provided by engineer.</note>
</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">
<source>Contact already exists</source>
<target>Der Kontakt ist bereits vorhanden</target>
@@ -588,11 +638,21 @@
<target>Kontaktname</target>
<note>No comment provided by engineer.</note>
</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">
<source>Contact requests</source>
<target>Kontaktanfragen</target>
<note>No comment provided by engineer.</note>
</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">
<source>Copy</source>
<target>Kopieren</target>
@@ -1221,6 +1281,11 @@
<target>Für Konsole</target>
<note>No comment provided by engineer.</note>
</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">
<source>Full name (optional)</source>
<target>Vollständiger Name (optional)</target>
@@ -1271,11 +1336,26 @@
<target>Gruppen-Link</target>
<note>No comment provided by engineer.</note>
</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">
<source>Group message:</source>
<target>Grppennachricht:</target>
<note>notification</note>
</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">
<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>
@@ -1453,6 +1533,11 @@
<target>In Gruppe einladen</target>
<note>No comment provided by engineer.</note>
</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">
<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>
@@ -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>
<note>No comment provided by engineer.</note>
</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">
<source>Open Settings</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>Privacy &amp; security</source>
<target>Datenschutz &amp; Sicherheit</target>
@@ -1873,6 +1988,16 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Profilbild</target>
<note>No comment provided by engineer.</note>
</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">
<source>Protocol timeout</source>
<target>Protokollzeitüberschreitung</target>
@@ -1885,7 +2010,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
</trans-unit>
<trans-unit id="Rate the app" xml:space="preserve">
<source>Rate the app</source>
<target>Bewerte die App</target>
<target>Bewerten Sie die App</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>Reset colors</source>
<target>Farben zurücksetzen</target>
@@ -2038,11 +2168,21 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Speichern</target>
<note>chat item action</note>
</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">
<source>Save (and notify contacts)</source>
<target>Speichern (und Kontakte benachrichtigen)</target>
<note>No comment provided by engineer.</note>
</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">
<source>Save archive</source>
<target>Archiv speichern</target>
@@ -2255,7 +2395,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
</trans-unit>
<trans-unit id="Support SimpleX Chat" xml:space="preserve">
<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>
</trans-unit>
<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 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>
<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>
</trans-unit>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>Waiting for file</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>You are already connected to %@.</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>Your privacy</source>
<target>Meine Privatsphäre</target>
@@ -2878,7 +3038,7 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
</trans-unit>
<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>
<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>
</trans-unit>
<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 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>
<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>
</trans-unit>
<trans-unit id="_italic_" xml:space="preserve">
@@ -2916,6 +3076,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Admin</target>
<note>member role</note>
</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">
<source>audio call (not e2e encrypted)</source>
<target>Audioanruf (nicht E2E verschlüsselt)</target>
@@ -3056,6 +3221,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Ersteller</target>
<note>No comment provided by engineer.</note>
</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">
<source>deleted</source>
<target>Gelöscht</target>
@@ -3206,11 +3376,26 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Neue Nachricht</target>
<note>notification</note>
</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">
<source>no e2e encryption</source>
<target>Keine E2E-Verschlüsselung</target>
<note>No comment provided by engineer.</note>
</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">
<source>or chat with the developers</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>you are invited to group</source>
<target>Sie sind zur Gruppe eingeladen</target>

View File

@@ -278,6 +278,36 @@
<target>All your contacts will remain connected</target>
<note>No comment provided by engineer.</note>
</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">
<source>Already connected?</source>
<target>Already connected?</target>
@@ -328,6 +358,16 @@
<target>Automatically</target>
<note>No comment provided by engineer.</note>
</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">
<source>Call already ended!</source>
<target>Call already ended!</target>
@@ -428,6 +468,11 @@
<target>Chat is stopped</target>
<note>No comment provided by engineer.</note>
</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">
<source>Chats</source>
<target>Chats</target>
@@ -558,6 +603,11 @@
<target>Connection timeout</target>
<note>No comment provided by engineer.</note>
</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">
<source>Contact already exists</source>
<target>Contact already exists</target>
@@ -588,11 +638,21 @@
<target>Contact name</target>
<note>No comment provided by engineer.</note>
</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">
<source>Contact requests</source>
<target>Contact requests</target>
<note>No comment provided by engineer.</note>
</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">
<source>Copy</source>
<target>Copy</target>
@@ -1221,6 +1281,11 @@
<target>For console</target>
<note>No comment provided by engineer.</note>
</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">
<source>Full name (optional)</source>
<target>Full name (optional)</target>
@@ -1271,11 +1336,26 @@
<target>Group link</target>
<note>No comment provided by engineer.</note>
</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">
<source>Group message:</source>
<target>Group message:</target>
<note>notification</note>
</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">
<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>
@@ -1453,6 +1533,11 @@
<target>Invite to group</target>
<note>No comment provided by engineer.</note>
</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">
<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>
@@ -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>
<note>No comment provided by engineer.</note>
</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">
<source>Open Settings</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>Privacy &amp; security</source>
<target>Privacy &amp; security</target>
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Profile image</target>
<note>No comment provided by engineer.</note>
</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">
<source>Protocol timeout</source>
<target>Protocol timeout</target>
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Required</target>
<note>No comment provided by engineer.</note>
</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">
<source>Reset colors</source>
<target>Reset colors</target>
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Save</target>
<note>chat item action</note>
</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">
<source>Save (and notify contacts)</source>
<target>Save (and notify contacts)</target>
<note>No comment provided by engineer.</note>
</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">
<source>Save archive</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>Waiting for file</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>You are already connected to %@.</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>Your privacy</source>
<target>Your privacy</target>
@@ -2916,6 +3076,11 @@ SimpleX servers cannot see your profile.</target>
<target>admin</target>
<note>member role</note>
</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">
<source>audio call (not e2e encrypted)</source>
<target>audio call (not e2e encrypted)</target>
@@ -3056,6 +3221,11 @@ SimpleX servers cannot see your profile.</target>
<target>creator</target>
<note>No comment provided by engineer.</note>
</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">
<source>deleted</source>
<target>deleted</target>
@@ -3206,11 +3376,26 @@ SimpleX servers cannot see your profile.</target>
<target>new message</target>
<note>notification</note>
</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">
<source>no e2e encryption</source>
<target>no e2e encryption</target>
<note>No comment provided by engineer.</note>
</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">
<source>or chat with the developers</source>
<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>
<note>No comment provided by engineer.</note>
</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">
<source>you are invited to group</source>
<target>you are invited to group</target>

View File

@@ -278,6 +278,36 @@
<target>Все контакты, которые соединились через этот адрес, сохранятся.</target>
<note>No comment provided by engineer.</note>
</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">
<source>Already connected?</source>
<target>Соединение уже установлено?</target>
@@ -328,6 +358,16 @@
<target>Автоматически</target>
<note>No comment provided by engineer.</note>
</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">
<source>Call already ended!</source>
<target>Звонок уже завершен!</target>
@@ -428,6 +468,11 @@
<target>Чат остановлен</target>
<note>No comment provided by engineer.</note>
</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">
<source>Chats</source>
<target>Чаты</target>
@@ -558,6 +603,11 @@
<target>Превышено время соединения</target>
<note>No comment provided by engineer.</note>
</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">
<source>Contact already exists</source>
<target>Существующий контакт</target>
@@ -588,11 +638,21 @@
<target>Имена контактов</target>
<note>No comment provided by engineer.</note>
</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">
<source>Contact requests</source>
<target>Запросы контактов</target>
<note>No comment provided by engineer.</note>
</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">
<source>Copy</source>
<target>Скопировать</target>
@@ -1221,6 +1281,11 @@
<target>Для консоли</target>
<note>No comment provided by engineer.</note>
</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">
<source>Full name (optional)</source>
<target>Полное имя (не обязательно)</target>
@@ -1271,11 +1336,26 @@
<target>Ссылка группы</target>
<note>No comment provided by engineer.</note>
</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">
<source>Group message:</source>
<target>Групповое сообщение:</target>
<note>notification</note>
</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">
<source>Group profile is stored on members' devices, not on the servers.</source>
<target>Профиль группы хранится на устройствах членов, а не на серверах.</target>
@@ -1453,6 +1533,11 @@
<target>Пригласить в группу</target>
<note>No comment provided by engineer.</note>
</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">
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target>
@@ -1768,6 +1853,31 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**</target>
<note>No comment provided by engineer.</note>
</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">
<source>Open Settings</source>
<target>Открыть Настройки</target>
@@ -1858,6 +1968,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="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">
<source>Privacy &amp; security</source>
<target>Конфиденциальность</target>
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Аватар</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="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">
<source>Protocol timeout</source>
<target>Таймаут протокола</target>
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Обязательно</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="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">
<source>Reset colors</source>
<target>Сбросить цвета</target>
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Сохранить</target>
<note>chat item action</note>
</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">
<source>Save (and notify contacts)</source>
<target>Сохранить (и уведомить контакты)</target>
<note>No comment provided by engineer.</note>
</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">
<source>Save archive</source>
<target>Сохранить архив</target>
@@ -2572,6 +2712,16 @@ To connect, please ask your contact to create another connection link and check
<target>Видеозвонок</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="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">
<source>Waiting for file</source>
<target>Ожидается прием файла</target>
@@ -2627,6 +2777,11 @@ To connect, please ask your contact to create another connection link and check
<target>Вы приняли приглашение соединиться</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="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">
<source>You are already connected to %@.</source>
<target>Вы уже соединены с контактом %@.</target>
@@ -2844,6 +2999,11 @@ You can cancel this connection and remove the contact (and try later with a new
<target>Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.</target>
<note>No comment provided by engineer.</note>
</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">
<source>Your privacy</source>
<target>Конфиденциальность</target>
@@ -2916,6 +3076,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>админ</target>
<note>member role</note>
</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">
<source>audio call (not e2e encrypted)</source>
<target>аудиозвонок (не e2e зашифрованный)</target>
@@ -3056,6 +3221,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>создатель</target>
<note>No comment provided by engineer.</note>
</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">
<source>deleted</source>
<target>удалено</target>
@@ -3206,11 +3376,26 @@ SimpleX серверы не могут получить доступ к ваше
<target>новое сообщение</target>
<note>notification</note>
</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">
<source>no e2e encryption</source>
<target>нет e2e шифрования</target>
<note>No comment provided by engineer.</note>
</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">
<source>or chat with the developers</source>
<target>или соединитесь с разработчиками</target>
@@ -3336,6 +3521,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>хочет соединиться с вами!</target>
<note>No comment provided by engineer.</note>
</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">
<source>you are invited to group</source>
<target>вы приглашены в группу</target>

View File

@@ -53,6 +53,13 @@
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
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 */; };
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.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 */; };
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; };
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 */; };
5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; };
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 */; };
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.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 */; };
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 */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.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>"; };
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>"; };
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>"; };
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>"; };
@@ -267,6 +279,8 @@
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>"; };
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>"; };
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>"; };
@@ -319,11 +333,7 @@
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; };
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>"; };
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>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.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>"; };
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
@@ -374,13 +384,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */,
6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */,
6432856C291CDEF200FBE5C8 /* libffi.a 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 */,
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;
};
@@ -428,6 +438,7 @@
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */,
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */,
5CE4407127ADB1D0007B033A /* Emoji.swift */,
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
);
path = Chat;
sourceTree = "<group>";
@@ -435,11 +446,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
64328567291CDEF200FBE5C8 /* libffi.a */,
64328568291CDEF200FBE5C8 /* libgmp.a */,
64328565291CDEF200FBE5C8 /* libgmpxx.a */,
64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */,
64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */,
5C93293229241CD90090FFF9 /* libffi.a */,
5C93293629241CDA0090FFF9 /* libgmp.a */,
5C93293429241CD90090FFF9 /* libgmpxx.a */,
5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */,
5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -571,6 +582,7 @@
5CB346E62868D76D001FD2EF /* NotificationsView.swift */,
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
5CADE79929211BB900072E13 /* PreferencesView.swift */,
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */,
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
@@ -579,6 +591,8 @@
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
640F50E227CF991C001E05C2 /* SMPServers.swift */,
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
@@ -680,6 +694,7 @@
6440CA01288AEC770062C672 /* Group */ = {
isa = PBXGroup;
children = (
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */,
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */,
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */,
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */,
@@ -885,12 +900,15 @@
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */,
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */,
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
@@ -956,6 +974,7 @@
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */,
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
@@ -978,6 +997,7 @@
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -48,6 +48,7 @@ public enum ChatCommand {
case apiGetGroupLink(groupId: Int64)
case getUserSMPServers
case setUserSMPServers(smpServers: [String])
case testSMPServer(smpServer: String)
case apiSetChatItemTTL(seconds: Int64?)
case apiGetChatItemTTL
case apiSetNetworkConfig(networkConfig: NetCfg)
@@ -63,6 +64,7 @@ public enum ChatCommand {
case apiClearChat(type: ChatType, id: Int64)
case listContacts
case apiUpdateProfile(profile: Profile)
case apiSetContactPrefs(contactId: Int64, preferences: Preferences)
case apiSetContactAlias(contactId: Int64, localAlias: String)
case apiSetConnectionAlias(connId: Int64, localAlias: String)
case createMyAddress
@@ -124,8 +126,9 @@ public enum ChatCommand {
case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)"
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case .getUserSMPServers: return "/smp_servers"
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
case .getUserSMPServers: return "/smp"
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 .apiGetChatItemTTL: return "/ttl"
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 .listContacts: return "/contacts"
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 .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
case .createMyAddress: return "/address"
@@ -203,6 +207,7 @@ public enum ChatCommand {
case .apiGetGroupLink: return "apiGetGroupLink"
case .getUserSMPServers: return "getUserSMPServers"
case .setUserSMPServers: return "setUserSMPServers"
case .testSMPServer: return "testSMPServer"
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
@@ -218,6 +223,7 @@ public enum ChatCommand {
case .apiClearChat: return "apiClearChat"
case .listContacts: return "listContacts"
case .apiUpdateProfile: return "apiUpdateProfile"
case .apiSetContactPrefs: return "apiSetContactPrefs"
case .apiSetContactAlias: return "apiSetContactAlias"
case .apiSetConnectionAlias: return "apiSetConnectionAlias"
case .createMyAddress: return "createMyAddress"
@@ -288,7 +294,8 @@ public enum ChatResponse: Decodable, Error {
case chatSuspended
case apiChats(chats: [ChatData])
case apiChat(chat: ChatData)
case userSMPServers(smpServers: [String])
case userSMPServers(smpServers: [ServerCfg], presetSMPServers: [String])
case sMPTestResult(smpTestFailure: SMPTestFailure?)
case chatItemTTL(chatItemTTL: Int64?)
case networkConfig(networkConfig: NetCfg)
case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
@@ -303,6 +310,7 @@ public enum ChatResponse: Decodable, Error {
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
case contactAliasUpdated(toContact: Contact)
case connectionAliasUpdated(toConnection: PendingContactConnection)
case contactPrefsUpdated(fromContact: Contact, toContact: Contact)
case userContactLink(contactLink: UserContactLink)
case userContactLinkUpdated(contactLink: UserContactLink)
case userContactLinkCreated(connReqContact: String)
@@ -390,6 +398,7 @@ public enum ChatResponse: Decodable, Error {
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .userSMPServers: return "userSMPServers"
case .sMPTestResult: return "smpTestResult"
case .chatItemTTL: return "chatItemTTL"
case .networkConfig: return "networkConfig"
case .contactInfo: return "contactInfo"
@@ -404,6 +413,7 @@ public enum ChatResponse: Decodable, Error {
case .userProfileUpdated: return "userProfileUpdated"
case .contactAliasUpdated: return "contactAliasUpdated"
case .connectionAliasUpdated: return "connectionAliasUpdated"
case .contactPrefsUpdated: return "contactPrefsUpdated"
case .userContactLink: return "userContactLink"
case .userContactLinkUpdated: return "userContactLinkUpdated"
case .userContactLinkCreated: return "userContactLinkCreated"
@@ -490,7 +500,8 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return noDetails
case let .apiChats(chats): return String(describing: chats)
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 .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))"
@@ -505,6 +516,7 @@ public enum ChatResponse: Decodable, Error {
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
case let .contactAliasUpdated(toContact): return String(describing: toContact)
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 .userContactLinkUpdated(contactLink): return contactLink.responseDetails
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) {
self.currentKey = currentKey
self.newKey = newKey
@@ -623,6 +635,121 @@ public struct DBEncryptionConfig: Encodable {
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 var socksProxy: String? = nil
public var hostMode: HostMode = .publicHost

View File

@@ -14,6 +14,7 @@ public struct User: Decodable, NamedChat {
var userContactId: Int64
var localDisplayName: ContactName
public var profile: LocalProfile
public var fullPreferences: FullPreferences
var activeUser: Bool
public var displayName: String { get { profile.displayName } }
@@ -26,6 +27,7 @@ public struct User: Decodable, NamedChat {
userContactId: 1,
localDisplayName: "alice",
profile: LocalProfile.sampleData,
fullPreferences: FullPreferences.sampleData,
activeUser: true
)
}
@@ -35,15 +37,17 @@ public typealias ContactName = String
public typealias GroupName = String
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.fullName = fullName
self.image = image
self.preferences = preferences
}
public var displayName: String
public var fullName: String
public var image: String?
public var preferences: Preferences?
public var localAlias: String { get { "" } }
var profileViewName: String {
@@ -57,11 +61,12 @@ public struct Profile: 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.displayName = displayName
self.fullName = fullName
self.image = image
self.preferences = preferences
self.localAlias = localAlias
}
@@ -69,6 +74,7 @@ public struct LocalProfile: Codable, NamedChat {
public var displayName: String
public var fullName: String
public var image: String?
public var preferences: Preferences?
public var localAlias: String
var profileViewName: String {
@@ -81,6 +87,7 @@ public struct LocalProfile: Codable, NamedChat {
profileId: 1,
displayName: "alice",
fullName: "Alice",
preferences: Preferences.sampleData,
localAlias: ""
)
}
@@ -117,6 +124,344 @@ extension NamedChat {
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 {
case direct(contact: Contact)
case group(groupInfo: GroupInfo)
@@ -321,6 +666,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var activeConn: Connection
public var viaGroup: Int64?
public var chatSettings: ChatSettings
public var userPreferences: Preferences
public var mergedPreferences: ContactUserPreferences
var createdAt: Date
var updatedAt: Date
@@ -351,6 +698,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
profile: LocalProfile.sampleData,
activeConn: Connection.sampleData,
chatSettings: ChatSettings.defaults,
userPreferences: Preferences.sampleData,
mergedPreferences: ContactUserPreferences.sampleData,
createdAt: .now,
updatedAt: .now
)
@@ -556,6 +905,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
public var groupId: Int64
var localDisplayName: GroupName
public var groupProfile: GroupProfile
public var fullGroupPreferences: FullGroupPreferences
public var membership: GroupMember
public var hostConnCustomUserProfileId: Int64?
public var chatSettings: ChatSettings
@@ -587,6 +937,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
groupId: 1,
localDisplayName: "team",
groupProfile: GroupProfile.sampleData,
fullGroupPreferences: FullGroupPreferences.sampleData,
membership: GroupMember.sampleData,
hostConnCustomUserProfileId: nil,
chatSettings: ChatSettings.defaults,
@@ -596,15 +947,17 @@ public struct GroupInfo: Identifiable, Decodable, 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.fullName = fullName
self.image = image
self.groupPreferences = groupPreferences
}
public var displayName: String
public var fullName: String
public var image: String?
public var groupPreferences: GroupPreferences?
public var localAlias: String { "" }
public static let sampleData = GroupProfile(
@@ -1561,6 +1914,8 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
case seconds(_ seconds: Int64)
case none
public static var values: [ChatItemTTL] { [.none, .month, .week, .day] }
public var id: Self { self }
public init(_ seconds: Int64?) {

View File

@@ -29,13 +29,13 @@
")" = ")";
/* 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. */
"[Send us email](mailto:chat@simplex.chat)" = "[Senden Sie uns eine E-Mail](mailto:chat@simplex.chat)";
/* 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. */
"**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. */
"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. */
"Already connected?" = "Sind Sie bereits verbunden?";
/* pref value */
"always" = "***always";
/* No comment provided by engineer. */
"Answer call" = "Anruf annehmen";
@@ -230,6 +251,12 @@
/* No comment provided by engineer. */
"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. */
"Call already ended!" = "Anruf ist bereits beendet!";
@@ -314,6 +341,9 @@
/* No comment provided by engineer. */
"Chat is stopped" = "Der Chat ist beendet";
/* No comment provided by engineer. */
"Chat preferences" = "***Chat preferences";
/* No comment provided by engineer. */
"Chats" = "Chats";
@@ -431,6 +461,9 @@
/* connection information */
"connection:%@" = "Verbindung:%@";
/* No comment provided by engineer. */
"Contact allows" = "***Contact allows";
/* No comment provided by engineer. */
"Contact already exists" = "Der Kontakt ist bereits vorhanden";
@@ -455,9 +488,15 @@
/* No comment provided by engineer. */
"Contact name" = "Kontaktname";
/* No comment provided by engineer. */
"Contact preferences" = "***Contact preferences";
/* No comment provided by engineer. */
"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 */
"Copy" = "Kopieren";
@@ -542,6 +581,9 @@
/* No comment provided by engineer. */
"Decentralized" = "Dezentral";
/* pref value */
"default (%@)" = "***default (%@)";
/* chat item action */
"Delete" = "Löschen";
@@ -857,6 +899,9 @@
/* No comment provided by engineer. */
"For console" = "Für Konsole";
/* No comment provided by engineer. */
"Full deletion" = "***Full deletion";
/* No comment provided by engineer. */
"Full name (optional)" = "Vollständiger Name (optional)";
@@ -890,9 +935,18 @@
/* No comment provided by engineer. */
"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 */
"Group message:" = "Grppennachricht:";
/* No comment provided by engineer. */
"Group preferences" = "***Group preferences";
/* 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.";
@@ -1034,6 +1088,9 @@
/* 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.";
/* 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. */
"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. */
"New passphrase…" = "Neues Passwort…";
/* pref value */
"no" = "***no";
/* No comment provided by engineer. */
"No" = "Nein";
@@ -1220,6 +1280,9 @@
/* No comment provided by engineer. */
"Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!";
/* group pref value */
"off" = "***off";
/* No comment provided by engineer. */
"Off (Local)" = "Aus (Lokal)";
@@ -1232,6 +1295,9 @@
/* No comment provided by engineer. */
"Old database archive" = "Altes Datenbankarchiv";
/* group pref value */
"on" = "***on";
/* No comment provided by engineer. */
"One-time invitation link" = "Einmal-Einladungslink";
@@ -1247,6 +1313,21 @@
/* 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.";
/* 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. */
"Open chat" = "Chat öffnen";
@@ -1310,6 +1391,9 @@
/* 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.";
/* No comment provided by engineer. */
"Preferences" = "***Preferences";
/* No comment provided by engineer. */
"Privacy & security" = "Datenschutz & Sicherheit";
@@ -1319,6 +1403,12 @@
/* No comment provided by engineer. */
"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. */
"Protocol timeout" = "Protokollzeitüberschreitung";
@@ -1326,7 +1416,7 @@
"Push notifications" = "Push-Benachrichtigungen";
/* No comment provided by engineer. */
"Rate the app" = "Bewerte die App";
"Rate the app" = "Bewerten Sie die App";
/* No comment provided by engineer. */
"Read" = "Lesen";
@@ -1394,6 +1484,9 @@
/* No comment provided by engineer. */
"Required" = "Erforderlich";
/* No comment provided by engineer. */
"Reset" = "***Reset";
/* No comment provided by engineer. */
"Reset colors" = "Farben zurücksetzen";
@@ -1430,9 +1523,15 @@
/* chat item action */
"Save" = "Speichern";
/* No comment provided by engineer. */
"Save (and notify contact)" = "***Save (and notify contact)";
/* No comment provided by engineer. */
"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. */
"Save archive" = "Archiv speichern";
@@ -1578,7 +1677,7 @@
"strike" = "durchstreichen";
/* 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. */
"System" = "System";
@@ -1665,7 +1764,7 @@
"this contact" = "Dieser Kontakt";
/* 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. */
"This group no longer exists." = "Diese Gruppe existiert nicht mehr.";
@@ -1793,6 +1892,12 @@
/* No comment provided by engineer. */
"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. */
"waiting for answer…" = "Warten auf Antwort…";
@@ -1829,12 +1934,18 @@
/* No comment provided by engineer. */
"Wrong passphrase!" = "Falsches Passwort!";
/* pref value */
"yes" = "***yes";
/* No comment provided by engineer. */
"You" = "Meine Daten";
/* No comment provided by engineer. */
"You accepted connection" = "Sie haben die Verbindung akzeptiert";
/* No comment provided by engineer. */
"You allow" = "***You allow";
/* No comment provided by engineer. */
"You are already connected to %@." = "Sie sind bereits mit %@ verbunden.";
@@ -1988,6 +2099,9 @@
/* No comment provided by engineer. */
"Your ICE servers" = "Ihre ICE-Server";
/* No comment provided by engineer. */
"Your preferences" = "***Your preferences";
/* No comment provided by engineer. */
"Your privacy" = "Meine Privatsphäre";

View File

@@ -188,9 +188,30 @@
/* No comment provided by engineer. */
"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. */
"Already connected?" = "Соединение уже установлено?";
/* pref value */
"always" = "всегда";
/* No comment provided by engineer. */
"Answer call" = "Принять звонок";
@@ -230,6 +251,12 @@
/* No comment provided by engineer. */
"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. */
"Call already ended!" = "Звонок уже завершен!";
@@ -314,6 +341,9 @@
/* No comment provided by engineer. */
"Chat is stopped" = "Чат остановлен";
/* No comment provided by engineer. */
"Chat preferences" = "Предпочтения";
/* No comment provided by engineer. */
"Chats" = "Чаты";
@@ -431,6 +461,9 @@
/* connection information */
"connection:%@" = "connection:%@";
/* No comment provided by engineer. */
"Contact allows" = "Контакт разрешает";
/* No comment provided by engineer. */
"Contact already exists" = "Существующий контакт";
@@ -455,9 +488,15 @@
/* No comment provided by engineer. */
"Contact name" = "Имена контактов";
/* No comment provided by engineer. */
"Contact preferences" = "Предпочтения контакта";
/* No comment provided by engineer. */
"Contact requests" = "Запросы контактов";
/* No comment provided by engineer. */
"Contacts can mark messages for deletion; you will be able to view them." = "Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.";
/* chat item action */
"Copy" = "Скопировать";
@@ -542,6 +581,9 @@
/* No comment provided by engineer. */
"Decentralized" = "Децентрализованный";
/* pref value */
"default (%@)" = "по умолчанию (%@)";
/* chat item action */
"Delete" = "Удалить";
@@ -857,6 +899,9 @@
/* No comment provided by engineer. */
"For console" = "Для консоли";
/* No comment provided by engineer. */
"Full deletion" = "Полное удаление";
/* No comment provided by engineer. */
"Full name (optional)" = "Полное имя (не обязательно)";
@@ -890,9 +935,18 @@
/* No comment provided by engineer. */
"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 */
"Group message:" = "Групповое сообщение:";
/* No comment provided by engineer. */
"Group preferences" = "Предпочтения группы";
/* No comment provided by engineer. */
"Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах.";
@@ -1034,6 +1088,9 @@
/* 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 после запуска чата или изменения пароля - это позволит получать мгновенные уведомления.";
/* No comment provided by engineer. */
"Irreversible message deletion is prohibited in this chat." = "Необратимое удаление сообщений запрещено в этом чате.";
/* No comment provided by engineer. */
"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. */
"New passphrase…" = "Новый пароль…";
/* pref value */
"no" = "нет";
/* No comment provided by engineer. */
"No" = "Нет";
@@ -1220,6 +1280,9 @@
/* No comment provided by engineer. */
"Notifications are disabled!" = "Уведомления выключены";
/* group pref value */
"off" = "нет";
/* No comment provided by engineer. */
"Off (Local)" = "Выключить (Локальные)";
@@ -1232,6 +1295,9 @@
/* No comment provided by engineer. */
"Old database archive" = "Старый архив чата";
/* group pref value */
"on" = "да";
/* No comment provided by engineer. */
"One-time invitation link" = "Одноразовая ссылка";
@@ -1247,6 +1313,21 @@
/* 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 шифрованием**";
/* 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. */
"Open chat" = "Открыть чат";
@@ -1310,6 +1391,9 @@
/* No comment provided by engineer. */
"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. */
"Privacy & security" = "Конфиденциальность";
@@ -1319,6 +1403,12 @@
/* No comment provided by engineer. */
"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. */
"Protocol timeout" = "Таймаут протокола";
@@ -1394,6 +1484,9 @@
/* No comment provided by engineer. */
"Required" = "Обязательно";
/* No comment provided by engineer. */
"Reset" = "Сбросить";
/* No comment provided by engineer. */
"Reset colors" = "Сбросить цвета";
@@ -1430,9 +1523,15 @@
/* chat item action */
"Save" = "Сохранить";
/* No comment provided by engineer. */
"Save (and notify contact)" = "Сохранить (и уведомить контакт)";
/* No comment provided by engineer. */
"Save (and notify contacts)" = "Сохранить (и уведомить контакты)";
/* No comment provided by engineer. */
"Save (and notify group members)" = "Сохранить (и уведомить членов группы)";
/* No comment provided by engineer. */
"Save archive" = "Сохранить архив";
@@ -1793,6 +1892,12 @@
/* No comment provided by engineer. */
"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. */
"waiting for answer…" = "ожидается ответ…";
@@ -1829,12 +1934,18 @@
/* No comment provided by engineer. */
"Wrong passphrase!" = "Неправильный пароль!";
/* pref value */
"yes" = "да";
/* No comment provided by engineer. */
"You" = "Вы";
/* No comment provided by engineer. */
"You accepted connection" = "Вы приняли приглашение соединиться";
/* No comment provided by engineer. */
"You allow" = "Вы разрешаете";
/* No comment provided by engineer. */
"You are already connected to %@." = "Вы уже соединены с контактом %@.";
@@ -1988,6 +2099,9 @@
/* No comment provided by engineer. */
"Your ICE servers" = "Ваши ICE серверы";
/* No comment provided by engineer. */
"Your preferences" = "Ваши предпочтения";
/* No comment provided by engineer. */
"Your privacy" = "Конфиденциальность";

View File

@@ -3,6 +3,7 @@
module Main where
import Control.Concurrent (threadDelay)
import Data.Time.Clock (getCurrentTime)
import Server
import Simplex.Chat.Controller (versionNumber)
import Simplex.Chat.Core
@@ -27,7 +28,8 @@ main = do
simplexChatTerminal terminalChatConfig opts t
else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do
r <- sendChatCmd cc chatCmd
putStrLn $ serializeChatResponse (Just user) r
ts <- getCurrentTime
putStrLn $ serializeChatResponse (Just user) ts r
threadDelay $ chatCmdDelay opts * 1000000
welcome :: ChatOpts -> IO ()

View File

@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: d2b88a1baa390ec64b6535e32ce69f26f53f4d7a
tag: c2342cba057fa2333b5936a2254507b5b62e8de2
source-repository-package
type: git

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):
```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
git clone git@github.com:simplex-chat/simplex-chat.git
cd simplex-chat
git checkout stable
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
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
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ stack install
git clone git@github.com:simplex-chat/simplex-chat.git
cd simplex-chat
git checkout stable
# 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
@@ -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.
### 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:

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:
```
ignore-project: False
package direct-sqlcipher
extra-include-dirs: /opt/homebrew/opt/openssl@3/include
extra-lib-dirs: /opt/homebrew/opt/openssl@3/lib
flags: +openssl
cp scripts/cabal.project.local.mac cabal.project.local
# or
# cp scripts/cabal.project.local.linux cabal.project.local
```
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

View File

@@ -1,6 +1,6 @@
{
"name": "simplex-chat",
"version": "0.1.0",
"version": "0.1.1",
"description": "SimpleX Chat client",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -249,7 +249,7 @@ export class ChatClient {
const r = await this.sendChatCommand({type: "showMyAddress"})
switch (r.type) {
case "userContactLink":
return r.connReqContact
return r.contactLink.connReqContact
default:
if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") {
return undefined

View File

@@ -280,8 +280,7 @@ export interface CRCmdOk extends CR {
export interface CRUserContactLink extends CR {
type: "userContactLink"
connReqContact: string
autoAccept: boolean
contactLink: UserContactLink
}
export interface CRUserContactLinkUpdated extends CR {
@@ -913,6 +912,16 @@ interface FileTransferMeta {
cancelled: boolean
}
interface UserContactLink {
connReqContact: string
autoAccept?: AutoAccept
}
interface AutoAccept {
acceptIncognito: boolean
autoReply?: MsgContent
}
export interface ChatStats {
unreadCount: number
minUnreadItemId: number

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

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

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/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";

View File

@@ -61,6 +61,8 @@ library
Simplex.Chat.Migrations.M20221024_contact_used
Simplex.Chat.Migrations.M20221025_chat_settings
Simplex.Chat.Migrations.M20221029_group_link_id
Simplex.Chat.Migrations.M20221112_server_password
Simplex.Chat.Migrations.M20221115_server_cfg
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.ProfileGenerator

View File

@@ -105,7 +105,7 @@ defaultChatConfig =
testView = False
}
_defaultSMPServers :: NonEmpty SMPServer
_defaultSMPServers :: NonEmpty SMPServerWithAuth
_defaultSMPServers =
L.fromList
[ "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 {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
firstTime = dbNew chatStore
activeTo <- newTVarIO ActiveNone
currentUser <- newTVarIO user
servers <- resolveServers defaultServers
smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers {netCfg = networkConfig}
smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers'
agentAsync <- newTVarIO Nothing
idsDrg <- newTVarIO =<< drgNew
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}
where
resolveServers :: InitialAgentServers -> IO InitialAgentServers
resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of
Just smpServers' -> pure ss {smp = smpServers'}
resolveServers ss = case nonEmpty smpServers of
Just smpServers' -> pure ss {smp = L.map (\ServerCfg {server} -> server) smpServers'}
_ -> case user of
Just usr -> do
userSmpServers <- withTransaction chatStore (`getSMPServers` usr)
pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers}
Just user' -> do
userSmpServers <- withTransaction chatStore (`getSMPServers` user')
pure ss {smp = activeAgentServers cfg userSmpServers}
_ -> 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 user subConns enableExpireCIs = do
asks smpAgent >>= resumeAgentClient
@@ -276,10 +284,11 @@ processChatCommand = \case
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search)
CTContactRequest -> pure $ chatCmdError "not implemented"
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
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
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct
(msgContainer, quotedItem_) <- prepareMsg fileInvitation_
@@ -378,6 +387,8 @@ processChatCommand = \case
| otherwise = case qmc of
MCImage _ image -> MCImage qTextOrFile image
MCFile _ -> MCFile qTextOrFile
-- consider same for voice messages
-- MCVoice _ voice -> MCVoice qTextOrFile voice
_ -> qmc
where
-- if the message we're quoting with is one of the "large" MsgContents
@@ -387,6 +398,7 @@ processChatCommand = \case
MCFile _ -> False
MCLink {} -> True
MCImage {} -> True
MCVoice {} -> False
MCUnknown {} -> True
qText = msgContentText qmc
qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_
@@ -396,7 +408,7 @@ processChatCommand = \case
unzipMaybe3 _ = (Nothing, Nothing, Nothing)
APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock "updateChatItem" $ case cType of
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
CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do
case (ciContent, itemSharedMsgId) of
@@ -425,7 +437,7 @@ processChatCommand = \case
CTContactConnection -> pure $ chatCmdError "not supported"
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock "deleteChatItem" $ case cType of
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
(CIDMInternal, _, _) -> do
deleteCIFile user file
@@ -468,10 +480,10 @@ processChatCommand = \case
CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk
CTContactRequest -> 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
withStore $ \db -> do
ct <- getContact db userId chatId
ct <- getContact db user chatId
liftIO $ updateContactUnreadChat db user ct unreadChat
pure CRCmdOk
CTGroup -> do
@@ -482,7 +494,7 @@ processChatCommand = \case
_ -> pure $ chatCmdError "not supported"
APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
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
conns <- withStore $ \db -> getContactConnections db userId ct
withChatLock "deleteChat direct" . procCmd $ do
@@ -516,9 +528,9 @@ processChatCommand = \case
withStore' $ \db -> deleteGroup db user gInfo
pure $ CRGroupDeletedUser gInfo
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
ct <- withStore $ \db -> getContact db userId chatId
ct <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
@@ -561,7 +573,7 @@ processChatCommand = \case
pure $ CRContactRequestRejected cReq
APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do
-- party initiating call
ct <- withStore $ \db -> getContact db userId contactId
ct <- withStore $ \db -> getContact db user contactId
calls <- asks currentCalls
withChatLock "sendCallInvitation" $ do
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
@@ -629,27 +641,27 @@ processChatCommand = \case
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId)
updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
pure Nothing
APIGetCallInvitations -> withUser $ \User {userId} -> do
APIGetCallInvitations -> withUser $ \user -> do
calls <- asks currentCalls >>= readTVarIO
let invs = mapMaybe callInvitation $ M.elems calls
CRCallInvitations <$> mapM (rcvCallInvitation userId) invs
CRCallInvitations <$> mapM (rcvCallInvitation user) invs
where
callInvitation Call {contactId, callState, callTs} = case callState of
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey)
_ -> Nothing
rcvCallInvitation userId (contactId, callTs, peerCallType, sharedKey) = do
contact <- withStore (\db -> getContact db userId contactId)
rcvCallInvitation user (contactId, callTs, peerCallType, sharedKey) = do
contact <- withStore (\db -> getContact db user contactId)
pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs}
APICallStatus contactId receivedStatus ->
withCurrentCall contactId $ \userId ct call ->
updateCallItemStatus userId ct call receivedStatus Nothing $> Just call
APIUpdateProfile profile -> withUser (`updateProfile` profile)
APISetContactPrefs contactId prefs' -> withUser $ \user@User {userId} -> do
ct <- withStore $ \db -> getContact db userId contactId
APISetContactPrefs contactId prefs' -> withUser $ \user -> do
ct <- withStore $ \db -> getContact db user contactId
updateContactPrefs user ct prefs'
APISetContactAlias contactId localAlias -> withUser $ \User {userId} -> do
APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do
ct' <- withStore $ \db -> do
ct <- getContact db userId contactId
ct <- getContact db user contactId
liftIO $ updateContactAlias db userId ct localAlias
pure $ CRContactAliasUpdated ct'
APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do
@@ -668,12 +680,19 @@ processChatCommand = \case
msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta
connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing
pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages}
GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore' (`getSMPServers` user))
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
withStore $ \db -> overwriteSMPServers db user smpServers
GetUserSMPServers -> do
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
TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer)
APISetChatItemTTL newTTL_ -> withUser' $ \user ->
checkStoreNotChanged $
withChatLock "setChatItemTTL" $ do
@@ -692,10 +711,10 @@ processChatCommand = \case
APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user))
APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk
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
ct <- withStore $ \db -> do
ct <- getContact db userId chatId
ct <- getContact db user chatId
liftIO $ updateContactSettings db user chatId chatSettings
pure ct
withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings)
@@ -709,9 +728,9 @@ processChatCommand = \case
withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError)
pure CRCmdOk
_ -> 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
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)
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
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
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
pure $ CRGroupMemberInfo g m connectionStats
APISwitchContact contactId -> withUser $ \User {userId} -> do
ct <- withStore $ \db -> getContact db userId contactId
APISwitchContact contactId -> withUser $ \user -> do
ct <- withStore $ \db -> getContact db user contactId
withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct
pure CRCmdOk
APISwitchGroupMember gId gMemberId -> withUser $ \user -> do
@@ -807,7 +826,7 @@ processChatCommand = \case
contacts <- withStore' (`getUserContacts` user)
withChatLock "sendMessageBroadcast" . procCmd $ do
let mc = MCText $ safeDecodeUtf8 msg
cts = filter isReady contacts
cts = filter (\ct -> isReady ct && isDirect ct) contacts
forM_ cts $ \ct ->
void
( do
@@ -816,6 +835,9 @@ processChatCommand = \case
)
`catchError` (toView . CRChatError)
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
contactId <- withStore $ \db -> getContactIdByName db user cName
quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg)
@@ -834,9 +856,9 @@ processChatCommand = \case
gVar <- asks idsDrg
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile)
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
(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
GroupMember {memberRole = userRole} = membership
Contact {localDisplayName = cName} = contact
@@ -888,7 +910,7 @@ processChatCommand = \case
Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole
_ -> throwChatError CEGroupMemberNotFound
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
GroupMember {memberRole = userRole} = membership
canChangeRole = userRole >= GRAdmin && userRole >= mRole && userRole >= memRole && memberCurrent membership
@@ -898,7 +920,7 @@ processChatCommand = \case
withStore' $ \db -> updateGroupMemberRole db user m memRole
case mStatus of
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
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
_ -> do
@@ -1012,11 +1034,11 @@ processChatCommand = \case
quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName (safeDecodeUtf8 quotedMsg)
let mc = MCText $ safeDecodeUtf8 msg
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
CRLastMessages . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) Nothing)
LastMessages Nothing count -> withUser $ \user -> withStore $ \db ->
CRLastMessages <$> getAllChatItems db user (CPLast count)
CRApiChatItems . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) search)
LastMessages Nothing count search ->
processChatCommand (APIGetChatItems (CPLast count) search)
SendFile chatName f -> withUser $ \user -> do
chatRef <- getChatRef user chatName
processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "")
@@ -1048,7 +1070,7 @@ processChatCommand = \case
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
withStore (\db -> getChatRefByFileId db user fileId) >>= \case
ChatRef CTDirect contactId -> do
contact <- withStore $ \db -> getContact db userId contactId
contact <- withStore $ \db -> getContact db user contactId
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
ChatRef CTGroup groupId -> do
Group gInfo ms <- withStore $ \db -> getGroup db user groupId
@@ -1111,7 +1133,7 @@ processChatCommand = \case
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
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
(_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
@@ -1162,25 +1184,22 @@ processChatCommand = \case
void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError)
pure $ CRUserProfileUpdated (fromLocalProfile p) p'
updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse
updateContactPrefs user@User {userId} ct@Contact {contactId, activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct $ contactUserPreferences user ct -- nothing changed actually
updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct
| otherwise = do
withStore' $ \db -> updateContactUserPreferences db userId contactId contactUserPrefs'
-- [incognito] filter out contacts with whom user has incognito connections
let ct' = (ct :: Contact) {userPreferences = contactUserPrefs'}
ct' <- withStore' $ \db -> updateContactUserPreferences db user ct contactUserPrefs'
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId
let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct')
withChatLock "updateProfile" . procCmd $ do
void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError)
pure $ CRContactPrefsUpdated ct ct' $ contactUserPreferences user ct'
pure $ CRContactPrefsUpdated ct ct'
isReady :: Contact -> Bool
isReady ct =
let s = connStatus $ activeConn (ct :: Contact)
in s == ConnReady || s == ConnSndReady
withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse
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
withChatLock "currentCall" $
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
case (chatRef, grpMemberId) of
(ChatRef CTDirect contactId, Nothing) -> do
ct <- withStore $ \db -> getContact db userId contactId
ct <- withStore $ \db -> getContact db user contactId
(msg, ci) <- acceptFile
void $ sendDirectContactMessage ct msg
pure ci
@@ -2054,7 +2073,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
where
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
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
CORRequest cReq@UserContactRequest {localDisplayName} -> do
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
@@ -2148,7 +2167,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
if connectedIncognito
then withStore' $ \db -> deleteSentProbe db userId probeId
else do
cs <- withStore' $ \db -> getMatchingContacts db userId ct
cs <- withStore' $ \db -> getMatchingContacts db user ct
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
where
@@ -2463,21 +2482,21 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
xInfo :: Contact -> Profile -> m ()
xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do
c' <- withStore $ \db -> updateContactProfile db userId c p'
toView $ CRContactUpdated c c' $ contactUserPreferences user c'
c' <- withStore $ \db -> updateContactProfile db user c p'
toView $ CRContactUpdated c c'
xInfoProbe :: Contact -> Probe -> m ()
xInfoProbe c2 probe =
-- [incognito] unless connected incognito
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
xInfoProbeCheck :: Contact -> ProbeHash -> m ()
xInfoProbeCheck c1 probeHash =
-- [incognito] unless connected incognito
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
probeMatch :: Contact -> Contact -> Probe -> m ()
@@ -2490,7 +2509,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
xInfoProbeOk :: Contact -> Probe -> m ()
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} ->
if cId1 /= cId2
then mergeContacts c1 c2
@@ -2605,7 +2624,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
ChatMessage {chatMsgEvent} <- parseChatMessage connInfo
case chatMsgEvent of
XInfo p -> do
ct <- withStore $ \db -> createDirectContact db userId activeConn p
ct <- withStore $ \db -> createDirectContact db user activeConn p
toView $ CRContactConnecting ct
-- TODO show/log error, other events in SMP confirmation
_ -> pure ()
@@ -3163,8 +3182,8 @@ chatCommandP =
"/sql chat " *> (ExecChatStoreSQL <$> textP),
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
"/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional searchP),
"/_get items count=" *> (APIGetChatItems <$> A.decimal),
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
"/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
@@ -3199,9 +3218,15 @@ chatCommandP =
"/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal),
"/_leave #" *> (APILeaveGroup <$> A.decimal),
"/_members #" *> (APIListMembers <$> A.decimal),
"/smp_servers default" $> SetUserSMPServers [],
"/smp_servers " *> (SetUserSMPServers <$> smpServersP),
-- /smp_servers is deprecated, use /smp and /_smp
"/smp_servers default" $> SetUserSMPServers (SMPServersConfig []),
"/smp_servers " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP),
"/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 <$> ciTTL),
"/ttl" $> APIGetChatItemTTL,
@@ -3256,7 +3281,8 @@ chatCommandP =
("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString),
("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> 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),
("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath),
("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
@@ -3316,8 +3342,8 @@ chatCommandP =
n <- (A.space *> A.takeByteString) <|> pure ""
pure $ if B.null n then name else safeDecodeUtf8 n
textP = safeDecodeUtf8 <$> A.takeByteString
filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString
searchP = T.unpack . safeDecodeUtf8 <$> (" search=" *> A.takeByteString)
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
filePath = stringP
memberRole =
A.choice
[ " owner" $> GROwner,

View File

@@ -23,6 +23,7 @@ import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (ord)
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import Data.String
import Data.Text (Text)
@@ -39,7 +40,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
import Simplex.Chat.Types
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.Lock
import Simplex.Messaging.Agent.Protocol
@@ -146,7 +147,7 @@ data ChatCommand
| ExecAgentStoreSQL Text
| APIGetChats {pendingConnections :: Bool}
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems Int
| APIGetChatItems ChatPagination (Maybe String)
| APISendMessage ChatRef ComposedMessage
| APIUpdateChatItem ChatRef ChatItemId MsgContent
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
@@ -166,7 +167,7 @@ data ChatCommand
| APIGetCallInvitations
| APICallStatus ContactId WebRTCCallStatus
| APIUpdateProfile Profile
| APISetContactPrefs Int64 Preferences
| APISetContactPrefs ContactId Preferences
| APISetContactAlias ContactId LocalAlias
| APISetConnectionAlias Int64 LocalAlias
| APIParseMarkdown Text
@@ -186,7 +187,8 @@ data ChatCommand
| APIDeleteGroupLink GroupId
| APIGetGroupLink GroupId
| GetUserSMPServers
| SetUserSMPServers [SMPServer]
| SetUserSMPServers SMPServersConfig
| TestSMPServer SMPServerWithAuth
| APISetChatItemTTL (Maybe Int64)
| APIGetChatItemTTL
| APISetNetworkConfig NetworkConfig
@@ -235,7 +237,7 @@ data ChatCommand
| DeleteGroupLink GroupName
| ShowGroupLink GroupName
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString}
| LastMessages (Maybe ChatName) Int
| LastMessages (Maybe ChatName) Int (Maybe String)
| SendFile ChatName FilePath
| SendImage ChatName FilePath
| ForwardFile ChatName FileTransferId
@@ -259,9 +261,10 @@ data ChatResponse
| CRChatSuspended
| CRApiChats {chats :: [AChat]}
| CRApiChat {chat :: AChat}
| CRLastMessages {chatItems :: [AChatItem]}
| CRApiChatItems {chatItems :: [AChatItem]}
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
| CRUserSMPServers {smpServers :: [SMPServer]}
| CRUserSMPServers {smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth}
| CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure}
| CRChatItemTTL {chatItemTTL :: Maybe Int64}
| CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
@@ -296,7 +299,7 @@ data ChatResponse
| CRInvitation {connReqInvitation :: ConnReqInvitation}
| CRSentConfirmation
| CRSentInvitation {customUserProfile :: Maybe Profile}
| CRContactUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences}
| CRContactUpdated {fromContact :: Contact, toContact :: Contact}
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
| CRContactDeleted {contact :: Contact}
| CRChatCleared {chatInfo :: AChatInfo}
@@ -322,7 +325,7 @@ data ChatResponse
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
| CRContactAliasUpdated {toContact :: Contact}
| CRConnectionAliasUpdated {toConnection :: PendingContactConnection}
| CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences}
| CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact}
| CRContactConnecting {contact :: Contact}
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
| CRContactAnotherClient {contact :: Contact}
@@ -383,6 +386,9 @@ instance ToJSON ChatResponse where
toJSON = J.genericToJSON . 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}
deriving (Show, Generic, FromJSON)
@@ -465,6 +471,24 @@ data SwitchProgress = SwitchProgress
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
= ChatError {errorType :: ChatErrorType}
| ChatErrorAgent {agentError :: AgentErrorType}

View File

@@ -155,6 +155,12 @@ messagesHelpInfo =
indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team",
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",
"To quote a message that starts with \"hi\":",
indent <> highlight "> @alice (hi) <msg> " <> " - to reply to alice's most recent message",
@@ -164,8 +170,8 @@ messagesHelpInfo =
"",
green "Deleting sent messages (for everyone)",
"To delete a message that starts with \"hi\":",
indent <> highlight "\\ @alice hi " <> " - to delete your message to alice",
indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team",
indent <> highlight "\\ @alice hi " <> " - to delete your message to alice",
indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team",
"",
green "Editing sent messages",
"To edit your last message press up arrow, edit (keep the initial ! symbol) and press enter.",
@@ -193,7 +199,7 @@ settingsInfo =
styleMarkdown
[ green "Chat settings:",
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 #<group> <member> " <> " - information about member connection",
indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command",

View File

@@ -881,14 +881,9 @@ ciCallInfoText status duration = case status of
CISCallRejected -> "rejected"
CISCallAccepted -> "accepted"
CISCallNegotiated -> "connecting..."
CISCallProgress -> "in progress " <> d
CISCallEnded -> "ended " <> d
CISCallProgress -> "in progress " <> durationText duration
CISCallEnded -> "ended " <> durationText duration
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
SCTDirect :: SChatType 'CTDirect

View File

@@ -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;
|]

View File

@@ -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;
|]

View File

@@ -384,6 +384,10 @@ CREATE TABLE smp_servers(
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
created_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)
);
CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id);

View File

@@ -16,6 +16,7 @@ import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Functor (($>))
import Data.List (find)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe)
import Database.SQLite.Simple (SQLError (..))
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.Store.SQLite (closeSQLiteStore)
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.Protocol (CorrId (..))
import Simplex.Messaging.Protocol (BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth)
import Simplex.Messaging.Util (catchAll, safeDecodeUtf8)
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_server" cChatParseServer :: CString -> IO CJSONString
-- | check / migrate database and initialize chat controller on success
cChatMigrateInit :: CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
cChatMigrateInit fp key ctrl = do
@@ -107,6 +112,10 @@ cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t)
cChatParseMarkdown :: CString -> IO CJSONString
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
@@ -206,6 +215,18 @@ chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc)
chatParseMarkdown :: String -> JSONString
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}
deriving (Generic)

View File

@@ -16,7 +16,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B
import Options.Applicative
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.Encoding.String
import Simplex.Messaging.Parsers (parseAll)
@@ -26,7 +26,7 @@ import System.FilePath (combine)
data ChatOpts = ChatOpts
{ dbFilePrefix :: String,
dbKey :: String,
smpServers :: [SMPServer],
smpServers :: [ServerCfg],
networkConfig :: NetworkConfig,
logConnections :: Bool,
logServerHosts :: Bool,
@@ -155,7 +155,7 @@ fullNetworkConfig socksProxy tcpTimeout =
let tcpConnectTimeout = (tcpTimeout * 3) `div` 2
in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout}
parseSMPServers :: ReadM [SMPServer]
parseSMPServers :: ReadM [ServerCfg]
parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
parseSocksProxy :: ReadM (Maybe SocksProxy)
@@ -167,8 +167,10 @@ parseServerPort = eitherReader $ parseAll serverPortP . B.pack
serverPortP :: A.Parser (Maybe String)
serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit
smpServersP :: A.Parser [SMPServer]
smpServersP = strP `A.sepBy1` A.char ';'
smpServersP :: A.Parser [ServerCfg]
smpServersP = (toServerCfg <$> strP) `A.sepBy1` A.char ';'
where
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
getChatOpts :: FilePath -> FilePath -> IO ChatOpts
getChatOpts appDir defaultDbFileName =

View File

@@ -29,6 +29,7 @@ import Data.ByteString.Internal (c2w, w2c)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime)
import Data.Type.Equality
@@ -263,7 +264,7 @@ cmToQuotedMsg = \case
ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg
_ -> Nothing
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCFile_ | MCUnknown_ Text
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVoice_ | MCFile_ | MCUnknown_ Text
instance StrEncoding MsgContentTag where
strEncode = \case
@@ -271,11 +272,13 @@ instance StrEncoding MsgContentTag where
MCLink_ -> "link"
MCImage_ -> "image"
MCFile_ -> "file"
MCVoice_ -> "voice"
MCUnknown_ t -> encodeUtf8 t
strDecode = \case
"text" -> Right MCText_
"link" -> Right MCLink_
"image" -> Right MCImage_
"voice" -> Right MCVoice_
"file" -> Right MCFile_
t -> Right . MCUnknown_ $ safeDecodeUtf8 t
strP = strDecode <$?> A.takeTill (== ' ')
@@ -313,6 +316,7 @@ data MsgContent
= MCText Text
| MCLink {text :: Text, preview :: LinkPreview}
| MCImage {text :: Text, image :: ImageData}
| MCVoice {text :: Text, duration :: Int}
| MCFile Text
| MCUnknown {tag :: Text, text :: Text, json :: J.Object}
deriving (Eq, Show)
@@ -322,14 +326,27 @@ msgContentText = \case
MCText t -> t
MCLink {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
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 = \case
MCText _ -> MCText_
MCLink {} -> MCLink_
MCImage {} -> MCImage_
MCVoice {} -> MCVoice_
MCFile {} -> MCFile_
MCUnknown {tag} -> MCUnknown_ tag
@@ -356,6 +373,10 @@ instance FromJSON MsgContent where
text <- v .: "text"
image <- v .: "image"
pure MCImage {image, text}
MCVoice_ -> do
text <- v .: "text"
duration <- v .: "duration"
pure MCVoice {text, duration}
MCFile_ -> MCFile <$> v .: "text"
MCUnknown_ tag -> do
text <- fromMaybe unknownMsgType <$> v .:? "text"
@@ -382,12 +403,14 @@ instance ToJSON MsgContent where
MCText t -> J.object ["type" .= MCText_, "text" .= t]
MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview]
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]
toEncoding = \case
MCUnknown {json} -> JE.value $ J.Object json
MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t
MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview
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
instance ToField MsgContent where

View File

@@ -252,6 +252,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe)
import Data.Ord (Down (..))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Data.Time.LocalTime (TimeZone, getCurrentTimeZone)
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.M20221025_chat_settings
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.Types
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 qualified Simplex.Messaging.Crypto as C
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.Util (eitherToMaybe)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
import UnliftIO.STM
schemaMigrations :: [(String, Query)]
@@ -341,7 +344,9 @@ schemaMigrations =
("20221021_auto_accept__group_links", m20221021_auto_accept__group_links),
("20221024_contact_used", m20221024_contact_used),
("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
@@ -411,7 +416,7 @@ getUsers db =
toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User
toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) =
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 userId = do
@@ -435,15 +440,15 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou
pccConnId <- insertedRowId db
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 userId cReqHash = do
getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId)
getConnReqContactXContactId db user@User {userId} cReqHash = do
getContact' >>= \case
c@(Just _) -> pure (c, Nothing)
Nothing -> (Nothing,) <$> getXContactId
where
getContact' :: IO (Maybe Contact)
getContact' =
maybeFirstRow toContact $
maybeFirstRow (toContact user) $
DB.query
db
[sql|
@@ -531,11 +536,14 @@ createConnection_ db userId connType entityId acId viaContact viaUserContactLink
where
ent ct = if connType == ct then entityId else Nothing
createDirectContact :: DB.Connection -> UserId -> Connection -> Profile -> ExceptT StoreError IO Contact
createDirectContact db userId activeConn@Connection {connId, localAlias} profile = do
createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do
createdAt <- liftIO getCurrentTime
(localDisplayName, contactId, profileId) <- createContact_ db userId connId profile localAlias Nothing createdAt
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile localAlias, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences = emptyChatPrefs, createdAt, updatedAt = createdAt}
(localDisplayName, contactId, profileId) <- createContact_ db userId connId p localAlias Nothing 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 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
updateContact_ db userId userContactId localDisplayName newName currentTs
updateContactProfile :: DB.Connection -> UserId -> Contact -> Profile -> ExceptT StoreError IO Contact
updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} p'@Profile {displayName = newName}
| displayName == newName =
liftIO $ updateContactProfile_ db userId profileId p' $> (c :: Contact) {profile = toLocalProfile profileId p' localAlias}
updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact
updateContactProfile db user@User {userId} c p'
| displayName == newName = do
liftIO $ updateContactProfile_ db userId profileId p'
pure $ c {profile, mergedPreferences}
| otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime
updateContactProfile_' db userId profileId p' currentTs
updateContact_ db userId contactId localDisplayName ldn currentTs
pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p' 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 userId contactId userPreferences = do
updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact
updateContactUserPreferences db user@User {userId} c@Contact {contactId, activeConn} userPreferences = do
updatedAt <- getCurrentTime
DB.execute
db
"UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
(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 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)
toContact :: ContactRow :. ConnectionRow -> Contact
toContact (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
toContact :: User -> ContactRow :. ConnectionRow -> Contact
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}
activeConn = toConnection connRow
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 (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact
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}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
in case toMaybeConnection connRow of
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
-- TODO return the last connection that is ready, not any last connection
-- requires updating connection status
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
getContact db userId cId
getContact db user cId
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)
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 userId agentConnId cReq =
@@ -974,8 +992,8 @@ getGroupLinkId db User {userId} GroupInfo {groupId} =
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)
createOrUpdateContactRequest :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
liftIO (maybeM getContact' xContactId_) >>= \case
Just contact -> pure $ CORContact contact
Nothing -> CORRequest <$> createOrUpdate_
@@ -1011,7 +1029,7 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN
insertedRowId db
getContact' :: XContactId -> IO (Maybe Contact)
getContact' xContactId =
maybeFirstRow toContact $
maybeFirstRow (toContact user) $
DB.query
db
[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)
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)
createdAt <- getCurrentTime
customUserProfileId <- forM incognitoProfile $ \case
NewIncognito p -> createIncognitoProfile_ db userId createdAt p
ExistingIncognito LocalProfile {profileId = pId} -> pure pId
let contactUserPrefs = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
DB.execute
db
"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
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 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)
toMaybeConnection _ = Nothing
getMatchingContacts :: DB.Connection -> UserId -> Contact -> IO [Contact]
getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact]
getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
contactIds <-
map fromOnly
<$> 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 = ?)
|]
(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 gVar userId _to@Contact {contactId} =
@@ -1288,8 +1307,8 @@ deleteSentProbe db userId probeId =
"DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?"
(userId, probeId)
matchReceivedProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact)
matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
matchReceivedProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
matchReceivedProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
let probeHash = C.sha256Hash probe
contactIds <-
map fromOnly
@@ -1309,10 +1328,10 @@ matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
(contactId, probe, probeHash, userId, currentTs, currentTs)
case contactIds of
[] -> 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 userId _from@Contact {contactId} (ProbeHash probeHash) = do
matchReceivedProbeHash :: DB.Connection -> User -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe))
matchReceivedProbeHash db user@User {userId} _from@Contact {contactId} (ProbeHash probeHash) = do
namesAndProbes <-
DB.query
db
@@ -1332,10 +1351,10 @@ matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash)
[] -> pure Nothing
(cId, 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 userId _from@Contact {contactId} (Probe probe) = do
matchSentProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
contactIds <-
map fromOnly
<$> DB.query
@@ -1350,7 +1369,7 @@ matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do
(userId, probe, contactId)
case contactIds of
[] -> 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 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)] =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
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"
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
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 gVar user@User {userId} groupProfile = ExceptT $ do
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
fullGroupPreferences = mergeGroupPreferences groupPreferences
currentTs <- getCurrentTime
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
groupId <- liftIO $ do
@@ -1603,7 +1624,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
memberId <- liftIO $ encodedRandomBytes gVar 12
membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs
let chatSettings = ChatSettings {enableNtfs = True}
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, 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
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_ = do
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
fullGroupPreferences = mergeGroupPreferences groupPreferences
ExceptT $
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
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
membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs
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 User {userId} groupId =
@@ -1802,7 +1824,8 @@ toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) =
let membership = toGroupMember userContactId userMemberRow
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 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 User {userId} GroupMember {groupMemberId} =
maybeFirstRow toContact $
getContactViaMember db user@User {userId} GroupMember {groupMemberId} =
maybeFirstRow (toContact user) $
DB.query
db
[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}} =
case memberContactId of
Just contactId ->
runExceptT (getContact db userId contactId) >>= \case
runExceptT (getContact db user contactId) >>= \case
Right ct@Contact {activeConn = Connection {connLevel, viaGroupLink}, contactUsed} ->
unless ((connLevel == 0 && not viaGroupLink) || contactUsed) $ deleteContact db user ct
_ -> pure ()
@@ -2317,7 +2340,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow})
getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
getViaGroupContact db User {userId} GroupMember {groupMemberId} =
getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
maybeFirstRow toContact' $
DB.query
db
@@ -2344,7 +2367,8 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
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 userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do
@@ -2656,7 +2680,7 @@ getRcvFileTransfer db user@User {userId} fileId = do
rfi_ = \case
(Just filePath, Just connId, Just agentConnId, _, _, _, _) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId}
(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}
(Just filePath, Nothing, Nothing, _, Just groupId, Just groupMemberId, True) -> do
getGroupMember db user groupId groupMemberId >>= \case
@@ -3191,7 +3215,7 @@ getChatPreviews db user withPCC = do
ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo
getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat]
getDirectChatPreviews_ db User {userId} = do
getDirectChatPreviews_ db user@User {userId} = do
tz <- getCurrentTimeZone
currentTs <- getCurrentTime
map (toDirectChatPreview tz currentTs)
@@ -3230,7 +3254,7 @@ getDirectChatPreviews_ db User {userId} = do
WHERE item_status = ? AND item_deleted != 1
GROUP BY 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 = ?
AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1)
AND c.connection_id = (
@@ -3250,7 +3274,7 @@ getDirectChatPreviews_ db User {userId} = do
where
toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) =
let contact = toContact $ contactRow :. connRow
let contact = toContact user $ contactRow :. connRow
ci_ = toDirectChatItemList tz currentTs ciRow_
stats = toChatStats statsRow
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
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 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 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 = ?
@@ -3417,8 +3441,8 @@ getDirectChat db user contactId pagination search_ = do
CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search
getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatLast_ db User {userId} contactId count search = do
contact <- getContact db userId contactId
getDirectChatLast_ db user@User {userId} contactId count search = do
contact <- getContact db user contactId
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsLast_
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
FROM chat_items i
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 '%' || ? || '%'
ORDER BY i.chat_item_id DESC
LIMIT ?
@@ -3448,8 +3472,8 @@ getDirectChatLast_ db User {userId} contactId count search = do
(userId, contactId, search, count)
getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
contact <- getContact db userId contactId
getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search = do
contact <- getContact db user contactId
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsAfter_
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
FROM chat_items i
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 '%' || ? || '%'
AND i.chat_item_id > ?
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)
getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search = do
contact <- getContact db userId contactId
getDirectChatBefore_ db user@User {userId} contactId beforeChatItemId count search = do
contact <- getContact db user contactId
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsBefore_
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
FROM chat_items i
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 '%' || ? || '%'
AND i.chat_item_id < ?
ORDER BY i.chat_item_id DESC
@@ -3516,9 +3540,9 @@ getContactIdByName db User {userId} cName =
ExceptT . firstRow fromOnly (SEContactNotFoundByName 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 userId contactId =
ExceptT . fmap join . firstRow toContactOrError (SEContactNotFound contactId) $
getContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact
getContact db user@User {userId} contactId =
ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $
DB.query
db
[sql|
@@ -3646,17 +3670,18 @@ getGroupInfo db User {userId, userContactId} groupId =
(groupId, userId, userContactId)
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
currentTs <- getCurrentTime
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p'}
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p', fullGroupPreferences}
| otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime
updateGroupProfile_ currentTs
updateGroup_ ldn currentTs
pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p'}
pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences}
where
fullGroupPreferences = mergeGroupPreferences groupPreferences
updateGroupProfile_ currentTs =
DB.execute
db
@@ -3677,15 +3702,16 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou
(ldn, currentTs, userId, groupId)
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 user pagination = do
getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem]
getAllChatItems db user pagination search_ = do
let search = fromMaybe "" search_
case pagination of
CPLast count -> getAllChatItemsLast_ db user count
CPAfter _afterId _count -> throwError $ SEInternalError "not implemented"
CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented"
CPLast count -> getAllChatItemsLast_ db user count search
CPAfter afterId count -> getAllChatItemsAfter_ db user afterId count search
CPBefore beforeId count -> getAllChatItemsBefore_ db user beforeId count search
getAllChatItemsLast_ :: DB.Connection -> User -> Int -> ExceptT StoreError IO [AChatItem]
getAllChatItemsLast_ db user@User {userId} count = do
getAllChatItemsLast_ :: DB.Connection -> User -> Int -> String -> ExceptT StoreError IO [AChatItem]
getAllChatItemsLast_ db user@User {userId} count search = do
itemRefs <-
liftIO $
reverse . rights . map toChatItemRef
@@ -3694,13 +3720,59 @@ getAllChatItemsLast_ db user@User {userId} count = do
[sql|
SELECT chat_item_id, contact_id, group_id
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
LIMIT ?
|]
(userId, count)
(userId, search, count)
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 User {userId} 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
FROM chat_items i
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 = ?
|]
(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 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 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 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 = ?
@@ -4072,7 +4144,7 @@ getChatItemByGroupId db user@User {userId} groupId = do
getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem
getAChatItem_ db user@User {userId} itemId = \case
ChatRef CTDirect contactId -> do
ct <- getContact db userId contactId
ct <- getContact db user contactId
(CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId
pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci
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_)
toGroupChatItemList _ _ _ _ = []
getSMPServers :: DB.Connection -> User -> IO [SMPServer]
getSMPServers :: DB.Connection -> User -> IO [ServerCfg]
getSMPServers db User {userId} =
map toSmpServer
map toServerCfg
<$> DB.query
db
[sql|
SELECT host, port, key_hash
SELECT host, port, key_hash, basic_auth, preset, tested, enabled
FROM smp_servers
WHERE user_id = ?;
|]
(Only userId)
where
toSmpServer :: (NonEmpty TransportHost, String, C.KeyHash) -> SMPServer
toSmpServer (host, port, keyHash) = SMPServer host port keyHash
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg
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 User {userId} smpServers =
overwriteSMPServers :: DB.Connection -> User -> [ServerCfg] -> ExceptT StoreError IO ()
overwriteSMPServers db User {userId} servers =
checkConstraint SEUniqueID . ExceptT $ do
currentTs <- getCurrentTime
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
[sql|
INSERT INTO smp_servers
(host, port, key_hash, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?)
(host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
|]
(host, port, keyHash, userId, currentTs, currentTs)
(host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, preset, tested, enabled, userId, currentTs, currentTs)
pure $ Right ()
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()

View File

@@ -12,6 +12,7 @@ import Control.Monad.Reader
import Data.List (dropWhileEnd)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (getCurrentTime)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Styled
@@ -41,7 +42,8 @@ runInputLoop ct cc = forever $ do
_ -> pure ()
let testV = testView $ config cc
user <- readTVarIO $ currentUser cc
printToTerminal ct $ responseToView user testV r
ts <- getCurrentTime
printToTerminal ct $ responseToView user testV ts r
where
echo s = printToTerminal ct [plain s]
isMessage = \case

View File

@@ -10,6 +10,7 @@ module Simplex.Chat.Terminal.Output where
import Control.Monad.Catch (MonadMask)
import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Data.Time.Clock (getCurrentTime)
import Simplex.Chat.Controller
import Simplex.Chat.Styled
import Simplex.Chat.View
@@ -78,7 +79,8 @@ runTerminalOutput ct cc = do
forever $ do
(_, r) <- atomically . readTBQueue $ outputQ 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 ct s =

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.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Protocol (SMPServerWithAuth)
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
class IsContact a where
@@ -66,6 +67,7 @@ data User = User
userContactId :: ContactId,
localDisplayName :: ContactName,
profile :: LocalProfile,
fullPreferences :: FullPreferences,
activeUser :: Bool
}
deriving (Show, Generic, FromJSON)
@@ -87,6 +89,7 @@ data Contact = Contact
contactUsed :: Bool,
chatSettings :: ChatSettings,
userPreferences :: Preferences,
mergedPreferences :: ContactUserPreferences,
createdAt :: UTCTime,
updatedAt :: UTCTime
}
@@ -100,13 +103,10 @@ contactConn :: Contact -> Connection
contactConn = activeConn
contactConnId :: Contact -> ConnId
contactConnId Contact {activeConn} = aConnId activeConn
contactConnId = aConnId . contactConn
contactConnIncognito :: Contact -> Bool
contactConnIncognito = isJust . customUserProfileId'
customUserProfileId' :: Contact -> Maybe Int64
customUserProfileId' Contact {activeConn} = customUserProfileId (activeConn :: Connection)
contactConnIncognito = connIncognito . contactConn
data ContactRef = ContactRef
{ contactId :: ContactId,
@@ -207,6 +207,7 @@ data GroupInfo = GroupInfo
{ groupId :: GroupId,
localDisplayName :: GroupName,
groupProfile :: GroupProfile,
fullGroupPreferences :: FullGroupPreferences,
membership :: GroupMember,
hostConnCustomUserProfileId :: Maybe ProfileId,
chatSettings :: ChatSettings,
@@ -293,6 +294,39 @@ data Preferences = Preferences
}
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
{ fullDelete :: Maybe GroupPreference,
-- receipts :: Maybe GroupPreference,
@@ -317,7 +351,20 @@ data FullPreferences = FullPreferences
-- receipts :: 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
data ContactUserPreferences = ContactUserPreferences
@@ -325,17 +372,17 @@ data ContactUserPreferences = ContactUserPreferences
-- receipts :: ContactUserPreference,
voice :: ContactUserPreference
}
deriving (Show, Generic)
deriving (Eq, Show, Generic)
data ContactUserPreference = ContactUserPreference
{ enabled :: PrefEnabled,
userPreference :: ContactUserPref,
contactPreference :: Preference
}
deriving (Show, Generic)
deriving (Eq, Show, Generic)
data ContactUserPref = CUPContact {preference :: Preference} | CUPUser {preference :: Preference}
deriving (Show, Generic)
deriving (Eq, Show, Generic)
instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions
@@ -364,26 +411,24 @@ defaultChatPrefs =
emptyChatPrefs :: Preferences
emptyChatPrefs = Preferences Nothing Nothing
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
defaultGroupPrefs :: FullGroupPreferences
defaultGroupPrefs =
FullGroupPreferences
{ fullDelete = GroupPreference {enable = FEOff},
-- receipts = GroupPreference {enable = FEOff},
voice = GroupPreference {enable = FEOn}
}
data Preference = Preference
{allow :: FeatureAllowed}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
data GroupPreference = GroupPreference
{enable :: GroupFeatureEnabled}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
data FeatureAllowed
@@ -392,9 +437,6 @@ data FeatureAllowed
| FANo -- do not allow
deriving (Eq, Show, Generic)
data GroupFeatureEnabled = FEOn | FEOff
deriving (Eq, Show, Generic)
instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode
instance ToField FeatureAllowed where toField = toField . strEncode
@@ -418,6 +460,9 @@ instance ToJSON FeatureAllowed where
toJSON = strToJSON
toEncoding = strToJEncoding
data GroupFeatureEnabled = FEOn | FEOff
deriving (Eq, Show, Generic)
instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode
instance ToField GroupFeatureEnabled where toField = toField . strEncode
@@ -452,12 +497,25 @@ mergePreferences contactPrefs userPreferences =
in fromMaybe (getPreference pt defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel)
mergeUserChatPrefs :: User -> Contact -> FullPreferences
mergeUserChatPrefs user ct =
let userPrefs = if contactConnIncognito ct then Nothing else preferences' user
in mergePreferences (Just $ userPreferences ct) userPrefs
mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct)
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}
deriving (Show, Generic)
deriving (Eq, Show, Generic)
instance ToJSON PrefEnabled where
toJSON = J.genericToJSON J.defaultOptions
@@ -471,8 +529,8 @@ prefEnabled Preference {allow = user} Preference {allow = contact} = case (user,
(FANo, _) -> PrefEnabled False False
_ -> PrefEnabled True True
contactUserPreferences :: User -> Contact -> ContactUserPreferences
contactUserPreferences user ct =
contactUserPreferences :: User -> Preferences -> Maybe Preferences -> Bool -> ContactUserPreferences
contactUserPreferences user userPreferences contactPreferences connectedIncognito =
ContactUserPreferences
{ fullDelete = pref CFFullDelete,
-- receipts = pref CFReceipts,
@@ -483,19 +541,19 @@ contactUserPreferences user ct =
ContactUserPreference
{ enabled = prefEnabled userPref ctPref,
-- 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
}
where
ctUserPref = getPreference pt $ userPreferences ct
ctUserPref_ = chatPrefSel pt $ userPreferences ct
ctUserPref = getPreference pt userPreferences
ctUserPref_ = chatPrefSel pt userPreferences
userPref = getPreference pt ctUserPrefs
ctPref = getPreference pt ctPrefs
ctUserPrefs = mergeUserChatPrefs user ct
ctPrefs = mergePreferences (preferences' ct) Nothing
ctUserPrefs = mergeUserChatPrefs' user connectedIncognito userPreferences
ctPrefs = mergePreferences contactPreferences Nothing
getContactUserPrefefence :: ChatFeature -> ContactUserPreferences -> ContactUserPreference
getContactUserPrefefence = \case
getContactUserPreference :: ChatFeature -> ContactUserPreferences -> ContactUserPreference
getContactUserPreference = \case
CFFullDelete -> fullDelete
-- CFReceipts -> receipts
CFVoice -> voice
@@ -1144,6 +1202,9 @@ data Connection = Connection
aConnId :: Connection -> ConnId
aConnId Connection {agentConnId = AgentConnId cId} = cId
connIncognito :: Connection -> Bool
connIncognito Connection {customUserProfileId} = isJust customUserProfileId
instance ToJSON Connection where
toJSON = J.genericToJSON 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 = 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}

View File

@@ -18,10 +18,11 @@ import Data.Char (toUpper)
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
import qualified Data.List.NonEmpty as L
import Data.Maybe (isJust, isNothing, mapMaybe)
import Data.Text (Text)
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.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime)
import GHC.Generics (Generic)
@@ -37,6 +38,7 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
import Simplex.Chat.Styled
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Client (SMPTestFailure (..), SMPTestStep (..))
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C
@@ -49,11 +51,13 @@ import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (bshow)
import System.Console.ANSI.Types
serializeChatResponse :: Maybe User -> ChatResponse -> String
serializeChatResponse user_ = unlines . map unStyle . responseToView user_ False
type CurrentTime = UTCTime
responseToView :: Maybe User -> Bool -> ChatResponse -> [StyledString]
responseToView user_ testView = \case
serializeChatResponse :: Maybe User -> CurrentTime -> ChatResponse -> String
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
CRChatStarted -> ["chat started"]
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]
CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat]
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
CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats
CRContactSwitch ct progress -> viewContactSwitch ct progress
CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress
CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems
CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts
CRApiChatItems chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems
CRChatItemStatusUpdated _ -> []
CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item
CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem
CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item ts
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]"]
CRBroadcastSent mc n ts -> viewSentBroadcast mc n ts
CRBroadcastSent mc n t -> viewSentBroadcast mc n ts t
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
CRCmdAccepted _ -> []
CRCmdOk -> ["ok"]
@@ -123,13 +128,13 @@ responseToView user_ testView = \case
CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts
CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft
CRUserProfileUpdated p p' -> viewUserProfileUpdated p p'
CRContactPrefsUpdated {fromContact, toContact, preferences} -> case user_ of
Just user -> viewUserContactPrefsUpdated user fromContact toContact preferences
CRContactPrefsUpdated {fromContact, toContact} -> case user_ of
Just user -> viewUserContactPrefsUpdated user fromContact toContact
_ -> ["unexpected chat event CRContactPrefsUpdated without current user"]
CRContactAliasUpdated c -> viewContactAliasUpdated c
CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c
CRContactUpdated {fromContact = c, toContact = c', preferences} -> case user_ of
Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' preferences
CRContactUpdated {fromContact = c, toContact = c'} -> case user_ of
Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c'
_ -> ["unexpected chat event CRContactUpdated without current user"]
CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt
CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile
@@ -256,8 +261,8 @@ showSMPServer = B.unpack . strEncode . host
viewHostEvent :: AProtocolType -> TransportHost -> String
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 chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = case chat of
viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> [StyledString]
viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow ts = case chat of
DirectChat c -> case chatDir of
CIDirectSnd -> case content of
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
CIDirectRcv -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err meta
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta
CIRcvGroupEvent {} -> showRcvItemProhibited from
_ -> showRcvItem from
where
@@ -283,7 +288,7 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
to = ttyToGroup g
CIGroupRcv m -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
CIRcvIntegrityError err -> viewRcvIntegrityError from err meta
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta
CIRcvGroupInvitation {} -> showRcvItemProhibited from
_ -> showRcvItem from
where
@@ -294,26 +299,26 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
where
withSndFile = withFile viewSentFileInvitation
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
rcvMsg = msg viewReceivedMessage
msg view dir quote mc = case (msgContentText mc, file, quote) of
("", Just _, []) -> []
("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) meta
_ -> view dir quote mc meta
showSndItem to = showItem $ sentWithTime_ [to <> plainContent content] meta
showRcvItem from = showItem $ receivedWithTime_ from [] meta [plainContent content]
showSndItemProhibited to = showItem $ sentWithTime_ [to <> plainContent content <> " " <> prohibited] meta
showRcvItemProhibited from = showItem $ receivedWithTime_ from [] meta [plainContent content <> " " <> prohibited]
("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) ts meta
_ -> view dir quote mc ts meta
showSndItem to = showItem $ sentWithTime_ ts [to <> plainContent content] meta
showRcvItem from = showItem $ receivedWithTime_ ts from [] meta [plainContent content]
showSndItemProhibited to = showItem $ sentWithTime_ ts [to <> plainContent content <> " " <> prohibited] meta
showRcvItemProhibited from = showItem $ receivedWithTime_ ts from [] meta [plainContent content <> " " <> prohibited]
showItem ss = if doShow then ss else []
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)
viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString]
viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> CurrentTime -> [StyledString]
viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} ts = case chat of
DirectChat Contact {localDisplayName = c} -> case chatDir of
CIDirectRcv -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta
CIRcvMsgContent mc -> viewReceivedMessage from quote mc ts meta
_ -> []
where
from = ttyFromContactEdited c
@@ -321,7 +326,7 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
CIDirectSnd -> ["message updated"]
GroupChat g -> case chatDir of
CIGroupRcv GroupMember {localDisplayName = m} -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta
CIRcvMsgContent mc -> viewReceivedMessage from quote mc ts meta
_ -> []
where
from = ttyFromGroupEdited g m
@@ -329,16 +334,16 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
CIGroupSnd -> ["message updated"]
_ -> []
viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> [StyledString]
viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} = case chat of
viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> CurrentTime -> [StyledString]
viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} ts = case chat of
DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) 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"]
_ -> ["message deleted"]
GroupChat g -> case (chatDir, deletedContent, toContent) 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"]
_ -> ["message deleted"]
_ -> []
@@ -365,8 +370,8 @@ msgPreview = msgPlain . preview . msgContentText
| T.length t <= 120 = t
| otherwise = T.take 120 t <> "..."
viewRcvIntegrityError :: StyledString -> MsgErrorType -> CIMeta 'MDRcv -> [StyledString]
viewRcvIntegrityError from msgErr meta = receivedWithTime_ from [] meta $ viewMsgIntegrityError msgErr
viewRcvIntegrityError :: StyledString -> MsgErrorType -> CurrentTime -> CIMeta 'MDRcv -> [StyledString]
viewRcvIntegrityError from msgErr ts meta = receivedWithTime_ ts from [] meta $ viewMsgIntegrityError msgErr
viewMsgIntegrityError :: MsgErrorType -> [StyledString]
viewMsgIntegrityError err = msgError $ case err of
@@ -618,15 +623,16 @@ viewUserProfile Profile {displayName, fullName} =
"(the updated profile will be sent to all your contacts)"
]
viewSMPServers :: [SMPServer] -> Bool -> [StyledString]
viewSMPServers :: [ServerCfg] -> Bool -> [StyledString]
viewSMPServers smpServers testView =
if testView
then [customSMPServers]
else
[ customSMPServers,
"",
"use " <> highlight' "/smp_servers <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
"use " <> highlight' "/smp_servers default" <> " to remove custom SMP servers and use default",
"use " <> highlight' "/smp test <srv>" <> " to test SMP server connection",
"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)"
]
where
@@ -635,6 +641,16 @@ viewSMPServers smpServers testView =
then "no custom SMP servers saved"
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 = \case
Nothing -> ["old messages are not being deleted"]
@@ -650,7 +666,7 @@ viewNetworkConfig :: NetworkConfig -> [StyledString]
viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
[ plain $ maybe "direct network connection" (("using SOCKS5 proxy " <>) . show) socksProxy,
"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]
@@ -675,8 +691,8 @@ viewConnectionStats ConnectionStats {rcvServers, sndServers} =
["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers]
<> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers]
viewServers :: [SMPServer] -> StyledString
viewServers = plain . intercalate ", " . map (B.unpack . strEncode)
viewServers :: [ServerCfg] -> StyledString
viewServers = plain . intercalate ", " . map (B.unpack . strEncode . (\ServerCfg {server} -> server))
viewServerHosts :: [SMPServer] -> StyledString
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]
notified = " (your contacts are notified)"
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString]
viewUserContactPrefsUpdated user ct ct' cups
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
viewUserContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
| null prefs = ["your preferences for " <> ttyContact' ct' <> " did not change"]
| otherwise = ("you updated preferences for " <> ttyContact' ct' <> ":") : prefs
where
prefs = viewContactPreferences user ct ct' cups
viewContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString]
viewContactPrefsUpdated user ct ct' cups
viewContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
viewContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
| null prefs = []
| otherwise = (ttyContact' ct' <> " updated preferences for you:") : prefs
where
@@ -734,7 +750,7 @@ viewContactPref userPrefs userPrefs' ctPrefs cups pt
userPref = getPreference pt userPrefs
userPref' = getPreference pt userPrefs'
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 ps ps'
@@ -769,15 +785,36 @@ viewPrefEnabled = \case
viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString]
viewGroupUpdated
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image}}
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image'}}
m
| n == n' && fullName == fullName' && image == image' = []
| n == n' && fullName == fullName' = ["group " <> ttyGroup n <> ": profile image " <> (if isNothing image' then "removed" else "updated") <> byMember]
| n == n' = ["group " <> ttyGroup n <> ": full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName' <> byMember]
| otherwise = ["group " <> ttyGroup n <> " is changed to " <> ttyFullGroup g' <> byMember]
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image, groupPreferences = gps}}
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image', groupPreferences = gps'}}
m = do
let update = groupProfileUpdated <> groupPrefsUpdated
if null update
then []
else memberUpdated <> update
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 {localDisplayName = n, profile = LocalProfile {localAlias}}
@@ -802,36 +839,37 @@ viewContactUpdated
where
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 from quote mc meta = receivedWithTime_ from quote meta (ttyMsgContent mc)
viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta d -> [StyledString]
viewReceivedMessage from quote mc ts meta = receivedWithTime_ ts from quote meta (ttyMsgContent mc)
receivedWithTime_ :: StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString]
receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do
prependFirst (formattedTime <> " " <> 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))
receivedWithTime_ :: CurrentTime -> StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString]
receivedWithTime_ ts from quote CIMeta {localItemTs} styledMsg = do
prependFirst (ttyMsgTime ts localItemTs <> " " <> from) (quote <> prependFirst indent styledMsg)
where
indent = if null quote then "" else " "
viewSentBroadcast :: MsgContent -> Int -> ZonedTime -> [StyledString]
viewSentBroadcast mc n ts = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts <> " ") (ttyMsgContent mc)
ttyMsgTime :: CurrentTime -> ZonedTime -> StyledString
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]
viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} = case filePath of
Just fPath -> sentWithTime_ $ ttySentFile fPath
viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta d -> [StyledString]
viewSentMessage to quote mc ts = sentWithTime_ ts (prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc))
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 []
where
ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending
@@ -839,12 +877,9 @@ viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} = case filePath
CIFSSndTransfer -> []
_ -> ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"]
sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString]
sentWithTime_ styledMsg CIMeta {localItemTs} =
prependFirst (ttyMsgTime localItemTs <> " ") styledMsg
ttyMsgTime :: ZonedTime -> StyledString
ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M"
sentWithTime_ :: CurrentTime -> [StyledString] -> CIMeta d -> [StyledString]
sentWithTime_ ts styledMsg CIMeta {localItemTs} =
prependFirst (ttyMsgTime ts localItemTs <> " ") styledMsg
ttyMsgContent :: MsgContent -> [StyledString]
ttyMsgContent = msgPlain . msgContentText
@@ -873,8 +908,8 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
sndFile :: SndFileTransfer -> StyledString
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
viewReceivedFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString]
viewReceivedFileInvitation from file meta = receivedWithTime_ from [] meta (receivedFileInvitation_ file)
viewReceivedFileInvitation :: StyledString -> CIFile d -> CurrentTime -> CIMeta d -> [StyledString]
viewReceivedFileInvitation from file ts meta = receivedWithTime_ ts from [] meta (receivedFileInvitation_ file)
receivedFileInvitation_ :: CIFile d -> [StyledString]
receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} =

View File

@@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: d2b88a1baa390ec64b6535e32ce69f26f53f4d7a
commit: c2342cba057fa2333b5936a2254507b5b62e8de2
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294

View File

@@ -25,7 +25,7 @@ import Simplex.Chat.Options
import Simplex.Chat.Store
import Simplex.Chat.Terminal
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.RetryInterval
import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig)
@@ -51,7 +51,7 @@ testOpts =
{ dbFilePrefix = undefined,
dbKey = "",
-- 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,
logConnections = False,
logServerHosts = False,
@@ -275,6 +275,7 @@ serverCfg =
storeLogFile = Nothing,
storeMsgsFile = Nothing,
allowNewQueues = True,
newQueueBasicAuth = Nothing, -- Just "server_password",
messageExpiration = Just defaultMessageExpiration,
inactiveClientExpiration = Just defaultInactiveClientExpiration,
caCertificateFile = "tests/fixtures/tls/ca.crt",

View File

@@ -74,7 +74,7 @@ chatTests = do
describe "async group connections" $ do
xit "create and join group when clients go offline" testGroupAsync
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
describe "sending and receiving files" $ do
describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer
@@ -112,12 +112,15 @@ chatTests = do
it "join group incognito" testJoinGroupIncognito
it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito
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 connection alias" testSetConnectionAlias
it "set contact prefs" testSetContactPrefs
describe "SMP servers" $
describe "preferences" $ do
it "set contact preferences" testSetContactPrefs
it "update group preferences" testUpdateGroupPrefs
describe "SMP servers" $ do
it "get and set SMP servers" testGetSetSMPServers
it "test SMP server connection" testTestSMPServerConnection
describe "async connection handshake" $ do
it "connect when initiating client goes offline" testAsyncInitiatingOffline
it "connect when accepting client goes offline" testAsyncAcceptingOffline
@@ -1297,10 +1300,15 @@ testUpdateGroupProfile =
bob ##> "/gp team my_team"
bob <## "you have insufficient permissions for this group command"
alice ##> "/gp team my_team"
alice <## "group #team is changed to #my_team"
concurrently_
(bob <## "group #team is changed to #my_team by alice")
(cath <## "group #team is changed to #my_team by alice")
alice <## "changed to #my_team"
concurrentlyN_
[ do
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"
concurrently_
(alice <# "#my_team bob> hi")
@@ -2862,17 +2870,63 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $
bob <## "alice updated preferences for you:"
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 =
testChat2 aliceProfile bobProfile $
\alice _ -> do
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok")
alice #$> ("/smp_servers", 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_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
alice #$> ("/smp_servers default", id, "ok")
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001")
alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok")
alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im")
alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok")
alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok")
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 = withTmpFiles $ do

View File

@@ -32,9 +32,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\"
activeUser :: String
#if defined(darwin_HOST_OS) && defined(swiftJSON)
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"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
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
chatStarted :: String