mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
29 Commits
v4.2.2
...
ep/all-items
| Author | SHA1 | Date | |
|---|---|---|---|
| 41d7a47b37 | |||
| b8298aa458 | |||
| c3244f1b76 | |||
| 0ad74d9538 | |||
| a4be68f4bd | |||
| 0cb8f8ad82 | |||
| 9d7bb06396 | |||
| a9c2a7dcaa | |||
| 38b28f866c | |||
| bfa7ff16ff | |||
| 5c2b70a214 | |||
| 7e3f91f87c | |||
| f54faebff3 | |||
| 4e5aa3dcbc | |||
| 56f3874a93 | |||
| 828b502431 | |||
| 491fe4a9bf | |||
| f8302e2030 | |||
| fd34c39552 | |||
| b1fa1a84fe | |||
| cf23399262 | |||
| b5a812769b | |||
| 40e1b01baf | |||
| 9c925ab040 | |||
| faceeb6fce | |||
| 07e8c1d76e | |||
| b1d8600215 | |||
| e14ab0fed0 | |||
| cb0c499f57 |
+25
-6
@@ -1,10 +1,29 @@
|
|||||||
FROM haskell:8.10.4 AS build-stage
|
FROM ubuntu:focal AS build
|
||||||
# if you encounter "version `GLIBC_2.28' not found" error when running
|
|
||||||
# chat client executable, build with the following base image instead:
|
# Install curl and simplex-chat-related dependencies
|
||||||
# FROM haskell:8.10.4-stretch AS build-stage
|
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev
|
||||||
|
|
||||||
|
# Install ghcup
|
||||||
|
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
||||||
|
chmod +x /usr/bin/ghcup
|
||||||
|
|
||||||
|
# Install ghc
|
||||||
|
RUN ghcup install ghc 8.10.7
|
||||||
|
# Install cabal
|
||||||
|
RUN ghcup install cabal
|
||||||
|
# Set both as default
|
||||||
|
RUN ghcup set ghc 8.10.7 && \
|
||||||
|
ghcup set cabal
|
||||||
|
|
||||||
COPY . /project
|
COPY . /project
|
||||||
WORKDIR /project
|
WORKDIR /project
|
||||||
RUN stack install
|
|
||||||
|
# Adjust PATH
|
||||||
|
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||||
|
|
||||||
|
# Compile simplex-chat
|
||||||
|
RUN cabal update
|
||||||
|
RUN cabal install
|
||||||
|
|
||||||
FROM scratch AS export-stage
|
FROM scratch AS export-stage
|
||||||
COPY --from=build-stage /root/.local/bin/simplex-chat /
|
COPY --from=build /root/.cabal/bin/simplex-chat /
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "chat.simplex.app"
|
applicationId "chat.simplex.app"
|
||||||
minSdk 29
|
minSdk 29
|
||||||
targetSdk 32
|
targetSdk 32
|
||||||
versionCode 68
|
versionCode 69
|
||||||
versionName "4.2.1"
|
versionName "4.3-beta.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.os.SystemClock.elapsedRealtime
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -19,7 +20,9 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
@@ -36,6 +39,9 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import chat.simplex.app.views.newchat.*
|
import chat.simplex.app.views.newchat.*
|
||||||
import chat.simplex.app.views.onboarding.*
|
import chat.simplex.app.views.onboarding.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity: FragmentActivity() {
|
class MainActivity: FragmentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -317,14 +323,65 @@ fun MainPage(
|
|||||||
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||||
else {
|
else {
|
||||||
showAdvertiseLAAlert = true
|
showAdvertiseLAAlert = true
|
||||||
val stopped = chatModel.chatRunning.value == false
|
BoxWithConstraints {
|
||||||
if (chatModel.chatId.value == null) {
|
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||||
if (chatModel.sharedContent.value == null)
|
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||||
ChatListView(chatModel, setPerformLA, stopped)
|
Box(
|
||||||
else
|
Modifier
|
||||||
ShareListView(chatModel, stopped)
|
.graphicsLayer {
|
||||||
|
translationX = -offset.value.dp.toPx()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
if (chatModel.sharedContent.value == null)
|
||||||
|
ChatListView(chatModel, setPerformLA, stopped)
|
||||||
|
else
|
||||||
|
ShareListView(chatModel, stopped)
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val onComposed: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
offset.animateTo(
|
||||||
|
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||||
|
chatListAnimationSpec()
|
||||||
|
)
|
||||||
|
if (offset.value == 0f) {
|
||||||
|
currentChatId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
snapshotFlow { chatModel.chatId.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
if (it != null) currentChatId = it
|
||||||
|
else onComposed()
|
||||||
|
|
||||||
|
// Deletes files that were not sent but already stored in files directory.
|
||||||
|
// Currently, it's voice records only
|
||||||
|
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
|
||||||
|
chatModel.filesToDelete.forEach { it.delete() }
|
||||||
|
chatModel.filesToDelete.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
snapshotFlow { chatModel.sharedContent.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it != null }
|
||||||
|
.collect {
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
currentChatId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||||
|
currentChatId?.let {
|
||||||
|
ChatView(it, chatModel, onComposed)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else ChatView(chatModel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
class SimplexService: Service() {
|
class SimplexService: Service() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var isServiceStarted = false
|
|
||||||
private var isStartingService = false
|
private var isStartingService = false
|
||||||
private var notificationManager: NotificationManager? = null
|
private var notificationManager: NotificationManager? = null
|
||||||
private var serviceNotification: Notification? = null
|
private var serviceNotification: Notification? = null
|
||||||
@@ -48,11 +46,32 @@ class SimplexService: Service() {
|
|||||||
notificationManager = createNotificationChannel()
|
notificationManager = createNotificationChannel()
|
||||||
serviceNotification = createNotification(title, text)
|
serviceNotification = createNotification(title, text)
|
||||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||||
|
/**
|
||||||
|
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
|
||||||
|
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
|
||||||
|
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
|
||||||
|
* */
|
||||||
|
if (stopAfterStart) {
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
isServiceStarted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.d(TAG, "Simplex service destroyed")
|
Log.d(TAG, "Simplex service destroyed")
|
||||||
stopService()
|
try {
|
||||||
|
wakeLock?.let {
|
||||||
|
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||||
|
}
|
||||||
|
isServiceStarted = false
|
||||||
|
stopAfterStart = false
|
||||||
|
saveServiceState(this, ServiceState.STOPPED)
|
||||||
|
|
||||||
// If notification service is enabled and battery optimization is disabled, restart the service
|
// If notification service is enabled and battery optimization is disabled, restart the service
|
||||||
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||||
@@ -62,7 +81,7 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
private fun startService() {
|
private fun startService() {
|
||||||
Log.d(TAG, "SimplexService startService")
|
Log.d(TAG, "SimplexService startService")
|
||||||
if (isServiceStarted || isStartingService) return
|
if (wakeLock != null || isStartingService) return
|
||||||
val self = this
|
val self = this
|
||||||
isStartingService = true
|
isStartingService = true
|
||||||
withApi {
|
withApi {
|
||||||
@@ -73,10 +92,9 @@ class SimplexService: Service() {
|
|||||||
if (chatDbStatus != DBMigrationResult.OK) {
|
if (chatDbStatus != DBMigrationResult.OK) {
|
||||||
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||||
showPassphraseNotification(chatDbStatus)
|
showPassphraseNotification(chatDbStatus)
|
||||||
stopService()
|
safeStopService(self)
|
||||||
return@withApi
|
return@withApi
|
||||||
}
|
}
|
||||||
isServiceStarted = true
|
|
||||||
saveServiceState(self, ServiceState.STARTED)
|
saveServiceState(self, ServiceState.STARTED)
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||||
@@ -89,22 +107,6 @@ class SimplexService: Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopService() {
|
|
||||||
Log.d(TAG, "Stopping foreground service")
|
|
||||||
try {
|
|
||||||
wakeLock?.let {
|
|
||||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
|
||||||
}
|
|
||||||
wakeLock = null
|
|
||||||
stopForeground(true)
|
|
||||||
stopSelf()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
|
||||||
}
|
|
||||||
isServiceStarted = false
|
|
||||||
saveServiceState(this, ServiceState.STOPPED)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel(): NotificationManager? {
|
private fun createNotificationChannel(): NotificationManager? {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -235,6 +237,9 @@ class SimplexService: Service() {
|
|||||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||||
|
|
||||||
|
private var isServiceStarted = false
|
||||||
|
private var stopAfterStart = false
|
||||||
|
|
||||||
fun scheduleStart(context: Context) {
|
fun scheduleStart(context: Context) {
|
||||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||||
val workManager = WorkManager.getInstance(context)
|
val workManager = WorkManager.getInstance(context)
|
||||||
@@ -244,7 +249,17 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||||
|
|
||||||
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
|
/**
|
||||||
|
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
|
||||||
|
* exception related to foreground services lifecycle
|
||||||
|
* */
|
||||||
|
fun safeStopService(context: Context) {
|
||||||
|
if (isServiceStarted) {
|
||||||
|
context.stopService(Intent(context, SimplexService::class.java))
|
||||||
|
} else {
|
||||||
|
stopAfterStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun serviceAction(context: Context, action: Action) {
|
private suspend fun serviceAction(context: Context, action: Action) {
|
||||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import kotlinx.serialization.descriptors.*
|
|||||||
import kotlinx.serialization.encoding.Decoder
|
import kotlinx.serialization.encoding.Decoder
|
||||||
import kotlinx.serialization.encoding.Encoder
|
import kotlinx.serialization.encoding.Encoder
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
|
||||||
|
* */
|
||||||
|
@Stable
|
||||||
class ChatModel(val controller: ChatController) {
|
class ChatModel(val controller: ChatController) {
|
||||||
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
||||||
val currentUser = mutableStateOf<User?>(null)
|
val currentUser = mutableStateOf<User?>(null)
|
||||||
@@ -70,6 +75,8 @@ class ChatModel(val controller: ChatController) {
|
|||||||
// working with external intents
|
// working with external intents
|
||||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||||
|
|
||||||
|
val filesToDelete = mutableSetOf<File>()
|
||||||
|
|
||||||
fun updateUserProfile(profile: LocalProfile) {
|
fun updateUserProfile(profile: LocalProfile) {
|
||||||
val user = currentUser.value
|
val user = currentUser.value
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
@@ -218,6 +225,7 @@ class ChatModel(val controller: ChatController) {
|
|||||||
if (chatId.value == cInfo.id) {
|
if (chatId.value == cInfo.id) {
|
||||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||||
if (itemIndex >= 0) {
|
if (itemIndex >= 0) {
|
||||||
|
AudioPlayer.stop(chatItems[itemIndex])
|
||||||
chatItems.removeAt(itemIndex)
|
chatItems.removeAt(itemIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,7 +390,7 @@ interface SomeChat {
|
|||||||
val updatedAt: Instant
|
val updatedAt: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable @Stable
|
||||||
data class Chat (
|
data class Chat (
|
||||||
val chatInfo: ChatInfo,
|
val chatInfo: ChatInfo,
|
||||||
val chatItems: List<ChatItem>,
|
val chatItems: List<ChatItem>,
|
||||||
@@ -1015,7 +1023,7 @@ class AChatItem (
|
|||||||
val chatItem: ChatItem
|
val chatItem: ChatItem
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable @Stable
|
||||||
data class ChatItem (
|
data class ChatItem (
|
||||||
val chatDir: CIDirection,
|
val chatDir: CIDirection,
|
||||||
val meta: CIMeta,
|
val meta: CIMeta,
|
||||||
@@ -1029,6 +1037,9 @@ data class ChatItem (
|
|||||||
|
|
||||||
val text: String get() =
|
val text: String get() =
|
||||||
when {
|
when {
|
||||||
|
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
|
||||||
|
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
|
||||||
|
}
|
||||||
content.text == "" && file != null -> file.fileName
|
content.text == "" && file != null -> file.fileName
|
||||||
else -> content.text
|
else -> content.text
|
||||||
}
|
}
|
||||||
@@ -1301,6 +1312,8 @@ class CIFile(
|
|||||||
CIFileStatus.RcvComplete -> true
|
CIFileStatus.RcvComplete -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val audioInfo: MutableState<ProgressAndDuration> by lazy { mutableStateOf(ProgressAndDuration()) }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getSample(
|
fun getSample(
|
||||||
fileId: Long = 1,
|
fileId: Long = 1,
|
||||||
@@ -1334,6 +1347,7 @@ sealed class MsgContent {
|
|||||||
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
||||||
|
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||||
|
|
||||||
@@ -1341,11 +1355,17 @@ sealed class MsgContent {
|
|||||||
is MCText -> "text $text"
|
is MCText -> "text $text"
|
||||||
is MCLink -> "json ${json.encodeToString(this)}"
|
is MCLink -> "json ${json.encodeToString(this)}"
|
||||||
is MCImage -> "json ${json.encodeToString(this)}"
|
is MCImage -> "json ${json.encodeToString(this)}"
|
||||||
|
is MCVoice-> "json ${json.encodeToString(this)}"
|
||||||
is MCFile -> "json ${json.encodeToString(this)}"
|
is MCFile -> "json ${json.encodeToString(this)}"
|
||||||
is MCUnknown -> "json $json"
|
is MCUnknown -> "json $json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
|
||||||
|
val time = String.format("%02d:%02d", duration / 60, duration % 60)
|
||||||
|
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class CIGroupInvitation (
|
class CIGroupInvitation (
|
||||||
val groupId: Long,
|
val groupId: Long,
|
||||||
@@ -1414,6 +1434,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||||
MsgContent.MCImage(text, image)
|
MsgContent.MCImage(text, image)
|
||||||
}
|
}
|
||||||
|
"voice" -> {
|
||||||
|
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||||
|
MsgContent.MCVoice(text, duration)
|
||||||
|
}
|
||||||
"file" -> MsgContent.MCFile(text)
|
"file" -> MsgContent.MCFile(text)
|
||||||
else -> MsgContent.MCUnknown(t, text, json)
|
else -> MsgContent.MCUnknown(t, text, json)
|
||||||
}
|
}
|
||||||
@@ -1445,6 +1469,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
put("text", value.text)
|
put("text", value.text)
|
||||||
put("image", value.image)
|
put("image", value.image)
|
||||||
}
|
}
|
||||||
|
is MsgContent.MCVoice ->
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "voice")
|
||||||
|
put("text", value.text)
|
||||||
|
put("duration", value.duration)
|
||||||
|
}
|
||||||
is MsgContent.MCFile ->
|
is MsgContent.MCFile ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("type", "file")
|
put("type", "file")
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
if (cItem.content.text != "") {
|
if (cItem.content.text != "") {
|
||||||
cItem.content.text
|
cItem.content.text
|
||||||
} else {
|
} else {
|
||||||
cItem.file?.fileName ?: ""
|
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var res = ""
|
var res = ""
|
||||||
|
|||||||
@@ -1014,6 +1014,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
|||||||
val file = cItem.file
|
val file = cItem.file
|
||||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
||||||
withApi { receiveFile(file.fileId) }
|
withApi { receiveFile(file.fileId) }
|
||||||
|
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
||||||
|
withApi { receiveFile(file.fileId) }
|
||||||
}
|
}
|
||||||
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||||
@@ -1039,6 +1041,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
|||||||
chatModel.removeChatItem(cInfo, cItem)
|
chatModel.removeChatItem(cInfo, cItem)
|
||||||
} else {
|
} else {
|
||||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||||
|
AudioPlayer.stop(cItem)
|
||||||
chatModel.upsertChatItem(cInfo, cItem)
|
chatModel.upsertChatItem(cInfo, cItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1220,7 +1223,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
|||||||
chatModel.notificationsMode.value = NotificationsMode.OFF
|
chatModel.notificationsMode.value = NotificationsMode.OFF
|
||||||
SimplexService.StartReceiver.toggleReceiver(false)
|
SimplexService.StartReceiver.toggleReceiver(false)
|
||||||
MessagesFetcherWorker.cancelAll()
|
MessagesFetcherWorker.cancelAll()
|
||||||
SimplexService.stop(SimplexApp.context)
|
SimplexService.safeStopService(SimplexApp.context)
|
||||||
} else {
|
} else {
|
||||||
// show battery optimization notice
|
// show battery optimization notice
|
||||||
showBGServiceNoticeIgnoreOptimization(mode)
|
showBGServiceNoticeIgnoreOptimization(mode)
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ fun TerminalLayout(
|
|||||||
topBar = { CloseSheetBar(close) },
|
topBar = { CloseSheetBar(close) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.navigationBarsWithImePadding()
|
modifier = Modifier.navigationBarsWithImePadding()
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ import chat.simplex.app.views.helpers.ProfileImage
|
|||||||
import chat.simplex.app.views.helpers.withApi
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
// Stop it when call ended
|
// Stop it when call ended
|
||||||
if (!ntfModeService) SimplexService.stop(SimplexApp.context)
|
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||||
// Clear selected communication device to default value after we changed it in call
|
// Clear selected communication device to default value after we changed it in call
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
@@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||||
val permissionsState = rememberMultiplePermissionsState(
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
permissions = listOf(
|
permissions = listOf(
|
||||||
@@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
|||||||
Log.d(TAG, "WebRTCView: webview ready")
|
Log.d(TAG, "WebRTCView: webview ready")
|
||||||
// for debugging
|
// for debugging
|
||||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||||
withApi {
|
scope.launch {
|
||||||
delay(2000L)
|
delay(2000L)
|
||||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||||
webView.value = wv
|
webView.value = wv
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.gestures.*
|
import androidx.compose.foundation.gestures.*
|
||||||
@@ -52,8 +50,8 @@ import java.io.File
|
|||||||
import kotlin.math.sign
|
import kotlin.math.sign
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatView(chatModel: ChatModel) {
|
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||||
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
|
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
|
||||||
val searchText = rememberSaveable { mutableStateOf("") }
|
val searchText = rememberSaveable { mutableStateOf("") }
|
||||||
val user = chatModel.currentUser.value
|
val user = chatModel.currentUser.value
|
||||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
@@ -63,7 +61,6 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
||||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
||||||
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
||||||
@@ -97,17 +94,15 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
} else {
|
} else {
|
||||||
val chat = activeChat.value!!
|
val chat = activeChat.value!!
|
||||||
BackHandler { chatModel.chatId.value = null }
|
|
||||||
// We need to have real unreadCount value for displaying it inside top right button
|
// We need to have real unreadCount value for displaying it inside top right button
|
||||||
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||||
val unreadCount = remember {
|
val unreadCount = remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
|
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user,
|
|
||||||
chat,
|
chat,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
composeState,
|
composeState,
|
||||||
@@ -120,7 +115,6 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
attachmentOption,
|
attachmentOption,
|
||||||
scope,
|
|
||||||
attachmentBottomSheetState,
|
attachmentBottomSheetState,
|
||||||
chatModel.chatItems,
|
chatModel.chatItems,
|
||||||
searchText,
|
searchText,
|
||||||
@@ -128,6 +122,7 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
chatModelIncognito = chatModel.incognito.value,
|
chatModelIncognito = chatModel.incognito.value,
|
||||||
back = {
|
back = {
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
|
AudioPlayer.stop()
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
},
|
},
|
||||||
info = {
|
info = {
|
||||||
@@ -228,20 +223,19 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
apiFindMessages(c.chatInfo, chatModel, value)
|
apiFindMessages(c.chatInfo, chatModel, value)
|
||||||
searchText.value = value
|
searchText.value = value
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onComposed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatLayout(
|
fun ChatLayout(
|
||||||
user: User,
|
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
unreadCount: State<Int>,
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
composeView: (@Composable () -> Unit),
|
composeView: (@Composable () -> Unit),
|
||||||
attachmentOption: MutableState<AttachmentOption?>,
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
scope: CoroutineScope,
|
|
||||||
attachmentBottomSheetState: ModalBottomSheetState,
|
attachmentBottomSheetState: ModalBottomSheetState,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
searchValue: State<String>,
|
searchValue: State<String>,
|
||||||
@@ -260,8 +254,10 @@ fun ChatLayout(
|
|||||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||||
onSearchValueChanged: (String) -> Unit,
|
onSearchValueChanged: (String) -> Unit,
|
||||||
|
onComposed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
val scope = rememberCoroutineScope()
|
||||||
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.background)
|
.background(MaterialTheme.colors.background)
|
||||||
@@ -292,9 +288,9 @@ fun ChatLayout(
|
|||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
||||||
ChatItemsList(
|
ChatItemsList(
|
||||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
chat, unreadCount, composeState, chatItems, searchValue,
|
||||||
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
|
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,7 +441,6 @@ val CIListStateSaver = run {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxWithConstraintsScope.ChatItemsList(
|
fun BoxWithConstraintsScope.ChatItemsList(
|
||||||
user: User,
|
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
unreadCount: State<Int>,
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
@@ -461,11 +456,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||||
|
onComposed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
val cxt = LocalContext.current
|
|
||||||
ScrollToBottom(chat.id, listState)
|
ScrollToBottom(chat.id, listState)
|
||||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||||
// Scroll to bottom when search value changes from something to nothing and back
|
// Scroll to bottom when search value changes from something to nothing and back
|
||||||
@@ -493,6 +487,16 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
var stopListening = false
|
||||||
|
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { !stopListening }
|
||||||
|
.collect {
|
||||||
|
onComposed()
|
||||||
|
stopListening = true
|
||||||
|
}
|
||||||
|
}
|
||||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||||
itemsIndexed(reversedChatItems) { i, cItem ->
|
itemsIndexed(reversedChatItems) { i, cItem ->
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
@@ -555,11 +559,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(42.dp))
|
Spacer(Modifier.size(42.dp))
|
||||||
}
|
}
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // direct message
|
} else { // direct message
|
||||||
@@ -570,7 +574,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
end = if (sent) 12.dp else 76.dp,
|
end = if (sent) 12.dp else 76.dp,
|
||||||
).then(swipeableModifier)
|
).then(swipeableModifier)
|
||||||
) {
|
) {
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,7 +721,7 @@ fun PreloadItems(
|
|||||||
.map {
|
.map {
|
||||||
val totalItemsNumber = it.totalItemsCount
|
val totalItemsNumber = it.totalItemsCount
|
||||||
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||||
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
|
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
|
||||||
totalItemsNumber
|
totalItemsNumber
|
||||||
else
|
else
|
||||||
0
|
0
|
||||||
@@ -931,7 +935,6 @@ fun PreviewChatLayout() {
|
|||||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
val searchValue = remember { mutableStateOf("") }
|
val searchValue = remember { mutableStateOf("") }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Direct.sampleData,
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
@@ -941,7 +944,6 @@ fun PreviewChatLayout() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
scope = rememberCoroutineScope(),
|
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
searchValue,
|
searchValue,
|
||||||
@@ -960,6 +962,7 @@ fun PreviewChatLayout() {
|
|||||||
markRead = { _, _ -> },
|
markRead = { _, _ -> },
|
||||||
changeNtfsState = { _, _ -> },
|
changeNtfsState = { _, _ -> },
|
||||||
onSearchValueChanged = {},
|
onSearchValueChanged = {},
|
||||||
|
onComposed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -989,7 +992,6 @@ fun PreviewGroupChatLayout() {
|
|||||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
val searchValue = remember { mutableStateOf("") }
|
val searchValue = remember { mutableStateOf("") }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Group.sampleData,
|
chatInfo = ChatInfo.Group.sampleData,
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
@@ -999,7 +1001,6 @@ fun PreviewGroupChatLayout() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
scope = rememberCoroutineScope(),
|
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
searchValue,
|
searchValue,
|
||||||
@@ -1018,6 +1019,7 @@ fun PreviewGroupChatLayout() {
|
|||||||
markRead = { _, _ -> },
|
markRead = { _, _ -> },
|
||||||
changeNtfsState = { _, _ -> },
|
changeNtfsState = { _, _ -> },
|
||||||
onSearchValueChanged = {},
|
onSearchValueChanged = {},
|
||||||
|
onComposed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import ComposeVoiceView
|
||||||
import ComposeFileView
|
import ComposeFileView
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.*
|
import android.content.*
|
||||||
@@ -14,11 +15,9 @@ import android.util.Log
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
@@ -29,26 +28,30 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class ComposePreview {
|
sealed class ComposePreview {
|
||||||
@Serializable object NoPreview: ComposePreview()
|
@Serializable object NoPreview: ComposePreview()
|
||||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||||
|
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ data class ComposeState(
|
|||||||
get() = {
|
get() = {
|
||||||
val hasContent = when (preview) {
|
val hasContent = when (preview) {
|
||||||
is ComposePreview.ImagePreview -> true
|
is ComposePreview.ImagePreview -> true
|
||||||
|
is ComposePreview.VoicePreview -> true
|
||||||
is ComposePreview.FilePreview -> true
|
is ComposePreview.FilePreview -> true
|
||||||
else -> message.isNotEmpty()
|
else -> message.isNotEmpty()
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,7 @@ data class ComposeState(
|
|||||||
get() =
|
get() =
|
||||||
when (preview) {
|
when (preview) {
|
||||||
is ComposePreview.ImagePreview -> false
|
is ComposePreview.ImagePreview -> false
|
||||||
|
is ComposePreview.VoicePreview -> false
|
||||||
is ComposePreview.FilePreview -> false
|
is ComposePreview.FilePreview -> false
|
||||||
else -> useLinkPreviews
|
else -> useLinkPreviews
|
||||||
}
|
}
|
||||||
@@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
|||||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
||||||
|
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCFile -> {
|
||||||
val fileName = chatItem.file?.fileName ?: ""
|
val fileName = chatItem.file?.fileName ?: ""
|
||||||
ComposePreview.FilePreview(fileName)
|
ComposePreview.FilePreview(fileName)
|
||||||
}
|
}
|
||||||
else -> ComposePreview.NoPreview
|
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +150,11 @@ fun ComposeView(
|
|||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
// attachments
|
// attachments
|
||||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||||
|
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
|
||||||
|
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
|
||||||
|
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
|
||||||
|
)
|
||||||
|
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
|
||||||
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -321,6 +332,7 @@ fun ComposeView(
|
|||||||
is MsgContent.MCText -> checkLinkPreview()
|
is MsgContent.MCText -> checkLinkPreview()
|
||||||
is MsgContent.MCLink -> checkLinkPreview()
|
is MsgContent.MCLink -> checkLinkPreview()
|
||||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||||
|
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
|
||||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||||
}
|
}
|
||||||
@@ -330,6 +342,7 @@ fun ComposeView(
|
|||||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||||
textStyle.value = smallFont
|
textStyle.value = smallFont
|
||||||
chosenContent.value = emptyList()
|
chosenContent.value = emptyList()
|
||||||
|
chosenAudio.value = null
|
||||||
chosenFile.value = null
|
chosenFile.value = null
|
||||||
linkUrl.value = null
|
linkUrl.value = null
|
||||||
prevLinkUrl.value = null
|
prevLinkUrl.value = null
|
||||||
@@ -376,6 +389,15 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is ComposePreview.VoicePreview -> {
|
||||||
|
val chosenAudioVal = chosenAudio.value
|
||||||
|
if (chosenAudioVal != null) {
|
||||||
|
val file = chosenAudioVal.first.toFile().name
|
||||||
|
files.add((file))
|
||||||
|
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||||
|
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
is ComposePreview.FilePreview -> {
|
is ComposePreview.FilePreview -> {
|
||||||
val chosenFileVal = chosenFile.value
|
val chosenFileVal = chosenFile.value
|
||||||
if (chosenFileVal != null) {
|
if (chosenFileVal != null) {
|
||||||
@@ -426,6 +448,13 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
|
||||||
|
val file = File(filePath)
|
||||||
|
chosenAudio.value = file.toUri() to durationMs
|
||||||
|
chatModel.filesToDelete.add(file)
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelLinkPreview() {
|
fun cancelLinkPreview() {
|
||||||
val uri = composeState.value.linkPreview?.uri
|
val uri = composeState.value.linkPreview?.uri
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -440,6 +469,11 @@ fun ComposeView(
|
|||||||
chosenContent.value = emptyList()
|
chosenContent.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelVoice() {
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
|
chosenContent.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelFile() {
|
fun cancelFile() {
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
chosenFile.value = null
|
chosenFile.value = null
|
||||||
@@ -455,6 +489,13 @@ fun ComposeView(
|
|||||||
::cancelImages,
|
::cancelImages,
|
||||||
cancelEnabled = !composeState.value.editing
|
cancelEnabled = !composeState.value.editing
|
||||||
)
|
)
|
||||||
|
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||||
|
preview.voice,
|
||||||
|
preview.durationMs,
|
||||||
|
preview.finished,
|
||||||
|
cancelEnabled = !composeState.value.editing,
|
||||||
|
::cancelVoice
|
||||||
|
)
|
||||||
is ComposePreview.FilePreview -> ComposeFileView(
|
is ComposePreview.FilePreview -> ComposeFileView(
|
||||||
preview.fileName,
|
preview.fileName,
|
||||||
::cancelFile,
|
::cancelFile,
|
||||||
@@ -489,37 +530,34 @@ fun ComposeView(
|
|||||||
Column {
|
Column {
|
||||||
contextItemView()
|
contextItemView()
|
||||||
when {
|
when {
|
||||||
|
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||||
else -> previewView()
|
else -> previewView()
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
|
||||||
) {
|
) {
|
||||||
val attachEnabled = !composeState.value.editing
|
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
|
||||||
Box(Modifier.padding(bottom = 12.dp)) {
|
IconButton(showChooseAttachment, enabled = attachEnabled) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.AttachFile,
|
Icons.Filled.AttachFile,
|
||||||
contentDescription = stringResource(R.string.attach),
|
contentDescription = stringResource(R.string.attach),
|
||||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable {
|
|
||||||
if (attachEnabled) {
|
|
||||||
showChooseAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState,
|
composeState,
|
||||||
|
allowVoiceRecord = true,
|
||||||
sendMessage = {
|
sendMessage = {
|
||||||
sendMessage()
|
sendMessage()
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
},
|
},
|
||||||
::onMessageChange,
|
::onMessageChange,
|
||||||
|
::onAudioAdded,
|
||||||
textStyle
|
textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.item.AudioInfoUpdater
|
||||||
|
import chat.simplex.app.views.chat.item.SentColorLight
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cancelEnabled: Boolean, cancelVoice: () -> Unit) {
|
||||||
|
BoxWithConstraints(Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val audioInfo = rememberSaveable(saver = ProgressAndDuration.Saver) {
|
||||||
|
mutableStateOf(ProgressAndDuration(durationMs = durationMs))
|
||||||
|
}
|
||||||
|
LaunchedEffect(durationMs) {
|
||||||
|
audioInfo.value = audioInfo.value.copy(durationMs = durationMs)
|
||||||
|
}
|
||||||
|
val progressBarWidth = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(durationMs, finished) {
|
||||||
|
snapshotFlow { audioInfo.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
val number = if (audioPlaying.value) audioInfo.value.progressMs else if (!finished) durationMs else 0
|
||||||
|
val new = if (audioPlaying.value || finished)
|
||||||
|
((number.toDouble() / durationMs) * maxWidth.value).dp
|
||||||
|
else
|
||||||
|
(((number.toDouble()) / MAX_VOICE_MILLIS_FOR_SENDING) * maxWidth.value).dp
|
||||||
|
progressBarWidth.animateTo(new.value, audioProgressBarAnimationSpec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.requiredWidth(progressBarWidth.value.dp)
|
||||||
|
.padding(top = 58.dp)
|
||||||
|
.height(2.dp)
|
||||||
|
.background(MaterialTheme.colors.primary)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.height(60.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.background(SentColorLight),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val play = play@{
|
||||||
|
audioPlaying.value = AudioPlayer.start(filePath, audioInfo.value.progressMs) {
|
||||||
|
audioPlaying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pause = {
|
||||||
|
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
|
||||||
|
audioPlaying.value = false
|
||||||
|
}
|
||||||
|
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
|
||||||
|
|
||||||
|
IconButton({ if (!audioPlaying.value) play() else pause() }, enabled = finished) {
|
||||||
|
Icon(
|
||||||
|
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
stringResource(R.string.icon_descr_file),
|
||||||
|
Modifier
|
||||||
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
|
.size(36.dp),
|
||||||
|
tint = if (finished) MaterialTheme.colors.primary else HighOrLowlight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val numberInText = remember(durationMs, audioInfo.value) {
|
||||||
|
derivedStateOf { if (audioPlaying.value) audioInfo.value.progressMs / 1000 else durationMs / 1000 }
|
||||||
|
}
|
||||||
|
val text = "%02d:%02d".format(numberInText.value / 60, numberInText.value % 60)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = HighOrLowlight,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (cancelEnabled) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
AudioPlayer.stop(filePath)
|
||||||
|
cancelVoice()
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(0.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Close,
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier.padding(10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewComposeAudioView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ComposeFileView(
|
||||||
|
"test.txt",
|
||||||
|
cancelFile = {},
|
||||||
|
cancelEnabled = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -12,38 +15,198 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||||
import androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.SharedContent
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendMsgView(
|
fun SendMsgView(
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
|
allowVoiceRecord: Boolean,
|
||||||
sendMessage: () -> Unit,
|
sendMessage: () -> Unit,
|
||||||
onMessageChange: (String) -> Unit,
|
onMessageChange: (String) -> Unit,
|
||||||
|
onAudioAdded: (String, Int, Boolean) -> Unit,
|
||||||
textStyle: MutableState<TextStyle>
|
textStyle: MutableState<TextStyle>
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(vertical = 8.dp)) {
|
||||||
|
Box {
|
||||||
|
val cs = composeState.value
|
||||||
|
val attachEnabled = !composeState.value.editing
|
||||||
|
val filePath = rememberSaveable { mutableStateOf(null as String?) }
|
||||||
|
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
|
||||||
|
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && allowVoiceRecord && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
|
||||||
|
Box(if (recordingTimeRange.first == 0L)
|
||||||
|
Modifier
|
||||||
|
else
|
||||||
|
Modifier.clickable(false, onClick = {})
|
||||||
|
) {
|
||||||
|
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||||
|
}
|
||||||
|
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||||
|
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||||
|
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||||
|
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
|
||||||
|
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||||
|
} else if (!showVoiceButton) {
|
||||||
|
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(R.string.icon_descr_send_message),
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
|
permissions = listOf(
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||||
|
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
|
||||||
|
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (isActive) {
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val stopRecordingAndAddAudio: () -> Unit = {
|
||||||
|
rec.stop()
|
||||||
|
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
|
||||||
|
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
|
||||||
|
}
|
||||||
|
val startStopRecording: () -> Unit = {
|
||||||
|
when {
|
||||||
|
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
|
||||||
|
recordingInProgress.value -> stopRecordingAndAddAudio()
|
||||||
|
filePath.value == null -> {
|
||||||
|
recordingTimeRange = System.currentTimeMillis()..0L
|
||||||
|
filePath.value = rec.start(stopRecordingAndAddAudio)
|
||||||
|
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stopRecOnNextClick by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(stopRecOnNextClick) {
|
||||||
|
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||||
|
if (stopRecOnNextClick) {
|
||||||
|
// Lock orientation to current orientation because screen rotation will break the recording
|
||||||
|
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
else
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
}
|
||||||
|
// Unlock orientation
|
||||||
|
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||||
|
}
|
||||||
|
val cleanUp = { remove: Boolean ->
|
||||||
|
rec.stop()
|
||||||
|
if (remove) filePath.value?.let { File(it).delete() }
|
||||||
|
filePath.value = null
|
||||||
|
stopRecOnNextClick = false
|
||||||
|
recordingTimeRange = 0L..0L
|
||||||
|
}
|
||||||
|
LaunchedEffect(cs.preview) {
|
||||||
|
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
|
||||||
|
// Pressed on X icon in preview
|
||||||
|
cleanUp(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val interactionSource = interactionSourceWithTapDetection(
|
||||||
|
onPress = {
|
||||||
|
if (filePath.value == null) startStopRecording()
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (!recordingInProgress.value && filePath.value != null) {
|
||||||
|
sendMessage()
|
||||||
|
cleanUp(false)
|
||||||
|
} else if (stopRecOnNextClick) {
|
||||||
|
stopRecordingAndAddAudio()
|
||||||
|
stopRecOnNextClick = false
|
||||||
|
} else {
|
||||||
|
// tapped and didn't hold a finger
|
||||||
|
stopRecOnNextClick = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = startStopRecording,
|
||||||
|
onRelease = startStopRecording
|
||||||
|
)
|
||||||
|
val sendButtonModifier = if (recordingTimeRange.last != 0L)
|
||||||
|
Modifier.clip(CircleShape).background(color)
|
||||||
|
else
|
||||||
|
Modifier
|
||||||
|
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
|
||||||
|
Icon(
|
||||||
|
if (recordingTimeRange.last != 0L) Icons.Outlined.ArrowUpward else if (stopRecOnNextClick) Icons.Default.Stop else Icons.Default.Mic,
|
||||||
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
|
tint = if (recordingTimeRange.last != 0L) Color.White else if (!cs.inProgress) MaterialTheme.colors.primary else HighOrLowlight,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
.then(sendButtonModifier)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
rec.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NativeKeyboard(
|
||||||
|
composeState: MutableState<ComposeState>,
|
||||||
|
textStyle: MutableState<TextStyle>,
|
||||||
|
onMessageChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
|
val textColor = MaterialTheme.colors.onBackground
|
||||||
|
val tintColor = MaterialTheme.colors.secondary
|
||||||
|
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||||
|
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||||
|
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
|
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||||
|
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
|
|
||||||
var showKeyboard by remember { mutableStateOf(false) }
|
var showKeyboard by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(cs.contextItem) {
|
LaunchedEffect(cs.contextItem) {
|
||||||
when (cs.contextItem) {
|
when (cs.contextItem) {
|
||||||
@@ -58,99 +221,69 @@ fun SendMsgView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val textColor = MaterialTheme.colors.onBackground
|
|
||||||
val tintColor = MaterialTheme.colors.secondary
|
|
||||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
|
||||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
|
||||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
|
||||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
|
||||||
|
|
||||||
Column(Modifier.padding(vertical = 8.dp)) {
|
AndroidView(modifier = Modifier, factory = {
|
||||||
Box {
|
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||||
AndroidView(modifier = Modifier, factory = {
|
override fun setOnReceiveContentListener(
|
||||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
mimeTypes: Array<out String>?,
|
||||||
override fun setOnReceiveContentListener(
|
listener: android.view.OnReceiveContentListener?
|
||||||
mimeTypes: Array<out String>?,
|
) {
|
||||||
listener: android.view.OnReceiveContentListener?
|
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||||
) {
|
|
||||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
|
||||||
}
|
|
||||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
|
||||||
val connection = super.onCreateInputConnection(editorInfo)
|
|
||||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
|
||||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
|
||||||
try {
|
|
||||||
inputContentInfo.requestPermission()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return@OnCommitContentListener false
|
|
||||||
}
|
|
||||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
||||||
editText.maxLines = 16
|
|
||||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
|
||||||
editText.setTextColor(textColor.toArgb())
|
|
||||||
editText.textSize = textStyle.value.fontSize.value
|
|
||||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
|
||||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
|
||||||
editText.background = drawable
|
|
||||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
|
||||||
editText.setText(cs.message)
|
|
||||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
|
||||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
|
||||||
editText
|
|
||||||
}) {
|
|
||||||
it.setTextColor(textColor.toArgb())
|
|
||||||
it.textSize = textStyle.value.fontSize.value
|
|
||||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
|
||||||
if (cs.message != it.text.toString()) {
|
|
||||||
it.setText(cs.message)
|
|
||||||
// Set cursor to the end of the text
|
|
||||||
it.setSelection(it.text.length)
|
|
||||||
}
|
|
||||||
if (showKeyboard) {
|
|
||||||
it.requestFocus()
|
|
||||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
||||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
|
||||||
showKeyboard = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
val connection = super.onCreateInputConnection(editorInfo)
|
||||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||||
if (cs.inProgress
|
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
try {
|
||||||
) {
|
inputContentInfo.requestPermission()
|
||||||
CircularProgressIndicator(
|
} catch (e: Exception) {
|
||||||
Modifier
|
return@OnCommitContentListener false
|
||||||
.size(36.dp)
|
}
|
||||||
.padding(4.dp),
|
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||||
color = HighOrLowlight,
|
true
|
||||||
strokeWidth = 3.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
stringResource(R.string.icon_descr_send_message),
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.padding(4.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(color)
|
|
||||||
.clickable {
|
|
||||||
if (cs.sendEnabled()) {
|
|
||||||
sendMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
editText.maxLines = 16
|
||||||
|
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||||
|
editText.setTextColor(textColor.toArgb())
|
||||||
|
editText.textSize = textStyle.value.fontSize.value
|
||||||
|
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||||
|
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||||
|
editText.background = drawable
|
||||||
|
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||||
|
editText.setText(cs.message)
|
||||||
|
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||||
|
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||||
|
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||||
|
editText
|
||||||
|
}) {
|
||||||
|
it.setTextColor(textColor.toArgb())
|
||||||
|
it.textSize = textStyle.value.fontSize.value
|
||||||
|
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||||
|
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
|
||||||
|
it.isFocusableInTouchMode = it.isFocusable
|
||||||
|
if (cs.message != it.text.toString()) {
|
||||||
|
it.setText(cs.message)
|
||||||
|
// Set cursor to the end of the text
|
||||||
|
it.setSelection(it.text.length)
|
||||||
|
}
|
||||||
|
if (showKeyboard) {
|
||||||
|
it.requestFocus()
|
||||||
|
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
showKeyboard = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
Text(
|
||||||
|
generalGetString(R.string.voice_message_send_text),
|
||||||
|
Modifier.padding(padding),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +300,10 @@ fun PreviewSendMsgView() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
|
allowVoiceRecord = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -188,8 +323,10 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||||
|
allowVoiceRecord = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -209,8 +346,10 @@ fun PreviewSendMsgViewInProgress() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||||
|
allowVoiceRecord = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIVoiceView(
|
||||||
|
durationSec: Int,
|
||||||
|
file: CIFile?,
|
||||||
|
edited: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
ci: ChatItem,
|
||||||
|
metaColor: Color
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (file != null) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
|
||||||
|
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
|
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
|
val audioInfo = remember(file.filePath) {
|
||||||
|
file.audioInfo.value = file.audioInfo.value.copy(durationMs = durationSec * 1000)
|
||||||
|
file.audioInfo
|
||||||
|
}
|
||||||
|
val play = play@{
|
||||||
|
audioPlaying.value = AudioPlayer.start(filePath ?: return@play, audioInfo.value.progressMs) {
|
||||||
|
// If you want to preserve the position after switching a track, remove this line
|
||||||
|
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
|
||||||
|
audioPlaying.value = false
|
||||||
|
}
|
||||||
|
brokenAudio = !audioPlaying.value
|
||||||
|
}
|
||||||
|
val pause = {
|
||||||
|
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
|
||||||
|
audioPlaying.value = false
|
||||||
|
}
|
||||||
|
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
|
||||||
|
|
||||||
|
val time = if (audioPlaying.value) audioInfo.value.progressMs else audioInfo.value.durationMs
|
||||||
|
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
|
||||||
|
val text = String.format("%02d:%02d", time / 1000 / 60, time / 1000 % 60)
|
||||||
|
if (hasText) {
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
Modifier
|
||||||
|
.padding(start = 12.dp, end = 5.dp)
|
||||||
|
.widthIn(min = minWidth),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (sent) {
|
||||||
|
Row {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
Modifier
|
||||||
|
.padding(end = 12.dp)
|
||||||
|
.widthIn(min = minWidth),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
|
||||||
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
|
CIMetaView(ci, metaColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row {
|
||||||
|
Column {
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
|
||||||
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
|
CIMetaView(ci, metaColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
Modifier
|
||||||
|
.padding(start = 12.dp)
|
||||||
|
.widthIn(min = minWidth),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VoiceMsgIndicator(null, false, sent, hasText, null, false, {}, {})
|
||||||
|
val metaReserve = if (edited)
|
||||||
|
" "
|
||||||
|
else
|
||||||
|
" "
|
||||||
|
Text(metaReserve)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayPauseButton(
|
||||||
|
audioPlaying: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
angle: Float,
|
||||||
|
strokeWidth: Float,
|
||||||
|
strokeColor: Color,
|
||||||
|
enabled: Boolean,
|
||||||
|
error: Boolean,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = { if (!audioPlaying) play() else pause() },
|
||||||
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
|
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||||
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
Modifier.size(36.dp),
|
||||||
|
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceMsgIndicator(
|
||||||
|
file: CIFile?,
|
||||||
|
audioPlaying: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
audioInfo: State<ProgressAndDuration>?,
|
||||||
|
error: Boolean,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit
|
||||||
|
) {
|
||||||
|
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
|
||||||
|
val strokeColor = MaterialTheme.colors.primary
|
||||||
|
if (file != null && file.loaded && audioInfo != null) {
|
||||||
|
val angle = 360f * (audioInfo.value.progressMs.toDouble() / audioInfo.value.durationMs).toFloat()
|
||||||
|
if (hasText) {
|
||||||
|
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
Modifier.size(36.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (file?.fileStatus == CIFileStatus.RcvInvitation
|
||||||
|
|| file?.fileStatus == CIFileStatus.RcvTransfer
|
||||||
|
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
ProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||||
|
val brush = Brush.linearGradient(
|
||||||
|
0f to Color.Transparent,
|
||||||
|
0f to color,
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(strokeWidth, strokeWidth),
|
||||||
|
tileMode = TileMode.Clamp
|
||||||
|
)
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawArc(
|
||||||
|
brush = brush,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = angle,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
|
||||||
|
size = Size(size.width - strokeWidth, size.height - strokeWidth),
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(32.dp),
|
||||||
|
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||||
|
strokeWidth = 4.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AudioInfoUpdater(
|
||||||
|
filePath: String?,
|
||||||
|
audioPlaying: MutableState<Boolean>,
|
||||||
|
audioInfo: MutableState<ProgressAndDuration>
|
||||||
|
) {
|
||||||
|
LaunchedEffect(filePath) {
|
||||||
|
if (filePath != null && audioInfo.value.durationMs == 0) {
|
||||||
|
audioInfo.value = ProgressAndDuration(audioInfo.value.progressMs, AudioPlayer.duration(filePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(audioPlaying.value) {
|
||||||
|
while (isActive && audioPlaying.value) {
|
||||||
|
audioInfo.value = AudioPlayer.progressAndDurationOrEnded()
|
||||||
|
if (audioInfo.value.progressMs == audioInfo.value.durationMs) {
|
||||||
|
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
|
||||||
|
audioPlaying.value = false
|
||||||
|
}
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import android.content.*
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -29,15 +28,11 @@ import kotlinx.datetime.Clock
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatItemView(
|
fun ChatItemView(
|
||||||
user: User,
|
|
||||||
cInfo: ChatInfo,
|
cInfo: ChatInfo,
|
||||||
cItem: ChatItem,
|
cItem: ChatItem,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
cxt: Context,
|
|
||||||
uriHandler: UriHandler? = null,
|
|
||||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||||
showMember: Boolean = false,
|
showMember: Boolean = false,
|
||||||
chatModelIncognito: Boolean,
|
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
@@ -46,6 +41,7 @@ fun ChatItemView(
|
|||||||
scrollToItem: (Long) -> Unit,
|
scrollToItem: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
val sent = cItem.chatDir.sent
|
val sent = cItem.chatDir.sent
|
||||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
@@ -70,7 +66,7 @@ fun ChatItemView(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
|
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
|
||||||
) {
|
) {
|
||||||
@Composable fun ContentItem() {
|
@Composable fun ContentItem() {
|
||||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||||
@@ -95,29 +91,30 @@ fun ChatItemView(
|
|||||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
||||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||||
when {
|
when {
|
||||||
filePath != null -> shareFile(cxt, cItem.text, filePath)
|
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||||
else -> shareText(cxt, cItem.content.text)
|
else -> shareText(context, cItem.content.text)
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
||||||
copyText(cxt, cItem.content.text)
|
copyText(context, cItem.content.text)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
|
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||||
val filePath = getLoadedFilePath(context, cItem.file)
|
val filePath = getLoadedFilePath(context, cItem.file)
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||||
when (cItem.content.msgContent) {
|
when (cItem.content.msgContent) {
|
||||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
|
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cItem.meta.editable) {
|
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
|
||||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -233,15 +230,12 @@ private fun showMsgDeliveryErrorAlert(description: String) {
|
|||||||
fun PreviewChatItemView() {
|
fun PreviewChatItemView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
|
||||||
ChatInfo.Direct.sampleData,
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||||
),
|
),
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
|
||||||
chatModelIncognito = false,
|
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
@@ -256,13 +250,10 @@ fun PreviewChatItemView() {
|
|||||||
fun PreviewChatItemViewDeletedContent() {
|
fun PreviewChatItemViewDeletedContent() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
|
||||||
ChatInfo.Direct.sampleData,
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getDeletedContentSampleData(),
|
ChatItem.getDeletedContentSampleData(),
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
|
||||||
chatModelIncognito = false,
|
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -56,8 +57,12 @@ fun FramedItemView(
|
|||||||
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
||||||
contentAlignment = Alignment.TopStart
|
contentAlignment = Alignment.TopStart
|
||||||
) {
|
) {
|
||||||
|
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
|
||||||
|
qi.content.toTextWithDuration(true)
|
||||||
|
else
|
||||||
|
qi.text
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,13 +92,13 @@ fun FramedItemView(
|
|||||||
modifier = Modifier.size(68.dp).clipToBounds()
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
|
||||||
stringResource(R.string.icon_descr_file),
|
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 6.dp, end = 4.dp)
|
.padding(top = 6.dp, end = 4.dp)
|
||||||
.size(22.dp),
|
.size(22.dp),
|
||||||
@@ -105,7 +110,7 @@ fun FramedItemView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
|
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||||
Box(Modifier
|
Box(Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(
|
.background(
|
||||||
@@ -142,6 +147,12 @@ fun FramedItemView(
|
|||||||
CIMarkdownText(ci, showMember, uriHandler)
|
CIMarkdownText(ci, showMember, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is MsgContent.MCVoice -> {
|
||||||
|
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor)
|
||||||
|
if (mc.text != "") {
|
||||||
|
CIMarkdownText(ci, showMember, uriHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCFile -> {
|
||||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||||
if (mc.text != "") {
|
if (mc.text != "") {
|
||||||
@@ -157,8 +168,10 @@ fun FramedItemView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
|
||||||
CIMetaView(ci, metaColor)
|
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||||
|
CIMetaView(ci, metaColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -536,13 +536,11 @@ fun ChatListNavLinkLayout(
|
|||||||
) {
|
) {
|
||||||
var modifier = Modifier.fillMaxWidth()
|
var modifier = Modifier.fillMaxWidth()
|
||||||
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||||
Surface(modifier) {
|
Box(modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||||
.padding(start = 8.dp)
|
|
||||||
.padding(end = 12.dp),
|
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
chatLinkPreview()
|
chatLinkPreview()
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
|
|||||||
m.controller.apiStopChat()
|
m.controller.apiStopChat()
|
||||||
runChat.value = false
|
runChat.value = false
|
||||||
m.chatRunning.value = false
|
m.chatRunning.value = false
|
||||||
SimplexService.stop(context)
|
SimplexService.safeStopService(context)
|
||||||
MessagesFetcherWorker.cancelAll()
|
MessagesFetcherWorker.cancelAll()
|
||||||
} catch (e: Error) {
|
} catch (e: Error) {
|
||||||
runChat.value = true
|
runChat.value = true
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ package chat.simplex.app.views.helpers
|
|||||||
|
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
|
|
||||||
|
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||||
|
|
||||||
|
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
|
||||||
|
|||||||
@@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
|||||||
return interactionSource
|
return interactionSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
LaunchedEffect(interactionSource) {
|
||||||
|
var firstTapTime = 0L
|
||||||
|
interactionSource.interactions.collect { interaction ->
|
||||||
|
when (interaction) {
|
||||||
|
is PressInteraction.Press -> {
|
||||||
|
firstTapTime = System.currentTimeMillis(); onPress()
|
||||||
|
}
|
||||||
|
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
|
||||||
|
is PressInteraction.Cancel -> onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return interactionSource
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun PointerInputScope.detectTransformGestures(
|
suspend fun PointerInputScope.detectTransformGestures(
|
||||||
allowIntercept: () -> Boolean,
|
allowIntercept: () -> Boolean,
|
||||||
panZoomLock: Boolean = false,
|
panZoomLock: Boolean = false,
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import android.media.*
|
||||||
|
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import java.io.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
interface Recorder {
|
||||||
|
val recordingInProgress: MutableState<Boolean>
|
||||||
|
fun start(onStop: () -> Unit): String
|
||||||
|
fun stop()
|
||||||
|
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProgressAndDuration(
|
||||||
|
val progressMs: Int = 0,
|
||||||
|
val durationMs: Int = 0
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val Saver
|
||||||
|
get() = Saver<MutableState<ProgressAndDuration>, Pair<Int, Int>>(
|
||||||
|
save = { it.value.progressMs to it.value.durationMs },
|
||||||
|
restore = { mutableStateOf(ProgressAndDuration(it.first, it.second)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||||
|
companion object {
|
||||||
|
// Allows to stop the recorder from outside without having the recorder in a variable
|
||||||
|
var stopRecording: (() -> Unit)? = null
|
||||||
|
}
|
||||||
|
override val recordingInProgress = mutableStateOf(false)
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private fun initRecorder() =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaRecorder(SimplexApp.context)
|
||||||
|
} else {
|
||||||
|
MediaRecorder()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start(onStop: () -> Unit): String {
|
||||||
|
AudioPlayer.stop()
|
||||||
|
recordingInProgress.value = true
|
||||||
|
val rec: MediaRecorder
|
||||||
|
recorder = initRecorder().also { rec = it }
|
||||||
|
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
|
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
rec.setAudioChannels(1)
|
||||||
|
rec.setAudioSamplingRate(16000)
|
||||||
|
rec.setAudioEncodingBitRate(16000)
|
||||||
|
rec.setMaxDuration(-1)
|
||||||
|
rec.setMaxFileSize(recordedBytesLimit)
|
||||||
|
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
|
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
|
||||||
|
rec.setOutputFile(filePath)
|
||||||
|
rec.prepare()
|
||||||
|
rec.start()
|
||||||
|
rec.setOnInfoListener { mr, what, extra ->
|
||||||
|
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
|
||||||
|
stop()
|
||||||
|
onStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopRecording = { stop(); onStop() }
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
if (!recordingInProgress.value) return
|
||||||
|
stopRecording = null
|
||||||
|
recordingInProgress.value = false
|
||||||
|
recorder?.metrics?.
|
||||||
|
runCatching {
|
||||||
|
recorder?.stop()
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
recorder?.reset()
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
// release all resources
|
||||||
|
recorder?.release()
|
||||||
|
}
|
||||||
|
recorder = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
|
||||||
|
stop()
|
||||||
|
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object AudioPlayer {
|
||||||
|
private val player = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Filepath: String, onStop: () -> Unit
|
||||||
|
private val currentlyPlaying: MutableState<Pair<String, () -> Unit>?> = mutableStateOf(null)
|
||||||
|
|
||||||
|
fun start(filePath: String, seek: Int? = null, onStop: () -> Unit): Boolean {
|
||||||
|
if (!File(filePath).exists()) {
|
||||||
|
Log.e(TAG, "No such file: $filePath")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
RecorderNative.stopRecording?.invoke()
|
||||||
|
val current = currentlyPlaying.value
|
||||||
|
if (current == null || current.first != filePath) {
|
||||||
|
player.reset()
|
||||||
|
// Notify prev audio listener about stop
|
||||||
|
current?.second?.invoke()
|
||||||
|
runCatching {
|
||||||
|
player.setDataSource(filePath)
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, it.stackTraceToString())
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runCatching { player.prepare() }.onFailure {
|
||||||
|
// Can happen when audio file is broken
|
||||||
|
Log.e(TAG, it.stackTraceToString())
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seek != null) player.seekTo(seek)
|
||||||
|
player.start()
|
||||||
|
// Repeated calls to play/pause on the same track will not recompose all dependent views
|
||||||
|
if (currentlyPlaying.value?.first != filePath) {
|
||||||
|
currentlyPlaying.value = filePath to onStop
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause(): Int {
|
||||||
|
player.pause()
|
||||||
|
return player.currentPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!player.isPlaying) return
|
||||||
|
// Notify prev audio listener about stop
|
||||||
|
currentlyPlaying.value?.second?.invoke()
|
||||||
|
currentlyPlaying.value = null
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(item: ChatItem) = stop(item.file?.fileName)
|
||||||
|
|
||||||
|
// FileName or filePath are ok
|
||||||
|
fun stop(fileName: String?) {
|
||||||
|
if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If player starts playing at 2637 ms in a track 2816 ms long (these numbers are just an example),
|
||||||
|
* it will stop immediately after start but will not change currentPosition, so it will not be equal to duration.
|
||||||
|
* However, it sets isPlaying to false. Let's do it ourselves in order to prevent endless waiting loop
|
||||||
|
* */
|
||||||
|
fun progressAndDurationOrEnded(): ProgressAndDuration =
|
||||||
|
ProgressAndDuration(if (player.isPlaying) player.currentPosition else player.duration, player.duration)
|
||||||
|
|
||||||
|
fun duration(filePath: String): Int {
|
||||||
|
var res = 0
|
||||||
|
kotlin.runCatching {
|
||||||
|
helperPlayer.setDataSource(filePath)
|
||||||
|
helperPlayer.prepare()
|
||||||
|
helperPlayer.start()
|
||||||
|
helperPlayer.stop()
|
||||||
|
res = helperPlayer.duration
|
||||||
|
helperPlayer.reset()
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import android.view.ViewTreeObserver
|
|||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.BaselineShift
|
|||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.model.CIFile
|
import chat.simplex.app.model.CIFile
|
||||||
@@ -220,6 +222,11 @@ private fun spannableStringToAnnotatedString(
|
|||||||
// maximum image file size to be auto-accepted
|
// maximum image file size to be auto-accepted
|
||||||
const val MAX_IMAGE_SIZE: Long = 236700
|
const val MAX_IMAGE_SIZE: Long = 236700
|
||||||
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||||
|
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
|
||||||
|
|
||||||
|
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
||||||
|
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
|
||||||
|
|
||||||
const val MAX_FILE_SIZE: Long = 8000000
|
const val MAX_FILE_SIZE: Long = 8000000
|
||||||
|
|
||||||
fun getFilesDirectory(context: Context): String {
|
fun getFilesDirectory(context: Context): String {
|
||||||
@@ -449,3 +456,9 @@ fun Color.darker(factor: Float = 0.1f): Color =
|
|||||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||||
|
|
||||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val LongRange.Companion.saver
|
||||||
|
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
|
||||||
|
save = { it.value.first to it.value.last },
|
||||||
|
restore = { mutableStateOf(it.first..it.second) }
|
||||||
|
)
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ fun NotificationsSettingsView(
|
|||||||
if (mode == NotificationsMode.SERVICE)
|
if (mode == NotificationsMode.SERVICE)
|
||||||
SimplexService.start(SimplexApp.context)
|
SimplexService.start(SimplexApp.context)
|
||||||
else
|
else
|
||||||
SimplexService.stop(SimplexApp.context)
|
SimplexService.safeStopService(SimplexApp.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode != NotificationsMode.PERIODIC) {
|
if (mode != NotificationsMode.PERIODIC) {
|
||||||
|
|||||||
@@ -208,6 +208,10 @@
|
|||||||
<string name="file_not_found">Datei nicht gefunden</string>
|
<string name="file_not_found">Datei nicht gefunden</string>
|
||||||
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">***Voice message</string>
|
||||||
|
<string name="voice_message_send_text">***Voice message…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Benachrichtigungen</string>
|
<string name="notifications">Benachrichtigungen</string>
|
||||||
|
|
||||||
@@ -221,10 +225,11 @@
|
|||||||
<string name="icon_descr_server_status_error">Fehler</string>
|
<string name="icon_descr_server_status_error">Fehler</string>
|
||||||
<string name="icon_descr_server_status_pending">Ausstehend</string>
|
<string name="icon_descr_server_status_pending">Ausstehend</string>
|
||||||
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
|
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
|
||||||
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Nachricht senden</string>
|
<string name="icon_descr_send_message">Nachricht senden</string>
|
||||||
|
<string name="icon_descr_record_voice_message">***Record voice message</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Zurück</string>
|
<string name="back">Zurück</string>
|
||||||
@@ -364,9 +369,9 @@
|
|||||||
<string name="chat_console">Chat Konsole</string>
|
<string name="chat_console">Chat Konsole</string>
|
||||||
<string name="smp_servers">SMP-Server</string>
|
<string name="smp_servers">SMP-Server</string>
|
||||||
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
|
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
|
||||||
<string name="star_on_github">Stern auf GitHub</string>
|
<string name="star_on_github">Stern auf GitHub vergeben</string>
|
||||||
<string name="contribute">Beitragen</string>
|
<string name="contribute">Unterstützen Sie uns</string>
|
||||||
<string name="rate_the_app">Bewerte die App</string>
|
<string name="rate_the_app">Bewerten Sie die App</string>
|
||||||
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
|
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
|
||||||
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
|
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
|
||||||
<string name="your_SMP_servers">Ihre SMP-Server</string>
|
<string name="your_SMP_servers">Ihre SMP-Server</string>
|
||||||
@@ -567,7 +572,7 @@
|
|||||||
<string name="settings_section_title_you">MEINE DATEN</string>
|
<string name="settings_section_title_you">MEINE DATEN</string>
|
||||||
<string name="settings_section_title_settings">EINSTELLUNGEN</string>
|
<string name="settings_section_title_settings">EINSTELLUNGEN</string>
|
||||||
<string name="settings_section_title_help">HILFE</string>
|
<string name="settings_section_title_help">HILFE</string>
|
||||||
<string name="settings_section_title_support">UNTERSTÜTZEN SIMPLEX CHAT</string>
|
<string name="settings_section_title_support">UNTERSTÜTZUNG VON SIMPLEX CHAT</string>
|
||||||
<string name="settings_section_title_develop">ENTWICKLUNG</string>
|
<string name="settings_section_title_develop">ENTWICKLUNG</string>
|
||||||
<string name="settings_section_title_device">GERÄT</string>
|
<string name="settings_section_title_device">GERÄT</string>
|
||||||
<string name="settings_section_title_chats">CHATS</string>
|
<string name="settings_section_title_chats">CHATS</string>
|
||||||
|
|||||||
@@ -208,6 +208,10 @@
|
|||||||
<string name="file_not_found">Файл не найден</string>
|
<string name="file_not_found">Файл не найден</string>
|
||||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">Голосовое сообщение</string>
|
||||||
|
<string name="voice_message_send_text">Голосовое сообщение…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Уведомления</string>
|
<string name="notifications">Уведомления</string>
|
||||||
|
|
||||||
@@ -225,6 +229,7 @@
|
|||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Отправить сообщение</string>
|
<string name="icon_descr_send_message">Отправить сообщение</string>
|
||||||
|
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Назад</string>
|
<string name="back">Назад</string>
|
||||||
|
|||||||
@@ -208,6 +208,10 @@
|
|||||||
<string name="file_not_found">File not found</string>
|
<string name="file_not_found">File not found</string>
|
||||||
<string name="error_saving_file">Error saving file</string>
|
<string name="error_saving_file">Error saving file</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">Voice message</string>
|
||||||
|
<string name="voice_message_send_text">Voice message…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Notifications</string>
|
<string name="notifications">Notifications</string>
|
||||||
|
|
||||||
@@ -225,6 +229,7 @@
|
|||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Send Message</string>
|
<string name="icon_descr_send_message">Send Message</string>
|
||||||
|
<string name="icon_descr_record_voice_message">Record voice message</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Back</string>
|
<string name="back">Back</string>
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
|||||||
|
|
||||||
func getUserSMPServers() throws -> [String] {
|
func getUserSMPServers() throws -> [String] {
|
||||||
let r = chatSendCmdSync(.getUserSMPServers)
|
let r = chatSendCmdSync(.getUserSMPServers)
|
||||||
if case let .userSMPServers(smpServers) = r { return smpServers }
|
if case let .userSMPServers(smpServers, _) = r { return smpServers.map { $0.server } }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +319,17 @@ func setUserSMPServers(smpServers: [String]) async throws {
|
|||||||
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
|
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||||
|
let r = await chatSendCmd(.testSMPServer(smpServer: smpServer))
|
||||||
|
if case let .sMPTestResult(testFailure) = r {
|
||||||
|
if let t = testFailure {
|
||||||
|
return .failure(t)
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func getChatItemTTL() throws -> ChatItemTTL {
|
func getChatItemTTL() throws -> ChatItemTTL {
|
||||||
let r = chatSendCmdSync(.apiGetChatItemTTL)
|
let r = chatSendCmdSync(.apiGetChatItemTTL)
|
||||||
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||||
@@ -476,6 +487,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
|
||||||
|
let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
|
||||||
|
if case let .contactPrefsUpdated(_, toContact) = r { return toContact }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
|
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
|
||||||
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
|
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
|
||||||
if case let .contactAliasUpdated(toContact) = r { return toContact }
|
if case let .contactAliasUpdated(toContact) = r { return toContact }
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ struct ChatInfoView: View {
|
|||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var contact: Contact
|
@State var contact: Contact
|
||||||
@Binding var connectionStats: ConnectionStats?
|
@Binding var connectionStats: ConnectionStats?
|
||||||
var customUserProfile: Profile?
|
var customUserProfile: Profile?
|
||||||
@State var localAlias: String
|
@State var localAlias: String
|
||||||
@@ -99,6 +99,10 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
contactPreferencesButton()
|
||||||
|
}
|
||||||
|
|
||||||
Section("Servers") {
|
Section("Servers") {
|
||||||
networkStatusRow()
|
networkStatusRow()
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
@@ -192,6 +196,20 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contactPreferencesButton() -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
ContactPreferencesView(
|
||||||
|
contact: $contact,
|
||||||
|
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||||
|
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Contact preferences")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
Label("Contact preferences", systemImage: "switch.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func networkStatusRow() -> some View {
|
func networkStatusRow() -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Network status")
|
Text("Network status")
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// ContactPreferencesView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 13/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct ContactPreferencesView: View {
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Binding var contact: Contact
|
||||||
|
@State var featuresAllowed: ContactFeaturesAllowed
|
||||||
|
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let user: User = chatModel.currentUser!
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
List {
|
||||||
|
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
||||||
|
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
|
||||||
|
Button("Save (and notify contact)") { savePreferences() }
|
||||||
|
}
|
||||||
|
.disabled(currentFeaturesAllowed == featuresAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureSection(_ feature: Feature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
|
||||||
|
let enabled = FeatureEnabled.enabled(
|
||||||
|
user: Preference(allow: allowFeature.wrappedValue.allowed),
|
||||||
|
contact: pref.contactPreference
|
||||||
|
)
|
||||||
|
return Section {
|
||||||
|
Picker("You allow", selection: allowFeature) {
|
||||||
|
ForEach(ContactFeatureAllowed.values(userDefault)) { allow in
|
||||||
|
Text(allow.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "\(feature.icon).fill")
|
||||||
|
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
|
||||||
|
Text(feature.text)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(feature.enabledDescription(enabled))
|
||||||
|
.frame(height: 36, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePreferences() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||||
|
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||||
|
await MainActor.run {
|
||||||
|
contact = toContact
|
||||||
|
chatModel.updateContact(toContact)
|
||||||
|
currentFeaturesAllowed = featuresAllowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContactPreferencesView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ContactPreferencesView(
|
||||||
|
contact: Binding.constant(Contact.sampleData),
|
||||||
|
featuresAllowed: ContactFeaturesAllowed.sampleData,
|
||||||
|
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,12 @@ struct GroupChatInfoView: View {
|
|||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
@ObservedObject private var alertManager = AlertManager.shared
|
@ObservedObject private var alertManager = AlertManager.shared
|
||||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||||
@State private var groupLink: String?
|
@State private var groupLink: String?
|
||||||
@State private var showAddMembersSheet: Bool = false
|
@State private var showAddMembersSheet: Bool = false
|
||||||
@State private var selectedMember: GroupMember? = nil
|
@State private var selectedMember: GroupMember? = nil
|
||||||
@State private var showGroupProfile: Bool = false
|
|
||||||
@State private var connectionStats: ConnectionStats?
|
@State private var connectionStats: ConnectionStats?
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
|
||||||
@@ -42,6 +41,17 @@ struct GroupChatInfoView: View {
|
|||||||
groupInfoHeader()
|
groupInfoHeader()
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if groupInfo.canEdit {
|
||||||
|
editGroupButton()
|
||||||
|
}
|
||||||
|
groupPreferencesButton()
|
||||||
|
} header: {
|
||||||
|
Text("")
|
||||||
|
} footer: {
|
||||||
|
Text("Only group owners can change group preferences.")
|
||||||
|
}
|
||||||
|
|
||||||
Section("\(members.count + 1) members") {
|
Section("\(members.count + 1) members") {
|
||||||
if groupInfo.canAddMembers {
|
if groupInfo.canAddMembers {
|
||||||
groupLinkButton()
|
groupLinkButton()
|
||||||
@@ -77,14 +87,8 @@ struct GroupChatInfoView: View {
|
|||||||
}) { _ in
|
}) { _ in
|
||||||
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
|
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showGroupProfile) {
|
|
||||||
GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
if groupInfo.canEdit {
|
|
||||||
editGroupButton()
|
|
||||||
}
|
|
||||||
clearChatButton()
|
clearChatButton()
|
||||||
if groupInfo.canDelete {
|
if groupInfo.canDelete {
|
||||||
deleteGroupButton()
|
deleteGroupButton()
|
||||||
@@ -189,16 +193,35 @@ struct GroupChatInfoView: View {
|
|||||||
private func groupLinkButton() -> some View {
|
private func groupLinkButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitle("Group link")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Group link", systemImage: "link")
|
Label("Group link", systemImage: "link")
|
||||||
.foregroundColor(.accentColor)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupPreferencesButton() -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
GroupPreferencesView(
|
||||||
|
groupInfo: $groupInfo,
|
||||||
|
preferences: groupInfo.fullGroupPreferences,
|
||||||
|
currentPreferences: groupInfo.fullGroupPreferences
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Group preferences")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
Label("Group preferences", systemImage: "switch.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func editGroupButton() -> some View {
|
func editGroupButton() -> some View {
|
||||||
Button {
|
NavigationLink {
|
||||||
showGroupProfile = true
|
GroupProfileView(
|
||||||
|
groupInfo: $groupInfo,
|
||||||
|
groupProfile: groupInfo.groupProfile
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Group profile")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Edit group profile", systemImage: "pencil")
|
Label("Edit group profile", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ struct GroupLinkView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack (alignment: .leading) {
|
VStack (alignment: .leading) {
|
||||||
Text("Group link")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.bold()
|
|
||||||
.padding(.bottom)
|
|
||||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
if let groupLink = groupLink {
|
if let groupLink = groupLink {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// GroupPreferencesView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by JRoberts on 16.11.2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct GroupPreferencesView: View {
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Binding var groupInfo: GroupInfo
|
||||||
|
@State var preferences: FullGroupPreferences
|
||||||
|
@State var currentPreferences: FullGroupPreferences
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
List {
|
||||||
|
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||||
|
featureSection(.voice, $preferences.voice.enable)
|
||||||
|
|
||||||
|
if groupInfo.canEdit {
|
||||||
|
Section {
|
||||||
|
Button("Reset") { preferences = currentPreferences }
|
||||||
|
Button("Save (and notify group members)") { savePreferences() }
|
||||||
|
}
|
||||||
|
.disabled(currentPreferences == preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureSection(_ feature: Feature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
|
||||||
|
Section {
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
settingsRow(feature.icon) {
|
||||||
|
Picker(feature.text, selection: enableFeature) {
|
||||||
|
ForEach(GroupFeatureEnabled.values) { enable in
|
||||||
|
Text(enable.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
settingsRow(feature.icon) {
|
||||||
|
infoRow(feature.text, enableFeature.wrappedValue.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(feature.enableGroupPrefDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||||
|
.frame(height: 36, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePreferences() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
var gp = groupInfo.groupProfile
|
||||||
|
gp.groupPreferences = toGroupPreferences(preferences)
|
||||||
|
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
|
||||||
|
await MainActor.run {
|
||||||
|
groupInfo = gInfo
|
||||||
|
chatModel.updateGroup(gInfo)
|
||||||
|
currentPreferences = preferences
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroupPreferencesView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
GroupPreferencesView(
|
||||||
|
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||||
|
preferences: FullGroupPreferences.sampleData,
|
||||||
|
currentPreferences: FullGroupPreferences.sampleData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import SimpleXChat
|
|||||||
struct GroupProfileView: View {
|
struct GroupProfileView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
var groupId: Int64
|
@Binding var groupInfo: GroupInfo
|
||||||
@State var groupProfile: GroupProfile
|
@State var groupProfile: GroupProfile
|
||||||
@State private var showChooseSource = false
|
@State private var showChooseSource = false
|
||||||
@State private var showImagePicker = false
|
@State private var showImagePicker = false
|
||||||
@@ -120,8 +120,9 @@ struct GroupProfileView: View {
|
|||||||
func saveProfile() {
|
func saveProfile() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let gInfo = try await apiUpdateGroup(groupId, groupProfile)
|
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
groupInfo = gInfo
|
||||||
chatModel.updateGroup(gInfo)
|
chatModel.updateGroup(gInfo)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
@@ -137,6 +138,6 @@ struct GroupProfileView: View {
|
|||||||
|
|
||||||
struct GroupProfileView_Previews: PreviewProvider {
|
struct GroupProfileView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GroupProfileView(groupId: 1, groupProfile: GroupProfile.sampleData)
|
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ struct DatabaseView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Delete messages after", selection: $chatItemTTL) {
|
Picker("Delete messages after", selection: $chatItemTTL) {
|
||||||
ForEach([ChatItemTTL.none, ChatItemTTL.month, ChatItemTTL.week, ChatItemTTL.day]) { ttl in
|
ForEach(ChatItemTTL.values) { ttl in
|
||||||
Text(ttl.deleteAfterText).tag(ttl)
|
Text(ttl.deleteAfterText).tag(ttl)
|
||||||
}
|
}
|
||||||
if case .seconds = chatItemTTL {
|
if case .seconds = chatItemTTL {
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ struct NetworkAndServers: View {
|
|||||||
Text("SMP servers")
|
Text("SMP servers")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
SMPServersView()
|
||||||
|
.navigationTitle("Your SMP servers")
|
||||||
|
} label: {
|
||||||
|
Text("SMP servers (new)")
|
||||||
|
}
|
||||||
|
|
||||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//
|
||||||
|
// PreferencesView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 13/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct PreferencesView: View {
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@State var profile: LocalProfile
|
||||||
|
@State var preferences: FullPreferences
|
||||||
|
@State var currentPreferences: FullPreferences
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
List {
|
||||||
|
featureSection(.fullDelete, $preferences.fullDelete.allow)
|
||||||
|
featureSection(.voice, $preferences.voice.allow)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Reset") { preferences = currentPreferences }
|
||||||
|
Button("Save (and notify contacts)") { savePreferences() }
|
||||||
|
}
|
||||||
|
.disabled(currentPreferences == preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureSection(_ feature: Feature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
|
||||||
|
Section {
|
||||||
|
settingsRow(feature.icon) {
|
||||||
|
Picker(feature.text, selection: allowFeature) {
|
||||||
|
ForEach(FeatureAllowed.values) { allow in
|
||||||
|
Text(allow.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(feature.allowDescription(allowFeature.wrappedValue))
|
||||||
|
.frame(height: 36, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePreferences() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
var p = fromLocalProfile(profile)
|
||||||
|
p.preferences = toPreferences(preferences)
|
||||||
|
if let newProfile = try await apiUpdateProfile(profile: p) {
|
||||||
|
await MainActor.run {
|
||||||
|
if let profileId = chatModel.currentUser?.profile.profileId {
|
||||||
|
chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "")
|
||||||
|
chatModel.currentUser?.fullPreferences = preferences
|
||||||
|
}
|
||||||
|
currentPreferences = preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("PreferencesView apiUpdateProfile error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreferencesView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
PreferencesView(
|
||||||
|
profile: LocalProfile(profileId: 1, displayName: "alice", fullName: "", localAlias: ""),
|
||||||
|
preferences: FullPreferences.sampleData,
|
||||||
|
currentPreferences: FullPreferences.sampleData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// SMPServerView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 15/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct SMPServerView: View {
|
||||||
|
@State var server: ServerCfg
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if server.preset {
|
||||||
|
presetServer()
|
||||||
|
} else {
|
||||||
|
customServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presetServer() -> some View {
|
||||||
|
return VStack {
|
||||||
|
List {
|
||||||
|
Section("Preset server address") {
|
||||||
|
Text(server.server)
|
||||||
|
}
|
||||||
|
useServerSection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func customServer() -> some View {
|
||||||
|
VStack {
|
||||||
|
List {
|
||||||
|
Section("Your server address") {
|
||||||
|
TextEditor(text: $server.server)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.lineLimit(10)
|
||||||
|
.frame(height: 108)
|
||||||
|
.padding(-6)
|
||||||
|
}
|
||||||
|
useServerSection()
|
||||||
|
Section("Add to another device") {
|
||||||
|
QRCode(uri: server.server)
|
||||||
|
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func useServerSection() -> some View {
|
||||||
|
Section("Use server") {
|
||||||
|
HStack {
|
||||||
|
Button("Test server") {
|
||||||
|
Task { await testServerConnection(server: $server) }
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
showTestStatus(server: server)
|
||||||
|
}
|
||||||
|
Toggle("Enabled", isOn: $server.enabled)
|
||||||
|
Button("Remove server", role: .destructive) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
|
||||||
|
switch server.tested {
|
||||||
|
case .some(true):
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
case .some(false):
|
||||||
|
Image(systemName: "multiply")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
case .none:
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerConnection(server: Binding<ServerCfg>) async {
|
||||||
|
do {
|
||||||
|
let r = try await testSMPServer(smpServer: server.wrappedValue.server)
|
||||||
|
await MainActor.run {
|
||||||
|
switch r {
|
||||||
|
case .success: server.wrappedValue.tested = true
|
||||||
|
case .failure: server.wrappedValue.tested = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
await MainActor.run {
|
||||||
|
server.wrappedValue.tested = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SMPServerView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
SMPServerView(server: ServerCfg.sampleData.custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// SMPServersView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 15/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct SMPServersView: View {
|
||||||
|
@Environment(\.editMode) var editMode
|
||||||
|
@State var servers: [ServerCfg] = [
|
||||||
|
ServerCfg.sampleData.preset,
|
||||||
|
ServerCfg.sampleData.custom,
|
||||||
|
ServerCfg.sampleData.untested,
|
||||||
|
]
|
||||||
|
@State var showAddServer = false
|
||||||
|
@State var showSaveAlert = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("SMP servers") {
|
||||||
|
ForEach(servers) { srv in
|
||||||
|
smpServerView(srv)
|
||||||
|
}
|
||||||
|
.onMove { indexSet, offset in
|
||||||
|
servers.move(fromOffsets: indexSet, toOffset: offset)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
servers.remove(atOffsets: indexSet)
|
||||||
|
}
|
||||||
|
if isEditing {
|
||||||
|
Button("Add server…") {
|
||||||
|
showAddServer = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: isEditing) { value in
|
||||||
|
if value == false {
|
||||||
|
showSaveAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar { EditButton() }
|
||||||
|
.confirmationDialog("Add server…", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||||
|
Button("Scan server QR code") {
|
||||||
|
}
|
||||||
|
Button("Add preset servers") {
|
||||||
|
}
|
||||||
|
Button("Enter server manually") {
|
||||||
|
servers.append(ServerCfg.empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("Save servers?", isPresented: $showSaveAlert, titleVisibility: .visible) {
|
||||||
|
Button("Test & save servers") {
|
||||||
|
for i in 0..<servers.count {
|
||||||
|
servers[i].tested = nil
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
for i in 0..<servers.count {
|
||||||
|
await testServerConnection(server: $servers[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Save servers") {
|
||||||
|
}
|
||||||
|
Button("Revert changes") {
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
editMode?.wrappedValue = .active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isEditing: Bool {
|
||||||
|
editMode?.wrappedValue.isEditing == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smpServerView(_ srv: ServerCfg) -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
SMPServerView(server: srv)
|
||||||
|
.navigationBarTitle("Server")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
let v = Text(srv.server)
|
||||||
|
HStack {
|
||||||
|
showTestStatus(server: srv)
|
||||||
|
.frame(width: 16, alignment: .center)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
if srv.enabled {
|
||||||
|
v
|
||||||
|
} else {
|
||||||
|
(v + Text(" (disabled)")).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SMPServersView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
SMPServersView()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,10 +89,8 @@ struct SettingsView: View {
|
|||||||
ProfilePreview(profileOf: user)
|
ProfilePreview(profileOf: user)
|
||||||
.padding(.leading, -8)
|
.padding(.leading, -8)
|
||||||
}
|
}
|
||||||
.disabled(chatModel.chatRunning != true)
|
|
||||||
|
|
||||||
incognitoRow()
|
incognitoRow()
|
||||||
.disabled(chatModel.chatRunning != true)
|
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
CreateLinkView(selection: .longTerm, viaNavLink: true)
|
CreateLinkView(selection: .longTerm, viaNavLink: true)
|
||||||
@@ -100,24 +98,15 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
settingsRow("qrcode") { Text("Your SimpleX contact address") }
|
settingsRow("qrcode") { Text("Your SimpleX contact address") }
|
||||||
}
|
}
|
||||||
.disabled(chatModel.chatRunning != true)
|
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
|
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
|
||||||
.navigationTitle("Your chat database")
|
.navigationTitle("Your preferences")
|
||||||
} label: {
|
} label: {
|
||||||
let color: Color = chatModel.chatDbEncrypted == false ? .orange : .secondary
|
settingsRow("switch.2") { Text("Chat preferences") }
|
||||||
settingsRow("internaldrive", color: color) {
|
|
||||||
HStack {
|
|
||||||
Text("Database passphrase & export")
|
|
||||||
Spacer()
|
|
||||||
if chatModel.chatRunning == false {
|
|
||||||
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(chatModel.chatRunning != true)
|
||||||
|
|
||||||
Section("Settings") {
|
Section("Settings") {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -129,18 +118,32 @@ struct SettingsView: View {
|
|||||||
Text("Notifications")
|
Text("Notifications")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(chatModel.chatRunning != true)
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
NetworkAndServers()
|
||||||
|
.navigationTitle("Network & servers")
|
||||||
|
} label: {
|
||||||
|
settingsRow("externaldrive.connected.to.line.below") { Text("Network & servers") }
|
||||||
|
}
|
||||||
|
.disabled(chatModel.chatRunning != true)
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
CallSettings()
|
CallSettings()
|
||||||
.navigationTitle("Your calls")
|
.navigationTitle("Your calls")
|
||||||
} label: {
|
} label: {
|
||||||
settingsRow("video") { Text("Audio & video calls") }
|
settingsRow("video") { Text("Audio & video calls") }
|
||||||
}
|
}
|
||||||
|
.disabled(chatModel.chatRunning != true)
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
PrivacySettings()
|
PrivacySettings()
|
||||||
.navigationTitle("Your privacy")
|
.navigationTitle("Your privacy")
|
||||||
} label: {
|
} label: {
|
||||||
settingsRow("lock") { Text("Privacy & security") }
|
settingsRow("lock") { Text("Privacy & security") }
|
||||||
}
|
}
|
||||||
|
.disabled(chatModel.chatRunning != true)
|
||||||
|
|
||||||
if UIApplication.shared.supportsAlternateIcons {
|
if UIApplication.shared.supportsAlternateIcons {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AppearanceSettings()
|
AppearanceSettings()
|
||||||
@@ -148,15 +151,11 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
settingsRow("sun.max") { Text("Appearance") }
|
settingsRow("sun.max") { Text("Appearance") }
|
||||||
}
|
}
|
||||||
|
.disabled(chatModel.chatRunning != true)
|
||||||
}
|
}
|
||||||
NavigationLink {
|
|
||||||
NetworkAndServers()
|
chatDatabaseRow()
|
||||||
.navigationTitle("Network & servers")
|
|
||||||
} label: {
|
|
||||||
settingsRow("externaldrive.connected.to.line.below") { Text("Network & servers") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.disabled(chatModel.chatRunning != true)
|
|
||||||
|
|
||||||
Section("Help") {
|
Section("Help") {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -277,6 +276,24 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func chatDatabaseRow() -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
|
||||||
|
.navigationTitle("Your chat database")
|
||||||
|
} label: {
|
||||||
|
let color: Color = chatModel.chatDbEncrypted == false ? .orange : .secondary
|
||||||
|
settingsRow("internaldrive", color: color) {
|
||||||
|
HStack {
|
||||||
|
Text("Database passphrase & export")
|
||||||
|
Spacer()
|
||||||
|
if chatModel.chatRunning == false {
|
||||||
|
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enum SettingsSheet: Identifiable {
|
private enum SettingsSheet: Identifiable {
|
||||||
case incognitoInfo
|
case incognitoInfo
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,36 @@
|
|||||||
<target>Alle Ihre Kontakte bleiben verbunden.</target>
|
<target>Alle Ihre Kontakte bleiben verbunden.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||||
|
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||||
|
<target>***Allow irreversible message deletion only if your contact allows it to you.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Allow to irreversibly delete sent messages.</source>
|
||||||
|
<target>***Allow to irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow to send voice messages." xml:space="preserve">
|
||||||
|
<source>Allow to send voice messages.</source>
|
||||||
|
<target>***Allow to send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
|
||||||
|
<source>Allow voice messages only if your contact allows them.</source>
|
||||||
|
<target>***Allow voice messages only if your contact allows them.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Allow your contacts to irreversibly delete sent messages.</source>
|
||||||
|
<target>***Allow your contacts to irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||||
|
<source>Allow your contacts to send voice messages.</source>
|
||||||
|
<target>***Allow your contacts to send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Already connected?" xml:space="preserve">
|
<trans-unit id="Already connected?" xml:space="preserve">
|
||||||
<source>Already connected?</source>
|
<source>Already connected?</source>
|
||||||
<target>Sind Sie bereits verbunden?</target>
|
<target>Sind Sie bereits verbunden?</target>
|
||||||
@@ -328,6 +358,16 @@
|
|||||||
<target>Automatisch</target>
|
<target>Automatisch</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Both you and your contact can irreversibly delete sent messages.</source>
|
||||||
|
<target>***Both you and your contact can irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||||
|
<source>Both you and your contact can send voice messages.</source>
|
||||||
|
<target>***Both you and your contact can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||||
<source>Call already ended!</source>
|
<source>Call already ended!</source>
|
||||||
<target>Anruf ist bereits beendet!</target>
|
<target>Anruf ist bereits beendet!</target>
|
||||||
@@ -428,6 +468,11 @@
|
|||||||
<target>Der Chat ist beendet</target>
|
<target>Der Chat ist beendet</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Chat preferences" xml:space="preserve">
|
||||||
|
<source>Chat preferences</source>
|
||||||
|
<target>***Chat preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Chats" xml:space="preserve">
|
<trans-unit id="Chats" xml:space="preserve">
|
||||||
<source>Chats</source>
|
<source>Chats</source>
|
||||||
<target>Chats</target>
|
<target>Chats</target>
|
||||||
@@ -558,6 +603,11 @@
|
|||||||
<target>Verbindungszeitüberschreitung</target>
|
<target>Verbindungszeitüberschreitung</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contact allows" xml:space="preserve">
|
||||||
|
<source>Contact allows</source>
|
||||||
|
<target>***Contact allows</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Contact already exists" xml:space="preserve">
|
<trans-unit id="Contact already exists" xml:space="preserve">
|
||||||
<source>Contact already exists</source>
|
<source>Contact already exists</source>
|
||||||
<target>Der Kontakt ist bereits vorhanden</target>
|
<target>Der Kontakt ist bereits vorhanden</target>
|
||||||
@@ -588,11 +638,21 @@
|
|||||||
<target>Kontaktname</target>
|
<target>Kontaktname</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contact preferences" xml:space="preserve">
|
||||||
|
<source>Contact preferences</source>
|
||||||
|
<target>***Contact preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Contact requests" xml:space="preserve">
|
<trans-unit id="Contact requests" xml:space="preserve">
|
||||||
<source>Contact requests</source>
|
<source>Contact requests</source>
|
||||||
<target>Kontaktanfragen</target>
|
<target>Kontaktanfragen</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
|
||||||
|
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
|
||||||
|
<target>***Contacts can mark messages for deletion; you will be able to view them.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Copy" xml:space="preserve">
|
<trans-unit id="Copy" xml:space="preserve">
|
||||||
<source>Copy</source>
|
<source>Copy</source>
|
||||||
<target>Kopieren</target>
|
<target>Kopieren</target>
|
||||||
@@ -1221,6 +1281,11 @@
|
|||||||
<target>Für Konsole</target>
|
<target>Für Konsole</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Full deletion" xml:space="preserve">
|
||||||
|
<source>Full deletion</source>
|
||||||
|
<target>***Full deletion</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Full name (optional)" xml:space="preserve">
|
<trans-unit id="Full name (optional)" xml:space="preserve">
|
||||||
<source>Full name (optional)</source>
|
<source>Full name (optional)</source>
|
||||||
<target>Vollständiger Name (optional)</target>
|
<target>Vollständiger Name (optional)</target>
|
||||||
@@ -1271,11 +1336,26 @@
|
|||||||
<target>Gruppen-Link</target>
|
<target>Gruppen-Link</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Group members can irreversibly delete sent messages.</source>
|
||||||
|
<target>***Group members can irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||||
|
<source>Group members can send voice messages.</source>
|
||||||
|
<target>***Group members can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Group message:" xml:space="preserve">
|
<trans-unit id="Group message:" xml:space="preserve">
|
||||||
<source>Group message:</source>
|
<source>Group message:</source>
|
||||||
<target>Grppennachricht:</target>
|
<target>Grppennachricht:</target>
|
||||||
<note>notification</note>
|
<note>notification</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Group preferences" xml:space="preserve">
|
||||||
|
<source>Group preferences</source>
|
||||||
|
<target>***Group preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
||||||
<source>Group profile is stored on members' devices, not on the servers.</source>
|
<source>Group profile is stored on members' devices, not on the servers.</source>
|
||||||
<target>Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.</target>
|
<target>Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.</target>
|
||||||
@@ -1453,6 +1533,11 @@
|
|||||||
<target>In Gruppe einladen</target>
|
<target>In Gruppe einladen</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||||
|
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||||
|
<target>***Irreversible message deletion is prohibited in this chat.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||||
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
||||||
<target>Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.</target>
|
<target>Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.</target>
|
||||||
@@ -1768,6 +1853,31 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
<target>Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.</target>
|
<target>Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||||
|
<source>Only group owners can change group preferences.</source>
|
||||||
|
<target>***Only group owners can change group preferences.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
|
||||||
|
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
|
||||||
|
<target>***Only you can irreversibly delete messages (your contact can mark them for deletion).</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||||
|
<source>Only you can send voice messages.</source>
|
||||||
|
<target>***Only you can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
|
||||||
|
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
|
||||||
|
<target>***Only your contact can irreversibly delete messages (you can mark them for deletion).</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||||
|
<source>Only your contact can send voice messages.</source>
|
||||||
|
<target>***Only your contact can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Open Settings" xml:space="preserve">
|
<trans-unit id="Open Settings" xml:space="preserve">
|
||||||
<source>Open Settings</source>
|
<source>Open Settings</source>
|
||||||
<target>Geräte-Einstellungen öffnen</target>
|
<target>Geräte-Einstellungen öffnen</target>
|
||||||
@@ -1858,6 +1968,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
<target>Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.</target>
|
<target>Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Preferences" xml:space="preserve">
|
||||||
|
<source>Preferences</source>
|
||||||
|
<target>***Preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||||
<source>Privacy & security</source>
|
<source>Privacy & security</source>
|
||||||
<target>Datenschutz & Sicherheit</target>
|
<target>Datenschutz & Sicherheit</target>
|
||||||
@@ -1873,6 +1988,16 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
<target>Profilbild</target>
|
<target>Profilbild</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
|
||||||
|
<source>Prohibit irreversible message deletion.</source>
|
||||||
|
<target>***Prohibit irreversible message deletion.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||||
|
<source>Prohibit sending voice messages.</source>
|
||||||
|
<target>***Prohibit sending voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Protocol timeout" xml:space="preserve">
|
<trans-unit id="Protocol timeout" xml:space="preserve">
|
||||||
<source>Protocol timeout</source>
|
<source>Protocol timeout</source>
|
||||||
<target>Protokollzeitüberschreitung</target>
|
<target>Protokollzeitüberschreitung</target>
|
||||||
@@ -1885,7 +2010,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Rate the app" xml:space="preserve">
|
<trans-unit id="Rate the app" xml:space="preserve">
|
||||||
<source>Rate the app</source>
|
<source>Rate the app</source>
|
||||||
<target>Bewerte die App</target>
|
<target>Bewerten Sie die App</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Read" xml:space="preserve">
|
<trans-unit id="Read" xml:space="preserve">
|
||||||
@@ -1968,6 +2093,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
<target>Erforderlich</target>
|
<target>Erforderlich</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Reset" xml:space="preserve">
|
||||||
|
<source>Reset</source>
|
||||||
|
<target>***Reset</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Reset colors" xml:space="preserve">
|
<trans-unit id="Reset colors" xml:space="preserve">
|
||||||
<source>Reset colors</source>
|
<source>Reset colors</source>
|
||||||
<target>Farben zurücksetzen</target>
|
<target>Farben zurücksetzen</target>
|
||||||
@@ -2038,11 +2168,21 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
<target>Speichern</target>
|
<target>Speichern</target>
|
||||||
<note>chat item action</note>
|
<note>chat item action</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Save (and notify contact)" xml:space="preserve">
|
||||||
|
<source>Save (and notify contact)</source>
|
||||||
|
<target>***Save (and notify contact)</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
||||||
<source>Save (and notify contacts)</source>
|
<source>Save (and notify contacts)</source>
|
||||||
<target>Speichern (und Kontakte benachrichtigen)</target>
|
<target>Speichern (und Kontakte benachrichtigen)</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Save (and notify group members)" xml:space="preserve">
|
||||||
|
<source>Save (and notify group members)</source>
|
||||||
|
<target>***Save (and notify group members)</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Save archive" xml:space="preserve">
|
<trans-unit id="Save archive" xml:space="preserve">
|
||||||
<source>Save archive</source>
|
<source>Save archive</source>
|
||||||
<target>Archiv speichern</target>
|
<target>Archiv speichern</target>
|
||||||
@@ -2255,7 +2395,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Support SimpleX Chat" xml:space="preserve">
|
<trans-unit id="Support SimpleX Chat" xml:space="preserve">
|
||||||
<source>Support SimpleX Chat</source>
|
<source>Support SimpleX Chat</source>
|
||||||
<target>Unterstützen SimpleX Chat</target>
|
<target>Unterstützung von SimpleX Chat</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="System" xml:space="preserve">
|
<trans-unit id="System" xml:space="preserve">
|
||||||
@@ -2395,7 +2535,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)." xml:space="preserve">
|
<trans-unit id="This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)." xml:space="preserve">
|
||||||
<source>This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member).</source>
|
<source>This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member).</source>
|
||||||
<target>Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</target>
|
<target>Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="This group no longer exists." xml:space="preserve">
|
<trans-unit id="This group no longer exists." xml:space="preserve">
|
||||||
@@ -2572,6 +2712,16 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
|||||||
<target>Videoanruf</target>
|
<target>Videoanruf</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Voice messages" xml:space="preserve">
|
||||||
|
<source>Voice messages</source>
|
||||||
|
<target>***Voice messages</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
|
||||||
|
<source>Voice messages are prohibited in this chat.</source>
|
||||||
|
<target>***Voice messages are prohibited in this chat.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Waiting for file" xml:space="preserve">
|
<trans-unit id="Waiting for file" xml:space="preserve">
|
||||||
<source>Waiting for file</source>
|
<source>Waiting for file</source>
|
||||||
<target>Warte auf Datei</target>
|
<target>Warte auf Datei</target>
|
||||||
@@ -2627,6 +2777,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
|||||||
<target>Sie haben die Verbindung akzeptiert</target>
|
<target>Sie haben die Verbindung akzeptiert</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="You allow" xml:space="preserve">
|
||||||
|
<source>You allow</source>
|
||||||
|
<target>***You allow</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
||||||
<source>You are already connected to %@.</source>
|
<source>You are already connected to %@.</source>
|
||||||
<target>Sie sind bereits mit %@ verbunden.</target>
|
<target>Sie sind bereits mit %@ verbunden.</target>
|
||||||
@@ -2844,6 +2999,11 @@ Sie können diese Verbindung abbrechen und den Kontakt entfernen (und es später
|
|||||||
<target>Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die Importierte ERSETZT.</target>
|
<target>Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die Importierte ERSETZT.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Your preferences" xml:space="preserve">
|
||||||
|
<source>Your preferences</source>
|
||||||
|
<target>***Your preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Your privacy" xml:space="preserve">
|
<trans-unit id="Your privacy" xml:space="preserve">
|
||||||
<source>Your privacy</source>
|
<source>Your privacy</source>
|
||||||
<target>Meine Privatsphäre</target>
|
<target>Meine Privatsphäre</target>
|
||||||
@@ -2878,7 +3038,7 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" xml:space="preserve">
|
<trans-unit id="[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" xml:space="preserve">
|
||||||
<source>[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)</source>
|
<source>[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)</source>
|
||||||
<target>[Beitragen](https://github.com/simplex-chat/simplex-chat#contribute)</target>
|
<target>[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="[Send us email](mailto:chat@simplex.chat)" xml:space="preserve">
|
<trans-unit id="[Send us email](mailto:chat@simplex.chat)" xml:space="preserve">
|
||||||
@@ -2888,7 +3048,7 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
|
<trans-unit id="[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
|
||||||
<source>[Star on GitHub](https://github.com/simplex-chat/simplex-chat)</source>
|
<source>[Star on GitHub](https://github.com/simplex-chat/simplex-chat)</source>
|
||||||
<target>[Stern auf GitHub](https://github.com/simplex-chat/simplex-chat)</target>
|
<target>[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="_italic_" xml:space="preserve">
|
<trans-unit id="_italic_" xml:space="preserve">
|
||||||
@@ -2916,6 +3076,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
|||||||
<target>Admin</target>
|
<target>Admin</target>
|
||||||
<note>member role</note>
|
<note>member role</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="always" xml:space="preserve">
|
||||||
|
<source>always</source>
|
||||||
|
<target>***always</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||||
<source>audio call (not e2e encrypted)</source>
|
<source>audio call (not e2e encrypted)</source>
|
||||||
<target>Audioanruf (nicht E2E verschlüsselt)</target>
|
<target>Audioanruf (nicht E2E verschlüsselt)</target>
|
||||||
@@ -3056,6 +3221,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
|||||||
<target>Ersteller</target>
|
<target>Ersteller</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="default (%@)" xml:space="preserve">
|
||||||
|
<source>default (%@)</source>
|
||||||
|
<target>***default (%@)</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="deleted" xml:space="preserve">
|
<trans-unit id="deleted" xml:space="preserve">
|
||||||
<source>deleted</source>
|
<source>deleted</source>
|
||||||
<target>Gelöscht</target>
|
<target>Gelöscht</target>
|
||||||
@@ -3206,11 +3376,26 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
|||||||
<target>Neue Nachricht</target>
|
<target>Neue Nachricht</target>
|
||||||
<note>notification</note>
|
<note>notification</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="no" xml:space="preserve">
|
||||||
|
<source>no</source>
|
||||||
|
<target>***no</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||||
<source>no e2e encryption</source>
|
<source>no e2e encryption</source>
|
||||||
<target>Keine E2E-Verschlüsselung</target>
|
<target>Keine E2E-Verschlüsselung</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="off" xml:space="preserve">
|
||||||
|
<source>off</source>
|
||||||
|
<target>***off</target>
|
||||||
|
<note>group pref value</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="on" xml:space="preserve">
|
||||||
|
<source>on</source>
|
||||||
|
<target>***on</target>
|
||||||
|
<note>group pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="or chat with the developers" xml:space="preserve">
|
<trans-unit id="or chat with the developers" xml:space="preserve">
|
||||||
<source>or chat with the developers</source>
|
<source>or chat with the developers</source>
|
||||||
<target>oder chatten Sie mit den Entwicklern</target>
|
<target>oder chatten Sie mit den Entwicklern</target>
|
||||||
@@ -3336,6 +3521,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
|||||||
<target>möchte sich mit Ihnen verbinden!</target>
|
<target>möchte sich mit Ihnen verbinden!</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="yes" xml:space="preserve">
|
||||||
|
<source>yes</source>
|
||||||
|
<target>***yes</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="you are invited to group" xml:space="preserve">
|
<trans-unit id="you are invited to group" xml:space="preserve">
|
||||||
<source>you are invited to group</source>
|
<source>you are invited to group</source>
|
||||||
<target>Sie sind zur Gruppe eingeladen</target>
|
<target>Sie sind zur Gruppe eingeladen</target>
|
||||||
|
|||||||
@@ -278,6 +278,36 @@
|
|||||||
<target>All your contacts will remain connected</target>
|
<target>All your contacts will remain connected</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||||
|
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||||
|
<target>Allow irreversible message deletion only if your contact allows it to you.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Allow to irreversibly delete sent messages.</source>
|
||||||
|
<target>Allow to irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow to send voice messages." xml:space="preserve">
|
||||||
|
<source>Allow to send voice messages.</source>
|
||||||
|
<target>Allow to send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
|
||||||
|
<source>Allow voice messages only if your contact allows them.</source>
|
||||||
|
<target>Allow voice messages only if your contact allows them.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Allow your contacts to irreversibly delete sent messages.</source>
|
||||||
|
<target>Allow your contacts to irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||||
|
<source>Allow your contacts to send voice messages.</source>
|
||||||
|
<target>Allow your contacts to send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Already connected?" xml:space="preserve">
|
<trans-unit id="Already connected?" xml:space="preserve">
|
||||||
<source>Already connected?</source>
|
<source>Already connected?</source>
|
||||||
<target>Already connected?</target>
|
<target>Already connected?</target>
|
||||||
@@ -328,6 +358,16 @@
|
|||||||
<target>Automatically</target>
|
<target>Automatically</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Both you and your contact can irreversibly delete sent messages.</source>
|
||||||
|
<target>Both you and your contact can irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||||
|
<source>Both you and your contact can send voice messages.</source>
|
||||||
|
<target>Both you and your contact can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||||
<source>Call already ended!</source>
|
<source>Call already ended!</source>
|
||||||
<target>Call already ended!</target>
|
<target>Call already ended!</target>
|
||||||
@@ -428,6 +468,11 @@
|
|||||||
<target>Chat is stopped</target>
|
<target>Chat is stopped</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Chat preferences" xml:space="preserve">
|
||||||
|
<source>Chat preferences</source>
|
||||||
|
<target>Chat preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Chats" xml:space="preserve">
|
<trans-unit id="Chats" xml:space="preserve">
|
||||||
<source>Chats</source>
|
<source>Chats</source>
|
||||||
<target>Chats</target>
|
<target>Chats</target>
|
||||||
@@ -558,6 +603,11 @@
|
|||||||
<target>Connection timeout</target>
|
<target>Connection timeout</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contact allows" xml:space="preserve">
|
||||||
|
<source>Contact allows</source>
|
||||||
|
<target>Contact allows</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Contact already exists" xml:space="preserve">
|
<trans-unit id="Contact already exists" xml:space="preserve">
|
||||||
<source>Contact already exists</source>
|
<source>Contact already exists</source>
|
||||||
<target>Contact already exists</target>
|
<target>Contact already exists</target>
|
||||||
@@ -588,11 +638,21 @@
|
|||||||
<target>Contact name</target>
|
<target>Contact name</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contact preferences" xml:space="preserve">
|
||||||
|
<source>Contact preferences</source>
|
||||||
|
<target>Contact preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Contact requests" xml:space="preserve">
|
<trans-unit id="Contact requests" xml:space="preserve">
|
||||||
<source>Contact requests</source>
|
<source>Contact requests</source>
|
||||||
<target>Contact requests</target>
|
<target>Contact requests</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
|
||||||
|
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
|
||||||
|
<target>Contacts can mark messages for deletion; you will be able to view them.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Copy" xml:space="preserve">
|
<trans-unit id="Copy" xml:space="preserve">
|
||||||
<source>Copy</source>
|
<source>Copy</source>
|
||||||
<target>Copy</target>
|
<target>Copy</target>
|
||||||
@@ -1221,6 +1281,11 @@
|
|||||||
<target>For console</target>
|
<target>For console</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Full deletion" xml:space="preserve">
|
||||||
|
<source>Full deletion</source>
|
||||||
|
<target>Full deletion</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Full name (optional)" xml:space="preserve">
|
<trans-unit id="Full name (optional)" xml:space="preserve">
|
||||||
<source>Full name (optional)</source>
|
<source>Full name (optional)</source>
|
||||||
<target>Full name (optional)</target>
|
<target>Full name (optional)</target>
|
||||||
@@ -1271,11 +1336,26 @@
|
|||||||
<target>Group link</target>
|
<target>Group link</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Group members can irreversibly delete sent messages.</source>
|
||||||
|
<target>Group members can irreversibly delete sent messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||||
|
<source>Group members can send voice messages.</source>
|
||||||
|
<target>Group members can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Group message:" xml:space="preserve">
|
<trans-unit id="Group message:" xml:space="preserve">
|
||||||
<source>Group message:</source>
|
<source>Group message:</source>
|
||||||
<target>Group message:</target>
|
<target>Group message:</target>
|
||||||
<note>notification</note>
|
<note>notification</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Group preferences" xml:space="preserve">
|
||||||
|
<source>Group preferences</source>
|
||||||
|
<target>Group preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
||||||
<source>Group profile is stored on members' devices, not on the servers.</source>
|
<source>Group profile is stored on members' devices, not on the servers.</source>
|
||||||
<target>Group profile is stored on members' devices, not on the servers.</target>
|
<target>Group profile is stored on members' devices, not on the servers.</target>
|
||||||
@@ -1453,6 +1533,11 @@
|
|||||||
<target>Invite to group</target>
|
<target>Invite to group</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||||
|
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||||
|
<target>Irreversible message deletion is prohibited in this chat.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||||
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
||||||
<target>It allows having many anonymous connections without any shared data between them in a single chat profile.</target>
|
<target>It allows having many anonymous connections without any shared data between them in a single chat profile.</target>
|
||||||
@@ -1768,6 +1853,31 @@ We will be adding server redundancy to prevent lost messages.</target>
|
|||||||
<target>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</target>
|
<target>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||||
|
<source>Only group owners can change group preferences.</source>
|
||||||
|
<target>Only group owners can change group preferences.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
|
||||||
|
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
|
||||||
|
<target>Only you can irreversibly delete messages (your contact can mark them for deletion).</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||||
|
<source>Only you can send voice messages.</source>
|
||||||
|
<target>Only you can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
|
||||||
|
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
|
||||||
|
<target>Only your contact can irreversibly delete messages (you can mark them for deletion).</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||||
|
<source>Only your contact can send voice messages.</source>
|
||||||
|
<target>Only your contact can send voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Open Settings" xml:space="preserve">
|
<trans-unit id="Open Settings" xml:space="preserve">
|
||||||
<source>Open Settings</source>
|
<source>Open Settings</source>
|
||||||
<target>Open Settings</target>
|
<target>Open Settings</target>
|
||||||
@@ -1858,6 +1968,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
|||||||
<target>Please store passphrase securely, you will NOT be able to change it if you lose it.</target>
|
<target>Please store passphrase securely, you will NOT be able to change it if you lose it.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Preferences" xml:space="preserve">
|
||||||
|
<source>Preferences</source>
|
||||||
|
<target>Preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||||
<source>Privacy & security</source>
|
<source>Privacy & security</source>
|
||||||
<target>Privacy & security</target>
|
<target>Privacy & security</target>
|
||||||
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</target>
|
|||||||
<target>Profile image</target>
|
<target>Profile image</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
|
||||||
|
<source>Prohibit irreversible message deletion.</source>
|
||||||
|
<target>Prohibit irreversible message deletion.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||||
|
<source>Prohibit sending voice messages.</source>
|
||||||
|
<target>Prohibit sending voice messages.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Protocol timeout" xml:space="preserve">
|
<trans-unit id="Protocol timeout" xml:space="preserve">
|
||||||
<source>Protocol timeout</source>
|
<source>Protocol timeout</source>
|
||||||
<target>Protocol timeout</target>
|
<target>Protocol timeout</target>
|
||||||
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
|||||||
<target>Required</target>
|
<target>Required</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Reset" xml:space="preserve">
|
||||||
|
<source>Reset</source>
|
||||||
|
<target>Reset</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Reset colors" xml:space="preserve">
|
<trans-unit id="Reset colors" xml:space="preserve">
|
||||||
<source>Reset colors</source>
|
<source>Reset colors</source>
|
||||||
<target>Reset colors</target>
|
<target>Reset colors</target>
|
||||||
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</target>
|
|||||||
<target>Save</target>
|
<target>Save</target>
|
||||||
<note>chat item action</note>
|
<note>chat item action</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Save (and notify contact)" xml:space="preserve">
|
||||||
|
<source>Save (and notify contact)</source>
|
||||||
|
<target>Save (and notify contact)</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
||||||
<source>Save (and notify contacts)</source>
|
<source>Save (and notify contacts)</source>
|
||||||
<target>Save (and notify contacts)</target>
|
<target>Save (and notify contacts)</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Save (and notify group members)" xml:space="preserve">
|
||||||
|
<source>Save (and notify group members)</source>
|
||||||
|
<target>Save (and notify group members)</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Save archive" xml:space="preserve">
|
<trans-unit id="Save archive" xml:space="preserve">
|
||||||
<source>Save archive</source>
|
<source>Save archive</source>
|
||||||
<target>Save archive</target>
|
<target>Save archive</target>
|
||||||
@@ -2572,6 +2712,16 @@ To connect, please ask your contact to create another connection link and check
|
|||||||
<target>Video call</target>
|
<target>Video call</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Voice messages" xml:space="preserve">
|
||||||
|
<source>Voice messages</source>
|
||||||
|
<target>Voice messages</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
|
||||||
|
<source>Voice messages are prohibited in this chat.</source>
|
||||||
|
<target>Voice messages are prohibited in this chat.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Waiting for file" xml:space="preserve">
|
<trans-unit id="Waiting for file" xml:space="preserve">
|
||||||
<source>Waiting for file</source>
|
<source>Waiting for file</source>
|
||||||
<target>Waiting for file</target>
|
<target>Waiting for file</target>
|
||||||
@@ -2627,6 +2777,11 @@ To connect, please ask your contact to create another connection link and check
|
|||||||
<target>You accepted connection</target>
|
<target>You accepted connection</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="You allow" xml:space="preserve">
|
||||||
|
<source>You allow</source>
|
||||||
|
<target>You allow</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
||||||
<source>You are already connected to %@.</source>
|
<source>You are already connected to %@.</source>
|
||||||
<target>You are already connected to %@.</target>
|
<target>You are already connected to %@.</target>
|
||||||
@@ -2844,6 +2999,11 @@ You can cancel this connection and remove the contact (and try later with a new
|
|||||||
<target>Your current chat database will be DELETED and REPLACED with the imported one.</target>
|
<target>Your current chat database will be DELETED and REPLACED with the imported one.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Your preferences" xml:space="preserve">
|
||||||
|
<source>Your preferences</source>
|
||||||
|
<target>Your preferences</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Your privacy" xml:space="preserve">
|
<trans-unit id="Your privacy" xml:space="preserve">
|
||||||
<source>Your privacy</source>
|
<source>Your privacy</source>
|
||||||
<target>Your privacy</target>
|
<target>Your privacy</target>
|
||||||
@@ -2916,6 +3076,11 @@ SimpleX servers cannot see your profile.</target>
|
|||||||
<target>admin</target>
|
<target>admin</target>
|
||||||
<note>member role</note>
|
<note>member role</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="always" xml:space="preserve">
|
||||||
|
<source>always</source>
|
||||||
|
<target>always</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||||
<source>audio call (not e2e encrypted)</source>
|
<source>audio call (not e2e encrypted)</source>
|
||||||
<target>audio call (not e2e encrypted)</target>
|
<target>audio call (not e2e encrypted)</target>
|
||||||
@@ -3056,6 +3221,11 @@ SimpleX servers cannot see your profile.</target>
|
|||||||
<target>creator</target>
|
<target>creator</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="default (%@)" xml:space="preserve">
|
||||||
|
<source>default (%@)</source>
|
||||||
|
<target>default (%@)</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="deleted" xml:space="preserve">
|
<trans-unit id="deleted" xml:space="preserve">
|
||||||
<source>deleted</source>
|
<source>deleted</source>
|
||||||
<target>deleted</target>
|
<target>deleted</target>
|
||||||
@@ -3206,11 +3376,26 @@ SimpleX servers cannot see your profile.</target>
|
|||||||
<target>new message</target>
|
<target>new message</target>
|
||||||
<note>notification</note>
|
<note>notification</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="no" xml:space="preserve">
|
||||||
|
<source>no</source>
|
||||||
|
<target>no</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||||
<source>no e2e encryption</source>
|
<source>no e2e encryption</source>
|
||||||
<target>no e2e encryption</target>
|
<target>no e2e encryption</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="off" xml:space="preserve">
|
||||||
|
<source>off</source>
|
||||||
|
<target>off</target>
|
||||||
|
<note>group pref value</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="on" xml:space="preserve">
|
||||||
|
<source>on</source>
|
||||||
|
<target>on</target>
|
||||||
|
<note>group pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="or chat with the developers" xml:space="preserve">
|
<trans-unit id="or chat with the developers" xml:space="preserve">
|
||||||
<source>or chat with the developers</source>
|
<source>or chat with the developers</source>
|
||||||
<target>or chat with the developers</target>
|
<target>or chat with the developers</target>
|
||||||
@@ -3336,6 +3521,11 @@ SimpleX servers cannot see your profile.</target>
|
|||||||
<target>wants to connect to you!</target>
|
<target>wants to connect to you!</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="yes" xml:space="preserve">
|
||||||
|
<source>yes</source>
|
||||||
|
<target>yes</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="you are invited to group" xml:space="preserve">
|
<trans-unit id="you are invited to group" xml:space="preserve">
|
||||||
<source>you are invited to group</source>
|
<source>you are invited to group</source>
|
||||||
<target>you are invited to group</target>
|
<target>you are invited to group</target>
|
||||||
|
|||||||
@@ -278,6 +278,36 @@
|
|||||||
<target>Все контакты, которые соединились через этот адрес, сохранятся.</target>
|
<target>Все контакты, которые соединились через этот адрес, сохранятся.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||||
|
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||||
|
<target>Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Allow to irreversibly delete sent messages.</source>
|
||||||
|
<target>Разрешить необратимо удалять отправленные сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow to send voice messages." xml:space="preserve">
|
||||||
|
<source>Allow to send voice messages.</source>
|
||||||
|
<target>Разрешить отправлять голосовые сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
|
||||||
|
<source>Allow voice messages only if your contact allows them.</source>
|
||||||
|
<target>Разрешить голосовые сообщения, только если их разрешает ваш контакт.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Allow your contacts to irreversibly delete sent messages.</source>
|
||||||
|
<target>Разрешить вашим контактам необратимо удалять отправленные сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||||
|
<source>Allow your contacts to send voice messages.</source>
|
||||||
|
<target>Разрешить вашим контактам отправлять голосовые сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Already connected?" xml:space="preserve">
|
<trans-unit id="Already connected?" xml:space="preserve">
|
||||||
<source>Already connected?</source>
|
<source>Already connected?</source>
|
||||||
<target>Соединение уже установлено?</target>
|
<target>Соединение уже установлено?</target>
|
||||||
@@ -328,6 +358,16 @@
|
|||||||
<target>Автоматически</target>
|
<target>Автоматически</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Both you and your contact can irreversibly delete sent messages.</source>
|
||||||
|
<target>Вы и ваш контакт можете необратимо удалять отправленные сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||||
|
<source>Both you and your contact can send voice messages.</source>
|
||||||
|
<target>Вы и ваш контакт можете отправлять голосовые сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||||
<source>Call already ended!</source>
|
<source>Call already ended!</source>
|
||||||
<target>Звонок уже завершен!</target>
|
<target>Звонок уже завершен!</target>
|
||||||
@@ -428,6 +468,11 @@
|
|||||||
<target>Чат остановлен</target>
|
<target>Чат остановлен</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Chat preferences" xml:space="preserve">
|
||||||
|
<source>Chat preferences</source>
|
||||||
|
<target>Предпочтения</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Chats" xml:space="preserve">
|
<trans-unit id="Chats" xml:space="preserve">
|
||||||
<source>Chats</source>
|
<source>Chats</source>
|
||||||
<target>Чаты</target>
|
<target>Чаты</target>
|
||||||
@@ -558,6 +603,11 @@
|
|||||||
<target>Превышено время соединения</target>
|
<target>Превышено время соединения</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contact allows" xml:space="preserve">
|
||||||
|
<source>Contact allows</source>
|
||||||
|
<target>Контакт разрешает</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Contact already exists" xml:space="preserve">
|
<trans-unit id="Contact already exists" xml:space="preserve">
|
||||||
<source>Contact already exists</source>
|
<source>Contact already exists</source>
|
||||||
<target>Существующий контакт</target>
|
<target>Существующий контакт</target>
|
||||||
@@ -588,11 +638,21 @@
|
|||||||
<target>Имена контактов</target>
|
<target>Имена контактов</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contact preferences" xml:space="preserve">
|
||||||
|
<source>Contact preferences</source>
|
||||||
|
<target>Предпочтения контакта</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Contact requests" xml:space="preserve">
|
<trans-unit id="Contact requests" xml:space="preserve">
|
||||||
<source>Contact requests</source>
|
<source>Contact requests</source>
|
||||||
<target>Запросы контактов</target>
|
<target>Запросы контактов</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
|
||||||
|
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
|
||||||
|
<target>Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Copy" xml:space="preserve">
|
<trans-unit id="Copy" xml:space="preserve">
|
||||||
<source>Copy</source>
|
<source>Copy</source>
|
||||||
<target>Скопировать</target>
|
<target>Скопировать</target>
|
||||||
@@ -1221,6 +1281,11 @@
|
|||||||
<target>Для консоли</target>
|
<target>Для консоли</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Full deletion" xml:space="preserve">
|
||||||
|
<source>Full deletion</source>
|
||||||
|
<target>Полное удаление</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Full name (optional)" xml:space="preserve">
|
<trans-unit id="Full name (optional)" xml:space="preserve">
|
||||||
<source>Full name (optional)</source>
|
<source>Full name (optional)</source>
|
||||||
<target>Полное имя (не обязательно)</target>
|
<target>Полное имя (не обязательно)</target>
|
||||||
@@ -1271,11 +1336,26 @@
|
|||||||
<target>Ссылка группы</target>
|
<target>Ссылка группы</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||||
|
<source>Group members can irreversibly delete sent messages.</source>
|
||||||
|
<target>Члены группы могут необратимо удалять отправленные сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||||
|
<source>Group members can send voice messages.</source>
|
||||||
|
<target>Члены группы могут отправлять голосовые сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Group message:" xml:space="preserve">
|
<trans-unit id="Group message:" xml:space="preserve">
|
||||||
<source>Group message:</source>
|
<source>Group message:</source>
|
||||||
<target>Групповое сообщение:</target>
|
<target>Групповое сообщение:</target>
|
||||||
<note>notification</note>
|
<note>notification</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Group preferences" xml:space="preserve">
|
||||||
|
<source>Group preferences</source>
|
||||||
|
<target>Предпочтения группы</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
||||||
<source>Group profile is stored on members' devices, not on the servers.</source>
|
<source>Group profile is stored on members' devices, not on the servers.</source>
|
||||||
<target>Профиль группы хранится на устройствах членов, а не на серверах.</target>
|
<target>Профиль группы хранится на устройствах членов, а не на серверах.</target>
|
||||||
@@ -1453,6 +1533,11 @@
|
|||||||
<target>Пригласить в группу</target>
|
<target>Пригласить в группу</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||||
|
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||||
|
<target>Необратимое удаление сообщений запрещено в этом чате.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||||
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
||||||
<target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target>
|
<target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target>
|
||||||
@@ -1768,6 +1853,31 @@ We will be adding server redundancy to prevent lost messages.</source>
|
|||||||
<target>Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**</target>
|
<target>Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||||
|
<source>Only group owners can change group preferences.</source>
|
||||||
|
<target>Только владельцы группы могут изменять предпочтения группы.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
|
||||||
|
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
|
||||||
|
<target>Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||||
|
<source>Only you can send voice messages.</source>
|
||||||
|
<target>Только вы можете отправлять голосовые сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
|
||||||
|
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
|
||||||
|
<target>Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||||
|
<source>Only your contact can send voice messages.</source>
|
||||||
|
<target>Только ваш контакт может отправлять голосовые сообщения.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Open Settings" xml:space="preserve">
|
<trans-unit id="Open Settings" xml:space="preserve">
|
||||||
<source>Open Settings</source>
|
<source>Open Settings</source>
|
||||||
<target>Открыть Настройки</target>
|
<target>Открыть Настройки</target>
|
||||||
@@ -1858,6 +1968,11 @@ We will be adding server redundancy to prevent lost messages.</source>
|
|||||||
<target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</target>
|
<target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Preferences" xml:space="preserve">
|
||||||
|
<source>Preferences</source>
|
||||||
|
<target>Предпочтения</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||||
<source>Privacy & security</source>
|
<source>Privacy & security</source>
|
||||||
<target>Конфиденциальность</target>
|
<target>Конфиденциальность</target>
|
||||||
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</source>
|
|||||||
<target>Аватар</target>
|
<target>Аватар</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
|
||||||
|
<source>Prohibit irreversible message deletion.</source>
|
||||||
|
<target>Запретить необратимое удаление сообщений.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||||
|
<source>Prohibit sending voice messages.</source>
|
||||||
|
<target>Запретить отправлять голосовые сообщений.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Protocol timeout" xml:space="preserve">
|
<trans-unit id="Protocol timeout" xml:space="preserve">
|
||||||
<source>Protocol timeout</source>
|
<source>Protocol timeout</source>
|
||||||
<target>Таймаут протокола</target>
|
<target>Таймаут протокола</target>
|
||||||
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</source>
|
|||||||
<target>Обязательно</target>
|
<target>Обязательно</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Reset" xml:space="preserve">
|
||||||
|
<source>Reset</source>
|
||||||
|
<target>Сбросить</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Reset colors" xml:space="preserve">
|
<trans-unit id="Reset colors" xml:space="preserve">
|
||||||
<source>Reset colors</source>
|
<source>Reset colors</source>
|
||||||
<target>Сбросить цвета</target>
|
<target>Сбросить цвета</target>
|
||||||
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</source>
|
|||||||
<target>Сохранить</target>
|
<target>Сохранить</target>
|
||||||
<note>chat item action</note>
|
<note>chat item action</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Save (and notify contact)" xml:space="preserve">
|
||||||
|
<source>Save (and notify contact)</source>
|
||||||
|
<target>Сохранить (и уведомить контакт)</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
||||||
<source>Save (and notify contacts)</source>
|
<source>Save (and notify contacts)</source>
|
||||||
<target>Сохранить (и уведомить контакты)</target>
|
<target>Сохранить (и уведомить контакты)</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Save (and notify group members)" xml:space="preserve">
|
||||||
|
<source>Save (and notify group members)</source>
|
||||||
|
<target>Сохранить (и уведомить членов группы)</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Save archive" xml:space="preserve">
|
<trans-unit id="Save archive" xml:space="preserve">
|
||||||
<source>Save archive</source>
|
<source>Save archive</source>
|
||||||
<target>Сохранить архив</target>
|
<target>Сохранить архив</target>
|
||||||
@@ -2572,6 +2712,16 @@ To connect, please ask your contact to create another connection link and check
|
|||||||
<target>Видеозвонок</target>
|
<target>Видеозвонок</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Voice messages" xml:space="preserve">
|
||||||
|
<source>Voice messages</source>
|
||||||
|
<target>Голосовые сообщения</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
|
||||||
|
<source>Voice messages are prohibited in this chat.</source>
|
||||||
|
<target>Голосовые сообщения запрещены в этом чате.</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Waiting for file" xml:space="preserve">
|
<trans-unit id="Waiting for file" xml:space="preserve">
|
||||||
<source>Waiting for file</source>
|
<source>Waiting for file</source>
|
||||||
<target>Ожидается прием файла</target>
|
<target>Ожидается прием файла</target>
|
||||||
@@ -2627,6 +2777,11 @@ To connect, please ask your contact to create another connection link and check
|
|||||||
<target>Вы приняли приглашение соединиться</target>
|
<target>Вы приняли приглашение соединиться</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="You allow" xml:space="preserve">
|
||||||
|
<source>You allow</source>
|
||||||
|
<target>Вы разрешаете</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
||||||
<source>You are already connected to %@.</source>
|
<source>You are already connected to %@.</source>
|
||||||
<target>Вы уже соединены с контактом %@.</target>
|
<target>Вы уже соединены с контактом %@.</target>
|
||||||
@@ -2844,6 +2999,11 @@ You can cancel this connection and remove the contact (and try later with a new
|
|||||||
<target>Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.</target>
|
<target>Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Your preferences" xml:space="preserve">
|
||||||
|
<source>Your preferences</source>
|
||||||
|
<target>Ваши предпочтения</target>
|
||||||
|
<note>No comment provided by engineer.</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Your privacy" xml:space="preserve">
|
<trans-unit id="Your privacy" xml:space="preserve">
|
||||||
<source>Your privacy</source>
|
<source>Your privacy</source>
|
||||||
<target>Конфиденциальность</target>
|
<target>Конфиденциальность</target>
|
||||||
@@ -2916,6 +3076,11 @@ SimpleX серверы не могут получить доступ к ваше
|
|||||||
<target>админ</target>
|
<target>админ</target>
|
||||||
<note>member role</note>
|
<note>member role</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="always" xml:space="preserve">
|
||||||
|
<source>always</source>
|
||||||
|
<target>всегда</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||||
<source>audio call (not e2e encrypted)</source>
|
<source>audio call (not e2e encrypted)</source>
|
||||||
<target>аудиозвонок (не e2e зашифрованный)</target>
|
<target>аудиозвонок (не e2e зашифрованный)</target>
|
||||||
@@ -3056,6 +3221,11 @@ SimpleX серверы не могут получить доступ к ваше
|
|||||||
<target>создатель</target>
|
<target>создатель</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="default (%@)" xml:space="preserve">
|
||||||
|
<source>default (%@)</source>
|
||||||
|
<target>по умолчанию (%@)</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="deleted" xml:space="preserve">
|
<trans-unit id="deleted" xml:space="preserve">
|
||||||
<source>deleted</source>
|
<source>deleted</source>
|
||||||
<target>удалено</target>
|
<target>удалено</target>
|
||||||
@@ -3206,11 +3376,26 @@ SimpleX серверы не могут получить доступ к ваше
|
|||||||
<target>новое сообщение</target>
|
<target>новое сообщение</target>
|
||||||
<note>notification</note>
|
<note>notification</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="no" xml:space="preserve">
|
||||||
|
<source>no</source>
|
||||||
|
<target>нет</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||||
<source>no e2e encryption</source>
|
<source>no e2e encryption</source>
|
||||||
<target>нет e2e шифрования</target>
|
<target>нет e2e шифрования</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="off" xml:space="preserve">
|
||||||
|
<source>off</source>
|
||||||
|
<target>нет</target>
|
||||||
|
<note>group pref value</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="on" xml:space="preserve">
|
||||||
|
<source>on</source>
|
||||||
|
<target>да</target>
|
||||||
|
<note>group pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="or chat with the developers" xml:space="preserve">
|
<trans-unit id="or chat with the developers" xml:space="preserve">
|
||||||
<source>or chat with the developers</source>
|
<source>or chat with the developers</source>
|
||||||
<target>или соединитесь с разработчиками</target>
|
<target>или соединитесь с разработчиками</target>
|
||||||
@@ -3336,6 +3521,11 @@ SimpleX серверы не могут получить доступ к ваше
|
|||||||
<target>хочет соединиться с вами!</target>
|
<target>хочет соединиться с вами!</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="yes" xml:space="preserve">
|
||||||
|
<source>yes</source>
|
||||||
|
<target>да</target>
|
||||||
|
<note>pref value</note>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="you are invited to group" xml:space="preserve">
|
<trans-unit id="you are invited to group" xml:space="preserve">
|
||||||
<source>you are invited to group</source>
|
<source>you are invited to group</source>
|
||||||
<target>вы приглашены в группу</target>
|
<target>вы приглашены в группу</target>
|
||||||
|
|||||||
@@ -53,6 +53,13 @@
|
|||||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
|
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
|
||||||
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
|
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
|
||||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
|
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
|
||||||
|
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* SMPServersView.swift */; };
|
||||||
|
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* SMPServerView.swift */; };
|
||||||
|
5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293229241CD90090FFF9 /* libffi.a */; };
|
||||||
|
5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */; };
|
||||||
|
5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293429241CD90090FFF9 /* libgmpxx.a */; };
|
||||||
|
5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */; };
|
||||||
|
5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293629241CDA0090FFF9 /* libgmp.a */; };
|
||||||
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
|
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
|
||||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
|
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
|
||||||
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; };
|
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; };
|
||||||
@@ -68,6 +75,8 @@
|
|||||||
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; };
|
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; };
|
||||||
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; };
|
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; };
|
||||||
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; };
|
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; };
|
||||||
|
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; };
|
||||||
|
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; };
|
||||||
5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; };
|
5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; };
|
||||||
5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; };
|
5CB0BA8B2826CB3A00B3292C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA892826CB3A00B3292C /* Localizable.strings */; };
|
||||||
5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8D2827126500B3292C /* OnboardingView.swift */; };
|
5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA8D2827126500B3292C /* OnboardingView.swift */; };
|
||||||
@@ -120,11 +129,7 @@
|
|||||||
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||||
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||||
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
|
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
|
||||||
64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */; };
|
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
|
||||||
6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328565291CDEF200FBE5C8 /* libgmpxx.a */; };
|
|
||||||
6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */; };
|
|
||||||
6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328567291CDEF200FBE5C8 /* libffi.a */; };
|
|
||||||
6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328568291CDEF200FBE5C8 /* libgmp.a */; };
|
|
||||||
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
|
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
|
||||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
|
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
|
||||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
|
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
|
||||||
@@ -248,6 +253,13 @@
|
|||||||
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
|
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
|
||||||
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
|
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
|
||||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
|
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
|
||||||
|
5C93292E29239A170090FFF9 /* SMPServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServersView.swift; sourceTree = "<group>"; };
|
||||||
|
5C93293029239BED0090FFF9 /* SMPServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServerView.swift; sourceTree = "<group>"; };
|
||||||
|
5C93293229241CD90090FFF9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||||
|
5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||||
|
5C93293429241CD90090FFF9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||||
|
5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = "<group>"; };
|
||||||
|
5C93293629241CDA0090FFF9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||||
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
|
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
|
||||||
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
|
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
|
||||||
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = "<group>"; };
|
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = "<group>"; };
|
||||||
@@ -267,6 +279,8 @@
|
|||||||
5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = "<group>"; };
|
5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = "<group>"; };
|
||||||
5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = "<group>"; };
|
5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = "<group>"; };
|
||||||
|
5CADE79929211BB900072E13 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
|
||||||
|
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPreferencesView.swift; sourceTree = "<group>"; };
|
||||||
5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
5CB0BA8A2826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
5CB0BA8D2827126500B3292C /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
5CB0BA8D2827126500B3292C /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
@@ -319,11 +333,7 @@
|
|||||||
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
|
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
|
||||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||||
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
||||||
64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a"; sourceTree = "<group>"; };
|
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
|
||||||
64328565291CDEF200FBE5C8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
|
||||||
64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a"; sourceTree = "<group>"; };
|
|
||||||
64328567291CDEF200FBE5C8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
|
||||||
64328568291CDEF200FBE5C8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
|
||||||
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
|
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
|
||||||
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
|
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
|
||||||
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
|
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
|
||||||
@@ -374,13 +384,13 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */,
|
|
||||||
6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */,
|
|
||||||
6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */,
|
|
||||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||||
|
5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */,
|
||||||
|
5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */,
|
||||||
|
5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */,
|
||||||
|
5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */,
|
||||||
|
5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */,
|
||||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||||
64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */,
|
|
||||||
6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -428,6 +438,7 @@
|
|||||||
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */,
|
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */,
|
||||||
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */,
|
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */,
|
||||||
5CE4407127ADB1D0007B033A /* Emoji.swift */,
|
5CE4407127ADB1D0007B033A /* Emoji.swift */,
|
||||||
|
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
|
||||||
);
|
);
|
||||||
path = Chat;
|
path = Chat;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -435,11 +446,11 @@
|
|||||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
64328567291CDEF200FBE5C8 /* libffi.a */,
|
5C93293229241CD90090FFF9 /* libffi.a */,
|
||||||
64328568291CDEF200FBE5C8 /* libgmp.a */,
|
5C93293629241CDA0090FFF9 /* libgmp.a */,
|
||||||
64328565291CDEF200FBE5C8 /* libgmpxx.a */,
|
5C93293429241CD90090FFF9 /* libgmpxx.a */,
|
||||||
64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */,
|
5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */,
|
||||||
64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */,
|
5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */,
|
||||||
);
|
);
|
||||||
path = Libraries;
|
path = Libraries;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -571,6 +582,7 @@
|
|||||||
5CB346E62868D76D001FD2EF /* NotificationsView.swift */,
|
5CB346E62868D76D001FD2EF /* NotificationsView.swift */,
|
||||||
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
|
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
|
||||||
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
|
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
|
||||||
|
5CADE79929211BB900072E13 /* PreferencesView.swift */,
|
||||||
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */,
|
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */,
|
||||||
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
|
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
|
||||||
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
|
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
|
||||||
@@ -579,6 +591,8 @@
|
|||||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
||||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||||
640F50E227CF991C001E05C2 /* SMPServers.swift */,
|
640F50E227CF991C001E05C2 /* SMPServers.swift */,
|
||||||
|
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
|
||||||
|
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
|
||||||
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
|
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
|
||||||
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
|
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
|
||||||
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
|
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
|
||||||
@@ -680,6 +694,7 @@
|
|||||||
6440CA01288AEC770062C672 /* Group */ = {
|
6440CA01288AEC770062C672 /* Group */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */,
|
||||||
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */,
|
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */,
|
||||||
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */,
|
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */,
|
||||||
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */,
|
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */,
|
||||||
@@ -885,12 +900,15 @@
|
|||||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
|
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
|
||||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
||||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
|
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
|
||||||
|
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */,
|
||||||
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
||||||
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
|
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
|
||||||
5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */,
|
5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */,
|
||||||
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
|
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
|
||||||
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
|
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
|
||||||
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
|
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
|
||||||
|
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
|
||||||
|
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */,
|
||||||
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
|
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
|
||||||
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
|
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
|
||||||
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
|
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
|
||||||
@@ -956,6 +974,7 @@
|
|||||||
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */,
|
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */,
|
||||||
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
|
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
|
||||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
|
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
|
||||||
|
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
|
||||||
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
|
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
|
||||||
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
|
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
|
||||||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
|
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
|
||||||
@@ -978,6 +997,7 @@
|
|||||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
|
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
|
||||||
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
|
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
|
||||||
|
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public enum ChatCommand {
|
|||||||
case apiGetGroupLink(groupId: Int64)
|
case apiGetGroupLink(groupId: Int64)
|
||||||
case getUserSMPServers
|
case getUserSMPServers
|
||||||
case setUserSMPServers(smpServers: [String])
|
case setUserSMPServers(smpServers: [String])
|
||||||
|
case testSMPServer(smpServer: String)
|
||||||
case apiSetChatItemTTL(seconds: Int64?)
|
case apiSetChatItemTTL(seconds: Int64?)
|
||||||
case apiGetChatItemTTL
|
case apiGetChatItemTTL
|
||||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||||
@@ -63,6 +64,7 @@ public enum ChatCommand {
|
|||||||
case apiClearChat(type: ChatType, id: Int64)
|
case apiClearChat(type: ChatType, id: Int64)
|
||||||
case listContacts
|
case listContacts
|
||||||
case apiUpdateProfile(profile: Profile)
|
case apiUpdateProfile(profile: Profile)
|
||||||
|
case apiSetContactPrefs(contactId: Int64, preferences: Preferences)
|
||||||
case apiSetContactAlias(contactId: Int64, localAlias: String)
|
case apiSetContactAlias(contactId: Int64, localAlias: String)
|
||||||
case apiSetConnectionAlias(connId: Int64, localAlias: String)
|
case apiSetConnectionAlias(connId: Int64, localAlias: String)
|
||||||
case createMyAddress
|
case createMyAddress
|
||||||
@@ -124,8 +126,9 @@ public enum ChatCommand {
|
|||||||
case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)"
|
case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)"
|
||||||
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
|
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
|
||||||
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
|
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
|
||||||
case .getUserSMPServers: return "/smp_servers"
|
case .getUserSMPServers: return "/smp"
|
||||||
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
|
case let .setUserSMPServers(smpServers): return "/smp \(smpServersStr(smpServers: smpServers))"
|
||||||
|
case let .testSMPServer(smpServer): return "/smp test \(smpServer)"
|
||||||
case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))"
|
case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))"
|
||||||
case .apiGetChatItemTTL: return "/ttl"
|
case .apiGetChatItemTTL: return "/ttl"
|
||||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||||
@@ -141,6 +144,7 @@ public enum ChatCommand {
|
|||||||
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
|
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
|
||||||
case .listContacts: return "/contacts"
|
case .listContacts: return "/contacts"
|
||||||
case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
|
case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
|
||||||
|
case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
|
||||||
case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||||
case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||||
case .createMyAddress: return "/address"
|
case .createMyAddress: return "/address"
|
||||||
@@ -203,6 +207,7 @@ public enum ChatCommand {
|
|||||||
case .apiGetGroupLink: return "apiGetGroupLink"
|
case .apiGetGroupLink: return "apiGetGroupLink"
|
||||||
case .getUserSMPServers: return "getUserSMPServers"
|
case .getUserSMPServers: return "getUserSMPServers"
|
||||||
case .setUserSMPServers: return "setUserSMPServers"
|
case .setUserSMPServers: return "setUserSMPServers"
|
||||||
|
case .testSMPServer: return "testSMPServer"
|
||||||
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
||||||
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
||||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||||
@@ -218,6 +223,7 @@ public enum ChatCommand {
|
|||||||
case .apiClearChat: return "apiClearChat"
|
case .apiClearChat: return "apiClearChat"
|
||||||
case .listContacts: return "listContacts"
|
case .listContacts: return "listContacts"
|
||||||
case .apiUpdateProfile: return "apiUpdateProfile"
|
case .apiUpdateProfile: return "apiUpdateProfile"
|
||||||
|
case .apiSetContactPrefs: return "apiSetContactPrefs"
|
||||||
case .apiSetContactAlias: return "apiSetContactAlias"
|
case .apiSetContactAlias: return "apiSetContactAlias"
|
||||||
case .apiSetConnectionAlias: return "apiSetConnectionAlias"
|
case .apiSetConnectionAlias: return "apiSetConnectionAlias"
|
||||||
case .createMyAddress: return "createMyAddress"
|
case .createMyAddress: return "createMyAddress"
|
||||||
@@ -288,7 +294,8 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case chatSuspended
|
case chatSuspended
|
||||||
case apiChats(chats: [ChatData])
|
case apiChats(chats: [ChatData])
|
||||||
case apiChat(chat: ChatData)
|
case apiChat(chat: ChatData)
|
||||||
case userSMPServers(smpServers: [String])
|
case userSMPServers(smpServers: [ServerCfg], presetSMPServers: [String])
|
||||||
|
case sMPTestResult(smpTestFailure: SMPTestFailure?)
|
||||||
case chatItemTTL(chatItemTTL: Int64?)
|
case chatItemTTL(chatItemTTL: Int64?)
|
||||||
case networkConfig(networkConfig: NetCfg)
|
case networkConfig(networkConfig: NetCfg)
|
||||||
case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
|
case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
|
||||||
@@ -303,6 +310,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
|
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
|
||||||
case contactAliasUpdated(toContact: Contact)
|
case contactAliasUpdated(toContact: Contact)
|
||||||
case connectionAliasUpdated(toConnection: PendingContactConnection)
|
case connectionAliasUpdated(toConnection: PendingContactConnection)
|
||||||
|
case contactPrefsUpdated(fromContact: Contact, toContact: Contact)
|
||||||
case userContactLink(contactLink: UserContactLink)
|
case userContactLink(contactLink: UserContactLink)
|
||||||
case userContactLinkUpdated(contactLink: UserContactLink)
|
case userContactLinkUpdated(contactLink: UserContactLink)
|
||||||
case userContactLinkCreated(connReqContact: String)
|
case userContactLinkCreated(connReqContact: String)
|
||||||
@@ -390,6 +398,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case .apiChats: return "apiChats"
|
case .apiChats: return "apiChats"
|
||||||
case .apiChat: return "apiChat"
|
case .apiChat: return "apiChat"
|
||||||
case .userSMPServers: return "userSMPServers"
|
case .userSMPServers: return "userSMPServers"
|
||||||
|
case .sMPTestResult: return "smpTestResult"
|
||||||
case .chatItemTTL: return "chatItemTTL"
|
case .chatItemTTL: return "chatItemTTL"
|
||||||
case .networkConfig: return "networkConfig"
|
case .networkConfig: return "networkConfig"
|
||||||
case .contactInfo: return "contactInfo"
|
case .contactInfo: return "contactInfo"
|
||||||
@@ -404,6 +413,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case .userProfileUpdated: return "userProfileUpdated"
|
case .userProfileUpdated: return "userProfileUpdated"
|
||||||
case .contactAliasUpdated: return "contactAliasUpdated"
|
case .contactAliasUpdated: return "contactAliasUpdated"
|
||||||
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
||||||
|
case .contactPrefsUpdated: return "contactPrefsUpdated"
|
||||||
case .userContactLink: return "userContactLink"
|
case .userContactLink: return "userContactLink"
|
||||||
case .userContactLinkUpdated: return "userContactLinkUpdated"
|
case .userContactLinkUpdated: return "userContactLinkUpdated"
|
||||||
case .userContactLinkCreated: return "userContactLinkCreated"
|
case .userContactLinkCreated: return "userContactLinkCreated"
|
||||||
@@ -490,7 +500,8 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case .chatSuspended: return noDetails
|
case .chatSuspended: return noDetails
|
||||||
case let .apiChats(chats): return String(describing: chats)
|
case let .apiChats(chats): return String(describing: chats)
|
||||||
case let .apiChat(chat): return String(describing: chat)
|
case let .apiChat(chat): return String(describing: chat)
|
||||||
case let .userSMPServers(smpServers): return String(describing: smpServers)
|
case let .userSMPServers(smpServers, _): return String(describing: smpServers)
|
||||||
|
case let .sMPTestResult(smpTestFailure): return String(describing: smpTestFailure)
|
||||||
case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL)
|
case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL)
|
||||||
case let .networkConfig(networkConfig): return String(describing: networkConfig)
|
case let .networkConfig(networkConfig): return String(describing: networkConfig)
|
||||||
case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))"
|
case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))"
|
||||||
@@ -505,6 +516,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
|
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
|
||||||
case let .contactAliasUpdated(toContact): return String(describing: toContact)
|
case let .contactAliasUpdated(toContact): return String(describing: toContact)
|
||||||
case let .connectionAliasUpdated(toConnection): return String(describing: toConnection)
|
case let .connectionAliasUpdated(toConnection): return String(describing: toConnection)
|
||||||
|
case let .contactPrefsUpdated(fromContact, toContact): return "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))"
|
||||||
case let .userContactLink(contactLink): return contactLink.responseDetails
|
case let .userContactLink(contactLink): return contactLink.responseDetails
|
||||||
case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails
|
case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails
|
||||||
case let .userContactLinkCreated(connReq): return connReq
|
case let .userContactLinkCreated(connReq): return connReq
|
||||||
@@ -613,7 +625,7 @@ public struct ArchiveConfig: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct DBEncryptionConfig: Encodable {
|
public struct DBEncryptionConfig: Codable {
|
||||||
public init(currentKey: String, newKey: String) {
|
public init(currentKey: String, newKey: String) {
|
||||||
self.currentKey = currentKey
|
self.currentKey = currentKey
|
||||||
self.newKey = newKey
|
self.newKey = newKey
|
||||||
@@ -623,6 +635,121 @@ public struct DBEncryptionConfig: Encodable {
|
|||||||
public var newKey: String
|
public var newKey: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ServerCfg: Identifiable, Decodable {
|
||||||
|
public var server: String
|
||||||
|
public var preset: Bool
|
||||||
|
public var tested: Bool?
|
||||||
|
public var enabled: Bool
|
||||||
|
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
|
||||||
|
// Even if we don't see the use case, it's probably better to allow it in the model
|
||||||
|
// In any case, "trusted/known" servers are out of scope of this change
|
||||||
|
|
||||||
|
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
|
||||||
|
self.server = server
|
||||||
|
self.preset = preset
|
||||||
|
self.tested = tested
|
||||||
|
self.enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String { server }
|
||||||
|
|
||||||
|
public static var empty = ServerCfg(server: "", preset: false, tested: false, enabled: true)
|
||||||
|
|
||||||
|
public struct SampleData {
|
||||||
|
public var preset: ServerCfg
|
||||||
|
public var custom: ServerCfg
|
||||||
|
public var untested: ServerCfg
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var sampleData = SampleData(
|
||||||
|
preset: ServerCfg(
|
||||||
|
server: "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
||||||
|
preset: true,
|
||||||
|
tested: true,
|
||||||
|
enabled: true
|
||||||
|
),
|
||||||
|
custom: ServerCfg(
|
||||||
|
server: "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
|
||||||
|
preset: false,
|
||||||
|
tested: false,
|
||||||
|
enabled: false
|
||||||
|
),
|
||||||
|
untested: ServerCfg(
|
||||||
|
server: "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
|
||||||
|
preset: false,
|
||||||
|
tested: nil,
|
||||||
|
enabled: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SMPTestStep: String, Decodable {
|
||||||
|
case connect
|
||||||
|
case createQueue
|
||||||
|
case secureQueue
|
||||||
|
case deleteQueue
|
||||||
|
case disconnect
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
switch self {
|
||||||
|
case .connect: return NSLocalizedString("Connect", comment: "server test step")
|
||||||
|
case .createQueue: return NSLocalizedString("Create queue", comment: "server test step")
|
||||||
|
case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step")
|
||||||
|
case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step")
|
||||||
|
case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SMPTestFailure: Decodable, Error {
|
||||||
|
var testStep: SMPTestStep
|
||||||
|
var testError: AgentErrorType
|
||||||
|
|
||||||
|
var localizedDescription: String {
|
||||||
|
let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@", comment: "server test failure"), testStep.text)
|
||||||
|
switch testError {
|
||||||
|
case .SMP(.AUTH):
|
||||||
|
return err + "," + NSLocalizedString("Server requires authentication to create queues, check password", comment: "server test error")
|
||||||
|
case .BROKER(.NETWORK):
|
||||||
|
return err + "," + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error")
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ServerAddress {
|
||||||
|
public var hostnames: [String]
|
||||||
|
public var port: String
|
||||||
|
public var keyHash: String
|
||||||
|
public var basicAuth: String
|
||||||
|
|
||||||
|
public init(hostnames: [String], port: String, keyHash: String, basicAuth: String = "") {
|
||||||
|
self.hostnames = hostnames
|
||||||
|
self.port = port
|
||||||
|
self.keyHash = keyHash
|
||||||
|
self.basicAuth = basicAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
public var uri: String {
|
||||||
|
"smp://\(keyHash)\(basicAuth == "" ? "" : ":" + basicAuth)@\(hostnames.joined(separator: ","))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static public var empty = ServerAddress(
|
||||||
|
hostnames: [],
|
||||||
|
port: "",
|
||||||
|
keyHash: "",
|
||||||
|
basicAuth: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
static public var sampleData = ServerAddress(
|
||||||
|
hostnames: ["smp.simplex.im", "1234.onion"],
|
||||||
|
port: "",
|
||||||
|
keyHash: "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
|
||||||
|
basicAuth: "server_password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public struct NetCfg: Codable, Equatable {
|
public struct NetCfg: Codable, Equatable {
|
||||||
public var socksProxy: String? = nil
|
public var socksProxy: String? = nil
|
||||||
public var hostMode: HostMode = .publicHost
|
public var hostMode: HostMode = .publicHost
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public struct User: Decodable, NamedChat {
|
|||||||
var userContactId: Int64
|
var userContactId: Int64
|
||||||
var localDisplayName: ContactName
|
var localDisplayName: ContactName
|
||||||
public var profile: LocalProfile
|
public var profile: LocalProfile
|
||||||
|
public var fullPreferences: FullPreferences
|
||||||
var activeUser: Bool
|
var activeUser: Bool
|
||||||
|
|
||||||
public var displayName: String { get { profile.displayName } }
|
public var displayName: String { get { profile.displayName } }
|
||||||
@@ -26,6 +27,7 @@ public struct User: Decodable, NamedChat {
|
|||||||
userContactId: 1,
|
userContactId: 1,
|
||||||
localDisplayName: "alice",
|
localDisplayName: "alice",
|
||||||
profile: LocalProfile.sampleData,
|
profile: LocalProfile.sampleData,
|
||||||
|
fullPreferences: FullPreferences.sampleData,
|
||||||
activeUser: true
|
activeUser: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -35,15 +37,17 @@ public typealias ContactName = String
|
|||||||
public typealias GroupName = String
|
public typealias GroupName = String
|
||||||
|
|
||||||
public struct Profile: Codable, NamedChat {
|
public struct Profile: Codable, NamedChat {
|
||||||
public init(displayName: String, fullName: String, image: String? = nil) {
|
public init(displayName: String, fullName: String, image: String? = nil, preferences: Preferences? = nil) {
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.fullName = fullName
|
self.fullName = fullName
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.preferences = preferences
|
||||||
}
|
}
|
||||||
|
|
||||||
public var displayName: String
|
public var displayName: String
|
||||||
public var fullName: String
|
public var fullName: String
|
||||||
public var image: String?
|
public var image: String?
|
||||||
|
public var preferences: Preferences?
|
||||||
public var localAlias: String { get { "" } }
|
public var localAlias: String { get { "" } }
|
||||||
|
|
||||||
var profileViewName: String {
|
var profileViewName: String {
|
||||||
@@ -57,11 +61,12 @@ public struct Profile: Codable, NamedChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct LocalProfile: Codable, NamedChat {
|
public struct LocalProfile: Codable, NamedChat {
|
||||||
public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, localAlias: String) {
|
public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, preferences: Preferences? = nil, localAlias: String) {
|
||||||
self.profileId = profileId
|
self.profileId = profileId
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.fullName = fullName
|
self.fullName = fullName
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.preferences = preferences
|
||||||
self.localAlias = localAlias
|
self.localAlias = localAlias
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@ public struct LocalProfile: Codable, NamedChat {
|
|||||||
public var displayName: String
|
public var displayName: String
|
||||||
public var fullName: String
|
public var fullName: String
|
||||||
public var image: String?
|
public var image: String?
|
||||||
|
public var preferences: Preferences?
|
||||||
public var localAlias: String
|
public var localAlias: String
|
||||||
|
|
||||||
var profileViewName: String {
|
var profileViewName: String {
|
||||||
@@ -81,6 +87,7 @@ public struct LocalProfile: Codable, NamedChat {
|
|||||||
profileId: 1,
|
profileId: 1,
|
||||||
displayName: "alice",
|
displayName: "alice",
|
||||||
fullName: "Alice",
|
fullName: "Alice",
|
||||||
|
preferences: Preferences.sampleData,
|
||||||
localAlias: ""
|
localAlias: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,6 +124,344 @@ extension NamedChat {
|
|||||||
|
|
||||||
public typealias ChatId = String
|
public typealias ChatId = String
|
||||||
|
|
||||||
|
public struct FullPreferences: Decodable, Equatable {
|
||||||
|
public var fullDelete: Preference
|
||||||
|
public var voice: Preference
|
||||||
|
|
||||||
|
public init(fullDelete: Preference, voice: Preference) {
|
||||||
|
self.fullDelete = fullDelete
|
||||||
|
self.voice = voice
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let sampleData = FullPreferences(fullDelete: Preference(allow: .no), voice: Preference(allow: .yes))
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Preferences: Codable {
|
||||||
|
public var fullDelete: Preference?
|
||||||
|
public var voice: Preference?
|
||||||
|
|
||||||
|
public init(fullDelete: Preference?, voice: Preference?) {
|
||||||
|
self.fullDelete = fullDelete
|
||||||
|
self.voice = voice
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let sampleData = Preferences(fullDelete: Preference(allow: .no), voice: Preference(allow: .yes))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func toPreferences(_ fullPreferences: FullPreferences) -> Preferences {
|
||||||
|
Preferences(fullDelete: fullPreferences.fullDelete, voice: fullPreferences.voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Preference: Codable, Equatable {
|
||||||
|
public var allow: FeatureAllowed
|
||||||
|
|
||||||
|
public init(allow: FeatureAllowed) {
|
||||||
|
self.allow = allow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ContactUserPreferences: Decodable {
|
||||||
|
public var fullDelete: ContactUserPreference
|
||||||
|
public var voice: ContactUserPreference
|
||||||
|
|
||||||
|
public init(fullDelete: ContactUserPreference, voice: ContactUserPreference) {
|
||||||
|
self.fullDelete = fullDelete
|
||||||
|
self.voice = voice
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let sampleData = ContactUserPreferences(
|
||||||
|
fullDelete: ContactUserPreference(
|
||||||
|
enabled: FeatureEnabled(forUser: false, forContact: false),
|
||||||
|
userPreference: .user(preference: Preference(allow: .no)),
|
||||||
|
contactPreference: Preference(allow: .no)
|
||||||
|
),
|
||||||
|
voice: ContactUserPreference(
|
||||||
|
enabled: FeatureEnabled(forUser: true, forContact: true),
|
||||||
|
userPreference: .user(preference: Preference(allow: .yes)),
|
||||||
|
contactPreference: Preference(allow: .yes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ContactUserPreference: Decodable {
|
||||||
|
public var enabled: FeatureEnabled
|
||||||
|
public var userPreference: ContactUserPref
|
||||||
|
public var contactPreference: Preference
|
||||||
|
|
||||||
|
public init(enabled: FeatureEnabled, userPreference: ContactUserPref, contactPreference: Preference) {
|
||||||
|
self.enabled = enabled
|
||||||
|
self.userPreference = userPreference
|
||||||
|
self.contactPreference = contactPreference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FeatureEnabled: Decodable {
|
||||||
|
public var forUser: Bool
|
||||||
|
public var forContact: Bool
|
||||||
|
|
||||||
|
public init(forUser: Bool, forContact: Bool) {
|
||||||
|
self.forUser = forUser
|
||||||
|
self.forContact = forContact
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func enabled(user: Preference, contact: Preference) -> FeatureEnabled {
|
||||||
|
switch (user.allow, contact.allow) {
|
||||||
|
case (.always, .no): return FeatureEnabled(forUser: false, forContact: true)
|
||||||
|
case (.no, .always): return FeatureEnabled(forUser: true, forContact: false)
|
||||||
|
case (_, .no): return FeatureEnabled(forUser: false, forContact: false)
|
||||||
|
case (.no, _): return FeatureEnabled(forUser: false, forContact: false)
|
||||||
|
default: return FeatureEnabled(forUser: true, forContact: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ContactUserPref: Decodable {
|
||||||
|
case contact(preference: Preference) // contact override is set
|
||||||
|
case user(preference: Preference) // global user default is used
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Feature {
|
||||||
|
case fullDelete
|
||||||
|
case voice
|
||||||
|
|
||||||
|
public var values: [Feature] { [.fullDelete, .voice] }
|
||||||
|
|
||||||
|
public var id: Self { self }
|
||||||
|
|
||||||
|
public var text: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .fullDelete: return "Full deletion"
|
||||||
|
case .voice: return "Voice messages"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .fullDelete: return "trash.slash"
|
||||||
|
case .voice: return "speaker.wave.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func allowDescription(_ allowed: FeatureAllowed) -> LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .fullDelete:
|
||||||
|
switch allowed {
|
||||||
|
case .always: return "Allow your contacts to irreversibly delete sent messages."
|
||||||
|
case .yes: return "Allow irreversible message deletion only if your contact allows it to you."
|
||||||
|
case .no: return "Contacts can mark messages for deletion; you will be able to view them."
|
||||||
|
}
|
||||||
|
case .voice:
|
||||||
|
switch allowed {
|
||||||
|
case .always: return "Allow your contacts to send voice messages."
|
||||||
|
case .yes: return "Allow voice messages only if your contact allows them."
|
||||||
|
case .no: return "Prohibit sending voice messages."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func enabledDescription(_ enabled: FeatureEnabled) -> LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .fullDelete:
|
||||||
|
return enabled.forUser && enabled.forContact
|
||||||
|
? "Both you and your contact can irreversibly delete sent messages."
|
||||||
|
: enabled.forUser
|
||||||
|
? "Only you can irreversibly delete messages (your contact can mark them for deletion)."
|
||||||
|
: enabled.forContact
|
||||||
|
? "Only your contact can irreversibly delete messages (you can mark them for deletion)."
|
||||||
|
: "Irreversible message deletion is prohibited in this chat."
|
||||||
|
case .voice:
|
||||||
|
return enabled.forUser && enabled.forContact
|
||||||
|
? "Both you and your contact can send voice messages."
|
||||||
|
: enabled.forUser
|
||||||
|
? "Only you can send voice messages."
|
||||||
|
: enabled.forContact
|
||||||
|
? "Only your contact can send voice messages."
|
||||||
|
: "Voice messages are prohibited in this chat."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func enableGroupPrefDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool) -> LocalizedStringKey {
|
||||||
|
if canEdit {
|
||||||
|
switch self {
|
||||||
|
case .fullDelete:
|
||||||
|
switch enabled {
|
||||||
|
case .on: return "Allow to irreversibly delete sent messages."
|
||||||
|
case .off: return "Prohibit irreversible message deletion."
|
||||||
|
}
|
||||||
|
case .voice:
|
||||||
|
switch enabled {
|
||||||
|
case .on: return "Allow to send voice messages."
|
||||||
|
case .off: return "Prohibit sending voice messages."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch self {
|
||||||
|
case .fullDelete:
|
||||||
|
switch enabled {
|
||||||
|
case .on: return "Group members can irreversibly delete sent messages."
|
||||||
|
case .off: return "Irreversible message deletion is prohibited in this chat."
|
||||||
|
}
|
||||||
|
case .voice:
|
||||||
|
switch enabled {
|
||||||
|
case .on: return "Group members can send voice messages."
|
||||||
|
case .off: return "Voice messages are prohibited in this chat."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ContactFeatureAllowed: Identifiable, Hashable {
|
||||||
|
case userDefault(FeatureAllowed)
|
||||||
|
case always
|
||||||
|
case yes
|
||||||
|
case no
|
||||||
|
|
||||||
|
public static func values(_ def: FeatureAllowed) -> [ContactFeatureAllowed] {
|
||||||
|
[.userDefault(def) , .always, .yes, .no]
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: Self { self }
|
||||||
|
|
||||||
|
public var allowed: FeatureAllowed {
|
||||||
|
switch self {
|
||||||
|
case let .userDefault(def): return def
|
||||||
|
case .always: return .always
|
||||||
|
case .yes: return .yes
|
||||||
|
case .no: return .no
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var text: String {
|
||||||
|
switch self {
|
||||||
|
case let .userDefault(def): return String.localizedStringWithFormat(NSLocalizedString("default (%@)", comment: "pref value"), def.text)
|
||||||
|
case .always: return NSLocalizedString("always", comment: "pref value")
|
||||||
|
case .yes: return NSLocalizedString("yes", comment: "pref value")
|
||||||
|
case .no: return NSLocalizedString("no", comment: "pref value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ContactFeaturesAllowed: Equatable {
|
||||||
|
public var fullDelete: ContactFeatureAllowed
|
||||||
|
public var voice: ContactFeatureAllowed
|
||||||
|
|
||||||
|
public init(fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) {
|
||||||
|
self.fullDelete = fullDelete
|
||||||
|
self.voice = voice
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let sampleData = ContactFeaturesAllowed(
|
||||||
|
fullDelete: ContactFeatureAllowed.userDefault(.no),
|
||||||
|
voice: ContactFeatureAllowed.userDefault(.yes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactUserPreferences) -> ContactFeaturesAllowed {
|
||||||
|
ContactFeaturesAllowed(
|
||||||
|
fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
|
||||||
|
voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference) -> ContactFeatureAllowed {
|
||||||
|
switch contactUserPreference.userPreference {
|
||||||
|
case let .user(preference): return .userDefault(preference.allow)
|
||||||
|
case let .contact(preference):
|
||||||
|
switch preference.allow {
|
||||||
|
case .always: return .always
|
||||||
|
case .yes: return .yes
|
||||||
|
case .no: return .no
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeaturesAllowed) -> Preferences {
|
||||||
|
Preferences(
|
||||||
|
fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
|
||||||
|
voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> Preference? {
|
||||||
|
switch contactFeatureAllowed {
|
||||||
|
case .userDefault: return nil
|
||||||
|
case .always: return Preference(allow: .always)
|
||||||
|
case .yes: return Preference(allow: .yes)
|
||||||
|
case .no: return Preference(allow: .no)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FeatureAllowed: String, Codable, Identifiable {
|
||||||
|
case always
|
||||||
|
case yes
|
||||||
|
case no
|
||||||
|
|
||||||
|
public static var values: [FeatureAllowed] { [.always, .yes, .no] }
|
||||||
|
|
||||||
|
public var id: Self { self }
|
||||||
|
|
||||||
|
public var text: String {
|
||||||
|
switch self {
|
||||||
|
case .always: return NSLocalizedString("always", comment: "pref value")
|
||||||
|
case .yes: return NSLocalizedString("yes", comment: "pref value")
|
||||||
|
case .no: return NSLocalizedString("no", comment: "pref value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FullGroupPreferences: Decodable, Equatable {
|
||||||
|
public var fullDelete: GroupPreference
|
||||||
|
public var voice: GroupPreference
|
||||||
|
|
||||||
|
public init(fullDelete: GroupPreference, voice: GroupPreference) {
|
||||||
|
self.fullDelete = fullDelete
|
||||||
|
self.voice = voice
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let sampleData = FullGroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GroupPreferences: Codable {
|
||||||
|
public var fullDelete: GroupPreference?
|
||||||
|
public var voice: GroupPreference?
|
||||||
|
|
||||||
|
public init(fullDelete: GroupPreference?, voice: GroupPreference?) {
|
||||||
|
self.fullDelete = fullDelete
|
||||||
|
self.voice = voice
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let sampleData = GroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> GroupPreferences {
|
||||||
|
GroupPreferences(fullDelete: fullPreferences.fullDelete, voice: fullPreferences.voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GroupPreference: Codable, Equatable {
|
||||||
|
public var enable: GroupFeatureEnabled
|
||||||
|
|
||||||
|
public init(enable: GroupFeatureEnabled) {
|
||||||
|
self.enable = enable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GroupFeatureEnabled: String, Codable, Identifiable {
|
||||||
|
case on
|
||||||
|
case off
|
||||||
|
|
||||||
|
public static var values: [GroupFeatureEnabled] { [.on, .off] }
|
||||||
|
|
||||||
|
public var id: Self { self }
|
||||||
|
|
||||||
|
public var text: String {
|
||||||
|
switch self {
|
||||||
|
case .on: return NSLocalizedString("on", comment: "group pref value")
|
||||||
|
case .off: return NSLocalizedString("off", comment: "group pref value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||||
case direct(contact: Contact)
|
case direct(contact: Contact)
|
||||||
case group(groupInfo: GroupInfo)
|
case group(groupInfo: GroupInfo)
|
||||||
@@ -321,6 +666,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
|||||||
public var activeConn: Connection
|
public var activeConn: Connection
|
||||||
public var viaGroup: Int64?
|
public var viaGroup: Int64?
|
||||||
public var chatSettings: ChatSettings
|
public var chatSettings: ChatSettings
|
||||||
|
public var userPreferences: Preferences
|
||||||
|
public var mergedPreferences: ContactUserPreferences
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
|
||||||
@@ -351,6 +698,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
|||||||
profile: LocalProfile.sampleData,
|
profile: LocalProfile.sampleData,
|
||||||
activeConn: Connection.sampleData,
|
activeConn: Connection.sampleData,
|
||||||
chatSettings: ChatSettings.defaults,
|
chatSettings: ChatSettings.defaults,
|
||||||
|
userPreferences: Preferences.sampleData,
|
||||||
|
mergedPreferences: ContactUserPreferences.sampleData,
|
||||||
createdAt: .now,
|
createdAt: .now,
|
||||||
updatedAt: .now
|
updatedAt: .now
|
||||||
)
|
)
|
||||||
@@ -556,6 +905,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
|||||||
public var groupId: Int64
|
public var groupId: Int64
|
||||||
var localDisplayName: GroupName
|
var localDisplayName: GroupName
|
||||||
public var groupProfile: GroupProfile
|
public var groupProfile: GroupProfile
|
||||||
|
public var fullGroupPreferences: FullGroupPreferences
|
||||||
public var membership: GroupMember
|
public var membership: GroupMember
|
||||||
public var hostConnCustomUserProfileId: Int64?
|
public var hostConnCustomUserProfileId: Int64?
|
||||||
public var chatSettings: ChatSettings
|
public var chatSettings: ChatSettings
|
||||||
@@ -587,6 +937,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
|||||||
groupId: 1,
|
groupId: 1,
|
||||||
localDisplayName: "team",
|
localDisplayName: "team",
|
||||||
groupProfile: GroupProfile.sampleData,
|
groupProfile: GroupProfile.sampleData,
|
||||||
|
fullGroupPreferences: FullGroupPreferences.sampleData,
|
||||||
membership: GroupMember.sampleData,
|
membership: GroupMember.sampleData,
|
||||||
hostConnCustomUserProfileId: nil,
|
hostConnCustomUserProfileId: nil,
|
||||||
chatSettings: ChatSettings.defaults,
|
chatSettings: ChatSettings.defaults,
|
||||||
@@ -596,15 +947,17 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct GroupProfile: Codable, NamedChat {
|
public struct GroupProfile: Codable, NamedChat {
|
||||||
public init(displayName: String, fullName: String, image: String? = nil) {
|
public init(displayName: String, fullName: String, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.fullName = fullName
|
self.fullName = fullName
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.groupPreferences = groupPreferences
|
||||||
}
|
}
|
||||||
|
|
||||||
public var displayName: String
|
public var displayName: String
|
||||||
public var fullName: String
|
public var fullName: String
|
||||||
public var image: String?
|
public var image: String?
|
||||||
|
public var groupPreferences: GroupPreferences?
|
||||||
public var localAlias: String { "" }
|
public var localAlias: String { "" }
|
||||||
|
|
||||||
public static let sampleData = GroupProfile(
|
public static let sampleData = GroupProfile(
|
||||||
@@ -1561,6 +1914,8 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
|
|||||||
case seconds(_ seconds: Int64)
|
case seconds(_ seconds: Int64)
|
||||||
case none
|
case none
|
||||||
|
|
||||||
|
public static var values: [ChatItemTTL] { [.none, .month, .week, .day] }
|
||||||
|
|
||||||
public var id: Self { self }
|
public var id: Self { self }
|
||||||
|
|
||||||
public init(_ seconds: Int64?) {
|
public init(_ seconds: Int64?) {
|
||||||
|
|||||||
@@ -29,13 +29,13 @@
|
|||||||
")" = ")";
|
")" = ")";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Beitragen](https://github.com/simplex-chat/simplex-chat#contribute)";
|
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"[Send us email](mailto:chat@simplex.chat)" = "[Senden Sie uns eine E-Mail](mailto:chat@simplex.chat)";
|
"[Send us email](mailto:chat@simplex.chat)" = "[Senden Sie uns eine E-Mail](mailto:chat@simplex.chat)";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub](https://github.com/simplex-chat/simplex-chat)";
|
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Fügen Sie einen neuen Kontakt hinzu**: Erzeugen Sie einen Einmal-QR-Code oder -Link für Ihren Kontakt.";
|
"**Add new contact**: to create your one-time QR Code for your contact." = "**Fügen Sie einen neuen Kontakt hinzu**: Erzeugen Sie einen Einmal-QR-Code oder -Link für Ihren Kontakt.";
|
||||||
@@ -188,9 +188,30 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"All your contacts will remain connected" = "Alle Ihre Kontakte bleiben verbunden.";
|
"All your contacts will remain connected" = "Alle Ihre Kontakte bleiben verbunden.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow irreversible message deletion only if your contact allows it to you." = "***Allow irreversible message deletion only if your contact allows it to you.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow to irreversibly delete sent messages." = "***Allow to irreversibly delete sent messages.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow to send voice messages." = "***Allow to send voice messages.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow voice messages only if your contact allows them." = "***Allow voice messages only if your contact allows them.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow your contacts to irreversibly delete sent messages." = "***Allow your contacts to irreversibly delete sent messages.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow your contacts to send voice messages." = "***Allow your contacts to send voice messages.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Already connected?" = "Sind Sie bereits verbunden?";
|
"Already connected?" = "Sind Sie bereits verbunden?";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"always" = "***always";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Answer call" = "Anruf annehmen";
|
"Answer call" = "Anruf annehmen";
|
||||||
|
|
||||||
@@ -230,6 +251,12 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"bold" = "fett";
|
"bold" = "fett";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Both you and your contact can irreversibly delete sent messages." = "***Both you and your contact can irreversibly delete sent messages.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Both you and your contact can send voice messages." = "***Both you and your contact can send voice messages.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Call already ended!" = "Anruf ist bereits beendet!";
|
"Call already ended!" = "Anruf ist bereits beendet!";
|
||||||
|
|
||||||
@@ -314,6 +341,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Chat is stopped" = "Der Chat ist beendet";
|
"Chat is stopped" = "Der Chat ist beendet";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Chat preferences" = "***Chat preferences";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Chats" = "Chats";
|
"Chats" = "Chats";
|
||||||
|
|
||||||
@@ -431,6 +461,9 @@
|
|||||||
/* connection information */
|
/* connection information */
|
||||||
"connection:%@" = "Verbindung:%@";
|
"connection:%@" = "Verbindung:%@";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Contact allows" = "***Contact allows";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Contact already exists" = "Der Kontakt ist bereits vorhanden";
|
"Contact already exists" = "Der Kontakt ist bereits vorhanden";
|
||||||
|
|
||||||
@@ -455,9 +488,15 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Contact name" = "Kontaktname";
|
"Contact name" = "Kontaktname";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Contact preferences" = "***Contact preferences";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Contact requests" = "Kontaktanfragen";
|
"Contact requests" = "Kontaktanfragen";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Contacts can mark messages for deletion; you will be able to view them." = "***Contacts can mark messages for deletion; you will be able to view them.";
|
||||||
|
|
||||||
/* chat item action */
|
/* chat item action */
|
||||||
"Copy" = "Kopieren";
|
"Copy" = "Kopieren";
|
||||||
|
|
||||||
@@ -542,6 +581,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Decentralized" = "Dezentral";
|
"Decentralized" = "Dezentral";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"default (%@)" = "***default (%@)";
|
||||||
|
|
||||||
/* chat item action */
|
/* chat item action */
|
||||||
"Delete" = "Löschen";
|
"Delete" = "Löschen";
|
||||||
|
|
||||||
@@ -857,6 +899,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"For console" = "Für Konsole";
|
"For console" = "Für Konsole";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Full deletion" = "***Full deletion";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Full name (optional)" = "Vollständiger Name (optional)";
|
"Full name (optional)" = "Vollständiger Name (optional)";
|
||||||
|
|
||||||
@@ -890,9 +935,18 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Group link" = "Gruppen-Link";
|
"Group link" = "Gruppen-Link";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Group members can irreversibly delete sent messages." = "***Group members can irreversibly delete sent messages.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Group members can send voice messages." = "***Group members can send voice messages.";
|
||||||
|
|
||||||
/* notification */
|
/* notification */
|
||||||
"Group message:" = "Grppennachricht:";
|
"Group message:" = "Grppennachricht:";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Group preferences" = "***Group preferences";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Group profile is stored on members' devices, not on the servers." = "Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.";
|
"Group profile is stored on members' devices, not on the servers." = "Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.";
|
||||||
|
|
||||||
@@ -1034,6 +1088,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Für die sichere Speicherung des Passworts nach dem Neustart der App und dem Wechsel des Passworts wird der iOS Schlüsselbund verwendet - dies erlaubt den Empfang von Push-Benachrichtigungen.";
|
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Für die sichere Speicherung des Passworts nach dem Neustart der App und dem Wechsel des Passworts wird der iOS Schlüsselbund verwendet - dies erlaubt den Empfang von Push-Benachrichtigungen.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Irreversible message deletion is prohibited in this chat." = "***Irreversible message deletion is prohibited in this chat.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.";
|
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.";
|
||||||
|
|
||||||
@@ -1193,6 +1250,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"New passphrase…" = "Neues Passwort…";
|
"New passphrase…" = "Neues Passwort…";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"no" = "***no";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"No" = "Nein";
|
"No" = "Nein";
|
||||||
|
|
||||||
@@ -1220,6 +1280,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!";
|
"Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!";
|
||||||
|
|
||||||
|
/* group pref value */
|
||||||
|
"off" = "***off";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Off (Local)" = "Aus (Lokal)";
|
"Off (Local)" = "Aus (Lokal)";
|
||||||
|
|
||||||
@@ -1232,6 +1295,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Old database archive" = "Altes Datenbankarchiv";
|
"Old database archive" = "Altes Datenbankarchiv";
|
||||||
|
|
||||||
|
/* group pref value */
|
||||||
|
"on" = "***on";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"One-time invitation link" = "Einmal-Einladungslink";
|
"One-time invitation link" = "Einmal-Einladungslink";
|
||||||
|
|
||||||
@@ -1247,6 +1313,21 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.";
|
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only group owners can change group preferences." = "***Only group owners can change group preferences.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "***Only you can irreversibly delete messages (your contact can mark them for deletion).";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only you can send voice messages." = "***Only you can send voice messages.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "***Only your contact can irreversibly delete messages (you can mark them for deletion).";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only your contact can send voice messages." = "***Only your contact can send voice messages.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Open chat" = "Chat öffnen";
|
"Open chat" = "Chat öffnen";
|
||||||
|
|
||||||
@@ -1310,6 +1391,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.";
|
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Preferences" = "***Preferences";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Privacy & security" = "Datenschutz & Sicherheit";
|
"Privacy & security" = "Datenschutz & Sicherheit";
|
||||||
|
|
||||||
@@ -1319,6 +1403,12 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Profile image" = "Profilbild";
|
"Profile image" = "Profilbild";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Prohibit irreversible message deletion." = "***Prohibit irreversible message deletion.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Prohibit sending voice messages." = "***Prohibit sending voice messages.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Protocol timeout" = "Protokollzeitüberschreitung";
|
"Protocol timeout" = "Protokollzeitüberschreitung";
|
||||||
|
|
||||||
@@ -1326,7 +1416,7 @@
|
|||||||
"Push notifications" = "Push-Benachrichtigungen";
|
"Push notifications" = "Push-Benachrichtigungen";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Rate the app" = "Bewerte die App";
|
"Rate the app" = "Bewerten Sie die App";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Read" = "Lesen";
|
"Read" = "Lesen";
|
||||||
@@ -1394,6 +1484,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Required" = "Erforderlich";
|
"Required" = "Erforderlich";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Reset" = "***Reset";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Reset colors" = "Farben zurücksetzen";
|
"Reset colors" = "Farben zurücksetzen";
|
||||||
|
|
||||||
@@ -1430,9 +1523,15 @@
|
|||||||
/* chat item action */
|
/* chat item action */
|
||||||
"Save" = "Speichern";
|
"Save" = "Speichern";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Save (and notify contact)" = "***Save (and notify contact)";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)";
|
"Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Save (and notify group members)" = "***Save (and notify group members)";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Save archive" = "Archiv speichern";
|
"Save archive" = "Archiv speichern";
|
||||||
|
|
||||||
@@ -1578,7 +1677,7 @@
|
|||||||
"strike" = "durchstreichen";
|
"strike" = "durchstreichen";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Support SimpleX Chat" = "Unterstützen SimpleX Chat";
|
"Support SimpleX Chat" = "Unterstützung von SimpleX Chat";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"System" = "System";
|
"System" = "System";
|
||||||
@@ -1665,7 +1764,7 @@
|
|||||||
"this contact" = "Dieser Kontakt";
|
"this contact" = "Dieser Kontakt";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)." = "Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.";
|
"This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)." = "Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"This group no longer exists." = "Diese Gruppe existiert nicht mehr.";
|
"This group no longer exists." = "Diese Gruppe existiert nicht mehr.";
|
||||||
@@ -1793,6 +1892,12 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"video call (not e2e encrypted)" = "Videoanruf (nicht E2E verschlüsselt)";
|
"video call (not e2e encrypted)" = "Videoanruf (nicht E2E verschlüsselt)";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Voice messages" = "***Voice messages";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Voice messages are prohibited in this chat." = "***Voice messages are prohibited in this chat.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"waiting for answer…" = "Warten auf Antwort…";
|
"waiting for answer…" = "Warten auf Antwort…";
|
||||||
|
|
||||||
@@ -1829,12 +1934,18 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Wrong passphrase!" = "Falsches Passwort!";
|
"Wrong passphrase!" = "Falsches Passwort!";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"yes" = "***yes";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"You" = "Meine Daten";
|
"You" = "Meine Daten";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"You accepted connection" = "Sie haben die Verbindung akzeptiert";
|
"You accepted connection" = "Sie haben die Verbindung akzeptiert";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"You allow" = "***You allow";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"You are already connected to %@." = "Sie sind bereits mit %@ verbunden.";
|
"You are already connected to %@." = "Sie sind bereits mit %@ verbunden.";
|
||||||
|
|
||||||
@@ -1988,6 +2099,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Your ICE servers" = "Ihre ICE-Server";
|
"Your ICE servers" = "Ihre ICE-Server";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Your preferences" = "***Your preferences";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Your privacy" = "Meine Privatsphäre";
|
"Your privacy" = "Meine Privatsphäre";
|
||||||
|
|
||||||
|
|||||||
@@ -188,9 +188,30 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся.";
|
"All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow irreversible message deletion only if your contact allows it to you." = "Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow to irreversibly delete sent messages." = "Разрешить необратимо удалять отправленные сообщения.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow to send voice messages." = "Разрешить отправлять голосовые сообщения.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow voice messages only if your contact allows them." = "Разрешить голосовые сообщения, только если их разрешает ваш контакт.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow your contacts to irreversibly delete sent messages." = "Разрешить вашим контактам необратимо удалять отправленные сообщения.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Allow your contacts to send voice messages." = "Разрешить вашим контактам отправлять голосовые сообщения.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Already connected?" = "Соединение уже установлено?";
|
"Already connected?" = "Соединение уже установлено?";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"always" = "всегда";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Answer call" = "Принять звонок";
|
"Answer call" = "Принять звонок";
|
||||||
|
|
||||||
@@ -230,6 +251,12 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"bold" = "жирный";
|
"bold" = "жирный";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Both you and your contact can irreversibly delete sent messages." = "Вы и ваш контакт можете необратимо удалять отправленные сообщения.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Both you and your contact can send voice messages." = "Вы и ваш контакт можете отправлять голосовые сообщения.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Call already ended!" = "Звонок уже завершен!";
|
"Call already ended!" = "Звонок уже завершен!";
|
||||||
|
|
||||||
@@ -314,6 +341,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Chat is stopped" = "Чат остановлен";
|
"Chat is stopped" = "Чат остановлен";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Chat preferences" = "Предпочтения";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Chats" = "Чаты";
|
"Chats" = "Чаты";
|
||||||
|
|
||||||
@@ -431,6 +461,9 @@
|
|||||||
/* connection information */
|
/* connection information */
|
||||||
"connection:%@" = "connection:%@";
|
"connection:%@" = "connection:%@";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Contact allows" = "Контакт разрешает";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Contact already exists" = "Существующий контакт";
|
"Contact already exists" = "Существующий контакт";
|
||||||
|
|
||||||
@@ -455,9 +488,15 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Contact name" = "Имена контактов";
|
"Contact name" = "Имена контактов";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Contact preferences" = "Предпочтения контакта";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Contact requests" = "Запросы контактов";
|
"Contact requests" = "Запросы контактов";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Contacts can mark messages for deletion; you will be able to view them." = "Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.";
|
||||||
|
|
||||||
/* chat item action */
|
/* chat item action */
|
||||||
"Copy" = "Скопировать";
|
"Copy" = "Скопировать";
|
||||||
|
|
||||||
@@ -542,6 +581,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Decentralized" = "Децентрализованный";
|
"Decentralized" = "Децентрализованный";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"default (%@)" = "по умолчанию (%@)";
|
||||||
|
|
||||||
/* chat item action */
|
/* chat item action */
|
||||||
"Delete" = "Удалить";
|
"Delete" = "Удалить";
|
||||||
|
|
||||||
@@ -857,6 +899,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"For console" = "Для консоли";
|
"For console" = "Для консоли";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Full deletion" = "Полное удаление";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Full name (optional)" = "Полное имя (не обязательно)";
|
"Full name (optional)" = "Полное имя (не обязательно)";
|
||||||
|
|
||||||
@@ -890,9 +935,18 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Group link" = "Ссылка группы";
|
"Group link" = "Ссылка группы";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Group members can irreversibly delete sent messages." = "Члены группы могут необратимо удалять отправленные сообщения.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Group members can send voice messages." = "Члены группы могут отправлять голосовые сообщения.";
|
||||||
|
|
||||||
/* notification */
|
/* notification */
|
||||||
"Group message:" = "Групповое сообщение:";
|
"Group message:" = "Групповое сообщение:";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Group preferences" = "Предпочтения группы";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах.";
|
"Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах.";
|
||||||
|
|
||||||
@@ -1034,6 +1088,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления.";
|
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Irreversible message deletion is prohibited in this chat." = "Необратимое удаление сообщений запрещено в этом чате.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.";
|
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.";
|
||||||
|
|
||||||
@@ -1193,6 +1250,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"New passphrase…" = "Новый пароль…";
|
"New passphrase…" = "Новый пароль…";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"no" = "нет";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"No" = "Нет";
|
"No" = "Нет";
|
||||||
|
|
||||||
@@ -1220,6 +1280,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Notifications are disabled!" = "Уведомления выключены";
|
"Notifications are disabled!" = "Уведомления выключены";
|
||||||
|
|
||||||
|
/* group pref value */
|
||||||
|
"off" = "нет";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Off (Local)" = "Выключить (Локальные)";
|
"Off (Local)" = "Выключить (Локальные)";
|
||||||
|
|
||||||
@@ -1232,6 +1295,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Old database archive" = "Старый архив чата";
|
"Old database archive" = "Старый архив чата";
|
||||||
|
|
||||||
|
/* group pref value */
|
||||||
|
"on" = "да";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"One-time invitation link" = "Одноразовая ссылка";
|
"One-time invitation link" = "Одноразовая ссылка";
|
||||||
|
|
||||||
@@ -1247,6 +1313,21 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**";
|
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only group owners can change group preferences." = "Только владельцы группы могут изменять предпочтения группы.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only you can send voice messages." = "Только вы можете отправлять голосовые сообщения.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Only your contact can send voice messages." = "Только ваш контакт может отправлять голосовые сообщения.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Open chat" = "Открыть чат";
|
"Open chat" = "Открыть чат";
|
||||||
|
|
||||||
@@ -1310,6 +1391,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.";
|
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Preferences" = "Предпочтения";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Privacy & security" = "Конфиденциальность";
|
"Privacy & security" = "Конфиденциальность";
|
||||||
|
|
||||||
@@ -1319,6 +1403,12 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Profile image" = "Аватар";
|
"Profile image" = "Аватар";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Prohibit irreversible message deletion." = "Запретить необратимое удаление сообщений.";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Prohibit sending voice messages." = "Запретить отправлять голосовые сообщений.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Protocol timeout" = "Таймаут протокола";
|
"Protocol timeout" = "Таймаут протокола";
|
||||||
|
|
||||||
@@ -1394,6 +1484,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Required" = "Обязательно";
|
"Required" = "Обязательно";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Reset" = "Сбросить";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Reset colors" = "Сбросить цвета";
|
"Reset colors" = "Сбросить цвета";
|
||||||
|
|
||||||
@@ -1430,9 +1523,15 @@
|
|||||||
/* chat item action */
|
/* chat item action */
|
||||||
"Save" = "Сохранить";
|
"Save" = "Сохранить";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Save (and notify contact)" = "Сохранить (и уведомить контакт)";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Save (and notify contacts)" = "Сохранить (и уведомить контакты)";
|
"Save (and notify contacts)" = "Сохранить (и уведомить контакты)";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Save (and notify group members)" = "Сохранить (и уведомить членов группы)";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Save archive" = "Сохранить архив";
|
"Save archive" = "Сохранить архив";
|
||||||
|
|
||||||
@@ -1793,6 +1892,12 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)";
|
"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Voice messages" = "Голосовые сообщения";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Voice messages are prohibited in this chat." = "Голосовые сообщения запрещены в этом чате.";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"waiting for answer…" = "ожидается ответ…";
|
"waiting for answer…" = "ожидается ответ…";
|
||||||
|
|
||||||
@@ -1829,12 +1934,18 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Wrong passphrase!" = "Неправильный пароль!";
|
"Wrong passphrase!" = "Неправильный пароль!";
|
||||||
|
|
||||||
|
/* pref value */
|
||||||
|
"yes" = "да";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"You" = "Вы";
|
"You" = "Вы";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"You accepted connection" = "Вы приняли приглашение соединиться";
|
"You accepted connection" = "Вы приняли приглашение соединиться";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"You allow" = "Вы разрешаете";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"You are already connected to %@." = "Вы уже соединены с контактом %@.";
|
"You are already connected to %@." = "Вы уже соединены с контактом %@.";
|
||||||
|
|
||||||
@@ -1988,6 +2099,9 @@
|
|||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Your ICE servers" = "Ваши ICE серверы";
|
"Your ICE servers" = "Ваши ICE серверы";
|
||||||
|
|
||||||
|
/* No comment provided by engineer. */
|
||||||
|
"Your preferences" = "Ваши предпочтения";
|
||||||
|
|
||||||
/* No comment provided by engineer. */
|
/* No comment provided by engineer. */
|
||||||
"Your privacy" = "Конфиденциальность";
|
"Your privacy" = "Конфиденциальность";
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
import Control.Concurrent (threadDelay)
|
import Control.Concurrent (threadDelay)
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
import Server
|
import Server
|
||||||
import Simplex.Chat.Controller (versionNumber)
|
import Simplex.Chat.Controller (versionNumber)
|
||||||
import Simplex.Chat.Core
|
import Simplex.Chat.Core
|
||||||
@@ -27,7 +28,8 @@ main = do
|
|||||||
simplexChatTerminal terminalChatConfig opts t
|
simplexChatTerminal terminalChatConfig opts t
|
||||||
else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do
|
else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do
|
||||||
r <- sendChatCmd cc chatCmd
|
r <- sendChatCmd cc chatCmd
|
||||||
putStrLn $ serializeChatResponse (Just user) r
|
ts <- getCurrentTime
|
||||||
|
putStrLn $ serializeChatResponse (Just user) ts r
|
||||||
threadDelay $ chatCmdDelay opts * 1000000
|
threadDelay $ chatCmdDelay opts * 1000000
|
||||||
|
|
||||||
welcome :: ChatOpts -> IO ()
|
welcome :: ChatOpts -> IO ()
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
|||||||
source-repository-package
|
source-repository-package
|
||||||
type: git
|
type: git
|
||||||
location: https://github.com/simplex-chat/simplexmq.git
|
location: https://github.com/simplex-chat/simplexmq.git
|
||||||
tag: d2b88a1baa390ec64b6535e32ce69f26f53f4d7a
|
tag: c2342cba057fa2333b5936a2254507b5b62e8de2
|
||||||
|
|
||||||
source-repository-package
|
source-repository-package
|
||||||
type: git
|
type: git
|
||||||
|
|||||||
+22
-14
@@ -85,29 +85,37 @@ move <binary> %APPDATA%/local/bin/simplex-chat.exe
|
|||||||
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
|
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
git clone git@github.com:simplex-chat/simplex-chat.git
|
||||||
$ cd simplex-chat
|
cd simplex-chat
|
||||||
$ git checkout stable
|
git checkout stable
|
||||||
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
|
DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
|
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.7-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
|
||||||
|
|
||||||
#### Using Haskell stack
|
#### In any OS
|
||||||
|
|
||||||
Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/):
|
1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 8.10.7 and cabal:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
curl -sSL https://get.haskellstack.org/ | sh
|
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
and build the project:
|
2. Build the project:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
git clone git@github.com:simplex-chat/simplex-chat.git
|
||||||
$ cd simplex-chat
|
cd simplex-chat
|
||||||
$ git checkout stable
|
git checkout stable
|
||||||
$ stack install
|
# on Linux
|
||||||
|
apt-get update && apt-get install -y build-essential libgmp3-dev zlib1g-dev
|
||||||
|
cp scripts/cabal.project.local.linux cabal.project.local
|
||||||
|
# or on MacOS:
|
||||||
|
# brew install openssl@1.1
|
||||||
|
# cp scripts/cabal.project.local.mac cabal.project.local
|
||||||
|
# you may need to amend cabal.project.local to point to the actual openssl location
|
||||||
|
cabal update
|
||||||
|
cabal install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -140,7 +148,7 @@ You can still talk to people using default or any other server - it only affects
|
|||||||
|
|
||||||
Run `simplex-chat -h` to see all available options.
|
Run `simplex-chat -h` to see all available options.
|
||||||
|
|
||||||
### Access messaging servers via Tor (BETA)
|
### Access messaging servers via Tor
|
||||||
|
|
||||||
Install Tor and run it as SOCKS5 proxy on port 9050, e.g. on Mac you can:
|
Install Tor and run it as SOCKS5 proxy on port 9050, e.g. on Mac you can:
|
||||||
|
|
||||||
|
|||||||
+10
-7
@@ -5,12 +5,15 @@
|
|||||||
Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode:
|
Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode:
|
||||||
|
|
||||||
```
|
```
|
||||||
ignore-project: False
|
cp scripts/cabal.project.local.mac cabal.project.local
|
||||||
|
# or
|
||||||
package direct-sqlcipher
|
# cp scripts/cabal.project.local.linux cabal.project.local
|
||||||
extra-include-dirs: /opt/homebrew/opt/openssl@3/include
|
|
||||||
extra-lib-dirs: /opt/homebrew/opt/openssl@3/lib
|
|
||||||
flags: +openssl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenSSL can be installed with `brew install openssl`
|
## OpenSSL on MacOS
|
||||||
|
|
||||||
|
MacOS comes with LibreSSL as default, OpenSSL must be installed to compile SimpleX from source.
|
||||||
|
|
||||||
|
OpenSSL can be installed with `brew install openssl@1.1`
|
||||||
|
|
||||||
|
You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order to have things working properly
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "simplex-chat",
|
"name": "simplex-chat",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "SimpleX Chat client",
|
"description": "SimpleX Chat client",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export class ChatClient {
|
|||||||
const r = await this.sendChatCommand({type: "showMyAddress"})
|
const r = await this.sendChatCommand({type: "showMyAddress"})
|
||||||
switch (r.type) {
|
switch (r.type) {
|
||||||
case "userContactLink":
|
case "userContactLink":
|
||||||
return r.connReqContact
|
return r.contactLink.connReqContact
|
||||||
default:
|
default:
|
||||||
if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") {
|
if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") {
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -280,8 +280,7 @@ export interface CRCmdOk extends CR {
|
|||||||
|
|
||||||
export interface CRUserContactLink extends CR {
|
export interface CRUserContactLink extends CR {
|
||||||
type: "userContactLink"
|
type: "userContactLink"
|
||||||
connReqContact: string
|
contactLink: UserContactLink
|
||||||
autoAccept: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CRUserContactLinkUpdated extends CR {
|
export interface CRUserContactLinkUpdated extends CR {
|
||||||
@@ -913,6 +912,16 @@ interface FileTransferMeta {
|
|||||||
cancelled: boolean
|
cancelled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserContactLink {
|
||||||
|
connReqContact: string
|
||||||
|
autoAccept?: AutoAccept
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoAccept {
|
||||||
|
acceptIncognito: boolean
|
||||||
|
autoReply?: MsgContent
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatStats {
|
export interface ChatStats {
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
minUnreadItemId: number
|
minUnreadItemId: number
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ignore-project: False
|
||||||
|
|
||||||
|
# amend to point to the actual openssl location
|
||||||
|
package direct-sqlcipher
|
||||||
|
extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include
|
||||||
|
extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib
|
||||||
|
flags: +openssl
|
||||||
|
|
||||||
|
test-show-details: direct
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"https://github.com/simplex-chat/simplexmq.git"."d2b88a1baa390ec64b6535e32ce69f26f53f4d7a" = "1ijmhi9srkyq43aflsgx38hfir3q3q5d9xlq13g1sdh43i4wmyvk";
|
"https://github.com/simplex-chat/simplexmq.git"."c2342cba057fa2333b5936a2254507b5b62e8de2" = "0fsi4lgq5x3dgy79g85s7isg3387ppwrqm4v8dndixlxn8cx3pyp";
|
||||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
|
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
|
||||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
|
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
|
||||||
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
|
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ library
|
|||||||
Simplex.Chat.Migrations.M20221024_contact_used
|
Simplex.Chat.Migrations.M20221024_contact_used
|
||||||
Simplex.Chat.Migrations.M20221025_chat_settings
|
Simplex.Chat.Migrations.M20221025_chat_settings
|
||||||
Simplex.Chat.Migrations.M20221029_group_link_id
|
Simplex.Chat.Migrations.M20221029_group_link_id
|
||||||
|
Simplex.Chat.Migrations.M20221112_server_password
|
||||||
|
Simplex.Chat.Migrations.M20221115_server_cfg
|
||||||
Simplex.Chat.Mobile
|
Simplex.Chat.Mobile
|
||||||
Simplex.Chat.Options
|
Simplex.Chat.Options
|
||||||
Simplex.Chat.ProfileGenerator
|
Simplex.Chat.ProfileGenerator
|
||||||
|
|||||||
+98
-72
@@ -105,7 +105,7 @@ defaultChatConfig =
|
|||||||
testView = False
|
testView = False
|
||||||
}
|
}
|
||||||
|
|
||||||
_defaultSMPServers :: NonEmpty SMPServer
|
_defaultSMPServers :: NonEmpty SMPServerWithAuth
|
||||||
_defaultSMPServers =
|
_defaultSMPServers =
|
||||||
L.fromList
|
L.fromList
|
||||||
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
||||||
@@ -133,13 +133,14 @@ createChatDatabase filePrefix key yesToMigrations = do
|
|||||||
|
|
||||||
newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController
|
newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController
|
||||||
newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {smpServers, networkConfig, logConnections, logServerHosts} sendToast = do
|
newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {smpServers, networkConfig, logConnections, logServerHosts} sendToast = do
|
||||||
let config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts}
|
servers <- resolveServers defaultServers
|
||||||
|
let servers' = servers {netCfg = networkConfig}
|
||||||
|
config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = servers'}
|
||||||
sendNotification = fromMaybe (const $ pure ()) sendToast
|
sendNotification = fromMaybe (const $ pure ()) sendToast
|
||||||
firstTime = dbNew chatStore
|
firstTime = dbNew chatStore
|
||||||
activeTo <- newTVarIO ActiveNone
|
activeTo <- newTVarIO ActiveNone
|
||||||
currentUser <- newTVarIO user
|
currentUser <- newTVarIO user
|
||||||
servers <- resolveServers defaultServers
|
smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers'
|
||||||
smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers {netCfg = networkConfig}
|
|
||||||
agentAsync <- newTVarIO Nothing
|
agentAsync <- newTVarIO Nothing
|
||||||
idsDrg <- newTVarIO =<< drgNew
|
idsDrg <- newTVarIO =<< drgNew
|
||||||
inputQ <- newTBQueueIO tbqSize
|
inputQ <- newTBQueueIO tbqSize
|
||||||
@@ -157,14 +158,21 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
|
|||||||
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs}
|
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs}
|
||||||
where
|
where
|
||||||
resolveServers :: InitialAgentServers -> IO InitialAgentServers
|
resolveServers :: InitialAgentServers -> IO InitialAgentServers
|
||||||
resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of
|
resolveServers ss = case nonEmpty smpServers of
|
||||||
Just smpServers' -> pure ss {smp = smpServers'}
|
Just smpServers' -> pure ss {smp = L.map (\ServerCfg {server} -> server) smpServers'}
|
||||||
_ -> case user of
|
_ -> case user of
|
||||||
Just usr -> do
|
Just user' -> do
|
||||||
userSmpServers <- withTransaction chatStore (`getSMPServers` usr)
|
userSmpServers <- withTransaction chatStore (`getSMPServers` user')
|
||||||
pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers}
|
pure ss {smp = activeAgentServers cfg userSmpServers}
|
||||||
_ -> pure ss
|
_ -> pure ss
|
||||||
|
|
||||||
|
activeAgentServers :: ChatConfig -> [ServerCfg] -> NonEmpty SMPServerWithAuth
|
||||||
|
activeAgentServers ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} =
|
||||||
|
fromMaybe defaultSMPServers
|
||||||
|
. nonEmpty
|
||||||
|
. map (\ServerCfg {server} -> server)
|
||||||
|
. filter (\ServerCfg {enabled} -> enabled)
|
||||||
|
|
||||||
startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ())
|
startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ())
|
||||||
startChatController user subConns enableExpireCIs = do
|
startChatController user subConns enableExpireCIs = do
|
||||||
asks smpAgent >>= resumeAgentClient
|
asks smpAgent >>= resumeAgentClient
|
||||||
@@ -276,10 +284,11 @@ processChatCommand = \case
|
|||||||
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search)
|
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search)
|
||||||
CTContactRequest -> pure $ chatCmdError "not implemented"
|
CTContactRequest -> pure $ chatCmdError "not implemented"
|
||||||
CTContactConnection -> pure $ chatCmdError "not supported"
|
CTContactConnection -> pure $ chatCmdError "not supported"
|
||||||
APIGetChatItems _pagination -> pure $ chatCmdError "not implemented"
|
APIGetChatItems pagination search -> withUser $ \user -> withStore $ \db ->
|
||||||
|
CRApiChatItems <$> getAllChatItems db user pagination search
|
||||||
APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
|
APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
ct@Contact {localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db userId chatId
|
ct@Contact {localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId
|
||||||
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
|
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
|
||||||
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct
|
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct
|
||||||
(msgContainer, quotedItem_) <- prepareMsg fileInvitation_
|
(msgContainer, quotedItem_) <- prepareMsg fileInvitation_
|
||||||
@@ -378,6 +387,8 @@ processChatCommand = \case
|
|||||||
| otherwise = case qmc of
|
| otherwise = case qmc of
|
||||||
MCImage _ image -> MCImage qTextOrFile image
|
MCImage _ image -> MCImage qTextOrFile image
|
||||||
MCFile _ -> MCFile qTextOrFile
|
MCFile _ -> MCFile qTextOrFile
|
||||||
|
-- consider same for voice messages
|
||||||
|
-- MCVoice _ voice -> MCVoice qTextOrFile voice
|
||||||
_ -> qmc
|
_ -> qmc
|
||||||
where
|
where
|
||||||
-- if the message we're quoting with is one of the "large" MsgContents
|
-- if the message we're quoting with is one of the "large" MsgContents
|
||||||
@@ -387,6 +398,7 @@ processChatCommand = \case
|
|||||||
MCFile _ -> False
|
MCFile _ -> False
|
||||||
MCLink {} -> True
|
MCLink {} -> True
|
||||||
MCImage {} -> True
|
MCImage {} -> True
|
||||||
|
MCVoice {} -> False
|
||||||
MCUnknown {} -> True
|
MCUnknown {} -> True
|
||||||
qText = msgContentText qmc
|
qText = msgContentText qmc
|
||||||
qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_
|
qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_
|
||||||
@@ -396,7 +408,7 @@ processChatCommand = \case
|
|||||||
unzipMaybe3 _ = (Nothing, Nothing, Nothing)
|
unzipMaybe3 _ = (Nothing, Nothing, Nothing)
|
||||||
APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock "updateChatItem" $ case cType of
|
APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock "updateChatItem" $ case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
(ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId
|
(ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db userId chatId itemId
|
||||||
case ci of
|
case ci of
|
||||||
CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do
|
CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do
|
||||||
case (ciContent, itemSharedMsgId) of
|
case (ciContent, itemSharedMsgId) of
|
||||||
@@ -425,7 +437,7 @@ processChatCommand = \case
|
|||||||
CTContactConnection -> pure $ chatCmdError "not supported"
|
CTContactConnection -> pure $ chatCmdError "not supported"
|
||||||
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock "deleteChatItem" $ case cType of
|
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock "deleteChatItem" $ case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
(ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId
|
(ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db userId chatId itemId
|
||||||
case (mode, msgDir, itemSharedMsgId) of
|
case (mode, msgDir, itemSharedMsgId) of
|
||||||
(CIDMInternal, _, _) -> do
|
(CIDMInternal, _, _) -> do
|
||||||
deleteCIFile user file
|
deleteCIFile user file
|
||||||
@@ -468,10 +480,10 @@ processChatCommand = \case
|
|||||||
CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk
|
CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk
|
||||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||||
CTContactConnection -> pure $ chatCmdError "not supported"
|
CTContactConnection -> pure $ chatCmdError "not supported"
|
||||||
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user@User {userId} -> case cType of
|
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
withStore $ \db -> do
|
withStore $ \db -> do
|
||||||
ct <- getContact db userId chatId
|
ct <- getContact db user chatId
|
||||||
liftIO $ updateContactUnreadChat db user ct unreadChat
|
liftIO $ updateContactUnreadChat db user ct unreadChat
|
||||||
pure CRCmdOk
|
pure CRCmdOk
|
||||||
CTGroup -> do
|
CTGroup -> do
|
||||||
@@ -482,7 +494,7 @@ processChatCommand = \case
|
|||||||
_ -> pure $ chatCmdError "not supported"
|
_ -> pure $ chatCmdError "not supported"
|
||||||
APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
|
APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db userId chatId
|
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId
|
||||||
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
||||||
conns <- withStore $ \db -> getContactConnections db userId ct
|
conns <- withStore $ \db -> getContactConnections db userId ct
|
||||||
withChatLock "deleteChat direct" . procCmd $ do
|
withChatLock "deleteChat direct" . procCmd $ do
|
||||||
@@ -516,9 +528,9 @@ processChatCommand = \case
|
|||||||
withStore' $ \db -> deleteGroup db user gInfo
|
withStore' $ \db -> deleteGroup db user gInfo
|
||||||
pure $ CRGroupDeletedUser gInfo
|
pure $ CRGroupDeletedUser gInfo
|
||||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||||
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
|
APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
ct <- withStore $ \db -> getContact db userId chatId
|
ct <- withStore $ \db -> getContact db user chatId
|
||||||
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
||||||
maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct
|
maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct
|
||||||
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
|
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
|
||||||
@@ -561,7 +573,7 @@ processChatCommand = \case
|
|||||||
pure $ CRContactRequestRejected cReq
|
pure $ CRContactRequestRejected cReq
|
||||||
APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do
|
APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do
|
||||||
-- party initiating call
|
-- party initiating call
|
||||||
ct <- withStore $ \db -> getContact db userId contactId
|
ct <- withStore $ \db -> getContact db user contactId
|
||||||
calls <- asks currentCalls
|
calls <- asks currentCalls
|
||||||
withChatLock "sendCallInvitation" $ do
|
withChatLock "sendCallInvitation" $ do
|
||||||
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||||
@@ -629,27 +641,27 @@ processChatCommand = \case
|
|||||||
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId)
|
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId)
|
||||||
updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
|
updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
|
||||||
pure Nothing
|
pure Nothing
|
||||||
APIGetCallInvitations -> withUser $ \User {userId} -> do
|
APIGetCallInvitations -> withUser $ \user -> do
|
||||||
calls <- asks currentCalls >>= readTVarIO
|
calls <- asks currentCalls >>= readTVarIO
|
||||||
let invs = mapMaybe callInvitation $ M.elems calls
|
let invs = mapMaybe callInvitation $ M.elems calls
|
||||||
CRCallInvitations <$> mapM (rcvCallInvitation userId) invs
|
CRCallInvitations <$> mapM (rcvCallInvitation user) invs
|
||||||
where
|
where
|
||||||
callInvitation Call {contactId, callState, callTs} = case callState of
|
callInvitation Call {contactId, callState, callTs} = case callState of
|
||||||
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey)
|
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey)
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
rcvCallInvitation userId (contactId, callTs, peerCallType, sharedKey) = do
|
rcvCallInvitation user (contactId, callTs, peerCallType, sharedKey) = do
|
||||||
contact <- withStore (\db -> getContact db userId contactId)
|
contact <- withStore (\db -> getContact db user contactId)
|
||||||
pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs}
|
pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs}
|
||||||
APICallStatus contactId receivedStatus ->
|
APICallStatus contactId receivedStatus ->
|
||||||
withCurrentCall contactId $ \userId ct call ->
|
withCurrentCall contactId $ \userId ct call ->
|
||||||
updateCallItemStatus userId ct call receivedStatus Nothing $> Just call
|
updateCallItemStatus userId ct call receivedStatus Nothing $> Just call
|
||||||
APIUpdateProfile profile -> withUser (`updateProfile` profile)
|
APIUpdateProfile profile -> withUser (`updateProfile` profile)
|
||||||
APISetContactPrefs contactId prefs' -> withUser $ \user@User {userId} -> do
|
APISetContactPrefs contactId prefs' -> withUser $ \user -> do
|
||||||
ct <- withStore $ \db -> getContact db userId contactId
|
ct <- withStore $ \db -> getContact db user contactId
|
||||||
updateContactPrefs user ct prefs'
|
updateContactPrefs user ct prefs'
|
||||||
APISetContactAlias contactId localAlias -> withUser $ \User {userId} -> do
|
APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do
|
||||||
ct' <- withStore $ \db -> do
|
ct' <- withStore $ \db -> do
|
||||||
ct <- getContact db userId contactId
|
ct <- getContact db user contactId
|
||||||
liftIO $ updateContactAlias db userId ct localAlias
|
liftIO $ updateContactAlias db userId ct localAlias
|
||||||
pure $ CRContactAliasUpdated ct'
|
pure $ CRContactAliasUpdated ct'
|
||||||
APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do
|
APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do
|
||||||
@@ -668,12 +680,19 @@ processChatCommand = \case
|
|||||||
msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta
|
msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta
|
||||||
connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing
|
connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing
|
||||||
pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages}
|
pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages}
|
||||||
GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore' (`getSMPServers` user))
|
GetUserSMPServers -> do
|
||||||
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
|
|
||||||
withStore $ \db -> overwriteSMPServers db user smpServers
|
|
||||||
ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config
|
ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config
|
||||||
withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers))
|
smpServers <- withUser (\user -> withStore' (`getSMPServers` user))
|
||||||
|
let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers
|
||||||
|
pure $ CRUserSMPServers smpServers' defaultSMPServers
|
||||||
|
where
|
||||||
|
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True}
|
||||||
|
SetUserSMPServers (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
|
||||||
|
withStore $ \db -> overwriteSMPServers db user smpServers
|
||||||
|
cfg <- asks config
|
||||||
|
withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers
|
||||||
pure CRCmdOk
|
pure CRCmdOk
|
||||||
|
TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer)
|
||||||
APISetChatItemTTL newTTL_ -> withUser' $ \user ->
|
APISetChatItemTTL newTTL_ -> withUser' $ \user ->
|
||||||
checkStoreNotChanged $
|
checkStoreNotChanged $
|
||||||
withChatLock "setChatItemTTL" $ do
|
withChatLock "setChatItemTTL" $ do
|
||||||
@@ -692,10 +711,10 @@ processChatCommand = \case
|
|||||||
APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user))
|
APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user))
|
||||||
APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk
|
APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk
|
||||||
APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig)
|
APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig)
|
||||||
APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user@User {userId} -> case cType of
|
APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of
|
||||||
CTDirect -> do
|
CTDirect -> do
|
||||||
ct <- withStore $ \db -> do
|
ct <- withStore $ \db -> do
|
||||||
ct <- getContact db userId chatId
|
ct <- getContact db user chatId
|
||||||
liftIO $ updateContactSettings db user chatId chatSettings
|
liftIO $ updateContactSettings db user chatId chatSettings
|
||||||
pure ct
|
pure ct
|
||||||
withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings)
|
withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings)
|
||||||
@@ -709,9 +728,9 @@ processChatCommand = \case
|
|||||||
withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError)
|
withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError)
|
||||||
pure CRCmdOk
|
pure CRCmdOk
|
||||||
_ -> pure $ chatCmdError "not supported"
|
_ -> pure $ chatCmdError "not supported"
|
||||||
APIContactInfo contactId -> withUser $ \User {userId} -> do
|
APIContactInfo contactId -> withUser $ \user@User {userId} -> do
|
||||||
-- [incognito] print user's incognito profile for this contact
|
-- [incognito] print user's incognito profile for this contact
|
||||||
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db userId contactId
|
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId
|
||||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
||||||
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
|
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
|
||||||
pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile)
|
pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile)
|
||||||
@@ -719,8 +738,8 @@ processChatCommand = \case
|
|||||||
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
|
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
|
||||||
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
|
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
|
||||||
pure $ CRGroupMemberInfo g m connectionStats
|
pure $ CRGroupMemberInfo g m connectionStats
|
||||||
APISwitchContact contactId -> withUser $ \User {userId} -> do
|
APISwitchContact contactId -> withUser $ \user -> do
|
||||||
ct <- withStore $ \db -> getContact db userId contactId
|
ct <- withStore $ \db -> getContact db user contactId
|
||||||
withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct
|
withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct
|
||||||
pure CRCmdOk
|
pure CRCmdOk
|
||||||
APISwitchGroupMember gId gMemberId -> withUser $ \user -> do
|
APISwitchGroupMember gId gMemberId -> withUser $ \user -> do
|
||||||
@@ -807,7 +826,7 @@ processChatCommand = \case
|
|||||||
contacts <- withStore' (`getUserContacts` user)
|
contacts <- withStore' (`getUserContacts` user)
|
||||||
withChatLock "sendMessageBroadcast" . procCmd $ do
|
withChatLock "sendMessageBroadcast" . procCmd $ do
|
||||||
let mc = MCText $ safeDecodeUtf8 msg
|
let mc = MCText $ safeDecodeUtf8 msg
|
||||||
cts = filter isReady contacts
|
cts = filter (\ct -> isReady ct && isDirect ct) contacts
|
||||||
forM_ cts $ \ct ->
|
forM_ cts $ \ct ->
|
||||||
void
|
void
|
||||||
( do
|
( do
|
||||||
@@ -816,6 +835,9 @@ processChatCommand = \case
|
|||||||
)
|
)
|
||||||
`catchError` (toView . CRChatError)
|
`catchError` (toView . CRChatError)
|
||||||
CRBroadcastSent mc (length cts) <$> liftIO getZonedTime
|
CRBroadcastSent mc (length cts) <$> liftIO getZonedTime
|
||||||
|
where
|
||||||
|
isDirect Contact {contactUsed, activeConn = Connection {connLevel, viaGroupLink}} =
|
||||||
|
(connLevel == 0 && not viaGroupLink) || contactUsed
|
||||||
SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do
|
SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do
|
||||||
contactId <- withStore $ \db -> getContactIdByName db user cName
|
contactId <- withStore $ \db -> getContactIdByName db user cName
|
||||||
quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg)
|
quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg)
|
||||||
@@ -834,9 +856,9 @@ processChatCommand = \case
|
|||||||
gVar <- asks idsDrg
|
gVar <- asks idsDrg
|
||||||
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile)
|
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile)
|
||||||
pure $ CRGroupCreated groupInfo
|
pure $ CRGroupCreated groupInfo
|
||||||
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock "addMember" $ do
|
APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do
|
||||||
-- TODO for large groups: no need to load all members to determine if contact is a member
|
-- TODO for large groups: no need to load all members to determine if contact is a member
|
||||||
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId
|
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId
|
||||||
let Group gInfo@GroupInfo {membership} members = group
|
let Group gInfo@GroupInfo {membership} members = group
|
||||||
GroupMember {memberRole = userRole} = membership
|
GroupMember {memberRole = userRole} = membership
|
||||||
Contact {localDisplayName = cName} = contact
|
Contact {localDisplayName = cName} = contact
|
||||||
@@ -888,7 +910,7 @@ processChatCommand = \case
|
|||||||
Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole
|
Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole
|
||||||
_ -> throwChatError CEGroupMemberNotFound
|
_ -> throwChatError CEGroupMemberNotFound
|
||||||
where
|
where
|
||||||
changeMemberRole user@User {userId} gInfo@GroupInfo {membership} members m gEvent = do
|
changeMemberRole user gInfo@GroupInfo {membership} members m gEvent = do
|
||||||
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m
|
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m
|
||||||
GroupMember {memberRole = userRole} = membership
|
GroupMember {memberRole = userRole} = membership
|
||||||
canChangeRole = userRole >= GRAdmin && userRole >= mRole && userRole >= memRole && memberCurrent membership
|
canChangeRole = userRole >= GRAdmin && userRole >= mRole && userRole >= memRole && memberCurrent membership
|
||||||
@@ -898,7 +920,7 @@ processChatCommand = \case
|
|||||||
withStore' $ \db -> updateGroupMemberRole db user m memRole
|
withStore' $ \db -> updateGroupMemberRole db user m memRole
|
||||||
case mStatus of
|
case mStatus of
|
||||||
GSMemInvited -> do
|
GSMemInvited -> do
|
||||||
withStore (\db -> (,) <$> mapM (getContact db userId) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case
|
withStore (\db -> (,) <$> mapM (getContact db user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case
|
||||||
(Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq
|
(Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq
|
||||||
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
||||||
_ -> do
|
_ -> do
|
||||||
@@ -1012,11 +1034,11 @@ processChatCommand = \case
|
|||||||
quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName (safeDecodeUtf8 quotedMsg)
|
quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName (safeDecodeUtf8 quotedMsg)
|
||||||
let mc = MCText $ safeDecodeUtf8 msg
|
let mc = MCText $ safeDecodeUtf8 msg
|
||||||
processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc
|
processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc
|
||||||
LastMessages (Just chatName) count -> withUser $ \user -> do
|
LastMessages (Just chatName) count search -> withUser $ \user -> do
|
||||||
chatRef <- getChatRef user chatName
|
chatRef <- getChatRef user chatName
|
||||||
CRLastMessages . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) Nothing)
|
CRApiChatItems . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) search)
|
||||||
LastMessages Nothing count -> withUser $ \user -> withStore $ \db ->
|
LastMessages Nothing count search ->
|
||||||
CRLastMessages <$> getAllChatItems db user (CPLast count)
|
processChatCommand (APIGetChatItems (CPLast count) search)
|
||||||
SendFile chatName f -> withUser $ \user -> do
|
SendFile chatName f -> withUser $ \user -> do
|
||||||
chatRef <- getChatRef user chatName
|
chatRef <- getChatRef user chatName
|
||||||
processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "")
|
processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "")
|
||||||
@@ -1048,7 +1070,7 @@ processChatCommand = \case
|
|||||||
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
|
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
|
||||||
withStore (\db -> getChatRefByFileId db user fileId) >>= \case
|
withStore (\db -> getChatRefByFileId db user fileId) >>= \case
|
||||||
ChatRef CTDirect contactId -> do
|
ChatRef CTDirect contactId -> do
|
||||||
contact <- withStore $ \db -> getContact db userId contactId
|
contact <- withStore $ \db -> getContact db user contactId
|
||||||
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
|
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
|
||||||
ChatRef CTGroup groupId -> do
|
ChatRef CTGroup groupId -> do
|
||||||
Group gInfo ms <- withStore $ \db -> getGroup db user groupId
|
Group gInfo ms <- withStore $ \db -> getGroup db user groupId
|
||||||
@@ -1111,7 +1133,7 @@ processChatCommand = \case
|
|||||||
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse
|
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse
|
||||||
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
|
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
|
||||||
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
|
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
|
||||||
withStore' (\db -> getConnReqContactXContactId db userId cReqHash) >>= \case
|
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
|
||||||
(Just contact, _) -> pure $ CRContactAlreadyExists contact
|
(Just contact, _) -> pure $ CRContactAlreadyExists contact
|
||||||
(_, xContactId_) -> procCmd $ do
|
(_, xContactId_) -> procCmd $ do
|
||||||
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||||
@@ -1162,25 +1184,22 @@ processChatCommand = \case
|
|||||||
void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError)
|
void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError)
|
||||||
pure $ CRUserProfileUpdated (fromLocalProfile p) p'
|
pure $ CRUserProfileUpdated (fromLocalProfile p) p'
|
||||||
updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse
|
updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse
|
||||||
updateContactPrefs user@User {userId} ct@Contact {contactId, activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
|
updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
|
||||||
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct $ contactUserPreferences user ct -- nothing changed actually
|
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct
|
||||||
| otherwise = do
|
| otherwise = do
|
||||||
withStore' $ \db -> updateContactUserPreferences db userId contactId contactUserPrefs'
|
ct' <- withStore' $ \db -> updateContactUserPreferences db user ct contactUserPrefs'
|
||||||
-- [incognito] filter out contacts with whom user has incognito connections
|
|
||||||
let ct' = (ct :: Contact) {userPreferences = contactUserPrefs'}
|
|
||||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId
|
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId
|
||||||
let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct')
|
let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct')
|
||||||
withChatLock "updateProfile" . procCmd $ do
|
withChatLock "updateProfile" . procCmd $ do
|
||||||
void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError)
|
void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError)
|
||||||
pure $ CRContactPrefsUpdated ct ct' $ contactUserPreferences user ct'
|
pure $ CRContactPrefsUpdated ct ct'
|
||||||
|
|
||||||
isReady :: Contact -> Bool
|
isReady :: Contact -> Bool
|
||||||
isReady ct =
|
isReady ct =
|
||||||
let s = connStatus $ activeConn (ct :: Contact)
|
let s = connStatus $ activeConn (ct :: Contact)
|
||||||
in s == ConnReady || s == ConnSndReady
|
in s == ConnReady || s == ConnSndReady
|
||||||
withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse
|
withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse
|
||||||
withCurrentCall ctId action = withUser $ \user@User {userId} -> do
|
withCurrentCall ctId action = withUser $ \user@User {userId} -> do
|
||||||
ct <- withStore $ \db -> getContact db userId ctId
|
ct <- withStore $ \db -> getContact db user ctId
|
||||||
calls <- asks currentCalls
|
calls <- asks currentCalls
|
||||||
withChatLock "currentCall" $
|
withChatLock "currentCall" $
|
||||||
atomically (TM.lookup ctId calls) >>= \case
|
atomically (TM.lookup ctId calls) >>= \case
|
||||||
@@ -1310,7 +1329,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F
|
|||||||
chatRef <- withStore $ \db -> getChatRefByFileId db user fileId
|
chatRef <- withStore $ \db -> getChatRefByFileId db user fileId
|
||||||
case (chatRef, grpMemberId) of
|
case (chatRef, grpMemberId) of
|
||||||
(ChatRef CTDirect contactId, Nothing) -> do
|
(ChatRef CTDirect contactId, Nothing) -> do
|
||||||
ct <- withStore $ \db -> getContact db userId contactId
|
ct <- withStore $ \db -> getContact db user contactId
|
||||||
(msg, ci) <- acceptFile
|
(msg, ci) <- acceptFile
|
||||||
void $ sendDirectContactMessage ct msg
|
void $ sendDirectContactMessage ct msg
|
||||||
pure ci
|
pure ci
|
||||||
@@ -2054,7 +2073,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
|||||||
where
|
where
|
||||||
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
|
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
|
||||||
profileContactRequest invId p xContactId_ = do
|
profileContactRequest invId p xContactId_ = do
|
||||||
withStore (\db -> createOrUpdateContactRequest db userId userContactLinkId invId p xContactId_) >>= \case
|
withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case
|
||||||
CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact
|
CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact
|
||||||
CORRequest cReq@UserContactRequest {localDisplayName} -> do
|
CORRequest cReq@UserContactRequest {localDisplayName} -> do
|
||||||
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
|
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
|
||||||
@@ -2148,7 +2167,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
|||||||
if connectedIncognito
|
if connectedIncognito
|
||||||
then withStore' $ \db -> deleteSentProbe db userId probeId
|
then withStore' $ \db -> deleteSentProbe db userId probeId
|
||||||
else do
|
else do
|
||||||
cs <- withStore' $ \db -> getMatchingContacts db userId ct
|
cs <- withStore' $ \db -> getMatchingContacts db user ct
|
||||||
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
|
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
|
||||||
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
|
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
|
||||||
where
|
where
|
||||||
@@ -2463,21 +2482,21 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
|||||||
|
|
||||||
xInfo :: Contact -> Profile -> m ()
|
xInfo :: Contact -> Profile -> m ()
|
||||||
xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do
|
xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do
|
||||||
c' <- withStore $ \db -> updateContactProfile db userId c p'
|
c' <- withStore $ \db -> updateContactProfile db user c p'
|
||||||
toView $ CRContactUpdated c c' $ contactUserPreferences user c'
|
toView $ CRContactUpdated c c'
|
||||||
|
|
||||||
xInfoProbe :: Contact -> Probe -> m ()
|
xInfoProbe :: Contact -> Probe -> m ()
|
||||||
xInfoProbe c2 probe =
|
xInfoProbe c2 probe =
|
||||||
-- [incognito] unless connected incognito
|
-- [incognito] unless connected incognito
|
||||||
unless (contactConnIncognito c2) $ do
|
unless (contactConnIncognito c2) $ do
|
||||||
r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe
|
r <- withStore' $ \db -> matchReceivedProbe db user c2 probe
|
||||||
forM_ r $ \c1 -> probeMatch c1 c2 probe
|
forM_ r $ \c1 -> probeMatch c1 c2 probe
|
||||||
|
|
||||||
xInfoProbeCheck :: Contact -> ProbeHash -> m ()
|
xInfoProbeCheck :: Contact -> ProbeHash -> m ()
|
||||||
xInfoProbeCheck c1 probeHash =
|
xInfoProbeCheck c1 probeHash =
|
||||||
-- [incognito] unless connected incognito
|
-- [incognito] unless connected incognito
|
||||||
unless (contactConnIncognito c1) $ do
|
unless (contactConnIncognito c1) $ do
|
||||||
r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash
|
r <- withStore' $ \db -> matchReceivedProbeHash db user c1 probeHash
|
||||||
forM_ r . uncurry $ probeMatch c1
|
forM_ r . uncurry $ probeMatch c1
|
||||||
|
|
||||||
probeMatch :: Contact -> Contact -> Probe -> m ()
|
probeMatch :: Contact -> Contact -> Probe -> m ()
|
||||||
@@ -2490,7 +2509,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
|||||||
|
|
||||||
xInfoProbeOk :: Contact -> Probe -> m ()
|
xInfoProbeOk :: Contact -> Probe -> m ()
|
||||||
xInfoProbeOk c1@Contact {contactId = cId1} probe = do
|
xInfoProbeOk c1@Contact {contactId = cId1} probe = do
|
||||||
r <- withStore' $ \db -> matchSentProbe db userId c1 probe
|
r <- withStore' $ \db -> matchSentProbe db user c1 probe
|
||||||
forM_ r $ \c2@Contact {contactId = cId2} ->
|
forM_ r $ \c2@Contact {contactId = cId2} ->
|
||||||
if cId1 /= cId2
|
if cId1 /= cId2
|
||||||
then mergeContacts c1 c2
|
then mergeContacts c1 c2
|
||||||
@@ -2605,7 +2624,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
|||||||
ChatMessage {chatMsgEvent} <- parseChatMessage connInfo
|
ChatMessage {chatMsgEvent} <- parseChatMessage connInfo
|
||||||
case chatMsgEvent of
|
case chatMsgEvent of
|
||||||
XInfo p -> do
|
XInfo p -> do
|
||||||
ct <- withStore $ \db -> createDirectContact db userId activeConn p
|
ct <- withStore $ \db -> createDirectContact db user activeConn p
|
||||||
toView $ CRContactConnecting ct
|
toView $ CRContactConnecting ct
|
||||||
-- TODO show/log error, other events in SMP confirmation
|
-- TODO show/log error, other events in SMP confirmation
|
||||||
_ -> pure ()
|
_ -> pure ()
|
||||||
@@ -3163,8 +3182,8 @@ chatCommandP =
|
|||||||
"/sql chat " *> (ExecChatStoreSQL <$> textP),
|
"/sql chat " *> (ExecChatStoreSQL <$> textP),
|
||||||
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
|
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
|
||||||
"/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
|
"/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
|
||||||
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional searchP),
|
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
|
||||||
"/_get items count=" *> (APIGetChatItems <$> A.decimal),
|
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
|
||||||
"/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
|
"/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
|
||||||
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP),
|
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP),
|
||||||
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
|
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
|
||||||
@@ -3199,9 +3218,15 @@ chatCommandP =
|
|||||||
"/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal),
|
"/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal),
|
||||||
"/_leave #" *> (APILeaveGroup <$> A.decimal),
|
"/_leave #" *> (APILeaveGroup <$> A.decimal),
|
||||||
"/_members #" *> (APIListMembers <$> A.decimal),
|
"/_members #" *> (APIListMembers <$> A.decimal),
|
||||||
"/smp_servers default" $> SetUserSMPServers [],
|
-- /smp_servers is deprecated, use /smp and /_smp
|
||||||
"/smp_servers " *> (SetUserSMPServers <$> smpServersP),
|
"/smp_servers default" $> SetUserSMPServers (SMPServersConfig []),
|
||||||
|
"/smp_servers " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP),
|
||||||
"/smp_servers" $> GetUserSMPServers,
|
"/smp_servers" $> GetUserSMPServers,
|
||||||
|
"/smp default" $> SetUserSMPServers (SMPServersConfig []),
|
||||||
|
"/smp test " *> (TestSMPServer <$> strP),
|
||||||
|
"/_smp " *> (SetUserSMPServers <$> jsonP),
|
||||||
|
"/smp " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP),
|
||||||
|
"/smp" $> GetUserSMPServers,
|
||||||
"/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal),
|
"/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal),
|
||||||
"/ttl " *> (APISetChatItemTTL <$> ciTTL),
|
"/ttl " *> (APISetChatItemTTL <$> ciTTL),
|
||||||
"/ttl" $> APIGetChatItemTTL,
|
"/ttl" $> APIGetChatItemTTL,
|
||||||
@@ -3256,7 +3281,8 @@ chatCommandP =
|
|||||||
("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString),
|
("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString),
|
||||||
("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString),
|
("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString),
|
||||||
"/feed " *> (SendMessageBroadcast <$> A.takeByteString),
|
"/feed " *> (SendMessageBroadcast <$> A.takeByteString),
|
||||||
("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP),
|
("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing),
|
||||||
|
("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> stringP))),
|
||||||
("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath),
|
("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath),
|
||||||
("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath),
|
("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath),
|
||||||
("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
|
("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
|
||||||
@@ -3316,8 +3342,8 @@ chatCommandP =
|
|||||||
n <- (A.space *> A.takeByteString) <|> pure ""
|
n <- (A.space *> A.takeByteString) <|> pure ""
|
||||||
pure $ if B.null n then name else safeDecodeUtf8 n
|
pure $ if B.null n then name else safeDecodeUtf8 n
|
||||||
textP = safeDecodeUtf8 <$> A.takeByteString
|
textP = safeDecodeUtf8 <$> A.takeByteString
|
||||||
filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
||||||
searchP = T.unpack . safeDecodeUtf8 <$> (" search=" *> A.takeByteString)
|
filePath = stringP
|
||||||
memberRole =
|
memberRole =
|
||||||
A.choice
|
A.choice
|
||||||
[ " owner" $> GROwner,
|
[ " owner" $> GROwner,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import Data.ByteString.Char8 (ByteString)
|
|||||||
import qualified Data.ByteString.Char8 as B
|
import qualified Data.ByteString.Char8 as B
|
||||||
import Data.Char (ord)
|
import Data.Char (ord)
|
||||||
import Data.Int (Int64)
|
import Data.Int (Int64)
|
||||||
|
import Data.List.NonEmpty (NonEmpty)
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
import Data.String
|
import Data.String
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
@@ -39,7 +40,7 @@ import Simplex.Chat.Protocol
|
|||||||
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
|
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
|
||||||
import Simplex.Chat.Types
|
import Simplex.Chat.Types
|
||||||
import Simplex.Messaging.Agent (AgentClient)
|
import Simplex.Messaging.Agent (AgentClient)
|
||||||
import Simplex.Messaging.Agent.Client (AgentLocks)
|
import Simplex.Messaging.Agent.Client (AgentLocks, SMPTestFailure)
|
||||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig)
|
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig)
|
||||||
import Simplex.Messaging.Agent.Lock
|
import Simplex.Messaging.Agent.Lock
|
||||||
import Simplex.Messaging.Agent.Protocol
|
import Simplex.Messaging.Agent.Protocol
|
||||||
@@ -146,7 +147,7 @@ data ChatCommand
|
|||||||
| ExecAgentStoreSQL Text
|
| ExecAgentStoreSQL Text
|
||||||
| APIGetChats {pendingConnections :: Bool}
|
| APIGetChats {pendingConnections :: Bool}
|
||||||
| APIGetChat ChatRef ChatPagination (Maybe String)
|
| APIGetChat ChatRef ChatPagination (Maybe String)
|
||||||
| APIGetChatItems Int
|
| APIGetChatItems ChatPagination (Maybe String)
|
||||||
| APISendMessage ChatRef ComposedMessage
|
| APISendMessage ChatRef ComposedMessage
|
||||||
| APIUpdateChatItem ChatRef ChatItemId MsgContent
|
| APIUpdateChatItem ChatRef ChatItemId MsgContent
|
||||||
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
|
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
|
||||||
@@ -166,7 +167,7 @@ data ChatCommand
|
|||||||
| APIGetCallInvitations
|
| APIGetCallInvitations
|
||||||
| APICallStatus ContactId WebRTCCallStatus
|
| APICallStatus ContactId WebRTCCallStatus
|
||||||
| APIUpdateProfile Profile
|
| APIUpdateProfile Profile
|
||||||
| APISetContactPrefs Int64 Preferences
|
| APISetContactPrefs ContactId Preferences
|
||||||
| APISetContactAlias ContactId LocalAlias
|
| APISetContactAlias ContactId LocalAlias
|
||||||
| APISetConnectionAlias Int64 LocalAlias
|
| APISetConnectionAlias Int64 LocalAlias
|
||||||
| APIParseMarkdown Text
|
| APIParseMarkdown Text
|
||||||
@@ -186,7 +187,8 @@ data ChatCommand
|
|||||||
| APIDeleteGroupLink GroupId
|
| APIDeleteGroupLink GroupId
|
||||||
| APIGetGroupLink GroupId
|
| APIGetGroupLink GroupId
|
||||||
| GetUserSMPServers
|
| GetUserSMPServers
|
||||||
| SetUserSMPServers [SMPServer]
|
| SetUserSMPServers SMPServersConfig
|
||||||
|
| TestSMPServer SMPServerWithAuth
|
||||||
| APISetChatItemTTL (Maybe Int64)
|
| APISetChatItemTTL (Maybe Int64)
|
||||||
| APIGetChatItemTTL
|
| APIGetChatItemTTL
|
||||||
| APISetNetworkConfig NetworkConfig
|
| APISetNetworkConfig NetworkConfig
|
||||||
@@ -235,7 +237,7 @@ data ChatCommand
|
|||||||
| DeleteGroupLink GroupName
|
| DeleteGroupLink GroupName
|
||||||
| ShowGroupLink GroupName
|
| ShowGroupLink GroupName
|
||||||
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString}
|
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString}
|
||||||
| LastMessages (Maybe ChatName) Int
|
| LastMessages (Maybe ChatName) Int (Maybe String)
|
||||||
| SendFile ChatName FilePath
|
| SendFile ChatName FilePath
|
||||||
| SendImage ChatName FilePath
|
| SendImage ChatName FilePath
|
||||||
| ForwardFile ChatName FileTransferId
|
| ForwardFile ChatName FileTransferId
|
||||||
@@ -259,9 +261,10 @@ data ChatResponse
|
|||||||
| CRChatSuspended
|
| CRChatSuspended
|
||||||
| CRApiChats {chats :: [AChat]}
|
| CRApiChats {chats :: [AChat]}
|
||||||
| CRApiChat {chat :: AChat}
|
| CRApiChat {chat :: AChat}
|
||||||
| CRLastMessages {chatItems :: [AChatItem]}
|
| CRApiChatItems {chatItems :: [AChatItem]}
|
||||||
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
|
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
|
||||||
| CRUserSMPServers {smpServers :: [SMPServer]}
|
| CRUserSMPServers {smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth}
|
||||||
|
| CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure}
|
||||||
| CRChatItemTTL {chatItemTTL :: Maybe Int64}
|
| CRChatItemTTL {chatItemTTL :: Maybe Int64}
|
||||||
| CRNetworkConfig {networkConfig :: NetworkConfig}
|
| CRNetworkConfig {networkConfig :: NetworkConfig}
|
||||||
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
|
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
|
||||||
@@ -296,7 +299,7 @@ data ChatResponse
|
|||||||
| CRInvitation {connReqInvitation :: ConnReqInvitation}
|
| CRInvitation {connReqInvitation :: ConnReqInvitation}
|
||||||
| CRSentConfirmation
|
| CRSentConfirmation
|
||||||
| CRSentInvitation {customUserProfile :: Maybe Profile}
|
| CRSentInvitation {customUserProfile :: Maybe Profile}
|
||||||
| CRContactUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences}
|
| CRContactUpdated {fromContact :: Contact, toContact :: Contact}
|
||||||
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
|
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
|
||||||
| CRContactDeleted {contact :: Contact}
|
| CRContactDeleted {contact :: Contact}
|
||||||
| CRChatCleared {chatInfo :: AChatInfo}
|
| CRChatCleared {chatInfo :: AChatInfo}
|
||||||
@@ -322,7 +325,7 @@ data ChatResponse
|
|||||||
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
|
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
|
||||||
| CRContactAliasUpdated {toContact :: Contact}
|
| CRContactAliasUpdated {toContact :: Contact}
|
||||||
| CRConnectionAliasUpdated {toConnection :: PendingContactConnection}
|
| CRConnectionAliasUpdated {toConnection :: PendingContactConnection}
|
||||||
| CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences}
|
| CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact}
|
||||||
| CRContactConnecting {contact :: Contact}
|
| CRContactConnecting {contact :: Contact}
|
||||||
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
|
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
|
||||||
| CRContactAnotherClient {contact :: Contact}
|
| CRContactAnotherClient {contact :: Contact}
|
||||||
@@ -383,6 +386,9 @@ instance ToJSON ChatResponse where
|
|||||||
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
|
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
|
||||||
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
|
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
|
||||||
|
|
||||||
|
data SMPServersConfig = SMPServersConfig {smpServers :: [ServerCfg]}
|
||||||
|
deriving (Show, Generic, FromJSON)
|
||||||
|
|
||||||
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
|
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
|
||||||
deriving (Show, Generic, FromJSON)
|
deriving (Show, Generic, FromJSON)
|
||||||
|
|
||||||
@@ -465,6 +471,24 @@ data SwitchProgress = SwitchProgress
|
|||||||
|
|
||||||
instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions
|
instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
|
data ParsedServerAddress = ParsedServerAddress
|
||||||
|
{ serverAddress :: Maybe ServerAddress,
|
||||||
|
parseError :: String
|
||||||
|
}
|
||||||
|
deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
|
data ServerAddress = ServerAddress
|
||||||
|
{ hostnames :: NonEmpty String,
|
||||||
|
port :: String,
|
||||||
|
keyHash :: String,
|
||||||
|
basicAuth :: String
|
||||||
|
}
|
||||||
|
deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
data ChatError
|
data ChatError
|
||||||
= ChatError {errorType :: ChatErrorType}
|
= ChatError {errorType :: ChatErrorType}
|
||||||
| ChatErrorAgent {agentError :: AgentErrorType}
|
| ChatErrorAgent {agentError :: AgentErrorType}
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ messagesHelpInfo =
|
|||||||
indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team",
|
indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team",
|
||||||
indent <> highlight "/tail [N] " <> " - the last N messages in all chats",
|
indent <> highlight "/tail [N] " <> " - the last N messages in all chats",
|
||||||
"",
|
"",
|
||||||
|
green "Search for messages",
|
||||||
|
indent <> highlight "/search @alice [N] <text>" <> " - the last N messages with alice containing <text> (10 by default)",
|
||||||
|
indent <> highlight "/search #team [N] <text> " <> " - the last N messages in the group team containing <text>",
|
||||||
|
indent <> highlight "/search [N] <text> " <> " - the last N messages in all chats containing <text>",
|
||||||
|
indent <> highlight "/?" <> " can be used instead of /search",
|
||||||
|
"",
|
||||||
green "Sending replies to messages",
|
green "Sending replies to messages",
|
||||||
"To quote a message that starts with \"hi\":",
|
"To quote a message that starts with \"hi\":",
|
||||||
indent <> highlight "> @alice (hi) <msg> " <> " - to reply to alice's most recent message",
|
indent <> highlight "> @alice (hi) <msg> " <> " - to reply to alice's most recent message",
|
||||||
@@ -164,8 +170,8 @@ messagesHelpInfo =
|
|||||||
"",
|
"",
|
||||||
green "Deleting sent messages (for everyone)",
|
green "Deleting sent messages (for everyone)",
|
||||||
"To delete a message that starts with \"hi\":",
|
"To delete a message that starts with \"hi\":",
|
||||||
indent <> highlight "\\ @alice hi " <> " - to delete your message to alice",
|
indent <> highlight "\\ @alice hi " <> " - to delete your message to alice",
|
||||||
indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team",
|
indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team",
|
||||||
"",
|
"",
|
||||||
green "Editing sent messages",
|
green "Editing sent messages",
|
||||||
"To edit your last message press up arrow, edit (keep the initial ! symbol) and press enter.",
|
"To edit your last message press up arrow, edit (keep the initial ! symbol) and press enter.",
|
||||||
@@ -193,7 +199,7 @@ settingsInfo =
|
|||||||
styleMarkdown
|
styleMarkdown
|
||||||
[ green "Chat settings:",
|
[ green "Chat settings:",
|
||||||
indent <> highlight "/network " <> " - show / set network access options",
|
indent <> highlight "/network " <> " - show / set network access options",
|
||||||
indent <> highlight "/smp_servers " <> " - show / set custom SMP servers",
|
indent <> highlight "/smp " <> " - show / set custom SMP servers",
|
||||||
indent <> highlight "/info <contact> " <> " - information about contact connection",
|
indent <> highlight "/info <contact> " <> " - information about contact connection",
|
||||||
indent <> highlight "/info #<group> <member> " <> " - information about member connection",
|
indent <> highlight "/info #<group> <member> " <> " - information about member connection",
|
||||||
indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command",
|
indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command",
|
||||||
|
|||||||
@@ -881,14 +881,9 @@ ciCallInfoText status duration = case status of
|
|||||||
CISCallRejected -> "rejected"
|
CISCallRejected -> "rejected"
|
||||||
CISCallAccepted -> "accepted"
|
CISCallAccepted -> "accepted"
|
||||||
CISCallNegotiated -> "connecting..."
|
CISCallNegotiated -> "connecting..."
|
||||||
CISCallProgress -> "in progress " <> d
|
CISCallProgress -> "in progress " <> durationText duration
|
||||||
CISCallEnded -> "ended " <> d
|
CISCallEnded -> "ended " <> durationText duration
|
||||||
CISCallError -> "error"
|
CISCallError -> "error"
|
||||||
where
|
|
||||||
d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")"
|
|
||||||
with0 n
|
|
||||||
| n < 9 = '0' : show n
|
|
||||||
| otherwise = show n
|
|
||||||
|
|
||||||
data SChatType (c :: ChatType) where
|
data SChatType (c :: ChatType) where
|
||||||
SCTDirect :: SChatType 'CTDirect
|
SCTDirect :: SChatType 'CTDirect
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.Migrations.M20221112_server_password where
|
||||||
|
|
||||||
|
import Database.SQLite.Simple (Query)
|
||||||
|
import Database.SQLite.Simple.QQ (sql)
|
||||||
|
|
||||||
|
m20221112_server_password :: Query
|
||||||
|
m20221112_server_password =
|
||||||
|
[sql|
|
||||||
|
ALTER TABLE smp_servers ADD COLUMN basic_auth TEXT;
|
||||||
|
|]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.Migrations.M20221115_server_cfg where
|
||||||
|
|
||||||
|
import Database.SQLite.Simple (Query)
|
||||||
|
import Database.SQLite.Simple.QQ (sql)
|
||||||
|
|
||||||
|
m20221115_server_cfg :: Query
|
||||||
|
m20221115_server_cfg =
|
||||||
|
[sql|
|
||||||
|
PRAGMA ignore_check_constraints=ON;
|
||||||
|
|
||||||
|
ALTER TABLE smp_servers ADD COLUMN preset INTEGER DEFAULT 0 CHECK (preset NOT NULL);
|
||||||
|
ALTER TABLE smp_servers ADD COLUMN tested INTEGER;
|
||||||
|
ALTER TABLE smp_servers ADD COLUMN enabled INTEGER DEFAULT 1 CHECK (enabled NOT NULL);
|
||||||
|
UPDATE smp_servers SET preset = 0, enabled = 1;
|
||||||
|
|
||||||
|
PRAGMA ignore_check_constraints=OFF;
|
||||||
|
|]
|
||||||
@@ -384,6 +384,10 @@ CREATE TABLE smp_servers(
|
|||||||
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||||
|
basic_auth TEXT,
|
||||||
|
preset INTEGER DEFAULT 0 CHECK(preset NOT NULL),
|
||||||
|
tested INTEGER,
|
||||||
|
enabled INTEGER DEFAULT 1 CHECK(enabled NOT NULL),
|
||||||
UNIQUE(host, port)
|
UNIQUE(host, port)
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id);
|
CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import qualified Data.ByteString.Char8 as B
|
|||||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||||
import Data.Functor (($>))
|
import Data.Functor (($>))
|
||||||
import Data.List (find)
|
import Data.List (find)
|
||||||
|
import qualified Data.List.NonEmpty as L
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Database.SQLite.Simple (SQLError (..))
|
import Database.SQLite.Simple (SQLError (..))
|
||||||
import qualified Database.SQLite.Simple as DB
|
import qualified Database.SQLite.Simple as DB
|
||||||
@@ -34,8 +35,10 @@ import Simplex.Chat.Types
|
|||||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore)
|
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore)
|
||||||
import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore)
|
import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore)
|
||||||
import Simplex.Messaging.Client (defaultNetworkConfig)
|
import Simplex.Messaging.Client (defaultNetworkConfig)
|
||||||
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
|
import Simplex.Messaging.Encoding.String
|
||||||
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
|
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
|
||||||
import Simplex.Messaging.Protocol (CorrId (..))
|
import Simplex.Messaging.Protocol (BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth)
|
||||||
import Simplex.Messaging.Util (catchAll, safeDecodeUtf8)
|
import Simplex.Messaging.Util (catchAll, safeDecodeUtf8)
|
||||||
import System.Timeout (timeout)
|
import System.Timeout (timeout)
|
||||||
|
|
||||||
@@ -58,6 +61,8 @@ foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatCont
|
|||||||
|
|
||||||
foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString
|
foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString
|
||||||
|
|
||||||
|
foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString
|
||||||
|
|
||||||
-- | check / migrate database and initialize chat controller on success
|
-- | check / migrate database and initialize chat controller on success
|
||||||
cChatMigrateInit :: CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
|
cChatMigrateInit :: CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
|
||||||
cChatMigrateInit fp key ctrl = do
|
cChatMigrateInit fp key ctrl = do
|
||||||
@@ -107,6 +112,10 @@ cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t)
|
|||||||
cChatParseMarkdown :: CString -> IO CJSONString
|
cChatParseMarkdown :: CString -> IO CJSONString
|
||||||
cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s
|
cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s
|
||||||
|
|
||||||
|
-- | parse server address - returns ParsedServerAddress JSON
|
||||||
|
cChatParseServer :: CString -> IO CJSONString
|
||||||
|
cChatParseServer s = newCAString . chatParseServer =<< peekCAString s
|
||||||
|
|
||||||
mobileChatOpts :: ChatOpts
|
mobileChatOpts :: ChatOpts
|
||||||
mobileChatOpts =
|
mobileChatOpts =
|
||||||
ChatOpts
|
ChatOpts
|
||||||
@@ -206,6 +215,18 @@ chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc)
|
|||||||
chatParseMarkdown :: String -> JSONString
|
chatParseMarkdown :: String -> JSONString
|
||||||
chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack
|
chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack
|
||||||
|
|
||||||
|
chatParseServer :: String -> JSONString
|
||||||
|
chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack
|
||||||
|
where
|
||||||
|
toServerAddress :: Either String SMPServerWithAuth -> ParsedServerAddress
|
||||||
|
toServerAddress = \case
|
||||||
|
Right (ProtoServerWithAuth ProtocolServer {host, port, keyHash = C.KeyHash kh} auth) ->
|
||||||
|
let basicAuth = maybe "" (\(BasicAuth a) -> enc a) auth
|
||||||
|
in ParsedServerAddress (Just ServerAddress {hostnames = L.map enc host, port, keyHash = enc kh, basicAuth}) ""
|
||||||
|
Left e -> ParsedServerAddress Nothing e
|
||||||
|
enc :: StrEncoding a => a -> String
|
||||||
|
enc = B.unpack . strEncode
|
||||||
|
|
||||||
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
|
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
|
||||||
deriving (Generic)
|
deriving (Generic)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
|
|||||||
import qualified Data.ByteString.Char8 as B
|
import qualified Data.ByteString.Char8 as B
|
||||||
import Options.Applicative
|
import Options.Applicative
|
||||||
import Simplex.Chat.Controller (updateStr, versionStr)
|
import Simplex.Chat.Controller (updateStr, versionStr)
|
||||||
import Simplex.Messaging.Agent.Protocol (SMPServer)
|
import Simplex.Chat.Types (ServerCfg (..))
|
||||||
import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig)
|
import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig)
|
||||||
import Simplex.Messaging.Encoding.String
|
import Simplex.Messaging.Encoding.String
|
||||||
import Simplex.Messaging.Parsers (parseAll)
|
import Simplex.Messaging.Parsers (parseAll)
|
||||||
@@ -26,7 +26,7 @@ import System.FilePath (combine)
|
|||||||
data ChatOpts = ChatOpts
|
data ChatOpts = ChatOpts
|
||||||
{ dbFilePrefix :: String,
|
{ dbFilePrefix :: String,
|
||||||
dbKey :: String,
|
dbKey :: String,
|
||||||
smpServers :: [SMPServer],
|
smpServers :: [ServerCfg],
|
||||||
networkConfig :: NetworkConfig,
|
networkConfig :: NetworkConfig,
|
||||||
logConnections :: Bool,
|
logConnections :: Bool,
|
||||||
logServerHosts :: Bool,
|
logServerHosts :: Bool,
|
||||||
@@ -155,7 +155,7 @@ fullNetworkConfig socksProxy tcpTimeout =
|
|||||||
let tcpConnectTimeout = (tcpTimeout * 3) `div` 2
|
let tcpConnectTimeout = (tcpTimeout * 3) `div` 2
|
||||||
in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout}
|
in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout}
|
||||||
|
|
||||||
parseSMPServers :: ReadM [SMPServer]
|
parseSMPServers :: ReadM [ServerCfg]
|
||||||
parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
|
parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
|
||||||
|
|
||||||
parseSocksProxy :: ReadM (Maybe SocksProxy)
|
parseSocksProxy :: ReadM (Maybe SocksProxy)
|
||||||
@@ -167,8 +167,10 @@ parseServerPort = eitherReader $ parseAll serverPortP . B.pack
|
|||||||
serverPortP :: A.Parser (Maybe String)
|
serverPortP :: A.Parser (Maybe String)
|
||||||
serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit
|
serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit
|
||||||
|
|
||||||
smpServersP :: A.Parser [SMPServer]
|
smpServersP :: A.Parser [ServerCfg]
|
||||||
smpServersP = strP `A.sepBy1` A.char ';'
|
smpServersP = (toServerCfg <$> strP) `A.sepBy1` A.char ';'
|
||||||
|
where
|
||||||
|
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
|
||||||
|
|
||||||
getChatOpts :: FilePath -> FilePath -> IO ChatOpts
|
getChatOpts :: FilePath -> FilePath -> IO ChatOpts
|
||||||
getChatOpts appDir defaultDbFileName =
|
getChatOpts appDir defaultDbFileName =
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import Data.ByteString.Internal (c2w, w2c)
|
|||||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
||||||
import Data.Time.Clock (UTCTime)
|
import Data.Time.Clock (UTCTime)
|
||||||
import Data.Type.Equality
|
import Data.Type.Equality
|
||||||
@@ -263,7 +264,7 @@ cmToQuotedMsg = \case
|
|||||||
ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg
|
ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
||||||
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCFile_ | MCUnknown_ Text
|
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVoice_ | MCFile_ | MCUnknown_ Text
|
||||||
|
|
||||||
instance StrEncoding MsgContentTag where
|
instance StrEncoding MsgContentTag where
|
||||||
strEncode = \case
|
strEncode = \case
|
||||||
@@ -271,11 +272,13 @@ instance StrEncoding MsgContentTag where
|
|||||||
MCLink_ -> "link"
|
MCLink_ -> "link"
|
||||||
MCImage_ -> "image"
|
MCImage_ -> "image"
|
||||||
MCFile_ -> "file"
|
MCFile_ -> "file"
|
||||||
|
MCVoice_ -> "voice"
|
||||||
MCUnknown_ t -> encodeUtf8 t
|
MCUnknown_ t -> encodeUtf8 t
|
||||||
strDecode = \case
|
strDecode = \case
|
||||||
"text" -> Right MCText_
|
"text" -> Right MCText_
|
||||||
"link" -> Right MCLink_
|
"link" -> Right MCLink_
|
||||||
"image" -> Right MCImage_
|
"image" -> Right MCImage_
|
||||||
|
"voice" -> Right MCVoice_
|
||||||
"file" -> Right MCFile_
|
"file" -> Right MCFile_
|
||||||
t -> Right . MCUnknown_ $ safeDecodeUtf8 t
|
t -> Right . MCUnknown_ $ safeDecodeUtf8 t
|
||||||
strP = strDecode <$?> A.takeTill (== ' ')
|
strP = strDecode <$?> A.takeTill (== ' ')
|
||||||
@@ -313,6 +316,7 @@ data MsgContent
|
|||||||
= MCText Text
|
= MCText Text
|
||||||
| MCLink {text :: Text, preview :: LinkPreview}
|
| MCLink {text :: Text, preview :: LinkPreview}
|
||||||
| MCImage {text :: Text, image :: ImageData}
|
| MCImage {text :: Text, image :: ImageData}
|
||||||
|
| MCVoice {text :: Text, duration :: Int}
|
||||||
| MCFile Text
|
| MCFile Text
|
||||||
| MCUnknown {tag :: Text, text :: Text, json :: J.Object}
|
| MCUnknown {tag :: Text, text :: Text, json :: J.Object}
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
@@ -322,14 +326,27 @@ msgContentText = \case
|
|||||||
MCText t -> t
|
MCText t -> t
|
||||||
MCLink {text} -> text
|
MCLink {text} -> text
|
||||||
MCImage {text} -> text
|
MCImage {text} -> text
|
||||||
|
MCVoice {text, duration} ->
|
||||||
|
if T.null text then msg else msg <> "; " <> text
|
||||||
|
where
|
||||||
|
msg = "voice message " <> durationText duration
|
||||||
MCFile t -> t
|
MCFile t -> t
|
||||||
MCUnknown {text} -> text
|
MCUnknown {text} -> text
|
||||||
|
|
||||||
|
durationText :: Int -> Text
|
||||||
|
durationText duration =
|
||||||
|
let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")"
|
||||||
|
where
|
||||||
|
with0 n
|
||||||
|
| n <= 9 = '0' : show n
|
||||||
|
| otherwise = show n
|
||||||
|
|
||||||
msgContentTag :: MsgContent -> MsgContentTag
|
msgContentTag :: MsgContent -> MsgContentTag
|
||||||
msgContentTag = \case
|
msgContentTag = \case
|
||||||
MCText _ -> MCText_
|
MCText _ -> MCText_
|
||||||
MCLink {} -> MCLink_
|
MCLink {} -> MCLink_
|
||||||
MCImage {} -> MCImage_
|
MCImage {} -> MCImage_
|
||||||
|
MCVoice {} -> MCVoice_
|
||||||
MCFile {} -> MCFile_
|
MCFile {} -> MCFile_
|
||||||
MCUnknown {tag} -> MCUnknown_ tag
|
MCUnknown {tag} -> MCUnknown_ tag
|
||||||
|
|
||||||
@@ -356,6 +373,10 @@ instance FromJSON MsgContent where
|
|||||||
text <- v .: "text"
|
text <- v .: "text"
|
||||||
image <- v .: "image"
|
image <- v .: "image"
|
||||||
pure MCImage {image, text}
|
pure MCImage {image, text}
|
||||||
|
MCVoice_ -> do
|
||||||
|
text <- v .: "text"
|
||||||
|
duration <- v .: "duration"
|
||||||
|
pure MCVoice {text, duration}
|
||||||
MCFile_ -> MCFile <$> v .: "text"
|
MCFile_ -> MCFile <$> v .: "text"
|
||||||
MCUnknown_ tag -> do
|
MCUnknown_ tag -> do
|
||||||
text <- fromMaybe unknownMsgType <$> v .:? "text"
|
text <- fromMaybe unknownMsgType <$> v .:? "text"
|
||||||
@@ -382,12 +403,14 @@ instance ToJSON MsgContent where
|
|||||||
MCText t -> J.object ["type" .= MCText_, "text" .= t]
|
MCText t -> J.object ["type" .= MCText_, "text" .= t]
|
||||||
MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview]
|
MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview]
|
||||||
MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image]
|
MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image]
|
||||||
|
MCVoice {text, duration} -> J.object ["type" .= MCVoice_, "text" .= text, "duration" .= duration]
|
||||||
MCFile t -> J.object ["type" .= MCFile_, "text" .= t]
|
MCFile t -> J.object ["type" .= MCFile_, "text" .= t]
|
||||||
toEncoding = \case
|
toEncoding = \case
|
||||||
MCUnknown {json} -> JE.value $ J.Object json
|
MCUnknown {json} -> JE.value $ J.Object json
|
||||||
MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t
|
MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t
|
||||||
MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview
|
MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview
|
||||||
MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image
|
MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image
|
||||||
|
MCVoice {text, duration} -> J.pairs $ "type" .= MCVoice_ <> "text" .= text <> "duration" .= duration
|
||||||
MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t
|
MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t
|
||||||
|
|
||||||
instance ToField MsgContent where
|
instance ToField MsgContent where
|
||||||
|
|||||||
+174
-99
@@ -252,6 +252,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe)
|
|||||||
import Data.Ord (Down (..))
|
import Data.Ord (Down (..))
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||||
import Data.Time.LocalTime (TimeZone, getCurrentTimeZone)
|
import Data.Time.LocalTime (TimeZone, getCurrentTimeZone)
|
||||||
import Data.Type.Equality
|
import Data.Type.Equality
|
||||||
@@ -295,6 +296,8 @@ import Simplex.Chat.Migrations.M20221021_auto_accept__group_links
|
|||||||
import Simplex.Chat.Migrations.M20221024_contact_used
|
import Simplex.Chat.Migrations.M20221024_contact_used
|
||||||
import Simplex.Chat.Migrations.M20221025_chat_settings
|
import Simplex.Chat.Migrations.M20221025_chat_settings
|
||||||
import Simplex.Chat.Migrations.M20221029_group_link_id
|
import Simplex.Chat.Migrations.M20221029_group_link_id
|
||||||
|
import Simplex.Chat.Migrations.M20221112_server_password
|
||||||
|
import Simplex.Chat.Migrations.M20221115_server_cfg
|
||||||
import Simplex.Chat.Protocol
|
import Simplex.Chat.Protocol
|
||||||
import Simplex.Chat.Types
|
import Simplex.Chat.Types
|
||||||
import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..))
|
import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..))
|
||||||
@@ -302,9 +305,9 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore
|
|||||||
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||||
import qualified Simplex.Messaging.Crypto as C
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
|
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
|
||||||
import Simplex.Messaging.Protocol (ProtocolServer (..), SMPServer, pattern SMPServer)
|
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), pattern SMPServer)
|
||||||
import Simplex.Messaging.Transport.Client (TransportHost)
|
import Simplex.Messaging.Transport.Client (TransportHost)
|
||||||
import Simplex.Messaging.Util (eitherToMaybe)
|
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
|
||||||
import UnliftIO.STM
|
import UnliftIO.STM
|
||||||
|
|
||||||
schemaMigrations :: [(String, Query)]
|
schemaMigrations :: [(String, Query)]
|
||||||
@@ -341,7 +344,9 @@ schemaMigrations =
|
|||||||
("20221021_auto_accept__group_links", m20221021_auto_accept__group_links),
|
("20221021_auto_accept__group_links", m20221021_auto_accept__group_links),
|
||||||
("20221024_contact_used", m20221024_contact_used),
|
("20221024_contact_used", m20221024_contact_used),
|
||||||
("20221025_chat_settings", m20221025_chat_settings),
|
("20221025_chat_settings", m20221025_chat_settings),
|
||||||
("20221029_group_link_id", m20221029_group_link_id)
|
("20221029_group_link_id", m20221029_group_link_id),
|
||||||
|
("20221112_server_password", m20221112_server_password),
|
||||||
|
("20221115_server_cfg", m20221115_server_cfg)
|
||||||
]
|
]
|
||||||
|
|
||||||
-- | The list of migrations in ascending order by date
|
-- | The list of migrations in ascending order by date
|
||||||
@@ -411,7 +416,7 @@ getUsers db =
|
|||||||
toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User
|
toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User
|
||||||
toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) =
|
toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) =
|
||||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
|
let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
|
||||||
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser}
|
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences}
|
||||||
|
|
||||||
setActiveUser :: DB.Connection -> UserId -> IO ()
|
setActiveUser :: DB.Connection -> UserId -> IO ()
|
||||||
setActiveUser db userId = do
|
setActiveUser db userId = do
|
||||||
@@ -435,15 +440,15 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou
|
|||||||
pccConnId <- insertedRowId db
|
pccConnId <- insertedRowId db
|
||||||
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt}
|
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt}
|
||||||
|
|
||||||
getConnReqContactXContactId :: DB.Connection -> UserId -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId)
|
getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId)
|
||||||
getConnReqContactXContactId db userId cReqHash = do
|
getConnReqContactXContactId db user@User {userId} cReqHash = do
|
||||||
getContact' >>= \case
|
getContact' >>= \case
|
||||||
c@(Just _) -> pure (c, Nothing)
|
c@(Just _) -> pure (c, Nothing)
|
||||||
Nothing -> (Nothing,) <$> getXContactId
|
Nothing -> (Nothing,) <$> getXContactId
|
||||||
where
|
where
|
||||||
getContact' :: IO (Maybe Contact)
|
getContact' :: IO (Maybe Contact)
|
||||||
getContact' =
|
getContact' =
|
||||||
maybeFirstRow toContact $
|
maybeFirstRow (toContact user) $
|
||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
@@ -531,11 +536,14 @@ createConnection_ db userId connType entityId acId viaContact viaUserContactLink
|
|||||||
where
|
where
|
||||||
ent ct = if connType == ct then entityId else Nothing
|
ent ct = if connType == ct then entityId else Nothing
|
||||||
|
|
||||||
createDirectContact :: DB.Connection -> UserId -> Connection -> Profile -> ExceptT StoreError IO Contact
|
createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
|
||||||
createDirectContact db userId activeConn@Connection {connId, localAlias} profile = do
|
createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do
|
||||||
createdAt <- liftIO getCurrentTime
|
createdAt <- liftIO getCurrentTime
|
||||||
(localDisplayName, contactId, profileId) <- createContact_ db userId connId profile localAlias Nothing createdAt
|
(localDisplayName, contactId, profileId) <- createContact_ db userId connId p localAlias Nothing createdAt
|
||||||
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile localAlias, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences = emptyChatPrefs, createdAt, updatedAt = createdAt}
|
let profile = toLocalProfile profileId p localAlias
|
||||||
|
userPreferences = emptyChatPrefs
|
||||||
|
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||||
|
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt}
|
||||||
|
|
||||||
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId)
|
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId)
|
||||||
createContact_ db userId connId Profile {displayName, fullName, image, preferences} localAlias viaGroup currentTs =
|
createContact_ db userId connId Profile {displayName, fullName, image, preferences} localAlias viaGroup currentTs =
|
||||||
@@ -629,24 +637,32 @@ updateUserProfile db User {userId, userContactId, localDisplayName, profile = Lo
|
|||||||
updateContactProfile_' db userId profileId p' currentTs
|
updateContactProfile_' db userId profileId p' currentTs
|
||||||
updateContact_ db userId userContactId localDisplayName newName currentTs
|
updateContact_ db userId userContactId localDisplayName newName currentTs
|
||||||
|
|
||||||
updateContactProfile :: DB.Connection -> UserId -> Contact -> Profile -> ExceptT StoreError IO Contact
|
updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact
|
||||||
updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} p'@Profile {displayName = newName}
|
updateContactProfile db user@User {userId} c p'
|
||||||
| displayName == newName =
|
| displayName == newName = do
|
||||||
liftIO $ updateContactProfile_ db userId profileId p' $> (c :: Contact) {profile = toLocalProfile profileId p' localAlias}
|
liftIO $ updateContactProfile_ db userId profileId p'
|
||||||
|
pure $ c {profile, mergedPreferences}
|
||||||
| otherwise =
|
| otherwise =
|
||||||
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
||||||
currentTs <- getCurrentTime
|
currentTs <- getCurrentTime
|
||||||
updateContactProfile_' db userId profileId p' currentTs
|
updateContactProfile_' db userId profileId p' currentTs
|
||||||
updateContact_ db userId contactId localDisplayName ldn currentTs
|
updateContact_ db userId contactId localDisplayName ldn currentTs
|
||||||
pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p' localAlias}
|
pure . Right $ c {localDisplayName = ldn, profile, mergedPreferences}
|
||||||
|
where
|
||||||
|
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, activeConn, userPreferences} = c
|
||||||
|
Profile {displayName = newName, preferences} = p'
|
||||||
|
profile = toLocalProfile profileId p' localAlias
|
||||||
|
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||||
|
|
||||||
updateContactUserPreferences :: DB.Connection -> UserId -> Int64 -> Preferences -> IO ()
|
updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact
|
||||||
updateContactUserPreferences db userId contactId userPreferences = do
|
updateContactUserPreferences db user@User {userId} c@Contact {contactId, activeConn} userPreferences = do
|
||||||
updatedAt <- getCurrentTime
|
updatedAt <- getCurrentTime
|
||||||
DB.execute
|
DB.execute
|
||||||
db
|
db
|
||||||
"UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
|
"UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
|
||||||
(userPreferences, updatedAt, userId, contactId)
|
(userPreferences, updatedAt, userId, contactId)
|
||||||
|
let mergedPreferences = contactUserPreferences user userPreferences (preferences' c) $ connIncognito activeConn
|
||||||
|
pure $ c {mergedPreferences, userPreferences}
|
||||||
|
|
||||||
updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact
|
updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact
|
||||||
updateContactAlias db userId c@Contact {profile = lp@LocalProfile {profileId}} localAlias = do
|
updateContactAlias db userId c@Contact {profile = lp@LocalProfile {profileId}} localAlias = do
|
||||||
@@ -719,33 +735,35 @@ updateContact_ db userId contactId displayName newName updatedAt = do
|
|||||||
|
|
||||||
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime)
|
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime)
|
||||||
|
|
||||||
toContact :: ContactRow :. ConnectionRow -> Contact
|
toContact :: User -> ContactRow :. ConnectionRow -> Contact
|
||||||
toContact (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
||||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||||
activeConn = toConnection connRow
|
activeConn = toConnection connRow
|
||||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||||
|
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||||
|
|
||||||
toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact
|
toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact
|
||||||
toContactOrError (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
||||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||||
in case toMaybeConnection connRow of
|
in case toMaybeConnection connRow of
|
||||||
Just activeConn ->
|
Just activeConn ->
|
||||||
Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||||
|
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||||
_ -> Left $ SEContactNotReady localDisplayName
|
_ -> Left $ SEContactNotReady localDisplayName
|
||||||
|
|
||||||
-- TODO return the last connection that is ready, not any last connection
|
-- TODO return the last connection that is ready, not any last connection
|
||||||
-- requires updating connection status
|
-- requires updating connection status
|
||||||
getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact
|
getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact
|
||||||
getContactByName db user@User {userId} localDisplayName = do
|
getContactByName db user localDisplayName = do
|
||||||
cId <- getContactIdByName db user localDisplayName
|
cId <- getContactIdByName db user localDisplayName
|
||||||
getContact db userId cId
|
getContact db user cId
|
||||||
|
|
||||||
getUserContacts :: DB.Connection -> User -> IO [Contact]
|
getUserContacts :: DB.Connection -> User -> IO [Contact]
|
||||||
getUserContacts db User {userId} = do
|
getUserContacts db user@User {userId} = do
|
||||||
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId)
|
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId)
|
||||||
rights <$> mapM (runExceptT . getContact db userId) contactIds
|
rights <$> mapM (runExceptT . getContact db user) contactIds
|
||||||
|
|
||||||
createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO ()
|
createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO ()
|
||||||
createUserContactLink db userId agentConnId cReq =
|
createUserContactLink db userId agentConnId cReq =
|
||||||
@@ -974,8 +992,8 @@ getGroupLinkId db User {userId} GroupInfo {groupId} =
|
|||||||
fmap join . maybeFirstRow fromOnly $
|
fmap join . maybeFirstRow fromOnly $
|
||||||
DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId)
|
DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId)
|
||||||
|
|
||||||
createOrUpdateContactRequest :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
|
createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
|
||||||
createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
|
createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
|
||||||
liftIO (maybeM getContact' xContactId_) >>= \case
|
liftIO (maybeM getContact' xContactId_) >>= \case
|
||||||
Just contact -> pure $ CORContact contact
|
Just contact -> pure $ CORContact contact
|
||||||
Nothing -> CORRequest <$> createOrUpdate_
|
Nothing -> CORRequest <$> createOrUpdate_
|
||||||
@@ -1011,7 +1029,7 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN
|
|||||||
insertedRowId db
|
insertedRowId db
|
||||||
getContact' :: XContactId -> IO (Maybe Contact)
|
getContact' :: XContactId -> IO (Maybe Contact)
|
||||||
getContact' xContactId =
|
getContact' xContactId =
|
||||||
maybeFirstRow toContact $
|
maybeFirstRow (toContact user) $
|
||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
@@ -1130,20 +1148,21 @@ deleteContactRequest db userId contactRequestId = do
|
|||||||
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
|
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
|
||||||
|
|
||||||
createAcceptedContact :: DB.Connection -> User -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact
|
createAcceptedContact :: DB.Connection -> User -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact
|
||||||
createAcceptedContact db User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do
|
createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do
|
||||||
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||||
createdAt <- getCurrentTime
|
createdAt <- getCurrentTime
|
||||||
customUserProfileId <- forM incognitoProfile $ \case
|
customUserProfileId <- forM incognitoProfile $ \case
|
||||||
NewIncognito p -> createIncognitoProfile_ db userId createdAt p
|
NewIncognito p -> createIncognitoProfile_ db userId createdAt p
|
||||||
ExistingIncognito LocalProfile {profileId = pId} -> pure pId
|
ExistingIncognito LocalProfile {profileId = pId} -> pure pId
|
||||||
let contactUserPrefs = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
|
let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
|
||||||
DB.execute
|
DB.execute
|
||||||
db
|
db
|
||||||
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?,?,?)"
|
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?,?,?)"
|
||||||
(userId, localDisplayName, profileId, True, contactUserPrefs, createdAt, createdAt, xContactId)
|
(userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, xContactId)
|
||||||
contactId <- insertedRowId db
|
contactId <- insertedRowId db
|
||||||
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt
|
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt
|
||||||
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences = contactUserPrefs, createdAt = createdAt, updatedAt = createdAt}
|
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||||
|
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt}
|
||||||
|
|
||||||
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
|
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
|
||||||
getLiveSndFileTransfers db User {userId} = do
|
getLiveSndFileTransfers db User {userId} = do
|
||||||
@@ -1246,8 +1265,8 @@ toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, v
|
|||||||
Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt)
|
Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt)
|
||||||
toMaybeConnection _ = Nothing
|
toMaybeConnection _ = Nothing
|
||||||
|
|
||||||
getMatchingContacts :: DB.Connection -> UserId -> Contact -> IO [Contact]
|
getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact]
|
||||||
getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
|
getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
|
||||||
contactIds <-
|
contactIds <-
|
||||||
map fromOnly
|
map fromOnly
|
||||||
<$> DB.query
|
<$> DB.query
|
||||||
@@ -1261,7 +1280,7 @@ getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displa
|
|||||||
AND ((p.image IS NULL AND ? IS NULL) OR p.image = ?)
|
AND ((p.image IS NULL AND ? IS NULL) OR p.image = ?)
|
||||||
|]
|
|]
|
||||||
(userId, contactId, displayName, fullName, image, image)
|
(userId, contactId, displayName, fullName, image, image)
|
||||||
rights <$> mapM (runExceptT . getContact db userId) contactIds
|
rights <$> mapM (runExceptT . getContact db user) contactIds
|
||||||
|
|
||||||
createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> Contact -> ExceptT StoreError IO (Probe, Int64)
|
createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> Contact -> ExceptT StoreError IO (Probe, Int64)
|
||||||
createSentProbe db gVar userId _to@Contact {contactId} =
|
createSentProbe db gVar userId _to@Contact {contactId} =
|
||||||
@@ -1288,8 +1307,8 @@ deleteSentProbe db userId probeId =
|
|||||||
"DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?"
|
"DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?"
|
||||||
(userId, probeId)
|
(userId, probeId)
|
||||||
|
|
||||||
matchReceivedProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact)
|
matchReceivedProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
|
||||||
matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
|
matchReceivedProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
|
||||||
let probeHash = C.sha256Hash probe
|
let probeHash = C.sha256Hash probe
|
||||||
contactIds <-
|
contactIds <-
|
||||||
map fromOnly
|
map fromOnly
|
||||||
@@ -1309,10 +1328,10 @@ matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
|
|||||||
(contactId, probe, probeHash, userId, currentTs, currentTs)
|
(contactId, probe, probeHash, userId, currentTs, currentTs)
|
||||||
case contactIds of
|
case contactIds of
|
||||||
[] -> pure Nothing
|
[] -> pure Nothing
|
||||||
cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId)
|
cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
|
||||||
|
|
||||||
matchReceivedProbeHash :: DB.Connection -> UserId -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe))
|
matchReceivedProbeHash :: DB.Connection -> User -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe))
|
||||||
matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash) = do
|
matchReceivedProbeHash db user@User {userId} _from@Contact {contactId} (ProbeHash probeHash) = do
|
||||||
namesAndProbes <-
|
namesAndProbes <-
|
||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
@@ -1332,10 +1351,10 @@ matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash)
|
|||||||
[] -> pure Nothing
|
[] -> pure Nothing
|
||||||
(cId, probe) : _ ->
|
(cId, probe) : _ ->
|
||||||
either (const Nothing) (Just . (,Probe probe))
|
either (const Nothing) (Just . (,Probe probe))
|
||||||
<$> runExceptT (getContact db userId cId)
|
<$> runExceptT (getContact db user cId)
|
||||||
|
|
||||||
matchSentProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact)
|
matchSentProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
|
||||||
matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do
|
matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
|
||||||
contactIds <-
|
contactIds <-
|
||||||
map fromOnly
|
map fromOnly
|
||||||
<$> DB.query
|
<$> DB.query
|
||||||
@@ -1350,7 +1369,7 @@ matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do
|
|||||||
(userId, probe, contactId)
|
(userId, probe, contactId)
|
||||||
case contactIds of
|
case contactIds of
|
||||||
[] -> pure Nothing
|
[] -> pure Nothing
|
||||||
cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId)
|
cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
|
||||||
|
|
||||||
mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO ()
|
mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO ()
|
||||||
mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do
|
mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do
|
||||||
@@ -1435,7 +1454,8 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
|
|||||||
toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)] =
|
toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)] =
|
||||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||||
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||||
|
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||||
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
|
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
|
||||||
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||||
getGroupAndMember_ groupMemberId c = ExceptT $ do
|
getGroupAndMember_ groupMemberId c = ExceptT $ do
|
||||||
@@ -1587,6 +1607,7 @@ updateConnectionStatus db Connection {connId} connStatus = do
|
|||||||
createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
||||||
createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
|
createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
|
||||||
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
||||||
|
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||||
currentTs <- getCurrentTime
|
currentTs <- getCurrentTime
|
||||||
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
|
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
|
||||||
groupId <- liftIO $ do
|
groupId <- liftIO $ do
|
||||||
@@ -1603,7 +1624,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
|
|||||||
memberId <- liftIO $ encodedRandomBytes gVar 12
|
memberId <- liftIO $ encodedRandomBytes gVar 12
|
||||||
membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs
|
membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs
|
||||||
let chatSettings = ChatSettings {enableNtfs = True}
|
let chatSettings = ChatSettings {enableNtfs = True}
|
||||||
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs}
|
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs}
|
||||||
|
|
||||||
-- | creates a new group record for the group the current user was invited to, or returns an existing one
|
-- | creates a new group record for the group the current user was invited to, or returns an existing one
|
||||||
createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
||||||
@@ -1630,6 +1651,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
|
|||||||
createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
||||||
createGroupInvitation_ = do
|
createGroupInvitation_ = do
|
||||||
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
||||||
|
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||||
ExceptT $
|
ExceptT $
|
||||||
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
|
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
|
||||||
currentTs <- liftIO getCurrentTime
|
currentTs <- liftIO getCurrentTime
|
||||||
@@ -1647,7 +1669,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
|
|||||||
GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs
|
GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs
|
||||||
membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs
|
membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs
|
||||||
let chatSettings = ChatSettings {enableNtfs = True}
|
let chatSettings = ChatSettings {enableNtfs = True}
|
||||||
pure (GroupInfo {groupId, localDisplayName, groupProfile, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}, groupMemberId)
|
pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}, groupMemberId)
|
||||||
|
|
||||||
getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId
|
getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId
|
||||||
getHostMemberId_ db User {userId} groupId =
|
getHostMemberId_ db User {userId} groupId =
|
||||||
@@ -1802,7 +1824,8 @@ toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
|
|||||||
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) =
|
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) =
|
||||||
let membership = toGroupMember userContactId userMemberRow
|
let membership = toGroupMember userContactId userMemberRow
|
||||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||||
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
|
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||||
|
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
|
||||||
|
|
||||||
getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember
|
getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember
|
||||||
getGroupMember db user@User {userId} groupId groupMemberId =
|
getGroupMember db user@User {userId} groupId groupMemberId =
|
||||||
@@ -1973,8 +1996,8 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co
|
|||||||
)
|
)
|
||||||
|
|
||||||
getContactViaMember :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
|
getContactViaMember :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
|
||||||
getContactViaMember db User {userId} GroupMember {groupMemberId} =
|
getContactViaMember db user@User {userId} GroupMember {groupMemberId} =
|
||||||
maybeFirstRow toContact $
|
maybeFirstRow (toContact user) $
|
||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
@@ -2099,7 +2122,7 @@ cleanupMemberContactAndProfile_ :: DB.Connection -> User -> GroupMember -> IO ()
|
|||||||
cleanupMemberContactAndProfile_ db user@User {userId} m@GroupMember {groupMemberId, localDisplayName, memberContactId, memberContactProfileId, memberProfile = LocalProfile {profileId}} =
|
cleanupMemberContactAndProfile_ db user@User {userId} m@GroupMember {groupMemberId, localDisplayName, memberContactId, memberContactProfileId, memberProfile = LocalProfile {profileId}} =
|
||||||
case memberContactId of
|
case memberContactId of
|
||||||
Just contactId ->
|
Just contactId ->
|
||||||
runExceptT (getContact db userId contactId) >>= \case
|
runExceptT (getContact db user contactId) >>= \case
|
||||||
Right ct@Contact {activeConn = Connection {connLevel, viaGroupLink}, contactUsed} ->
|
Right ct@Contact {activeConn = Connection {connLevel, viaGroupLink}, contactUsed} ->
|
||||||
unless ((connLevel == 0 && not viaGroupLink) || contactUsed) $ deleteContact db user ct
|
unless ((connLevel == 0 && not viaGroupLink) || contactUsed) $ deleteContact db user ct
|
||||||
_ -> pure ()
|
_ -> pure ()
|
||||||
@@ -2317,7 +2340,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
|
|||||||
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow})
|
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow})
|
||||||
|
|
||||||
getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
|
getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
|
||||||
getViaGroupContact db User {userId} GroupMember {groupMemberId} =
|
getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
|
||||||
maybeFirstRow toContact' $
|
maybeFirstRow toContact' $
|
||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
@@ -2344,7 +2367,8 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} =
|
|||||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||||
activeConn = toConnection connRow
|
activeConn = toConnection connRow
|
||||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||||
|
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||||
|
|
||||||
createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta
|
createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta
|
||||||
createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do
|
createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do
|
||||||
@@ -2656,7 +2680,7 @@ getRcvFileTransfer db user@User {userId} fileId = do
|
|||||||
rfi_ = \case
|
rfi_ = \case
|
||||||
(Just filePath, Just connId, Just agentConnId, _, _, _, _) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId}
|
(Just filePath, Just connId, Just agentConnId, _, _, _, _) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId}
|
||||||
(Just filePath, Nothing, Nothing, Just contactId, _, _, True) -> do
|
(Just filePath, Nothing, Nothing, Just contactId, _, _, True) -> do
|
||||||
Contact {activeConn = Connection {connId, agentConnId}} <- getContact db userId contactId
|
Contact {activeConn = Connection {connId, agentConnId}} <- getContact db user contactId
|
||||||
pure $ Just RcvFileInfo {filePath, connId, agentConnId}
|
pure $ Just RcvFileInfo {filePath, connId, agentConnId}
|
||||||
(Just filePath, Nothing, Nothing, _, Just groupId, Just groupMemberId, True) -> do
|
(Just filePath, Nothing, Nothing, _, Just groupId, Just groupMemberId, True) -> do
|
||||||
getGroupMember db user groupId groupMemberId >>= \case
|
getGroupMember db user groupId groupMemberId >>= \case
|
||||||
@@ -3191,7 +3215,7 @@ getChatPreviews db user withPCC = do
|
|||||||
ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo
|
ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo
|
||||||
|
|
||||||
getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat]
|
getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat]
|
||||||
getDirectChatPreviews_ db User {userId} = do
|
getDirectChatPreviews_ db user@User {userId} = do
|
||||||
tz <- getCurrentTimeZone
|
tz <- getCurrentTimeZone
|
||||||
currentTs <- getCurrentTime
|
currentTs <- getCurrentTime
|
||||||
map (toDirectChatPreview tz currentTs)
|
map (toDirectChatPreview tz currentTs)
|
||||||
@@ -3230,7 +3254,7 @@ getDirectChatPreviews_ db User {userId} = do
|
|||||||
WHERE item_status = ? AND item_deleted != 1
|
WHERE item_status = ? AND item_deleted != 1
|
||||||
GROUP BY contact_id
|
GROUP BY contact_id
|
||||||
) ChatStats ON ChatStats.contact_id = ct.contact_id
|
) ChatStats ON ChatStats.contact_id = ct.contact_id
|
||||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||||
WHERE ct.user_id = ?
|
WHERE ct.user_id = ?
|
||||||
AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1)
|
AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1)
|
||||||
AND c.connection_id = (
|
AND c.connection_id = (
|
||||||
@@ -3250,7 +3274,7 @@ getDirectChatPreviews_ db User {userId} = do
|
|||||||
where
|
where
|
||||||
toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
|
toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
|
||||||
toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) =
|
toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) =
|
||||||
let contact = toContact $ contactRow :. connRow
|
let contact = toContact user $ contactRow :. connRow
|
||||||
ci_ = toDirectChatItemList tz currentTs ciRow_
|
ci_ = toDirectChatItemList tz currentTs ciRow_
|
||||||
stats = toChatStats statsRow
|
stats = toChatStats statsRow
|
||||||
in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats
|
in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats
|
||||||
@@ -3307,7 +3331,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
|
|||||||
) ChatStats ON ChatStats.group_id = g.group_id
|
) ChatStats ON ChatStats.group_id = g.group_id
|
||||||
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
|
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
|
||||||
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id
|
||||||
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
|
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
|
||||||
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
|
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
|
||||||
WHERE g.user_id = ? AND mu.contact_id = ?
|
WHERE g.user_id = ? AND mu.contact_id = ?
|
||||||
@@ -3417,8 +3441,8 @@ getDirectChat db user contactId pagination search_ = do
|
|||||||
CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search
|
CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search
|
||||||
|
|
||||||
getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||||
getDirectChatLast_ db User {userId} contactId count search = do
|
getDirectChatLast_ db user@User {userId} contactId count search = do
|
||||||
contact <- getContact db userId contactId
|
contact <- getContact db user contactId
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItems <- ExceptT getDirectChatItemsLast_
|
chatItems <- ExceptT getDirectChatItemsLast_
|
||||||
pure $ Chat (DirectChat contact) (reverse chatItems) stats
|
pure $ Chat (DirectChat contact) (reverse chatItems) stats
|
||||||
@@ -3440,7 +3464,7 @@ getDirectChatLast_ db User {userId} contactId count search = do
|
|||||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||||
FROM chat_items i
|
FROM chat_items i
|
||||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
||||||
ORDER BY i.chat_item_id DESC
|
ORDER BY i.chat_item_id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -3448,8 +3472,8 @@ getDirectChatLast_ db User {userId} contactId count search = do
|
|||||||
(userId, contactId, search, count)
|
(userId, contactId, search, count)
|
||||||
|
|
||||||
getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||||
getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
|
getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search = do
|
||||||
contact <- getContact db userId contactId
|
contact <- getContact db user contactId
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItems <- ExceptT getDirectChatItemsAfter_
|
chatItems <- ExceptT getDirectChatItemsAfter_
|
||||||
pure $ Chat (DirectChat contact) chatItems stats
|
pure $ Chat (DirectChat contact) chatItems stats
|
||||||
@@ -3471,7 +3495,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
|
|||||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||||
FROM chat_items i
|
FROM chat_items i
|
||||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
||||||
AND i.chat_item_id > ?
|
AND i.chat_item_id > ?
|
||||||
ORDER BY i.chat_item_id ASC
|
ORDER BY i.chat_item_id ASC
|
||||||
@@ -3480,8 +3504,8 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
|
|||||||
(userId, contactId, search, afterChatItemId, count)
|
(userId, contactId, search, afterChatItemId, count)
|
||||||
|
|
||||||
getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||||
getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search = do
|
getDirectChatBefore_ db user@User {userId} contactId beforeChatItemId count search = do
|
||||||
contact <- getContact db userId contactId
|
contact <- getContact db user contactId
|
||||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||||
chatItems <- ExceptT getDirectChatItemsBefore_
|
chatItems <- ExceptT getDirectChatItemsBefore_
|
||||||
pure $ Chat (DirectChat contact) (reverse chatItems) stats
|
pure $ Chat (DirectChat contact) (reverse chatItems) stats
|
||||||
@@ -3503,7 +3527,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search =
|
|||||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||||
FROM chat_items i
|
FROM chat_items i
|
||||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
||||||
AND i.chat_item_id < ?
|
AND i.chat_item_id < ?
|
||||||
ORDER BY i.chat_item_id DESC
|
ORDER BY i.chat_item_id DESC
|
||||||
@@ -3516,9 +3540,9 @@ getContactIdByName db User {userId} cName =
|
|||||||
ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $
|
ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $
|
||||||
DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName)
|
DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName)
|
||||||
|
|
||||||
getContact :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO Contact
|
getContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact
|
||||||
getContact db userId contactId =
|
getContact db user@User {userId} contactId =
|
||||||
ExceptT . fmap join . firstRow toContactOrError (SEContactNotFound contactId) $
|
ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $
|
||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
@@ -3646,17 +3670,18 @@ getGroupInfo db User {userId, userContactId} groupId =
|
|||||||
(groupId, userId, userContactId)
|
(groupId, userId, userContactId)
|
||||||
|
|
||||||
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
||||||
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, groupPreferences}} p'@GroupProfile {displayName = newName, fullName, image}
|
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, image, groupPreferences}
|
||||||
| displayName == newName = liftIO $ do
|
| displayName == newName = liftIO $ do
|
||||||
currentTs <- getCurrentTime
|
currentTs <- getCurrentTime
|
||||||
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p'}
|
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p', fullGroupPreferences}
|
||||||
| otherwise =
|
| otherwise =
|
||||||
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
||||||
currentTs <- getCurrentTime
|
currentTs <- getCurrentTime
|
||||||
updateGroupProfile_ currentTs
|
updateGroupProfile_ currentTs
|
||||||
updateGroup_ ldn currentTs
|
updateGroup_ ldn currentTs
|
||||||
pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p'}
|
pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences}
|
||||||
where
|
where
|
||||||
|
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||||
updateGroupProfile_ currentTs =
|
updateGroupProfile_ currentTs =
|
||||||
DB.execute
|
DB.execute
|
||||||
db
|
db
|
||||||
@@ -3677,15 +3702,16 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou
|
|||||||
(ldn, currentTs, userId, groupId)
|
(ldn, currentTs, userId, groupId)
|
||||||
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
|
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
|
||||||
|
|
||||||
getAllChatItems :: DB.Connection -> User -> ChatPagination -> ExceptT StoreError IO [AChatItem]
|
getAllChatItems :: DB.Connection -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem]
|
||||||
getAllChatItems db user pagination = do
|
getAllChatItems db user pagination search_ = do
|
||||||
|
let search = fromMaybe "" search_
|
||||||
case pagination of
|
case pagination of
|
||||||
CPLast count -> getAllChatItemsLast_ db user count
|
CPLast count -> getAllChatItemsLast_ db user count search
|
||||||
CPAfter _afterId _count -> throwError $ SEInternalError "not implemented"
|
CPAfter afterId count -> getAllChatItemsAfter_ db user afterId count search
|
||||||
CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented"
|
CPBefore beforeId count -> getAllChatItemsBefore_ db user beforeId count search
|
||||||
|
|
||||||
getAllChatItemsLast_ :: DB.Connection -> User -> Int -> ExceptT StoreError IO [AChatItem]
|
getAllChatItemsLast_ :: DB.Connection -> User -> Int -> String -> ExceptT StoreError IO [AChatItem]
|
||||||
getAllChatItemsLast_ db user@User {userId} count = do
|
getAllChatItemsLast_ db user@User {userId} count search = do
|
||||||
itemRefs <-
|
itemRefs <-
|
||||||
liftIO $
|
liftIO $
|
||||||
reverse . rights . map toChatItemRef
|
reverse . rights . map toChatItemRef
|
||||||
@@ -3694,13 +3720,59 @@ getAllChatItemsLast_ db user@User {userId} count = do
|
|||||||
[sql|
|
[sql|
|
||||||
SELECT chat_item_id, contact_id, group_id
|
SELECT chat_item_id, contact_id, group_id
|
||||||
FROM chat_items
|
FROM chat_items
|
||||||
WHERE user_id = ?
|
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
|
||||||
ORDER BY item_ts DESC, chat_item_id DESC
|
ORDER BY item_ts DESC, chat_item_id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
|]
|
|]
|
||||||
(userId, count)
|
(userId, search, count)
|
||||||
mapM (uncurry $ getAChatItem_ db user) itemRefs
|
mapM (uncurry $ getAChatItem_ db user) itemRefs
|
||||||
|
|
||||||
|
getAllChatItemsAfter_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO [AChatItem]
|
||||||
|
getAllChatItemsAfter_ db user@User {userId} afterItemId count search = do
|
||||||
|
AChatItem _ _ _ afterItem <- getAChatItem db user afterItemId
|
||||||
|
itemRefs <- liftIO $ getChatItemRefsAfter_ (chatItemTs' afterItem)
|
||||||
|
mapM (uncurry $ getAChatItem_ db user) itemRefs
|
||||||
|
where
|
||||||
|
getChatItemRefsAfter_ afterItemTs =
|
||||||
|
rights . map toChatItemRef
|
||||||
|
<$> DB.query
|
||||||
|
db
|
||||||
|
[sql|
|
||||||
|
SELECT chat_item_id, contact_id, group_id
|
||||||
|
FROM chat_items
|
||||||
|
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
|
||||||
|
AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?))
|
||||||
|
ORDER BY item_ts ASC, chat_item_id ASC
|
||||||
|
LIMIT ?
|
||||||
|
|]
|
||||||
|
(userId, search, afterItemTs, afterItemTs, afterItemId, count)
|
||||||
|
|
||||||
|
getAllChatItemsBefore_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO [AChatItem]
|
||||||
|
getAllChatItemsBefore_ db user@User {userId} beforeItemId count search = do
|
||||||
|
AChatItem _ _ _ beforeItem <- getAChatItem db user beforeItemId
|
||||||
|
itemRefs <- liftIO $ getChatItemRefsBefore_ (chatItemTs' beforeItem)
|
||||||
|
mapM (uncurry $ getAChatItem_ db user) itemRefs
|
||||||
|
where
|
||||||
|
getChatItemRefsBefore_ beforeItemTs =
|
||||||
|
reverse . rights . map toChatItemRef
|
||||||
|
<$> DB.query
|
||||||
|
db
|
||||||
|
[sql|
|
||||||
|
SELECT chat_item_id, contact_id, group_id
|
||||||
|
FROM chat_items
|
||||||
|
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
|
||||||
|
AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?))
|
||||||
|
ORDER BY item_ts DESC, chat_item_id DESC
|
||||||
|
LIMIT ?
|
||||||
|
|]
|
||||||
|
(userId, search, beforeItemTs, beforeItemTs, beforeItemId, count)
|
||||||
|
|
||||||
|
getAChatItem :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO AChatItem
|
||||||
|
getAChatItem db user@User {userId} itemId = do
|
||||||
|
afterItemRef <- ExceptT $ firstRow' toChatItemRef (SEChatItemNotFound itemId) $
|
||||||
|
DB.query db "SELECT chat_item_id, contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId)
|
||||||
|
uncurry (getAChatItem_ db user) afterItemRef
|
||||||
|
|
||||||
getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId
|
getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId
|
||||||
getGroupIdByName db User {userId} gName =
|
getGroupIdByName db User {userId} gName =
|
||||||
ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $
|
ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $
|
||||||
@@ -3861,7 +3933,7 @@ getDirectChatItem db userId contactId itemId = ExceptT $ do
|
|||||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||||
FROM chat_items i
|
FROM chat_items i
|
||||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ?
|
WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ?
|
||||||
|]
|
|]
|
||||||
(userId, contactId, itemId)
|
(userId, contactId, itemId)
|
||||||
@@ -3986,7 +4058,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
|
|||||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||||
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
|
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
|
||||||
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id
|
||||||
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
|
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
|
||||||
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
|
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
|
||||||
WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ?
|
WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ?
|
||||||
@@ -4072,7 +4144,7 @@ getChatItemByGroupId db user@User {userId} groupId = do
|
|||||||
getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem
|
getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem
|
||||||
getAChatItem_ db user@User {userId} itemId = \case
|
getAChatItem_ db user@User {userId} itemId = \case
|
||||||
ChatRef CTDirect contactId -> do
|
ChatRef CTDirect contactId -> do
|
||||||
ct <- getContact db userId contactId
|
ct <- getContact db user contactId
|
||||||
(CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId
|
(CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId
|
||||||
pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci
|
pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci
|
||||||
ChatRef CTGroup groupId -> do
|
ChatRef CTGroup groupId -> do
|
||||||
@@ -4234,35 +4306,38 @@ toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just
|
|||||||
either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_)
|
either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_)
|
||||||
toGroupChatItemList _ _ _ _ = []
|
toGroupChatItemList _ _ _ _ = []
|
||||||
|
|
||||||
getSMPServers :: DB.Connection -> User -> IO [SMPServer]
|
getSMPServers :: DB.Connection -> User -> IO [ServerCfg]
|
||||||
getSMPServers db User {userId} =
|
getSMPServers db User {userId} =
|
||||||
map toSmpServer
|
map toServerCfg
|
||||||
<$> DB.query
|
<$> DB.query
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
SELECT host, port, key_hash
|
SELECT host, port, key_hash, basic_auth, preset, tested, enabled
|
||||||
FROM smp_servers
|
FROM smp_servers
|
||||||
WHERE user_id = ?;
|
WHERE user_id = ?;
|
||||||
|]
|
|]
|
||||||
(Only userId)
|
(Only userId)
|
||||||
where
|
where
|
||||||
toSmpServer :: (NonEmpty TransportHost, String, C.KeyHash) -> SMPServer
|
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg
|
||||||
toSmpServer (host, port, keyHash) = SMPServer host port keyHash
|
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
|
||||||
|
let server = ProtoServerWithAuth (SMPServer host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
|
||||||
|
in ServerCfg {server, preset, tested, enabled}
|
||||||
|
|
||||||
overwriteSMPServers :: DB.Connection -> User -> [SMPServer] -> ExceptT StoreError IO ()
|
overwriteSMPServers :: DB.Connection -> User -> [ServerCfg] -> ExceptT StoreError IO ()
|
||||||
overwriteSMPServers db User {userId} smpServers =
|
overwriteSMPServers db User {userId} servers =
|
||||||
checkConstraint SEUniqueID . ExceptT $ do
|
checkConstraint SEUniqueID . ExceptT $ do
|
||||||
currentTs <- getCurrentTime
|
currentTs <- getCurrentTime
|
||||||
DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId)
|
DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId)
|
||||||
forM_ smpServers $ \ProtocolServer {host, port, keyHash} ->
|
forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do
|
||||||
|
let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server
|
||||||
DB.execute
|
DB.execute
|
||||||
db
|
db
|
||||||
[sql|
|
[sql|
|
||||||
INSERT INTO smp_servers
|
INSERT INTO smp_servers
|
||||||
(host, port, key_hash, user_id, created_at, updated_at)
|
(host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
|
||||||
VALUES (?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|]
|
|]
|
||||||
(host, port, keyHash, userId, currentTs, currentTs)
|
(host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, preset, tested, enabled, userId, currentTs, currentTs)
|
||||||
pure $ Right ()
|
pure $ Right ()
|
||||||
|
|
||||||
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
|
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Control.Monad.Reader
|
|||||||
import Data.List (dropWhileEnd)
|
import Data.List (dropWhileEnd)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Text.Encoding (encodeUtf8)
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
import Simplex.Chat
|
import Simplex.Chat
|
||||||
import Simplex.Chat.Controller
|
import Simplex.Chat.Controller
|
||||||
import Simplex.Chat.Styled
|
import Simplex.Chat.Styled
|
||||||
@@ -41,7 +42,8 @@ runInputLoop ct cc = forever $ do
|
|||||||
_ -> pure ()
|
_ -> pure ()
|
||||||
let testV = testView $ config cc
|
let testV = testView $ config cc
|
||||||
user <- readTVarIO $ currentUser cc
|
user <- readTVarIO $ currentUser cc
|
||||||
printToTerminal ct $ responseToView user testV r
|
ts <- getCurrentTime
|
||||||
|
printToTerminal ct $ responseToView user testV ts r
|
||||||
where
|
where
|
||||||
echo s = printToTerminal ct [plain s]
|
echo s = printToTerminal ct [plain s]
|
||||||
isMessage = \case
|
isMessage = \case
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ module Simplex.Chat.Terminal.Output where
|
|||||||
import Control.Monad.Catch (MonadMask)
|
import Control.Monad.Catch (MonadMask)
|
||||||
import Control.Monad.IO.Unlift
|
import Control.Monad.IO.Unlift
|
||||||
import Control.Monad.Reader
|
import Control.Monad.Reader
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
import Simplex.Chat.Controller
|
import Simplex.Chat.Controller
|
||||||
import Simplex.Chat.Styled
|
import Simplex.Chat.Styled
|
||||||
import Simplex.Chat.View
|
import Simplex.Chat.View
|
||||||
@@ -78,7 +79,8 @@ runTerminalOutput ct cc = do
|
|||||||
forever $ do
|
forever $ do
|
||||||
(_, r) <- atomically . readTBQueue $ outputQ cc
|
(_, r) <- atomically . readTBQueue $ outputQ cc
|
||||||
user <- readTVarIO $ currentUser cc
|
user <- readTVarIO $ currentUser cc
|
||||||
printToTerminal ct $ responseToView user testV r
|
ts <- getCurrentTime
|
||||||
|
printToTerminal ct $ responseToView user testV ts r
|
||||||
|
|
||||||
printToTerminal :: ChatTerminal -> [StyledString] -> IO ()
|
printToTerminal :: ChatTerminal -> [StyledString] -> IO ()
|
||||||
printToTerminal ct s =
|
printToTerminal ct s =
|
||||||
|
|||||||
+112
-36
@@ -44,6 +44,7 @@ import GHC.Generics (Generic)
|
|||||||
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId)
|
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId)
|
||||||
import Simplex.Messaging.Encoding.String
|
import Simplex.Messaging.Encoding.String
|
||||||
import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON)
|
import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON)
|
||||||
|
import Simplex.Messaging.Protocol (SMPServerWithAuth)
|
||||||
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
|
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
|
||||||
|
|
||||||
class IsContact a where
|
class IsContact a where
|
||||||
@@ -66,6 +67,7 @@ data User = User
|
|||||||
userContactId :: ContactId,
|
userContactId :: ContactId,
|
||||||
localDisplayName :: ContactName,
|
localDisplayName :: ContactName,
|
||||||
profile :: LocalProfile,
|
profile :: LocalProfile,
|
||||||
|
fullPreferences :: FullPreferences,
|
||||||
activeUser :: Bool
|
activeUser :: Bool
|
||||||
}
|
}
|
||||||
deriving (Show, Generic, FromJSON)
|
deriving (Show, Generic, FromJSON)
|
||||||
@@ -87,6 +89,7 @@ data Contact = Contact
|
|||||||
contactUsed :: Bool,
|
contactUsed :: Bool,
|
||||||
chatSettings :: ChatSettings,
|
chatSettings :: ChatSettings,
|
||||||
userPreferences :: Preferences,
|
userPreferences :: Preferences,
|
||||||
|
mergedPreferences :: ContactUserPreferences,
|
||||||
createdAt :: UTCTime,
|
createdAt :: UTCTime,
|
||||||
updatedAt :: UTCTime
|
updatedAt :: UTCTime
|
||||||
}
|
}
|
||||||
@@ -100,13 +103,10 @@ contactConn :: Contact -> Connection
|
|||||||
contactConn = activeConn
|
contactConn = activeConn
|
||||||
|
|
||||||
contactConnId :: Contact -> ConnId
|
contactConnId :: Contact -> ConnId
|
||||||
contactConnId Contact {activeConn} = aConnId activeConn
|
contactConnId = aConnId . contactConn
|
||||||
|
|
||||||
contactConnIncognito :: Contact -> Bool
|
contactConnIncognito :: Contact -> Bool
|
||||||
contactConnIncognito = isJust . customUserProfileId'
|
contactConnIncognito = connIncognito . contactConn
|
||||||
|
|
||||||
customUserProfileId' :: Contact -> Maybe Int64
|
|
||||||
customUserProfileId' Contact {activeConn} = customUserProfileId (activeConn :: Connection)
|
|
||||||
|
|
||||||
data ContactRef = ContactRef
|
data ContactRef = ContactRef
|
||||||
{ contactId :: ContactId,
|
{ contactId :: ContactId,
|
||||||
@@ -207,6 +207,7 @@ data GroupInfo = GroupInfo
|
|||||||
{ groupId :: GroupId,
|
{ groupId :: GroupId,
|
||||||
localDisplayName :: GroupName,
|
localDisplayName :: GroupName,
|
||||||
groupProfile :: GroupProfile,
|
groupProfile :: GroupProfile,
|
||||||
|
fullGroupPreferences :: FullGroupPreferences,
|
||||||
membership :: GroupMember,
|
membership :: GroupMember,
|
||||||
hostConnCustomUserProfileId :: Maybe ProfileId,
|
hostConnCustomUserProfileId :: Maybe ProfileId,
|
||||||
chatSettings :: ChatSettings,
|
chatSettings :: ChatSettings,
|
||||||
@@ -293,6 +294,39 @@ data Preferences = Preferences
|
|||||||
}
|
}
|
||||||
deriving (Eq, Show, Generic, FromJSON)
|
deriving (Eq, Show, Generic, FromJSON)
|
||||||
|
|
||||||
|
instance ToJSON Preferences where
|
||||||
|
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||||
|
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||||
|
|
||||||
|
instance ToField Preferences where
|
||||||
|
toField = toField . encodeJSON
|
||||||
|
|
||||||
|
instance FromField Preferences where
|
||||||
|
fromField = fromTextField_ decodeJSON
|
||||||
|
|
||||||
|
groupPrefSel :: ChatFeature -> GroupPreferences -> Maybe GroupPreference
|
||||||
|
groupPrefSel = \case
|
||||||
|
CFFullDelete -> fullDelete
|
||||||
|
-- CFReceipts -> receipts
|
||||||
|
CFVoice -> voice
|
||||||
|
|
||||||
|
class GroupPreferenceI p where
|
||||||
|
getGroupPreference :: ChatFeature -> p -> GroupPreference
|
||||||
|
|
||||||
|
instance GroupPreferenceI GroupPreferences where
|
||||||
|
getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt prefs)
|
||||||
|
|
||||||
|
instance GroupPreferenceI (Maybe GroupPreferences) where
|
||||||
|
getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs)
|
||||||
|
|
||||||
|
instance GroupPreferenceI FullGroupPreferences where
|
||||||
|
getGroupPreference = \case
|
||||||
|
CFFullDelete -> fullDelete
|
||||||
|
-- CFReceipts -> receipts
|
||||||
|
CFVoice -> voice
|
||||||
|
{-# INLINE getGroupPreference #-}
|
||||||
|
|
||||||
|
-- collection of optional group preferences
|
||||||
data GroupPreferences = GroupPreferences
|
data GroupPreferences = GroupPreferences
|
||||||
{ fullDelete :: Maybe GroupPreference,
|
{ fullDelete :: Maybe GroupPreference,
|
||||||
-- receipts :: Maybe GroupPreference,
|
-- receipts :: Maybe GroupPreference,
|
||||||
@@ -317,7 +351,20 @@ data FullPreferences = FullPreferences
|
|||||||
-- receipts :: Preference,
|
-- receipts :: Preference,
|
||||||
voice :: Preference
|
voice :: Preference
|
||||||
}
|
}
|
||||||
deriving (Eq)
|
deriving (Eq, Show, Generic, FromJSON)
|
||||||
|
|
||||||
|
instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
|
-- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing
|
||||||
|
-- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here.
|
||||||
|
data FullGroupPreferences = FullGroupPreferences
|
||||||
|
{ fullDelete :: GroupPreference,
|
||||||
|
-- receipts :: GroupPreference,
|
||||||
|
voice :: GroupPreference
|
||||||
|
}
|
||||||
|
deriving (Eq, Show, Generic, FromJSON)
|
||||||
|
|
||||||
|
instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
-- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences
|
-- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences
|
||||||
data ContactUserPreferences = ContactUserPreferences
|
data ContactUserPreferences = ContactUserPreferences
|
||||||
@@ -325,17 +372,17 @@ data ContactUserPreferences = ContactUserPreferences
|
|||||||
-- receipts :: ContactUserPreference,
|
-- receipts :: ContactUserPreference,
|
||||||
voice :: ContactUserPreference
|
voice :: ContactUserPreference
|
||||||
}
|
}
|
||||||
deriving (Show, Generic)
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
data ContactUserPreference = ContactUserPreference
|
data ContactUserPreference = ContactUserPreference
|
||||||
{ enabled :: PrefEnabled,
|
{ enabled :: PrefEnabled,
|
||||||
userPreference :: ContactUserPref,
|
userPreference :: ContactUserPref,
|
||||||
contactPreference :: Preference
|
contactPreference :: Preference
|
||||||
}
|
}
|
||||||
deriving (Show, Generic)
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
data ContactUserPref = CUPContact {preference :: Preference} | CUPUser {preference :: Preference}
|
data ContactUserPref = CUPContact {preference :: Preference} | CUPUser {preference :: Preference}
|
||||||
deriving (Show, Generic)
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions
|
instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
@@ -364,26 +411,24 @@ defaultChatPrefs =
|
|||||||
emptyChatPrefs :: Preferences
|
emptyChatPrefs :: Preferences
|
||||||
emptyChatPrefs = Preferences Nothing Nothing
|
emptyChatPrefs = Preferences Nothing Nothing
|
||||||
|
|
||||||
instance ToJSON Preferences where
|
defaultGroupPrefs :: FullGroupPreferences
|
||||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
defaultGroupPrefs =
|
||||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
FullGroupPreferences
|
||||||
|
{ fullDelete = GroupPreference {enable = FEOff},
|
||||||
instance ToField Preferences where
|
-- receipts = GroupPreference {enable = FEOff},
|
||||||
toField = toField . encodeJSON
|
voice = GroupPreference {enable = FEOn}
|
||||||
|
}
|
||||||
instance FromField Preferences where
|
|
||||||
fromField = fromTextField_ decodeJSON
|
|
||||||
|
|
||||||
data Preference = Preference
|
data Preference = Preference
|
||||||
{allow :: FeatureAllowed}
|
{allow :: FeatureAllowed}
|
||||||
deriving (Eq, Show, Generic, FromJSON)
|
deriving (Eq, Show, Generic, FromJSON)
|
||||||
|
|
||||||
|
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
data GroupPreference = GroupPreference
|
data GroupPreference = GroupPreference
|
||||||
{enable :: GroupFeatureEnabled}
|
{enable :: GroupFeatureEnabled}
|
||||||
deriving (Eq, Show, Generic, FromJSON)
|
deriving (Eq, Show, Generic, FromJSON)
|
||||||
|
|
||||||
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
|
|
||||||
|
|
||||||
instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
|
instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
|
||||||
|
|
||||||
data FeatureAllowed
|
data FeatureAllowed
|
||||||
@@ -392,9 +437,6 @@ data FeatureAllowed
|
|||||||
| FANo -- do not allow
|
| FANo -- do not allow
|
||||||
deriving (Eq, Show, Generic)
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
data GroupFeatureEnabled = FEOn | FEOff
|
|
||||||
deriving (Eq, Show, Generic)
|
|
||||||
|
|
||||||
instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode
|
instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode
|
||||||
|
|
||||||
instance ToField FeatureAllowed where toField = toField . strEncode
|
instance ToField FeatureAllowed where toField = toField . strEncode
|
||||||
@@ -418,6 +460,9 @@ instance ToJSON FeatureAllowed where
|
|||||||
toJSON = strToJSON
|
toJSON = strToJSON
|
||||||
toEncoding = strToJEncoding
|
toEncoding = strToJEncoding
|
||||||
|
|
||||||
|
data GroupFeatureEnabled = FEOn | FEOff
|
||||||
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode
|
instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode
|
||||||
|
|
||||||
instance ToField GroupFeatureEnabled where toField = toField . strEncode
|
instance ToField GroupFeatureEnabled where toField = toField . strEncode
|
||||||
@@ -452,12 +497,25 @@ mergePreferences contactPrefs userPreferences =
|
|||||||
in fromMaybe (getPreference pt defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel)
|
in fromMaybe (getPreference pt defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel)
|
||||||
|
|
||||||
mergeUserChatPrefs :: User -> Contact -> FullPreferences
|
mergeUserChatPrefs :: User -> Contact -> FullPreferences
|
||||||
mergeUserChatPrefs user ct =
|
mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct)
|
||||||
let userPrefs = if contactConnIncognito ct then Nothing else preferences' user
|
|
||||||
in mergePreferences (Just $ userPreferences ct) userPrefs
|
mergeUserChatPrefs' :: User -> Bool -> Preferences -> FullPreferences
|
||||||
|
mergeUserChatPrefs' user connectedIncognito userPreferences =
|
||||||
|
let userPrefs = if connectedIncognito then Nothing else preferences' user
|
||||||
|
in mergePreferences (Just userPreferences) userPrefs
|
||||||
|
|
||||||
|
mergeGroupPreferences :: Maybe GroupPreferences -> FullGroupPreferences
|
||||||
|
mergeGroupPreferences groupPreferences =
|
||||||
|
FullGroupPreferences
|
||||||
|
{ fullDelete = pref CFFullDelete,
|
||||||
|
-- receipts = pref CFReceipts,
|
||||||
|
voice = pref CFVoice
|
||||||
|
}
|
||||||
|
where
|
||||||
|
pref pt = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPreferences >>= groupPrefSel pt)
|
||||||
|
|
||||||
data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool}
|
data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool}
|
||||||
deriving (Show, Generic)
|
deriving (Eq, Show, Generic)
|
||||||
|
|
||||||
instance ToJSON PrefEnabled where
|
instance ToJSON PrefEnabled where
|
||||||
toJSON = J.genericToJSON J.defaultOptions
|
toJSON = J.genericToJSON J.defaultOptions
|
||||||
@@ -471,8 +529,8 @@ prefEnabled Preference {allow = user} Preference {allow = contact} = case (user,
|
|||||||
(FANo, _) -> PrefEnabled False False
|
(FANo, _) -> PrefEnabled False False
|
||||||
_ -> PrefEnabled True True
|
_ -> PrefEnabled True True
|
||||||
|
|
||||||
contactUserPreferences :: User -> Contact -> ContactUserPreferences
|
contactUserPreferences :: User -> Preferences -> Maybe Preferences -> Bool -> ContactUserPreferences
|
||||||
contactUserPreferences user ct =
|
contactUserPreferences user userPreferences contactPreferences connectedIncognito =
|
||||||
ContactUserPreferences
|
ContactUserPreferences
|
||||||
{ fullDelete = pref CFFullDelete,
|
{ fullDelete = pref CFFullDelete,
|
||||||
-- receipts = pref CFReceipts,
|
-- receipts = pref CFReceipts,
|
||||||
@@ -483,19 +541,19 @@ contactUserPreferences user ct =
|
|||||||
ContactUserPreference
|
ContactUserPreference
|
||||||
{ enabled = prefEnabled userPref ctPref,
|
{ enabled = prefEnabled userPref ctPref,
|
||||||
-- incognito contact cannot have default user preference used
|
-- incognito contact cannot have default user preference used
|
||||||
userPreference = if contactConnIncognito ct then CUPContact ctUserPref else maybe (CUPUser userPref) CUPContact ctUserPref_,
|
userPreference = if connectedIncognito then CUPContact ctUserPref else maybe (CUPUser userPref) CUPContact ctUserPref_,
|
||||||
contactPreference = ctPref
|
contactPreference = ctPref
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
ctUserPref = getPreference pt $ userPreferences ct
|
ctUserPref = getPreference pt userPreferences
|
||||||
ctUserPref_ = chatPrefSel pt $ userPreferences ct
|
ctUserPref_ = chatPrefSel pt userPreferences
|
||||||
userPref = getPreference pt ctUserPrefs
|
userPref = getPreference pt ctUserPrefs
|
||||||
ctPref = getPreference pt ctPrefs
|
ctPref = getPreference pt ctPrefs
|
||||||
ctUserPrefs = mergeUserChatPrefs user ct
|
ctUserPrefs = mergeUserChatPrefs' user connectedIncognito userPreferences
|
||||||
ctPrefs = mergePreferences (preferences' ct) Nothing
|
ctPrefs = mergePreferences contactPreferences Nothing
|
||||||
|
|
||||||
getContactUserPrefefence :: ChatFeature -> ContactUserPreferences -> ContactUserPreference
|
getContactUserPreference :: ChatFeature -> ContactUserPreferences -> ContactUserPreference
|
||||||
getContactUserPrefefence = \case
|
getContactUserPreference = \case
|
||||||
CFFullDelete -> fullDelete
|
CFFullDelete -> fullDelete
|
||||||
-- CFReceipts -> receipts
|
-- CFReceipts -> receipts
|
||||||
CFVoice -> voice
|
CFVoice -> voice
|
||||||
@@ -1144,6 +1202,9 @@ data Connection = Connection
|
|||||||
aConnId :: Connection -> ConnId
|
aConnId :: Connection -> ConnId
|
||||||
aConnId Connection {agentConnId = AgentConnId cId} = cId
|
aConnId Connection {agentConnId = AgentConnId cId} = cId
|
||||||
|
|
||||||
|
connIncognito :: Connection -> Bool
|
||||||
|
connIncognito Connection {customUserProfileId} = isJust customUserProfileId
|
||||||
|
|
||||||
instance ToJSON Connection where
|
instance ToJSON Connection where
|
||||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||||
@@ -1389,3 +1450,18 @@ encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode
|
|||||||
|
|
||||||
decodeJSON :: FromJSON a => Text -> Maybe a
|
decodeJSON :: FromJSON a => Text -> Maybe a
|
||||||
decodeJSON = J.decode . LB.fromStrict . encodeUtf8
|
decodeJSON = J.decode . LB.fromStrict . encodeUtf8
|
||||||
|
|
||||||
|
data ServerCfg = ServerCfg
|
||||||
|
{ server :: SMPServerWithAuth,
|
||||||
|
preset :: Bool,
|
||||||
|
tested :: Maybe Bool,
|
||||||
|
enabled :: Bool
|
||||||
|
}
|
||||||
|
deriving (Show, Generic)
|
||||||
|
|
||||||
|
instance ToJSON ServerCfg where
|
||||||
|
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||||
|
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||||
|
|
||||||
|
instance FromJSON ServerCfg where
|
||||||
|
parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
|
||||||
|
|||||||
+123
-88
@@ -18,10 +18,11 @@ import Data.Char (toUpper)
|
|||||||
import Data.Function (on)
|
import Data.Function (on)
|
||||||
import Data.Int (Int64)
|
import Data.Int (Int64)
|
||||||
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
|
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
|
||||||
|
import qualified Data.List.NonEmpty as L
|
||||||
import Data.Maybe (isJust, isNothing, mapMaybe)
|
import Data.Maybe (isJust, isNothing, mapMaybe)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Time.Clock (DiffTime)
|
import Data.Time.Clock (DiffTime, UTCTime)
|
||||||
import Data.Time.Format (defaultTimeLocale, formatTime)
|
import Data.Time.Format (defaultTimeLocale, formatTime)
|
||||||
import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime)
|
import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime)
|
||||||
import GHC.Generics (Generic)
|
import GHC.Generics (Generic)
|
||||||
@@ -37,6 +38,7 @@ import Simplex.Chat.Protocol
|
|||||||
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
|
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
|
||||||
import Simplex.Chat.Styled
|
import Simplex.Chat.Styled
|
||||||
import Simplex.Chat.Types
|
import Simplex.Chat.Types
|
||||||
|
import Simplex.Messaging.Agent.Client (SMPTestFailure (..), SMPTestStep (..))
|
||||||
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
|
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
|
||||||
import Simplex.Messaging.Agent.Protocol
|
import Simplex.Messaging.Agent.Protocol
|
||||||
import qualified Simplex.Messaging.Crypto as C
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
@@ -49,11 +51,13 @@ import Simplex.Messaging.Transport.Client (TransportHost (..))
|
|||||||
import Simplex.Messaging.Util (bshow)
|
import Simplex.Messaging.Util (bshow)
|
||||||
import System.Console.ANSI.Types
|
import System.Console.ANSI.Types
|
||||||
|
|
||||||
serializeChatResponse :: Maybe User -> ChatResponse -> String
|
type CurrentTime = UTCTime
|
||||||
serializeChatResponse user_ = unlines . map unStyle . responseToView user_ False
|
|
||||||
|
|
||||||
responseToView :: Maybe User -> Bool -> ChatResponse -> [StyledString]
|
serializeChatResponse :: Maybe User -> CurrentTime -> ChatResponse -> String
|
||||||
responseToView user_ testView = \case
|
serializeChatResponse user_ ts = unlines . map unStyle . responseToView user_ False ts
|
||||||
|
|
||||||
|
responseToView :: Maybe User -> Bool -> CurrentTime -> ChatResponse -> [StyledString]
|
||||||
|
responseToView user_ testView ts = \case
|
||||||
CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
|
CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
|
||||||
CRChatStarted -> ["chat started"]
|
CRChatStarted -> ["chat started"]
|
||||||
CRChatRunning -> ["chat is running"]
|
CRChatRunning -> ["chat is running"]
|
||||||
@@ -62,20 +66,21 @@ responseToView user_ testView = \case
|
|||||||
CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats]
|
CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats]
|
||||||
CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat]
|
CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat]
|
||||||
CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft]
|
CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft]
|
||||||
CRUserSMPServers smpServers -> viewSMPServers smpServers testView
|
CRUserSMPServers smpServers _ -> viewSMPServers (L.toList smpServers) testView
|
||||||
|
CRSmpTestResult testFailure -> viewSMPTestResult testFailure
|
||||||
CRChatItemTTL ttl -> viewChatItemTTL ttl
|
CRChatItemTTL ttl -> viewChatItemTTL ttl
|
||||||
CRNetworkConfig cfg -> viewNetworkConfig cfg
|
CRNetworkConfig cfg -> viewNetworkConfig cfg
|
||||||
CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
|
CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
|
||||||
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats
|
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats
|
||||||
CRContactSwitch ct progress -> viewContactSwitch ct progress
|
CRContactSwitch ct progress -> viewContactSwitch ct progress
|
||||||
CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress
|
CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress
|
||||||
CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False
|
CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts
|
||||||
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems
|
CRApiChatItems chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems
|
||||||
CRChatItemStatusUpdated _ -> []
|
CRChatItemStatusUpdated _ -> []
|
||||||
CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item
|
CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item ts
|
||||||
CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem
|
CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem ts
|
||||||
CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"]
|
CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"]
|
||||||
CRBroadcastSent mc n ts -> viewSentBroadcast mc n ts
|
CRBroadcastSent mc n t -> viewSentBroadcast mc n ts t
|
||||||
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
|
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
|
||||||
CRCmdAccepted _ -> []
|
CRCmdAccepted _ -> []
|
||||||
CRCmdOk -> ["ok"]
|
CRCmdOk -> ["ok"]
|
||||||
@@ -123,13 +128,13 @@ responseToView user_ testView = \case
|
|||||||
CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts
|
CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts
|
||||||
CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft
|
CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft
|
||||||
CRUserProfileUpdated p p' -> viewUserProfileUpdated p p'
|
CRUserProfileUpdated p p' -> viewUserProfileUpdated p p'
|
||||||
CRContactPrefsUpdated {fromContact, toContact, preferences} -> case user_ of
|
CRContactPrefsUpdated {fromContact, toContact} -> case user_ of
|
||||||
Just user -> viewUserContactPrefsUpdated user fromContact toContact preferences
|
Just user -> viewUserContactPrefsUpdated user fromContact toContact
|
||||||
_ -> ["unexpected chat event CRContactPrefsUpdated without current user"]
|
_ -> ["unexpected chat event CRContactPrefsUpdated without current user"]
|
||||||
CRContactAliasUpdated c -> viewContactAliasUpdated c
|
CRContactAliasUpdated c -> viewContactAliasUpdated c
|
||||||
CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c
|
CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c
|
||||||
CRContactUpdated {fromContact = c, toContact = c', preferences} -> case user_ of
|
CRContactUpdated {fromContact = c, toContact = c'} -> case user_ of
|
||||||
Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' preferences
|
Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c'
|
||||||
_ -> ["unexpected chat event CRContactUpdated without current user"]
|
_ -> ["unexpected chat event CRContactUpdated without current user"]
|
||||||
CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt
|
CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt
|
||||||
CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile
|
CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile
|
||||||
@@ -256,8 +261,8 @@ showSMPServer = B.unpack . strEncode . host
|
|||||||
viewHostEvent :: AProtocolType -> TransportHost -> String
|
viewHostEvent :: AProtocolType -> TransportHost -> String
|
||||||
viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h)
|
viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h)
|
||||||
|
|
||||||
viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> [StyledString]
|
viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> [StyledString]
|
||||||
viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = case chat of
|
viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow ts = case chat of
|
||||||
DirectChat c -> case chatDir of
|
DirectChat c -> case chatDir of
|
||||||
CIDirectSnd -> case content of
|
CIDirectSnd -> case content of
|
||||||
CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc
|
CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc
|
||||||
@@ -267,7 +272,7 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
|
|||||||
to = ttyToContact' c
|
to = ttyToContact' c
|
||||||
CIDirectRcv -> case content of
|
CIDirectRcv -> case content of
|
||||||
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
|
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
|
||||||
CIRcvIntegrityError err -> viewRcvIntegrityError from err meta
|
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta
|
||||||
CIRcvGroupEvent {} -> showRcvItemProhibited from
|
CIRcvGroupEvent {} -> showRcvItemProhibited from
|
||||||
_ -> showRcvItem from
|
_ -> showRcvItem from
|
||||||
where
|
where
|
||||||
@@ -283,7 +288,7 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
|
|||||||
to = ttyToGroup g
|
to = ttyToGroup g
|
||||||
CIGroupRcv m -> case content of
|
CIGroupRcv m -> case content of
|
||||||
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
|
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
|
||||||
CIRcvIntegrityError err -> viewRcvIntegrityError from err meta
|
CIRcvIntegrityError err -> viewRcvIntegrityError from err ts meta
|
||||||
CIRcvGroupInvitation {} -> showRcvItemProhibited from
|
CIRcvGroupInvitation {} -> showRcvItemProhibited from
|
||||||
_ -> showRcvItem from
|
_ -> showRcvItem from
|
||||||
where
|
where
|
||||||
@@ -294,26 +299,26 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = c
|
|||||||
where
|
where
|
||||||
withSndFile = withFile viewSentFileInvitation
|
withSndFile = withFile viewSentFileInvitation
|
||||||
withRcvFile = withFile viewReceivedFileInvitation
|
withRcvFile = withFile viewReceivedFileInvitation
|
||||||
withFile view dir l = maybe l (\f -> l <> view dir f meta) file
|
withFile view dir l = maybe l (\f -> l <> view dir f ts meta) file
|
||||||
sndMsg = msg viewSentMessage
|
sndMsg = msg viewSentMessage
|
||||||
rcvMsg = msg viewReceivedMessage
|
rcvMsg = msg viewReceivedMessage
|
||||||
msg view dir quote mc = case (msgContentText mc, file, quote) of
|
msg view dir quote mc = case (msgContentText mc, file, quote) of
|
||||||
("", Just _, []) -> []
|
("", Just _, []) -> []
|
||||||
("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) meta
|
("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) ts meta
|
||||||
_ -> view dir quote mc meta
|
_ -> view dir quote mc ts meta
|
||||||
showSndItem to = showItem $ sentWithTime_ [to <> plainContent content] meta
|
showSndItem to = showItem $ sentWithTime_ ts [to <> plainContent content] meta
|
||||||
showRcvItem from = showItem $ receivedWithTime_ from [] meta [plainContent content]
|
showRcvItem from = showItem $ receivedWithTime_ ts from [] meta [plainContent content]
|
||||||
showSndItemProhibited to = showItem $ sentWithTime_ [to <> plainContent content <> " " <> prohibited] meta
|
showSndItemProhibited to = showItem $ sentWithTime_ ts [to <> plainContent content <> " " <> prohibited] meta
|
||||||
showRcvItemProhibited from = showItem $ receivedWithTime_ from [] meta [plainContent content <> " " <> prohibited]
|
showRcvItemProhibited from = showItem $ receivedWithTime_ ts from [] meta [plainContent content <> " " <> prohibited]
|
||||||
showItem ss = if doShow then ss else []
|
showItem ss = if doShow then ss else []
|
||||||
plainContent = plain . ciContentToText
|
plainContent = plain . ciContentToText
|
||||||
prohibited = styled (colored Red) ("[prohibited - it's a bug if this chat item was created in this context, please report it to dev team]" :: String)
|
prohibited = styled (colored Red) ("[prohibited - it's a bug if this chat item was created in this context, please report it to dev team]" :: String)
|
||||||
|
|
||||||
viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString]
|
viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> CurrentTime -> [StyledString]
|
||||||
viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
|
viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} ts = case chat of
|
||||||
DirectChat Contact {localDisplayName = c} -> case chatDir of
|
DirectChat Contact {localDisplayName = c} -> case chatDir of
|
||||||
CIDirectRcv -> case content of
|
CIDirectRcv -> case content of
|
||||||
CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta
|
CIRcvMsgContent mc -> viewReceivedMessage from quote mc ts meta
|
||||||
_ -> []
|
_ -> []
|
||||||
where
|
where
|
||||||
from = ttyFromContactEdited c
|
from = ttyFromContactEdited c
|
||||||
@@ -321,7 +326,7 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
|
|||||||
CIDirectSnd -> ["message updated"]
|
CIDirectSnd -> ["message updated"]
|
||||||
GroupChat g -> case chatDir of
|
GroupChat g -> case chatDir of
|
||||||
CIGroupRcv GroupMember {localDisplayName = m} -> case content of
|
CIGroupRcv GroupMember {localDisplayName = m} -> case content of
|
||||||
CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta
|
CIRcvMsgContent mc -> viewReceivedMessage from quote mc ts meta
|
||||||
_ -> []
|
_ -> []
|
||||||
where
|
where
|
||||||
from = ttyFromGroupEdited g m
|
from = ttyFromGroupEdited g m
|
||||||
@@ -329,16 +334,16 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
|
|||||||
CIGroupSnd -> ["message updated"]
|
CIGroupSnd -> ["message updated"]
|
||||||
_ -> []
|
_ -> []
|
||||||
|
|
||||||
viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> [StyledString]
|
viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> CurrentTime -> [StyledString]
|
||||||
viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} = case chat of
|
viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} ts = case chat of
|
||||||
DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) of
|
DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) of
|
||||||
(CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
|
(CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
|
||||||
CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc meta
|
CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc ts meta
|
||||||
CIDMInternal -> ["message deleted"]
|
CIDMInternal -> ["message deleted"]
|
||||||
_ -> ["message deleted"]
|
_ -> ["message deleted"]
|
||||||
GroupChat g -> case (chatDir, deletedContent, toContent) of
|
GroupChat g -> case (chatDir, deletedContent, toContent) of
|
||||||
(CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
|
(CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
|
||||||
CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] mc meta
|
CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] mc ts meta
|
||||||
CIDMInternal -> ["message deleted"]
|
CIDMInternal -> ["message deleted"]
|
||||||
_ -> ["message deleted"]
|
_ -> ["message deleted"]
|
||||||
_ -> []
|
_ -> []
|
||||||
@@ -365,8 +370,8 @@ msgPreview = msgPlain . preview . msgContentText
|
|||||||
| T.length t <= 120 = t
|
| T.length t <= 120 = t
|
||||||
| otherwise = T.take 120 t <> "..."
|
| otherwise = T.take 120 t <> "..."
|
||||||
|
|
||||||
viewRcvIntegrityError :: StyledString -> MsgErrorType -> CIMeta 'MDRcv -> [StyledString]
|
viewRcvIntegrityError :: StyledString -> MsgErrorType -> CurrentTime -> CIMeta 'MDRcv -> [StyledString]
|
||||||
viewRcvIntegrityError from msgErr meta = receivedWithTime_ from [] meta $ viewMsgIntegrityError msgErr
|
viewRcvIntegrityError from msgErr ts meta = receivedWithTime_ ts from [] meta $ viewMsgIntegrityError msgErr
|
||||||
|
|
||||||
viewMsgIntegrityError :: MsgErrorType -> [StyledString]
|
viewMsgIntegrityError :: MsgErrorType -> [StyledString]
|
||||||
viewMsgIntegrityError err = msgError $ case err of
|
viewMsgIntegrityError err = msgError $ case err of
|
||||||
@@ -618,15 +623,16 @@ viewUserProfile Profile {displayName, fullName} =
|
|||||||
"(the updated profile will be sent to all your contacts)"
|
"(the updated profile will be sent to all your contacts)"
|
||||||
]
|
]
|
||||||
|
|
||||||
viewSMPServers :: [SMPServer] -> Bool -> [StyledString]
|
viewSMPServers :: [ServerCfg] -> Bool -> [StyledString]
|
||||||
viewSMPServers smpServers testView =
|
viewSMPServers smpServers testView =
|
||||||
if testView
|
if testView
|
||||||
then [customSMPServers]
|
then [customSMPServers]
|
||||||
else
|
else
|
||||||
[ customSMPServers,
|
[ customSMPServers,
|
||||||
"",
|
"",
|
||||||
"use " <> highlight' "/smp_servers <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
|
"use " <> highlight' "/smp test <srv>" <> " to test SMP server connection",
|
||||||
"use " <> highlight' "/smp_servers default" <> " to remove custom SMP servers and use default",
|
"use " <> highlight' "/smp set <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
|
||||||
|
"use " <> highlight' "/smp default" <> " to remove custom SMP servers and use default",
|
||||||
"(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"
|
"(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"
|
||||||
]
|
]
|
||||||
where
|
where
|
||||||
@@ -635,6 +641,16 @@ viewSMPServers smpServers testView =
|
|||||||
then "no custom SMP servers saved"
|
then "no custom SMP servers saved"
|
||||||
else viewServers smpServers
|
else viewServers smpServers
|
||||||
|
|
||||||
|
viewSMPTestResult :: Maybe SMPTestFailure -> [StyledString]
|
||||||
|
viewSMPTestResult = \case
|
||||||
|
Just SMPTestFailure {testStep, testError} ->
|
||||||
|
result
|
||||||
|
<> ["Server requires authentication to create queues, check password" | testStep == TSCreateQueue && testError == SMP SMP.AUTH]
|
||||||
|
<> ["Possibly, certificate fingerprint in server address is incorrect" | testStep == TSConnect && testError == BROKER NETWORK]
|
||||||
|
where
|
||||||
|
result = ["SMP server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> plain (strEncode testError)]
|
||||||
|
_ -> ["SMP server test passed"]
|
||||||
|
|
||||||
viewChatItemTTL :: Maybe Int64 -> [StyledString]
|
viewChatItemTTL :: Maybe Int64 -> [StyledString]
|
||||||
viewChatItemTTL = \case
|
viewChatItemTTL = \case
|
||||||
Nothing -> ["old messages are not being deleted"]
|
Nothing -> ["old messages are not being deleted"]
|
||||||
@@ -650,7 +666,7 @@ viewNetworkConfig :: NetworkConfig -> [StyledString]
|
|||||||
viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
|
viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
|
||||||
[ plain $ maybe "direct network connection" (("using SOCKS5 proxy " <>) . show) socksProxy,
|
[ plain $ maybe "direct network connection" (("using SOCKS5 proxy " <>) . show) socksProxy,
|
||||||
"TCP timeout: " <> sShow tcpTimeout,
|
"TCP timeout: " <> sShow tcpTimeout,
|
||||||
"use `/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]` to change settings"
|
"use " <> highlight' "/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]" <> " to change settings"
|
||||||
]
|
]
|
||||||
|
|
||||||
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
|
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
|
||||||
@@ -675,8 +691,8 @@ viewConnectionStats ConnectionStats {rcvServers, sndServers} =
|
|||||||
["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers]
|
["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers]
|
||||||
<> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers]
|
<> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers]
|
||||||
|
|
||||||
viewServers :: [SMPServer] -> StyledString
|
viewServers :: [ServerCfg] -> StyledString
|
||||||
viewServers = plain . intercalate ", " . map (B.unpack . strEncode)
|
viewServers = plain . intercalate ", " . map (B.unpack . strEncode . (\ServerCfg {server} -> server))
|
||||||
|
|
||||||
viewServerHosts :: [SMPServer] -> StyledString
|
viewServerHosts :: [SMPServer] -> StyledString
|
||||||
viewServerHosts = plain . intercalate ", " . map showSMPServer
|
viewServerHosts = plain . intercalate ", " . map showSMPServer
|
||||||
@@ -708,15 +724,15 @@ viewUserProfileUpdated Profile {displayName = n, fullName, image, preferences} P
|
|||||||
| otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified]
|
| otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified]
|
||||||
notified = " (your contacts are notified)"
|
notified = " (your contacts are notified)"
|
||||||
|
|
||||||
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString]
|
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
|
||||||
viewUserContactPrefsUpdated user ct ct' cups
|
viewUserContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
|
||||||
| null prefs = ["your preferences for " <> ttyContact' ct' <> " did not change"]
|
| null prefs = ["your preferences for " <> ttyContact' ct' <> " did not change"]
|
||||||
| otherwise = ("you updated preferences for " <> ttyContact' ct' <> ":") : prefs
|
| otherwise = ("you updated preferences for " <> ttyContact' ct' <> ":") : prefs
|
||||||
where
|
where
|
||||||
prefs = viewContactPreferences user ct ct' cups
|
prefs = viewContactPreferences user ct ct' cups
|
||||||
|
|
||||||
viewContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString]
|
viewContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
|
||||||
viewContactPrefsUpdated user ct ct' cups
|
viewContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
|
||||||
| null prefs = []
|
| null prefs = []
|
||||||
| otherwise = (ttyContact' ct' <> " updated preferences for you:") : prefs
|
| otherwise = (ttyContact' ct' <> " updated preferences for you:") : prefs
|
||||||
where
|
where
|
||||||
@@ -734,7 +750,7 @@ viewContactPref userPrefs userPrefs' ctPrefs cups pt
|
|||||||
userPref = getPreference pt userPrefs
|
userPref = getPreference pt userPrefs
|
||||||
userPref' = getPreference pt userPrefs'
|
userPref' = getPreference pt userPrefs'
|
||||||
ctPref = getPreference pt ctPrefs
|
ctPref = getPreference pt ctPrefs
|
||||||
ContactUserPreference {enabled, userPreference, contactPreference} = getContactUserPrefefence pt cups
|
ContactUserPreference {enabled, userPreference, contactPreference} = getContactUserPreference pt cups
|
||||||
|
|
||||||
viewPrefsUpdated :: Maybe Preferences -> Maybe Preferences -> [StyledString]
|
viewPrefsUpdated :: Maybe Preferences -> Maybe Preferences -> [StyledString]
|
||||||
viewPrefsUpdated ps ps'
|
viewPrefsUpdated ps ps'
|
||||||
@@ -769,15 +785,36 @@ viewPrefEnabled = \case
|
|||||||
|
|
||||||
viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString]
|
viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString]
|
||||||
viewGroupUpdated
|
viewGroupUpdated
|
||||||
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image}}
|
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image, groupPreferences = gps}}
|
||||||
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image'}}
|
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image', groupPreferences = gps'}}
|
||||||
m
|
m = do
|
||||||
| n == n' && fullName == fullName' && image == image' = []
|
let update = groupProfileUpdated <> groupPrefsUpdated
|
||||||
| n == n' && fullName == fullName' = ["group " <> ttyGroup n <> ": profile image " <> (if isNothing image' then "removed" else "updated") <> byMember]
|
if null update
|
||||||
| n == n' = ["group " <> ttyGroup n <> ": full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName' <> byMember]
|
then []
|
||||||
| otherwise = ["group " <> ttyGroup n <> " is changed to " <> ttyFullGroup g' <> byMember]
|
else memberUpdated <> update
|
||||||
where
|
where
|
||||||
byMember = maybe "" ((" by " <>) . ttyMember) m
|
memberUpdated = maybe [] (\m' -> [ttyMember m' <> " updated group " <> ttyGroup n <> ":"]) m
|
||||||
|
groupProfileUpdated
|
||||||
|
| n == n' && fullName == fullName' && image == image' = []
|
||||||
|
| n == n' && fullName == fullName' = ["profile image " <> (if isNothing image' then "removed" else "updated")]
|
||||||
|
| n == n' = ["full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName']
|
||||||
|
| otherwise = ["changed to " <> ttyFullGroup g']
|
||||||
|
groupPrefsUpdated
|
||||||
|
| null prefs = []
|
||||||
|
| otherwise = "updated group preferences:" : prefs
|
||||||
|
where
|
||||||
|
prefs = mapMaybe viewPref allChatFeatures
|
||||||
|
viewPref pt
|
||||||
|
| pref gps == pref gps' = Nothing
|
||||||
|
| otherwise = Just $ plain (chatPrefName pt) <> " enabled: " <> viewGroupPreference (pref gps')
|
||||||
|
where
|
||||||
|
pref pss = getGroupPreference pt $ mergeGroupPreferences pss
|
||||||
|
|
||||||
|
viewGroupPreference :: GroupPreference -> StyledString
|
||||||
|
viewGroupPreference = \case
|
||||||
|
GroupPreference {enable} -> case enable of
|
||||||
|
FEOn -> "on"
|
||||||
|
FEOff -> "off"
|
||||||
|
|
||||||
viewContactAliasUpdated :: Contact -> [StyledString]
|
viewContactAliasUpdated :: Contact -> [StyledString]
|
||||||
viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}}
|
viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}}
|
||||||
@@ -802,36 +839,37 @@ viewContactUpdated
|
|||||||
where
|
where
|
||||||
fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName'
|
fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName'
|
||||||
|
|
||||||
viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString]
|
viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta d -> [StyledString]
|
||||||
viewReceivedMessage from quote mc meta = receivedWithTime_ from quote meta (ttyMsgContent mc)
|
viewReceivedMessage from quote mc ts meta = receivedWithTime_ ts from quote meta (ttyMsgContent mc)
|
||||||
|
|
||||||
receivedWithTime_ :: StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString]
|
receivedWithTime_ :: CurrentTime -> StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString]
|
||||||
receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do
|
receivedWithTime_ ts from quote CIMeta {localItemTs} styledMsg = do
|
||||||
prependFirst (formattedTime <> " " <> from) (quote <> prependFirst indent styledMsg)
|
prependFirst (ttyMsgTime ts localItemTs <> " " <> from) (quote <> prependFirst indent styledMsg)
|
||||||
where
|
|
||||||
indent = if null quote then "" else " "
|
|
||||||
formattedTime :: StyledString
|
|
||||||
formattedTime =
|
|
||||||
let localTime = zonedTimeToLocalTime localItemTs
|
|
||||||
tz = zonedTimeZone localItemTs
|
|
||||||
format =
|
|
||||||
if (localDay localTime < localDay (zonedTimeToLocalTime $ utcToZonedTime tz createdAt))
|
|
||||||
&& (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime))
|
|
||||||
then "%m-%d" -- if message is from yesterday or before and 6 hours has passed since midnight
|
|
||||||
else "%H:%M"
|
|
||||||
in styleTime $ formatTime defaultTimeLocale format localTime
|
|
||||||
|
|
||||||
viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString]
|
|
||||||
viewSentMessage to quote mc = sentWithTime_ (prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc))
|
|
||||||
where
|
where
|
||||||
indent = if null quote then "" else " "
|
indent = if null quote then "" else " "
|
||||||
|
|
||||||
viewSentBroadcast :: MsgContent -> Int -> ZonedTime -> [StyledString]
|
ttyMsgTime :: CurrentTime -> ZonedTime -> StyledString
|
||||||
viewSentBroadcast mc n ts = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts <> " ") (ttyMsgContent mc)
|
ttyMsgTime ts t =
|
||||||
|
let localTime = zonedTimeToLocalTime t
|
||||||
|
tz = zonedTimeZone t
|
||||||
|
fmt =
|
||||||
|
if (localDay localTime < localDay (zonedTimeToLocalTime $ utcToZonedTime tz ts))
|
||||||
|
&& (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime))
|
||||||
|
then "%m-%d" -- if message is from yesterday or before and 6 hours has passed since midnight
|
||||||
|
else "%H:%M"
|
||||||
|
in styleTime $ formatTime defaultTimeLocale fmt localTime
|
||||||
|
|
||||||
viewSentFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString]
|
viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta d -> [StyledString]
|
||||||
viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} = case filePath of
|
viewSentMessage to quote mc ts = sentWithTime_ ts (prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc))
|
||||||
Just fPath -> sentWithTime_ $ ttySentFile fPath
|
where
|
||||||
|
indent = if null quote then "" else " "
|
||||||
|
|
||||||
|
viewSentBroadcast :: MsgContent -> Int -> CurrentTime -> ZonedTime -> [StyledString]
|
||||||
|
viewSentBroadcast mc n ts t = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts t <> " ") (ttyMsgContent mc)
|
||||||
|
|
||||||
|
viewSentFileInvitation :: StyledString -> CIFile d -> CurrentTime -> CIMeta d -> [StyledString]
|
||||||
|
viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} ts = case filePath of
|
||||||
|
Just fPath -> sentWithTime_ ts $ ttySentFile fPath
|
||||||
_ -> const []
|
_ -> const []
|
||||||
where
|
where
|
||||||
ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending
|
ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending
|
||||||
@@ -839,12 +877,9 @@ viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} = case filePath
|
|||||||
CIFSSndTransfer -> []
|
CIFSSndTransfer -> []
|
||||||
_ -> ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"]
|
_ -> ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"]
|
||||||
|
|
||||||
sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString]
|
sentWithTime_ :: CurrentTime -> [StyledString] -> CIMeta d -> [StyledString]
|
||||||
sentWithTime_ styledMsg CIMeta {localItemTs} =
|
sentWithTime_ ts styledMsg CIMeta {localItemTs} =
|
||||||
prependFirst (ttyMsgTime localItemTs <> " ") styledMsg
|
prependFirst (ttyMsgTime ts localItemTs <> " ") styledMsg
|
||||||
|
|
||||||
ttyMsgTime :: ZonedTime -> StyledString
|
|
||||||
ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M"
|
|
||||||
|
|
||||||
ttyMsgContent :: MsgContent -> [StyledString]
|
ttyMsgContent :: MsgContent -> [StyledString]
|
||||||
ttyMsgContent = msgPlain . msgContentText
|
ttyMsgContent = msgPlain . msgContentText
|
||||||
@@ -873,8 +908,8 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
|
|||||||
sndFile :: SndFileTransfer -> StyledString
|
sndFile :: SndFileTransfer -> StyledString
|
||||||
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
|
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
|
||||||
|
|
||||||
viewReceivedFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString]
|
viewReceivedFileInvitation :: StyledString -> CIFile d -> CurrentTime -> CIMeta d -> [StyledString]
|
||||||
viewReceivedFileInvitation from file meta = receivedWithTime_ from [] meta (receivedFileInvitation_ file)
|
viewReceivedFileInvitation from file ts meta = receivedWithTime_ ts from [] meta (receivedFileInvitation_ file)
|
||||||
|
|
||||||
receivedFileInvitation_ :: CIFile d -> [StyledString]
|
receivedFileInvitation_ :: CIFile d -> [StyledString]
|
||||||
receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} =
|
receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} =
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ extra-deps:
|
|||||||
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
||||||
# - ../simplexmq
|
# - ../simplexmq
|
||||||
- github: simplex-chat/simplexmq
|
- github: simplex-chat/simplexmq
|
||||||
commit: d2b88a1baa390ec64b6535e32ce69f26f53f4d7a
|
commit: c2342cba057fa2333b5936a2254507b5b62e8de2
|
||||||
# - ../direct-sqlcipher
|
# - ../direct-sqlcipher
|
||||||
- github: simplex-chat/direct-sqlcipher
|
- github: simplex-chat/direct-sqlcipher
|
||||||
commit: 34309410eb2069b029b8fc1872deb1e0db123294
|
commit: 34309410eb2069b029b8fc1872deb1e0db123294
|
||||||
|
|||||||
+3
-2
@@ -25,7 +25,7 @@ import Simplex.Chat.Options
|
|||||||
import Simplex.Chat.Store
|
import Simplex.Chat.Store
|
||||||
import Simplex.Chat.Terminal
|
import Simplex.Chat.Terminal
|
||||||
import Simplex.Chat.Terminal.Output (newChatTerminal)
|
import Simplex.Chat.Terminal.Output (newChatTerminal)
|
||||||
import Simplex.Chat.Types (Profile, User (..))
|
import Simplex.Chat.Types (Profile, ServerCfg (..), User (..))
|
||||||
import Simplex.Messaging.Agent.Env.SQLite
|
import Simplex.Messaging.Agent.Env.SQLite
|
||||||
import Simplex.Messaging.Agent.RetryInterval
|
import Simplex.Messaging.Agent.RetryInterval
|
||||||
import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig)
|
import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig)
|
||||||
@@ -51,7 +51,7 @@ testOpts =
|
|||||||
{ dbFilePrefix = undefined,
|
{ dbFilePrefix = undefined,
|
||||||
dbKey = "",
|
dbKey = "",
|
||||||
-- dbKey = "this is a pass-phrase to encrypt the database",
|
-- dbKey = "this is a pass-phrase to encrypt the database",
|
||||||
smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"],
|
smpServers = [ServerCfg "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" False Nothing True],
|
||||||
networkConfig = defaultNetworkConfig,
|
networkConfig = defaultNetworkConfig,
|
||||||
logConnections = False,
|
logConnections = False,
|
||||||
logServerHosts = False,
|
logServerHosts = False,
|
||||||
@@ -275,6 +275,7 @@ serverCfg =
|
|||||||
storeLogFile = Nothing,
|
storeLogFile = Nothing,
|
||||||
storeMsgsFile = Nothing,
|
storeMsgsFile = Nothing,
|
||||||
allowNewQueues = True,
|
allowNewQueues = True,
|
||||||
|
newQueueBasicAuth = Nothing, -- Just "server_password",
|
||||||
messageExpiration = Just defaultMessageExpiration,
|
messageExpiration = Just defaultMessageExpiration,
|
||||||
inactiveClientExpiration = Just defaultInactiveClientExpiration,
|
inactiveClientExpiration = Just defaultInactiveClientExpiration,
|
||||||
caCertificateFile = "tests/fixtures/tls/ca.crt",
|
caCertificateFile = "tests/fixtures/tls/ca.crt",
|
||||||
|
|||||||
+69
-15
@@ -74,7 +74,7 @@ chatTests = do
|
|||||||
describe "async group connections" $ do
|
describe "async group connections" $ do
|
||||||
xit "create and join group when clients go offline" testGroupAsync
|
xit "create and join group when clients go offline" testGroupAsync
|
||||||
describe "user profiles" $ do
|
describe "user profiles" $ do
|
||||||
it "update user profiles and notify contacts" testUpdateProfile
|
it "update user profile and notify contacts" testUpdateProfile
|
||||||
it "update user profile with image" testUpdateProfileImage
|
it "update user profile with image" testUpdateProfileImage
|
||||||
describe "sending and receiving files" $ do
|
describe "sending and receiving files" $ do
|
||||||
describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer
|
describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer
|
||||||
@@ -112,12 +112,15 @@ chatTests = do
|
|||||||
it "join group incognito" testJoinGroupIncognito
|
it "join group incognito" testJoinGroupIncognito
|
||||||
it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito
|
it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito
|
||||||
it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito
|
it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito
|
||||||
describe "contact aliases and prefs" $ do
|
describe "contact aliases" $ do
|
||||||
it "set contact alias" testSetAlias
|
it "set contact alias" testSetAlias
|
||||||
it "set connection alias" testSetConnectionAlias
|
it "set connection alias" testSetConnectionAlias
|
||||||
it "set contact prefs" testSetContactPrefs
|
describe "preferences" $ do
|
||||||
describe "SMP servers" $
|
it "set contact preferences" testSetContactPrefs
|
||||||
|
it "update group preferences" testUpdateGroupPrefs
|
||||||
|
describe "SMP servers" $ do
|
||||||
it "get and set SMP servers" testGetSetSMPServers
|
it "get and set SMP servers" testGetSetSMPServers
|
||||||
|
it "test SMP server connection" testTestSMPServerConnection
|
||||||
describe "async connection handshake" $ do
|
describe "async connection handshake" $ do
|
||||||
it "connect when initiating client goes offline" testAsyncInitiatingOffline
|
it "connect when initiating client goes offline" testAsyncInitiatingOffline
|
||||||
it "connect when accepting client goes offline" testAsyncAcceptingOffline
|
it "connect when accepting client goes offline" testAsyncAcceptingOffline
|
||||||
@@ -1297,10 +1300,15 @@ testUpdateGroupProfile =
|
|||||||
bob ##> "/gp team my_team"
|
bob ##> "/gp team my_team"
|
||||||
bob <## "you have insufficient permissions for this group command"
|
bob <## "you have insufficient permissions for this group command"
|
||||||
alice ##> "/gp team my_team"
|
alice ##> "/gp team my_team"
|
||||||
alice <## "group #team is changed to #my_team"
|
alice <## "changed to #my_team"
|
||||||
concurrently_
|
concurrentlyN_
|
||||||
(bob <## "group #team is changed to #my_team by alice")
|
[ do
|
||||||
(cath <## "group #team is changed to #my_team by alice")
|
bob <## "alice updated group #team:"
|
||||||
|
bob <## "changed to #my_team",
|
||||||
|
do
|
||||||
|
cath <## "alice updated group #team:"
|
||||||
|
cath <## "changed to #my_team"
|
||||||
|
]
|
||||||
bob #> "#my_team hi"
|
bob #> "#my_team hi"
|
||||||
concurrently_
|
concurrently_
|
||||||
(alice <# "#my_team bob> hi")
|
(alice <# "#my_team bob> hi")
|
||||||
@@ -2862,17 +2870,63 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $
|
|||||||
bob <## "alice updated preferences for you:"
|
bob <## "alice updated preferences for you:"
|
||||||
bob <## "full message deletion: off (you allow: default (yes), contact allows: no)"
|
bob <## "full message deletion: off (you allow: default (yes), contact allows: no)"
|
||||||
|
|
||||||
|
testUpdateGroupPrefs :: IO ()
|
||||||
|
testUpdateGroupPrefs =
|
||||||
|
testChat2 aliceProfile bobProfile $
|
||||||
|
\alice bob -> do
|
||||||
|
createGroup2 "team" alice bob
|
||||||
|
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"on\"}}}"
|
||||||
|
alice <## "updated group preferences:"
|
||||||
|
alice <## "full message deletion enabled: on"
|
||||||
|
bob <## "alice updated group #team:"
|
||||||
|
bob <## "updated group preferences:"
|
||||||
|
bob <## "full message deletion enabled: on"
|
||||||
|
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"off\"}}}"
|
||||||
|
alice <## "updated group preferences:"
|
||||||
|
alice <## "full message deletion enabled: off"
|
||||||
|
alice <## "voice messages enabled: off"
|
||||||
|
bob <## "alice updated group #team:"
|
||||||
|
bob <## "updated group preferences:"
|
||||||
|
bob <## "full message deletion enabled: off"
|
||||||
|
bob <## "voice messages enabled: off"
|
||||||
|
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}"
|
||||||
|
alice <## "updated group preferences:"
|
||||||
|
alice <## "voice messages enabled: on"
|
||||||
|
bob <## "alice updated group #team:"
|
||||||
|
bob <## "updated group preferences:"
|
||||||
|
bob <## "voice messages enabled: on"
|
||||||
|
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}"
|
||||||
|
-- no update
|
||||||
|
alice #> "#team hey"
|
||||||
|
bob <# "#team alice> hey"
|
||||||
|
bob #> "#team hi"
|
||||||
|
alice <# "#team bob> hi"
|
||||||
|
|
||||||
testGetSetSMPServers :: IO ()
|
testGetSetSMPServers :: IO ()
|
||||||
testGetSetSMPServers =
|
testGetSetSMPServers =
|
||||||
testChat2 aliceProfile bobProfile $
|
testChat2 aliceProfile bobProfile $
|
||||||
\alice _ -> do
|
\alice _ -> do
|
||||||
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
|
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001")
|
||||||
alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok")
|
alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok")
|
||||||
alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im")
|
alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im")
|
||||||
alice #$> ("/smp_servers smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok")
|
alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok")
|
||||||
alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
|
alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
|
||||||
alice #$> ("/smp_servers default", id, "ok")
|
alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok")
|
||||||
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
|
alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
|
||||||
|
alice #$> ("/smp default", id, "ok")
|
||||||
|
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001")
|
||||||
|
|
||||||
|
testTestSMPServerConnection :: IO ()
|
||||||
|
testTestSMPServerConnection =
|
||||||
|
testChat2 aliceProfile bobProfile $
|
||||||
|
\alice _ -> do
|
||||||
|
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"
|
||||||
|
alice <## "SMP server test passed"
|
||||||
|
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001"
|
||||||
|
alice <## "SMP server test passed"
|
||||||
|
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZwjI=@localhost:5001"
|
||||||
|
alice <## "SMP server test failed at Connect, error: BROKER NETWORK"
|
||||||
|
alice <## "Possibly, certificate fingerprint in server address is incorrect"
|
||||||
|
|
||||||
testAsyncInitiatingOffline :: IO ()
|
testAsyncInitiatingOffline :: IO ()
|
||||||
testAsyncInitiatingOffline = withTmpFiles $ do
|
testAsyncInitiatingOffline = withTmpFiles $ do
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\"
|
|||||||
|
|
||||||
activeUser :: String
|
activeUser :: String
|
||||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||||
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}}"
|
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}"
|
||||||
#else
|
#else
|
||||||
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}"
|
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
chatStarted :: String
|
chatStarted :: String
|
||||||
|
|||||||
Reference in New Issue
Block a user