Compare commits
23 Commits
v4.3.0-bet
...
ep/all-ite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41d7a47b37 | ||
|
|
b8298aa458 | ||
|
|
c3244f1b76 | ||
|
|
0ad74d9538 | ||
|
|
a4be68f4bd | ||
|
|
0cb8f8ad82 | ||
|
|
9d7bb06396 | ||
|
|
a8b9200c9a | ||
|
|
a9c2a7dcaa | ||
|
|
38b28f866c | ||
|
|
bfa7ff16ff | ||
|
|
5c2b70a214 | ||
|
|
7e3f91f87c | ||
|
|
f54faebff3 | ||
|
|
4e5aa3dcbc | ||
|
|
56f3874a93 | ||
|
|
828b502431 | ||
|
|
491fe4a9bf | ||
|
|
f8302e2030 | ||
|
|
fd34c39552 | ||
|
|
b1fa1a84fe | ||
|
|
cf23399262 | ||
|
|
b5a812769b |
31
Dockerfile
31
Dockerfile
@@ -1,10 +1,29 @@
|
||||
FROM haskell:8.10.4 AS build-stage
|
||||
# if you encounter "version `GLIBC_2.28' not found" error when running
|
||||
# chat client executable, build with the following base image instead:
|
||||
# FROM haskell:8.10.4-stretch AS build-stage
|
||||
FROM ubuntu:focal AS build
|
||||
|
||||
# Install curl and simplex-chat-related dependencies
|
||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev
|
||||
|
||||
# Install ghcup
|
||||
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
||||
chmod +x /usr/bin/ghcup
|
||||
|
||||
# Install ghc
|
||||
RUN ghcup install ghc 8.10.7
|
||||
# Install cabal
|
||||
RUN ghcup install cabal
|
||||
# Set both as default
|
||||
RUN ghcup set ghc 8.10.7 && \
|
||||
ghcup set cabal
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
RUN stack install
|
||||
|
||||
# Adjust PATH
|
||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||
|
||||
# Compile simplex-chat
|
||||
RUN cabal update
|
||||
RUN cabal install
|
||||
|
||||
FROM scratch AS export-stage
|
||||
COPY --from=build-stage /root/.local/bin/simplex-chat /
|
||||
COPY --from=build /root/.cabal/bin/simplex-chat /
|
||||
|
||||
@@ -8,7 +8,7 @@ If you believe that some of the clauses in this document are not aligned with ou
|
||||
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).
|
||||
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
### Information you provide
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ SimpleX Chat founder
|
||||
|
||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
||||
|
||||
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).
|
||||
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
|
||||
|
||||
|
||||
@@ -357,6 +357,13 @@ fun MainPage(
|
||||
.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 {
|
||||
|
||||
@@ -20,7 +20,11 @@ import kotlinx.serialization.descriptors.*
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
|
||||
/*
|
||||
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
|
||||
* */
|
||||
@Stable
|
||||
class ChatModel(val controller: ChatController) {
|
||||
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
||||
@@ -71,6 +75,8 @@ class ChatModel(val controller: ChatController) {
|
||||
// working with external intents
|
||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||
|
||||
val filesToDelete = mutableSetOf<File>()
|
||||
|
||||
fun updateUserProfile(profile: LocalProfile) {
|
||||
val user = currentUser.value
|
||||
if (user != null) {
|
||||
@@ -219,6 +225,7 @@ class ChatModel(val controller: ChatController) {
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
AudioPlayer.stop(chatItems[itemIndex])
|
||||
chatItems.removeAt(itemIndex)
|
||||
}
|
||||
}
|
||||
@@ -1030,6 +1037,9 @@ data class ChatItem (
|
||||
|
||||
val text: String get() =
|
||||
when {
|
||||
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
|
||||
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
|
||||
}
|
||||
content.text == "" && file != null -> file.fileName
|
||||
else -> content.text
|
||||
}
|
||||
@@ -1302,6 +1312,8 @@ class CIFile(
|
||||
CIFileStatus.RcvComplete -> true
|
||||
}
|
||||
|
||||
val audioInfo: MutableState<ProgressAndDuration> by lazy { mutableStateOf(ProgressAndDuration()) }
|
||||
|
||||
companion object {
|
||||
fun getSample(
|
||||
fileId: Long = 1,
|
||||
@@ -1335,6 +1347,7 @@ sealed class MsgContent {
|
||||
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
|
||||
@@ -1342,11 +1355,17 @@ sealed class MsgContent {
|
||||
is MCText -> "text $text"
|
||||
is MCLink -> "json ${json.encodeToString(this)}"
|
||||
is MCImage -> "json ${json.encodeToString(this)}"
|
||||
is MCVoice-> "json ${json.encodeToString(this)}"
|
||||
is MCFile -> "json ${json.encodeToString(this)}"
|
||||
is MCUnknown -> "json $json"
|
||||
}
|
||||
}
|
||||
|
||||
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
|
||||
val time = String.format("%02d:%02d", duration / 60, duration % 60)
|
||||
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class CIGroupInvitation (
|
||||
val groupId: Long,
|
||||
@@ -1415,6 +1434,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||
MsgContent.MCImage(text, image)
|
||||
}
|
||||
"voice" -> {
|
||||
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
MsgContent.MCVoice(text, duration)
|
||||
}
|
||||
"file" -> MsgContent.MCFile(text)
|
||||
else -> MsgContent.MCUnknown(t, text, json)
|
||||
}
|
||||
@@ -1446,6 +1469,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
put("text", value.text)
|
||||
put("image", value.image)
|
||||
}
|
||||
is MsgContent.MCVoice ->
|
||||
buildJsonObject {
|
||||
put("type", "voice")
|
||||
put("text", value.text)
|
||||
put("duration", value.duration)
|
||||
}
|
||||
is MsgContent.MCFile ->
|
||||
buildJsonObject {
|
||||
put("type", "file")
|
||||
|
||||
@@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
if (cItem.content.text != "") {
|
||||
cItem.content.text
|
||||
} else {
|
||||
cItem.file?.fileName ?: ""
|
||||
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
|
||||
}
|
||||
} else {
|
||||
var res = ""
|
||||
|
||||
@@ -1014,6 +1014,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
val file = cItem.file
|
||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) }
|
||||
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) }
|
||||
}
|
||||
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
@@ -1039,6 +1041,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.removeChatItem(cInfo, cItem)
|
||||
} else {
|
||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||
AudioPlayer.stop(cItem)
|
||||
chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ fun TerminalLayout(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
||||
SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
|
||||
@@ -94,7 +94,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
val chat = activeChat.value!!
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
// We need to have real unreadCount value for displaying it inside top right button
|
||||
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||
val unreadCount = remember {
|
||||
@@ -123,6 +122,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
back = {
|
||||
hideKeyboard(view)
|
||||
AudioPlayer.stop()
|
||||
chatModel.chatId.value = null
|
||||
},
|
||||
info = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeVoiceView
|
||||
import ComposeFileView
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
@@ -14,11 +15,9 @@ import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
@@ -29,26 +28,30 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
sealed class ComposePreview {
|
||||
@Serializable object NoPreview: ComposePreview()
|
||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ data class ComposeState(
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty()
|
||||
}
|
||||
@@ -93,6 +97,7 @@ data class ComposeState(
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.VoicePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> useLinkPreviews
|
||||
}
|
||||
@@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
||||
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
|
||||
is MsgContent.MCFile -> {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
ComposePreview.FilePreview(fileName)
|
||||
}
|
||||
else -> ComposePreview.NoPreview
|
||||
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +150,11 @@ fun ComposeView(
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
// attachments
|
||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
|
||||
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
|
||||
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
|
||||
)
|
||||
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
|
||||
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
@@ -321,6 +332,7 @@ fun ComposeView(
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||
}
|
||||
@@ -330,6 +342,7 @@ fun ComposeView(
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
textStyle.value = smallFont
|
||||
chosenContent.value = emptyList()
|
||||
chosenAudio.value = null
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
@@ -376,6 +389,15 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val chosenAudioVal = chosenAudio.value
|
||||
if (chosenAudioVal != null) {
|
||||
val file = chosenAudioVal.first.toFile().name
|
||||
files.add((file))
|
||||
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
@@ -426,6 +448,13 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
|
||||
val file = File(filePath)
|
||||
chosenAudio.value = file.toUri() to durationMs
|
||||
chatModel.filesToDelete.add(file)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
|
||||
}
|
||||
|
||||
fun cancelLinkPreview() {
|
||||
val uri = composeState.value.linkPreview?.uri
|
||||
if (uri != null) {
|
||||
@@ -440,6 +469,11 @@ fun ComposeView(
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenContent.value = emptyList()
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenFile.value = null
|
||||
@@ -455,6 +489,13 @@ fun ComposeView(
|
||||
::cancelImages,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||
preview.voice,
|
||||
preview.durationMs,
|
||||
preview.finished,
|
||||
cancelEnabled = !composeState.value.editing,
|
||||
::cancelVoice
|
||||
)
|
||||
is ComposePreview.FilePreview -> ComposeFileView(
|
||||
preview.fileName,
|
||||
::cancelFile,
|
||||
@@ -489,37 +530,34 @@ fun ComposeView(
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||
else -> previewView()
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
val attachEnabled = !composeState.value.editing
|
||||
Box(Modifier.padding(bottom = 12.dp)) {
|
||||
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
|
||||
IconButton(showChooseAttachment, enabled = attachEnabled) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
if (attachEnabled) {
|
||||
showChooseAttachment()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
SendMsgView(
|
||||
composeState,
|
||||
allowVoiceRecord = true,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
::onMessageChange,
|
||||
::onAudioAdded,
|
||||
textStyle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.text.InputType
|
||||
import android.view.ViewGroup
|
||||
@@ -12,38 +15,198 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.core.widget.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.SharedContent
|
||||
import kotlinx.coroutines.delay
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
allowVoiceRecord: Boolean,
|
||||
sendMessage: () -> Unit,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onAudioAdded: (String, Int, Boolean) -> Unit,
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 8.dp)) {
|
||||
Box {
|
||||
val cs = composeState.value
|
||||
val attachEnabled = !composeState.value.editing
|
||||
val filePath = rememberSaveable { mutableStateOf(null as String?) }
|
||||
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
|
||||
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && allowVoiceRecord && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
|
||||
Box(if (recordingTimeRange.first == 0L)
|
||||
Modifier
|
||||
else
|
||||
Modifier.clickable(false, onClick = {})
|
||||
) {
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
|
||||
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||
} else if (!showVoiceButton) {
|
||||
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
)
|
||||
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
|
||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (isActive) {
|
||||
now = System.currentTimeMillis()
|
||||
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
val stopRecordingAndAddAudio: () -> Unit = {
|
||||
rec.stop()
|
||||
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
|
||||
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
|
||||
}
|
||||
val startStopRecording: () -> Unit = {
|
||||
when {
|
||||
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
|
||||
recordingInProgress.value -> stopRecordingAndAddAudio()
|
||||
filePath.value == null -> {
|
||||
recordingTimeRange = System.currentTimeMillis()..0L
|
||||
filePath.value = rec.start(stopRecordingAndAddAudio)
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
var stopRecOnNextClick by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(stopRecOnNextClick) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
if (stopRecOnNextClick) {
|
||||
// Lock orientation to current orientation because screen rotation will break the recording
|
||||
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
else
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
val cleanUp = { remove: Boolean ->
|
||||
rec.stop()
|
||||
if (remove) filePath.value?.let { File(it).delete() }
|
||||
filePath.value = null
|
||||
stopRecOnNextClick = false
|
||||
recordingTimeRange = 0L..0L
|
||||
}
|
||||
LaunchedEffect(cs.preview) {
|
||||
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
|
||||
// Pressed on X icon in preview
|
||||
cleanUp(true)
|
||||
}
|
||||
}
|
||||
val interactionSource = interactionSourceWithTapDetection(
|
||||
onPress = {
|
||||
if (filePath.value == null) startStopRecording()
|
||||
},
|
||||
onClick = {
|
||||
if (!recordingInProgress.value && filePath.value != null) {
|
||||
sendMessage()
|
||||
cleanUp(false)
|
||||
} else if (stopRecOnNextClick) {
|
||||
stopRecordingAndAddAudio()
|
||||
stopRecOnNextClick = false
|
||||
} else {
|
||||
// tapped and didn't hold a finger
|
||||
stopRecOnNextClick = true
|
||||
}
|
||||
},
|
||||
onCancel = startStopRecording,
|
||||
onRelease = startStopRecording
|
||||
)
|
||||
val sendButtonModifier = if (recordingTimeRange.last != 0L)
|
||||
Modifier.clip(CircleShape).background(color)
|
||||
else
|
||||
Modifier
|
||||
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
|
||||
Icon(
|
||||
if (recordingTimeRange.last != 0L) Icons.Outlined.ArrowUpward else if (stopRecOnNextClick) Icons.Default.Stop else Icons.Default.Mic,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = if (recordingTimeRange.last != 0L) Color.White else if (!cs.inProgress) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.then(sendButtonModifier)
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
rec.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NativeKeyboard(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
onMessageChange: (String) -> Unit
|
||||
) {
|
||||
val cs = composeState.value
|
||||
val textColor = MaterialTheme.colors.onBackground
|
||||
val tintColor = MaterialTheme.colors.secondary
|
||||
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
when (cs.contextItem) {
|
||||
@@ -58,99 +221,69 @@ fun SendMsgView(
|
||||
}
|
||||
}
|
||||
}
|
||||
val textColor = MaterialTheme.colors.onBackground
|
||||
val tintColor = MaterialTheme.colors.secondary
|
||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
|
||||
Column(Modifier.padding(vertical = 8.dp)) {
|
||||
Box {
|
||||
AndroidView(modifier = Modifier, factory = {
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||
override fun setOnReceiveContentListener(
|
||||
mimeTypes: Array<out String>?,
|
||||
listener: android.view.OnReceiveContentListener?
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
return@OnCommitContentListener false
|
||||
}
|
||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||
true
|
||||
}
|
||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||
}
|
||||
}
|
||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
editText.maxLines = 16
|
||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||
editText.setTextColor(textColor.toArgb())
|
||||
editText.textSize = textStyle.value.fontSize.value
|
||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||
editText
|
||||
}) {
|
||||
it.setTextColor(textColor.toArgb())
|
||||
it.textSize = textStyle.value.fontSize.value
|
||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||
if (cs.message != it.text.toString()) {
|
||||
it.setText(cs.message)
|
||||
// Set cursor to the end of the text
|
||||
it.setSelection(it.text.length)
|
||||
}
|
||||
if (showKeyboard) {
|
||||
it.requestFocus()
|
||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||
showKeyboard = false
|
||||
}
|
||||
AndroidView(modifier = Modifier, factory = {
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||
override fun setOnReceiveContentListener(
|
||||
mimeTypes: Array<out String>?,
|
||||
listener: android.view.OnReceiveContentListener?
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (cs.inProgress
|
||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.clickable {
|
||||
if (cs.sendEnabled()) {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
)
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
return@OnCommitContentListener false
|
||||
}
|
||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||
true
|
||||
}
|
||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||
}
|
||||
}
|
||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
editText.maxLines = 16
|
||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||
editText.setTextColor(textColor.toArgb())
|
||||
editText.textSize = textStyle.value.fontSize.value
|
||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||
editText
|
||||
}) {
|
||||
it.setTextColor(textColor.toArgb())
|
||||
it.textSize = textStyle.value.fontSize.value
|
||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
|
||||
it.isFocusableInTouchMode = it.isFocusable
|
||||
if (cs.message != it.text.toString()) {
|
||||
it.setText(cs.message)
|
||||
// Set cursor to the end of the text
|
||||
it.setSelection(it.text.length)
|
||||
}
|
||||
if (showKeyboard) {
|
||||
it.requestFocus()
|
||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||
showKeyboard = false
|
||||
}
|
||||
}
|
||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
Text(
|
||||
generalGetString(R.string.voice_message_send_text),
|
||||
Modifier.padding(padding),
|
||||
color = HighOrLowlight,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +300,10 @@ fun PreviewSendMsgView() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
allowVoiceRecord = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
@@ -188,8 +323,10 @@ fun PreviewSendMsgViewEditing() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
allowVoiceRecord = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
@@ -209,8 +346,10 @@ fun PreviewSendMsgViewInProgress() {
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
allowVoiceRecord = false,
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,20 +100,21 @@ fun ChatItemView(
|
||||
copyText(context, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
val filePath = getLoadedFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cItem.meta.editable) {
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
|
||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -56,8 +57,12 @@ fun FramedItemView(
|
||||
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
||||
contentAlignment = Alignment.TopStart
|
||||
) {
|
||||
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
|
||||
qi.content.toTextWithDuration(true)
|
||||
else
|
||||
qi.text
|
||||
MarkdownText(
|
||||
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||
text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
||||
)
|
||||
}
|
||||
@@ -87,13 +92,13 @@ fun FramedItemView(
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
)
|
||||
}
|
||||
is MsgContent.MCFile -> {
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
Icon(
|
||||
Icons.Filled.InsertDriveFile,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
|
||||
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||
Modifier
|
||||
.padding(top = 6.dp, end = 4.dp)
|
||||
.size(22.dp),
|
||||
@@ -105,7 +110,7 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
|
||||
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(
|
||||
@@ -142,6 +147,12 @@ fun FramedItemView(
|
||||
CIMarkdownText(ci, showMember, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVoice -> {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor)
|
||||
if (mc.text != "") {
|
||||
CIMarkdownText(ci, showMember, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||
if (mc.text != "") {
|
||||
@@ -157,8 +168,10 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,5 @@ import androidx.compose.animation.core.*
|
||||
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||
|
||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||
|
||||
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
|
||||
|
||||
@@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
||||
return interactionSource
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
LaunchedEffect(interactionSource) {
|
||||
var firstTapTime = 0L
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> {
|
||||
firstTapTime = System.currentTimeMillis(); onPress()
|
||||
}
|
||||
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
|
||||
is PressInteraction.Cancel -> onCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
return interactionSource
|
||||
}
|
||||
|
||||
suspend fun PointerInputScope.detectTransformGestures(
|
||||
allowIntercept: () -> Boolean,
|
||||
panZoomLock: Boolean = false,
|
||||
|
||||
@@ -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 androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.CIFile
|
||||
@@ -220,6 +222,11 @@ private fun spannableStringToAnnotatedString(
|
||||
// maximum image file size to be auto-accepted
|
||||
const val MAX_IMAGE_SIZE: Long = 236700
|
||||
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
|
||||
|
||||
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
||||
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
|
||||
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
@@ -449,3 +456,9 @@ fun Color.darker(factor: Float = 0.1f): Color =
|
||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||
|
||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||
|
||||
val LongRange.Companion.saver
|
||||
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
|
||||
save = { it.value.first to it.value.last },
|
||||
restore = { mutableStateOf(it.first..it.second) }
|
||||
)
|
||||
|
||||
@@ -208,6 +208,10 @@
|
||||
<string name="file_not_found">Datei nicht gefunden</string>
|
||||
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">***Voice message</string>
|
||||
<string name="voice_message_send_text">***Voice message…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Benachrichtigungen</string>
|
||||
|
||||
@@ -221,10 +225,11 @@
|
||||
<string name="icon_descr_server_status_error">Fehler</string>
|
||||
<string name="icon_descr_server_status_pending">Ausstehend</string>
|
||||
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
|
||||
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
||||
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Nachricht senden</string>
|
||||
<string name="icon_descr_record_voice_message">***Record voice message</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Zurück</string>
|
||||
@@ -364,9 +369,9 @@
|
||||
<string name="chat_console">Chat Konsole</string>
|
||||
<string name="smp_servers">SMP-Server</string>
|
||||
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
|
||||
<string name="star_on_github">Stern auf GitHub</string>
|
||||
<string name="contribute">Beitragen</string>
|
||||
<string name="rate_the_app">Bewerte die App</string>
|
||||
<string name="star_on_github">Stern auf GitHub vergeben</string>
|
||||
<string name="contribute">Unterstützen Sie uns</string>
|
||||
<string name="rate_the_app">Bewerten Sie die App</string>
|
||||
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
|
||||
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
|
||||
<string name="your_SMP_servers">Ihre SMP-Server</string>
|
||||
@@ -567,7 +572,7 @@
|
||||
<string name="settings_section_title_you">MEINE DATEN</string>
|
||||
<string name="settings_section_title_settings">EINSTELLUNGEN</string>
|
||||
<string name="settings_section_title_help">HILFE</string>
|
||||
<string name="settings_section_title_support">UNTERSTÜTZEN SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_support">UNTERSTÜTZUNG VON SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_develop">ENTWICKLUNG</string>
|
||||
<string name="settings_section_title_device">GERÄT</string>
|
||||
<string name="settings_section_title_chats">CHATS</string>
|
||||
|
||||
@@ -208,6 +208,10 @@
|
||||
<string name="file_not_found">Файл не найден</string>
|
||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">Голосовое сообщение</string>
|
||||
<string name="voice_message_send_text">Голосовое сообщение…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Уведомления</string>
|
||||
|
||||
@@ -225,6 +229,7 @@
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Отправить сообщение</string>
|
||||
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Назад</string>
|
||||
|
||||
@@ -208,6 +208,10 @@
|
||||
<string name="file_not_found">File not found</string>
|
||||
<string name="error_saving_file">Error saving file</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">Voice message</string>
|
||||
<string name="voice_message_send_text">Voice message…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Notifications</string>
|
||||
|
||||
@@ -225,6 +229,7 @@
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Send Message</string>
|
||||
<string name="icon_descr_record_voice_message">Record voice message</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Back</string>
|
||||
|
||||
@@ -311,7 +311,7 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
||||
|
||||
func getUserSMPServers() throws -> [String] {
|
||||
let r = chatSendCmdSync(.getUserSMPServers)
|
||||
if case let .userSMPServers(smpServers) = r { return smpServers }
|
||||
if case let .userSMPServers(smpServers, _) = r { return smpServers.map { $0.server } }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -319,6 +319,17 @@ func setUserSMPServers(smpServers: [String]) async throws {
|
||||
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
|
||||
}
|
||||
|
||||
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||
let r = await chatSendCmd(.testSMPServer(smpServer: smpServer))
|
||||
if case let .sMPTestResult(testFailure) = r {
|
||||
if let t = testFailure {
|
||||
return .failure(t)
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
throw r
|
||||
}
|
||||
|
||||
func getChatItemTTL() throws -> ChatItemTTL {
|
||||
let r = chatSendCmdSync(.apiGetChatItemTTL)
|
||||
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||
@@ -476,6 +487,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
|
||||
let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
|
||||
if case let .contactPrefsUpdated(_, toContact) = r { return toContact }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
|
||||
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
|
||||
if case let .contactAliasUpdated(toContact) = r { return toContact }
|
||||
|
||||
@@ -53,7 +53,7 @@ struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
var contact: Contact
|
||||
@State var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
var customUserProfile: Profile?
|
||||
@State var localAlias: String
|
||||
@@ -99,14 +99,8 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Preferences") {
|
||||
NavigationLink {
|
||||
ContactPreferencesView()
|
||||
.navigationBarTitle("Contact preferences")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Text("Contact preferences")
|
||||
}
|
||||
Section {
|
||||
contactPreferencesButton()
|
||||
}
|
||||
|
||||
Section("Servers") {
|
||||
@@ -202,6 +196,20 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func contactPreferencesButton() -> some View {
|
||||
NavigationLink {
|
||||
ContactPreferencesView(
|
||||
contact: $contact,
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||
)
|
||||
.navigationBarTitle("Contact preferences")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Contact preferences", systemImage: "switch.2")
|
||||
}
|
||||
}
|
||||
|
||||
func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
|
||||
@@ -10,37 +10,24 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ContactPreferencesView: View {
|
||||
@State var allowFullDeletion = ContactFeatureAllowed.yes
|
||||
@State var allowVoice = ContactFeatureAllowed.yes
|
||||
@State var prefs = ContactUserPreferences(
|
||||
fullDelete: ContactUserPreference(
|
||||
enabled: FeatureEnabled(forUser: true, forContact: true),
|
||||
userPreference: .user(preference: Preference(allow: .yes)),
|
||||
contactPreference: Preference(allow: .no)
|
||||
),
|
||||
voice: ContactUserPreference(
|
||||
enabled: FeatureEnabled(forUser: true, forContact: true),
|
||||
userPreference: .user(preference: Preference(allow: .yes)),
|
||||
contactPreference: Preference(allow: .no)
|
||||
)
|
||||
)
|
||||
@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, .yes, prefs.fullDelete, $allowFullDeletion)
|
||||
featureSection(.voice, .yes, prefs.voice, $allowVoice)
|
||||
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
||||
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("Reset")
|
||||
Spacer()
|
||||
Text("Save")
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.disabled(true)
|
||||
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
|
||||
Button("Save (and notify contact)") { savePreferences() }
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.disabled(currentFeaturesAllowed == featuresAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,11 +44,7 @@ struct ContactPreferencesView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
HStack {
|
||||
Text("Contact allows")
|
||||
Spacer()
|
||||
Text(pref.contactPreference.allow.text)
|
||||
}
|
||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||
} header: {
|
||||
HStack {
|
||||
Image(systemName: "\(feature.icon).fill")
|
||||
@@ -73,10 +56,31 @@ struct ContactPreferencesView: View {
|
||||
.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()
|
||||
ContactPreferencesView(
|
||||
contact: Binding.constant(Contact.sampleData),
|
||||
featuresAllowed: ContactFeaturesAllowed.sampleData,
|
||||
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,12 @@ struct GroupChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
@State var groupInfo: GroupInfo
|
||||
@ObservedObject private var alertManager = AlertManager.shared
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
@State private var showGroupProfile: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
@@ -42,6 +41,17 @@ struct GroupChatInfoView: View {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section {
|
||||
if groupInfo.canEdit {
|
||||
editGroupButton()
|
||||
}
|
||||
groupPreferencesButton()
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
Text("Only group owners can change group preferences.")
|
||||
}
|
||||
|
||||
Section("\(members.count + 1) members") {
|
||||
if groupInfo.canAddMembers {
|
||||
groupLinkButton()
|
||||
@@ -77,14 +87,8 @@ struct GroupChatInfoView: View {
|
||||
}) { _ in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
|
||||
}
|
||||
.sheet(isPresented: $showGroupProfile) {
|
||||
GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile)
|
||||
}
|
||||
|
||||
Section {
|
||||
if groupInfo.canEdit {
|
||||
editGroupButton()
|
||||
}
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
@@ -189,16 +193,35 @@ struct GroupChatInfoView: View {
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Group link", systemImage: "link")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
func groupPreferencesButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupPreferencesView(
|
||||
groupInfo: $groupInfo,
|
||||
preferences: groupInfo.fullGroupPreferences,
|
||||
currentPreferences: groupInfo.fullGroupPreferences
|
||||
)
|
||||
.navigationBarTitle("Group preferences")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Group preferences", systemImage: "switch.2")
|
||||
}
|
||||
}
|
||||
|
||||
func editGroupButton() -> some View {
|
||||
Button {
|
||||
showGroupProfile = true
|
||||
NavigationLink {
|
||||
GroupProfileView(
|
||||
groupInfo: $groupInfo,
|
||||
groupProfile: groupInfo.groupProfile
|
||||
)
|
||||
.navigationBarTitle("Group profile")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Edit group profile", systemImage: "pencil")
|
||||
}
|
||||
|
||||
@@ -29,10 +29,6 @@ struct GroupLinkView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack (alignment: .leading) {
|
||||
Text("Group link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
.padding(.bottom)
|
||||
if let groupLink = groupLink {
|
||||
|
||||
84
apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
Normal file
84
apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// GroupPreferencesView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by JRoberts on 16.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct GroupPreferencesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var preferences: FullGroupPreferences
|
||||
@State var currentPreferences: FullGroupPreferences
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
Button("Reset") { preferences = currentPreferences }
|
||||
Button("Save (and notify group members)") { savePreferences() }
|
||||
}
|
||||
.disabled(currentPreferences == preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func featureSection(_ feature: Feature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
|
||||
Section {
|
||||
if (groupInfo.canEdit) {
|
||||
settingsRow(feature.icon) {
|
||||
Picker(feature.text, selection: enableFeature) {
|
||||
ForEach(GroupFeatureEnabled.values) { enable in
|
||||
Text(enable.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
else {
|
||||
settingsRow(feature.icon) {
|
||||
infoRow(feature.text, enableFeature.wrappedValue.text)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enableGroupPrefDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
Task {
|
||||
do {
|
||||
var gp = groupInfo.groupProfile
|
||||
gp.groupPreferences = toGroupPreferences(preferences)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
chatModel.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
} catch {
|
||||
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupPreferencesView(
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
preferences: FullGroupPreferences.sampleData,
|
||||
currentPreferences: FullGroupPreferences.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import SimpleXChat
|
||||
struct GroupProfileView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var groupId: Int64
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var groupProfile: GroupProfile
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@@ -120,8 +120,9 @@ struct GroupProfileView: View {
|
||||
func saveProfile() {
|
||||
Task {
|
||||
do {
|
||||
let gInfo = try await apiUpdateGroup(groupId, groupProfile)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
chatModel.updateGroup(gInfo)
|
||||
dismiss()
|
||||
}
|
||||
@@ -137,6 +138,6 @@ struct GroupProfileView: View {
|
||||
|
||||
struct GroupProfileView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupProfileView(groupId: 1, groupProfile: GroupProfile.sampleData)
|
||||
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ struct NetworkAndServers: View {
|
||||
Text("SMP servers")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
SMPServersView()
|
||||
.navigationTitle("Your SMP servers")
|
||||
} label: {
|
||||
Text("SMP servers (new)")
|
||||
}
|
||||
|
||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
|
||||
@@ -10,25 +10,22 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct PreferencesView: View {
|
||||
@State var allowFullDeletion = FeatureAllowed.yes
|
||||
@State var allowVoice = FeatureAllowed.yes
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State var profile: LocalProfile
|
||||
@State var preferences: FullPreferences
|
||||
@State var currentPreferences: FullPreferences
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
featureSection(.fullDelete, $allowFullDeletion)
|
||||
featureSection(.voice, $allowVoice)
|
||||
featureSection(.fullDelete, $preferences.fullDelete.allow)
|
||||
featureSection(.voice, $preferences.voice.allow)
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("Reset")
|
||||
Spacer()
|
||||
Text("Save")
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.disabled(true)
|
||||
Button("Reset") { preferences = currentPreferences }
|
||||
Button("Save (and notify contacts)") { savePreferences() }
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.disabled(currentPreferences == preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,10 +45,34 @@ struct PreferencesView: View {
|
||||
.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()
|
||||
PreferencesView(
|
||||
profile: LocalProfile(profileId: 1, displayName: "alice", fullName: "", localAlias: ""),
|
||||
preferences: FullPreferences.sampleData,
|
||||
currentPreferences: FullPreferences.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
105
apps/ios/Shared/Views/UserSettings/SMPServerView.swift
Normal file
105
apps/ios/Shared/Views/UserSettings/SMPServerView.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// SMPServerView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 15/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SMPServerView: View {
|
||||
@State var server: ServerCfg
|
||||
|
||||
var body: some View {
|
||||
if server.preset {
|
||||
presetServer()
|
||||
} else {
|
||||
customServer()
|
||||
}
|
||||
}
|
||||
|
||||
private func presetServer() -> some View {
|
||||
return VStack {
|
||||
List {
|
||||
Section("Preset server address") {
|
||||
Text(server.server)
|
||||
}
|
||||
useServerSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func customServer() -> some View {
|
||||
VStack {
|
||||
List {
|
||||
Section("Your server address") {
|
||||
TextEditor(text: $server.server)
|
||||
.multilineTextAlignment(.leading)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.lineLimit(10)
|
||||
.frame(height: 108)
|
||||
.padding(-6)
|
||||
}
|
||||
useServerSection()
|
||||
Section("Add to another device") {
|
||||
QRCode(uri: server.server)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useServerSection() -> some View {
|
||||
Section("Use server") {
|
||||
HStack {
|
||||
Button("Test server") {
|
||||
Task { await testServerConnection(server: $server) }
|
||||
}
|
||||
Spacer()
|
||||
showTestStatus(server: server)
|
||||
}
|
||||
Toggle("Enabled", isOn: $server.enabled)
|
||||
Button("Remove server", role: .destructive) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
|
||||
switch server.tested {
|
||||
case .some(true):
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.green)
|
||||
case .some(false):
|
||||
Image(systemName: "multiply")
|
||||
.foregroundColor(.red)
|
||||
case .none:
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
func testServerConnection(server: Binding<ServerCfg>) async {
|
||||
do {
|
||||
let r = try await testSMPServer(smpServer: server.wrappedValue.server)
|
||||
await MainActor.run {
|
||||
switch r {
|
||||
case .success: server.wrappedValue.tested = true
|
||||
case .failure: server.wrappedValue.tested = false
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
server.wrappedValue.tested = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SMPServerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SMPServerView(server: ServerCfg.sampleData.custom)
|
||||
}
|
||||
}
|
||||
106
apps/ios/Shared/Views/UserSettings/SMPServersView.swift
Normal file
106
apps/ios/Shared/Views/UserSettings/SMPServersView.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// SMPServersView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 15/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SMPServersView: View {
|
||||
@Environment(\.editMode) var editMode
|
||||
@State var servers: [ServerCfg] = [
|
||||
ServerCfg.sampleData.preset,
|
||||
ServerCfg.sampleData.custom,
|
||||
ServerCfg.sampleData.untested,
|
||||
]
|
||||
@State var showAddServer = false
|
||||
@State var showSaveAlert = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("SMP servers") {
|
||||
ForEach(servers) { srv in
|
||||
smpServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
servers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
servers.remove(atOffsets: indexSet)
|
||||
}
|
||||
if isEditing {
|
||||
Button("Add server…") {
|
||||
showAddServer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: isEditing) { value in
|
||||
if value == false {
|
||||
showSaveAlert = true
|
||||
}
|
||||
}
|
||||
.toolbar { EditButton() }
|
||||
.confirmationDialog("Add server…", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
Button("Scan server QR code") {
|
||||
}
|
||||
Button("Add preset servers") {
|
||||
}
|
||||
Button("Enter server manually") {
|
||||
servers.append(ServerCfg.empty)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Save servers?", isPresented: $showSaveAlert, titleVisibility: .visible) {
|
||||
Button("Test & save servers") {
|
||||
for i in 0..<servers.count {
|
||||
servers[i].tested = nil
|
||||
}
|
||||
Task {
|
||||
for i in 0..<servers.count {
|
||||
await testServerConnection(server: $servers[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Save servers") {
|
||||
}
|
||||
Button("Revert changes") {
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
editMode?.wrappedValue = .active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isEditing: Bool {
|
||||
editMode?.wrappedValue.isEditing == true
|
||||
}
|
||||
|
||||
private func smpServerView(_ srv: ServerCfg) -> some View {
|
||||
NavigationLink {
|
||||
SMPServerView(server: srv)
|
||||
.navigationBarTitle("Server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
let v = Text(srv.server)
|
||||
HStack {
|
||||
showTestStatus(server: srv)
|
||||
.frame(width: 16, alignment: .center)
|
||||
.padding(.trailing, 4)
|
||||
if srv.enabled {
|
||||
v
|
||||
} else {
|
||||
(v + Text(" (disabled)")).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SMPServersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SMPServersView()
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,8 @@ struct SettingsView: View {
|
||||
ProfilePreview(profileOf: user)
|
||||
.padding(.leading, -8)
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
incognitoRow()
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
NavigationLink {
|
||||
CreateLinkView(selection: .longTerm, viaNavLink: true)
|
||||
@@ -100,33 +98,17 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
settingsRow("qrcode") { Text("Your SimpleX contact address") }
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
NavigationLink {
|
||||
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
|
||||
.navigationTitle("Your chat database")
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Settings") {
|
||||
NavigationLink {
|
||||
PreferencesView()
|
||||
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
|
||||
.navigationTitle("Your preferences")
|
||||
} label: {
|
||||
settingsRow("list.bullet") { Text("Chat preferences") }
|
||||
settingsRow("switch.2") { Text("Chat preferences") }
|
||||
}
|
||||
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
Section("Settings") {
|
||||
NavigationLink {
|
||||
NotificationsView()
|
||||
.navigationTitle("Notifications")
|
||||
@@ -136,18 +118,32 @@ struct SettingsView: View {
|
||||
Text("Notifications")
|
||||
}
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
NavigationLink {
|
||||
NetworkAndServers()
|
||||
.navigationTitle("Network & servers")
|
||||
} label: {
|
||||
settingsRow("externaldrive.connected.to.line.below") { Text("Network & servers") }
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
NavigationLink {
|
||||
CallSettings()
|
||||
.navigationTitle("Your calls")
|
||||
} label: {
|
||||
settingsRow("video") { Text("Audio & video calls") }
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
NavigationLink {
|
||||
PrivacySettings()
|
||||
.navigationTitle("Your privacy")
|
||||
} label: {
|
||||
settingsRow("lock") { Text("Privacy & security") }
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
if UIApplication.shared.supportsAlternateIcons {
|
||||
NavigationLink {
|
||||
AppearanceSettings()
|
||||
@@ -155,15 +151,11 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
settingsRow("sun.max") { Text("Appearance") }
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
NavigationLink {
|
||||
NetworkAndServers()
|
||||
.navigationTitle("Network & servers")
|
||||
} label: {
|
||||
settingsRow("externaldrive.connected.to.line.below") { Text("Network & servers") }
|
||||
}
|
||||
|
||||
chatDatabaseRow()
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
Section("Help") {
|
||||
NavigationLink {
|
||||
@@ -283,6 +275,24 @@ struct SettingsView: View {
|
||||
.padding(.leading, indent)
|
||||
}
|
||||
}
|
||||
|
||||
private func chatDatabaseRow() -> some View {
|
||||
NavigationLink {
|
||||
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
|
||||
.navigationTitle("Your chat database")
|
||||
} label: {
|
||||
let color: Color = chatModel.chatDbEncrypted == false ? .orange : .secondary
|
||||
settingsRow("internaldrive", color: color) {
|
||||
HStack {
|
||||
Text("Database passphrase & export")
|
||||
Spacer()
|
||||
if chatModel.chatRunning == false {
|
||||
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum SettingsSheet: Identifiable {
|
||||
case incognitoInfo
|
||||
|
||||
@@ -278,6 +278,36 @@
|
||||
<target>Alle Ihre Kontakte bleiben verbunden.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||
<target>***Allow irreversible message deletion only if your contact allows it to you.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow to irreversibly delete sent messages.</source>
|
||||
<target>***Allow to irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to send voice messages." xml:space="preserve">
|
||||
<source>Allow to send voice messages.</source>
|
||||
<target>***Allow to send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
|
||||
<source>Allow voice messages only if your contact allows them.</source>
|
||||
<target>***Allow voice messages only if your contact allows them.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow your contacts to irreversibly delete sent messages.</source>
|
||||
<target>***Allow your contacts to irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||
<source>Allow your contacts to send voice messages.</source>
|
||||
<target>***Allow your contacts to send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Already connected?" xml:space="preserve">
|
||||
<source>Already connected?</source>
|
||||
<target>Sind Sie bereits verbunden?</target>
|
||||
@@ -328,6 +358,16 @@
|
||||
<target>Automatisch</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Both you and your contact can irreversibly delete sent messages.</source>
|
||||
<target>***Both you and your contact can irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||
<source>Both you and your contact can send voice messages.</source>
|
||||
<target>***Both you and your contact can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||
<source>Call already ended!</source>
|
||||
<target>Anruf ist bereits beendet!</target>
|
||||
@@ -428,6 +468,11 @@
|
||||
<target>Der Chat ist beendet</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat preferences" xml:space="preserve">
|
||||
<source>Chat preferences</source>
|
||||
<target>***Chat preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chats" xml:space="preserve">
|
||||
<source>Chats</source>
|
||||
<target>Chats</target>
|
||||
@@ -558,6 +603,11 @@
|
||||
<target>Verbindungszeitüberschreitung</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact allows" xml:space="preserve">
|
||||
<source>Contact allows</source>
|
||||
<target>***Contact allows</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact already exists" xml:space="preserve">
|
||||
<source>Contact already exists</source>
|
||||
<target>Der Kontakt ist bereits vorhanden</target>
|
||||
@@ -588,11 +638,21 @@
|
||||
<target>Kontaktname</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact preferences" xml:space="preserve">
|
||||
<source>Contact preferences</source>
|
||||
<target>***Contact preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact requests" xml:space="preserve">
|
||||
<source>Contact requests</source>
|
||||
<target>Kontaktanfragen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
|
||||
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
|
||||
<target>***Contacts can mark messages for deletion; you will be able to view them.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Copy" xml:space="preserve">
|
||||
<source>Copy</source>
|
||||
<target>Kopieren</target>
|
||||
@@ -1221,6 +1281,11 @@
|
||||
<target>Für Konsole</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Full deletion" xml:space="preserve">
|
||||
<source>Full deletion</source>
|
||||
<target>***Full deletion</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Full name (optional)" xml:space="preserve">
|
||||
<source>Full name (optional)</source>
|
||||
<target>Vollständiger Name (optional)</target>
|
||||
@@ -1271,11 +1336,26 @@
|
||||
<target>Gruppen-Link</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<target>***Group members can irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<target>***Group members can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
<source>Group message:</source>
|
||||
<target>Grppennachricht:</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group preferences" xml:space="preserve">
|
||||
<source>Group preferences</source>
|
||||
<target>***Group preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
||||
<source>Group profile is stored on members' devices, not on the servers.</source>
|
||||
<target>Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.</target>
|
||||
@@ -1453,6 +1533,11 @@
|
||||
<target>In Gruppe einladen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<target>***Irreversible message deletion is prohibited in this chat.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
||||
<target>Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.</target>
|
||||
@@ -1768,6 +1853,31 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
<source>Only group owners can change group preferences.</source>
|
||||
<target>***Only group owners can change group preferences.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
|
||||
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
|
||||
<target>***Only you can irreversibly delete messages (your contact can mark them for deletion).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||
<source>Only you can send voice messages.</source>
|
||||
<target>***Only you can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
|
||||
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
|
||||
<target>***Only your contact can irreversibly delete messages (you can mark them for deletion).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||
<source>Only your contact can send voice messages.</source>
|
||||
<target>***Only your contact can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open Settings" xml:space="preserve">
|
||||
<source>Open Settings</source>
|
||||
<target>Geräte-Einstellungen öffnen</target>
|
||||
@@ -1858,6 +1968,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Preferences" xml:space="preserve">
|
||||
<source>Preferences</source>
|
||||
<target>***Preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||
<source>Privacy & security</source>
|
||||
<target>Datenschutz & Sicherheit</target>
|
||||
@@ -1873,6 +1988,16 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Profilbild</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
|
||||
<source>Prohibit irreversible message deletion.</source>
|
||||
<target>***Prohibit irreversible message deletion.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||
<source>Prohibit sending voice messages.</source>
|
||||
<target>***Prohibit sending voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Protocol timeout" xml:space="preserve">
|
||||
<source>Protocol timeout</source>
|
||||
<target>Protokollzeitüberschreitung</target>
|
||||
@@ -1885,7 +2010,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
</trans-unit>
|
||||
<trans-unit id="Rate the app" xml:space="preserve">
|
||||
<source>Rate the app</source>
|
||||
<target>Bewerte die App</target>
|
||||
<target>Bewerten Sie die App</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Read" xml:space="preserve">
|
||||
@@ -1968,6 +2093,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Erforderlich</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reset" xml:space="preserve">
|
||||
<source>Reset</source>
|
||||
<target>***Reset</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reset colors" xml:space="preserve">
|
||||
<source>Reset colors</source>
|
||||
<target>Farben zurücksetzen</target>
|
||||
@@ -2038,11 +2168,21 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Speichern</target>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify contact)" xml:space="preserve">
|
||||
<source>Save (and notify contact)</source>
|
||||
<target>***Save (and notify contact)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
||||
<source>Save (and notify contacts)</source>
|
||||
<target>Speichern (und Kontakte benachrichtigen)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify group members)" xml:space="preserve">
|
||||
<source>Save (and notify group members)</source>
|
||||
<target>***Save (and notify group members)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save archive" xml:space="preserve">
|
||||
<source>Save archive</source>
|
||||
<target>Archiv speichern</target>
|
||||
@@ -2255,7 +2395,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
</trans-unit>
|
||||
<trans-unit id="Support SimpleX Chat" xml:space="preserve">
|
||||
<source>Support SimpleX Chat</source>
|
||||
<target>Unterstützen SimpleX Chat</target>
|
||||
<target>Unterstützung von SimpleX Chat</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="System" xml:space="preserve">
|
||||
@@ -2395,7 +2535,7 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
</trans-unit>
|
||||
<trans-unit id="This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)." xml:space="preserve">
|
||||
<source>This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member).</source>
|
||||
<target>Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</target>
|
||||
<target>Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="This group no longer exists." xml:space="preserve">
|
||||
@@ -2572,6 +2712,16 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
||||
<target>Videoanruf</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages" xml:space="preserve">
|
||||
<source>Voice messages</source>
|
||||
<target>***Voice messages</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<target>***Voice messages are prohibited in this chat.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Waiting for file" xml:space="preserve">
|
||||
<source>Waiting for file</source>
|
||||
<target>Warte auf Datei</target>
|
||||
@@ -2627,6 +2777,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
||||
<target>Sie haben die Verbindung akzeptiert</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You allow" xml:space="preserve">
|
||||
<source>You allow</source>
|
||||
<target>***You allow</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
||||
<source>You are already connected to %@.</source>
|
||||
<target>Sie sind bereits mit %@ verbunden.</target>
|
||||
@@ -2844,6 +2999,11 @@ Sie können diese Verbindung abbrechen und den Kontakt entfernen (und es später
|
||||
<target>Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die Importierte ERSETZT.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your preferences" xml:space="preserve">
|
||||
<source>Your preferences</source>
|
||||
<target>***Your preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your privacy" xml:space="preserve">
|
||||
<source>Your privacy</source>
|
||||
<target>Meine Privatsphäre</target>
|
||||
@@ -2878,7 +3038,7 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" xml:space="preserve">
|
||||
<source>[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)</source>
|
||||
<target>[Beitragen](https://github.com/simplex-chat/simplex-chat#contribute)</target>
|
||||
<target>[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="[Send us email](mailto:chat@simplex.chat)" xml:space="preserve">
|
||||
@@ -2888,7 +3048,7 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
|
||||
<source>[Star on GitHub](https://github.com/simplex-chat/simplex-chat)</source>
|
||||
<target>[Stern auf GitHub](https://github.com/simplex-chat/simplex-chat)</target>
|
||||
<target>[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="_italic_" xml:space="preserve">
|
||||
@@ -2916,6 +3076,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
||||
<target>Admin</target>
|
||||
<note>member role</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="always" xml:space="preserve">
|
||||
<source>always</source>
|
||||
<target>***always</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted)</source>
|
||||
<target>Audioanruf (nicht E2E verschlüsselt)</target>
|
||||
@@ -3056,6 +3221,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
||||
<target>Ersteller</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="default (%@)" xml:space="preserve">
|
||||
<source>default (%@)</source>
|
||||
<target>***default (%@)</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="deleted" xml:space="preserve">
|
||||
<source>deleted</source>
|
||||
<target>Gelöscht</target>
|
||||
@@ -3206,11 +3376,26 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
||||
<target>Neue Nachricht</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no" xml:space="preserve">
|
||||
<source>no</source>
|
||||
<target>***no</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||
<source>no e2e encryption</source>
|
||||
<target>Keine E2E-Verschlüsselung</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="off" xml:space="preserve">
|
||||
<source>off</source>
|
||||
<target>***off</target>
|
||||
<note>group pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="on" xml:space="preserve">
|
||||
<source>on</source>
|
||||
<target>***on</target>
|
||||
<note>group pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="or chat with the developers" xml:space="preserve">
|
||||
<source>or chat with the developers</source>
|
||||
<target>oder chatten Sie mit den Entwicklern</target>
|
||||
@@ -3336,6 +3521,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
||||
<target>möchte sich mit Ihnen verbinden!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="yes" xml:space="preserve">
|
||||
<source>yes</source>
|
||||
<target>***yes</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="you are invited to group" xml:space="preserve">
|
||||
<source>you are invited to group</source>
|
||||
<target>Sie sind zur Gruppe eingeladen</target>
|
||||
|
||||
@@ -278,6 +278,36 @@
|
||||
<target>All your contacts will remain connected</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||
<target>Allow irreversible message deletion only if your contact allows it to you.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow to irreversibly delete sent messages.</source>
|
||||
<target>Allow to irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to send voice messages." xml:space="preserve">
|
||||
<source>Allow to send voice messages.</source>
|
||||
<target>Allow to send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
|
||||
<source>Allow voice messages only if your contact allows them.</source>
|
||||
<target>Allow voice messages only if your contact allows them.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow your contacts to irreversibly delete sent messages.</source>
|
||||
<target>Allow your contacts to irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||
<source>Allow your contacts to send voice messages.</source>
|
||||
<target>Allow your contacts to send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Already connected?" xml:space="preserve">
|
||||
<source>Already connected?</source>
|
||||
<target>Already connected?</target>
|
||||
@@ -328,6 +358,16 @@
|
||||
<target>Automatically</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Both you and your contact can irreversibly delete sent messages.</source>
|
||||
<target>Both you and your contact can irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||
<source>Both you and your contact can send voice messages.</source>
|
||||
<target>Both you and your contact can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||
<source>Call already ended!</source>
|
||||
<target>Call already ended!</target>
|
||||
@@ -428,6 +468,11 @@
|
||||
<target>Chat is stopped</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat preferences" xml:space="preserve">
|
||||
<source>Chat preferences</source>
|
||||
<target>Chat preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chats" xml:space="preserve">
|
||||
<source>Chats</source>
|
||||
<target>Chats</target>
|
||||
@@ -558,6 +603,11 @@
|
||||
<target>Connection timeout</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact allows" xml:space="preserve">
|
||||
<source>Contact allows</source>
|
||||
<target>Contact allows</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact already exists" xml:space="preserve">
|
||||
<source>Contact already exists</source>
|
||||
<target>Contact already exists</target>
|
||||
@@ -588,11 +638,21 @@
|
||||
<target>Contact name</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact preferences" xml:space="preserve">
|
||||
<source>Contact preferences</source>
|
||||
<target>Contact preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact requests" xml:space="preserve">
|
||||
<source>Contact requests</source>
|
||||
<target>Contact requests</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
|
||||
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
|
||||
<target>Contacts can mark messages for deletion; you will be able to view them.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Copy" xml:space="preserve">
|
||||
<source>Copy</source>
|
||||
<target>Copy</target>
|
||||
@@ -1221,6 +1281,11 @@
|
||||
<target>For console</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Full deletion" xml:space="preserve">
|
||||
<source>Full deletion</source>
|
||||
<target>Full deletion</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Full name (optional)" xml:space="preserve">
|
||||
<source>Full name (optional)</source>
|
||||
<target>Full name (optional)</target>
|
||||
@@ -1271,11 +1336,26 @@
|
||||
<target>Group link</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<target>Group members can irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<target>Group members can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
<source>Group message:</source>
|
||||
<target>Group message:</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group preferences" xml:space="preserve">
|
||||
<source>Group preferences</source>
|
||||
<target>Group preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
||||
<source>Group profile is stored on members' devices, not on the servers.</source>
|
||||
<target>Group profile is stored on members' devices, not on the servers.</target>
|
||||
@@ -1453,6 +1533,11 @@
|
||||
<target>Invite to group</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<target>Irreversible message deletion is prohibited in this chat.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
||||
<target>It allows having many anonymous connections without any shared data between them in a single chat profile.</target>
|
||||
@@ -1768,6 +1853,31 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
<source>Only group owners can change group preferences.</source>
|
||||
<target>Only group owners can change group preferences.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
|
||||
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
|
||||
<target>Only you can irreversibly delete messages (your contact can mark them for deletion).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||
<source>Only you can send voice messages.</source>
|
||||
<target>Only you can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
|
||||
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
|
||||
<target>Only your contact can irreversibly delete messages (you can mark them for deletion).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||
<source>Only your contact can send voice messages.</source>
|
||||
<target>Only your contact can send voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open Settings" xml:space="preserve">
|
||||
<source>Open Settings</source>
|
||||
<target>Open Settings</target>
|
||||
@@ -1858,6 +1968,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Please store passphrase securely, you will NOT be able to change it if you lose it.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Preferences" xml:space="preserve">
|
||||
<source>Preferences</source>
|
||||
<target>Preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||
<source>Privacy & security</source>
|
||||
<target>Privacy & security</target>
|
||||
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Profile image</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
|
||||
<source>Prohibit irreversible message deletion.</source>
|
||||
<target>Prohibit irreversible message deletion.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||
<source>Prohibit sending voice messages.</source>
|
||||
<target>Prohibit sending voice messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Protocol timeout" xml:space="preserve">
|
||||
<source>Protocol timeout</source>
|
||||
<target>Protocol timeout</target>
|
||||
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Required</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reset" xml:space="preserve">
|
||||
<source>Reset</source>
|
||||
<target>Reset</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reset colors" xml:space="preserve">
|
||||
<source>Reset colors</source>
|
||||
<target>Reset colors</target>
|
||||
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Save</target>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify contact)" xml:space="preserve">
|
||||
<source>Save (and notify contact)</source>
|
||||
<target>Save (and notify contact)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
||||
<source>Save (and notify contacts)</source>
|
||||
<target>Save (and notify contacts)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify group members)" xml:space="preserve">
|
||||
<source>Save (and notify group members)</source>
|
||||
<target>Save (and notify group members)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save archive" xml:space="preserve">
|
||||
<source>Save archive</source>
|
||||
<target>Save archive</target>
|
||||
@@ -2572,6 +2712,16 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Video call</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages" xml:space="preserve">
|
||||
<source>Voice messages</source>
|
||||
<target>Voice messages</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<target>Voice messages are prohibited in this chat.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Waiting for file" xml:space="preserve">
|
||||
<source>Waiting for file</source>
|
||||
<target>Waiting for file</target>
|
||||
@@ -2627,6 +2777,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>You accepted connection</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You allow" xml:space="preserve">
|
||||
<source>You allow</source>
|
||||
<target>You allow</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
||||
<source>You are already connected to %@.</source>
|
||||
<target>You are already connected to %@.</target>
|
||||
@@ -2844,6 +2999,11 @@ You can cancel this connection and remove the contact (and try later with a new
|
||||
<target>Your current chat database will be DELETED and REPLACED with the imported one.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your preferences" xml:space="preserve">
|
||||
<source>Your preferences</source>
|
||||
<target>Your preferences</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your privacy" xml:space="preserve">
|
||||
<source>Your privacy</source>
|
||||
<target>Your privacy</target>
|
||||
@@ -2916,6 +3076,11 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>admin</target>
|
||||
<note>member role</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="always" xml:space="preserve">
|
||||
<source>always</source>
|
||||
<target>always</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted)</source>
|
||||
<target>audio call (not e2e encrypted)</target>
|
||||
@@ -3056,6 +3221,11 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>creator</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="default (%@)" xml:space="preserve">
|
||||
<source>default (%@)</source>
|
||||
<target>default (%@)</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="deleted" xml:space="preserve">
|
||||
<source>deleted</source>
|
||||
<target>deleted</target>
|
||||
@@ -3206,11 +3376,26 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>new message</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no" xml:space="preserve">
|
||||
<source>no</source>
|
||||
<target>no</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||
<source>no e2e encryption</source>
|
||||
<target>no e2e encryption</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="off" xml:space="preserve">
|
||||
<source>off</source>
|
||||
<target>off</target>
|
||||
<note>group pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="on" xml:space="preserve">
|
||||
<source>on</source>
|
||||
<target>on</target>
|
||||
<note>group pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="or chat with the developers" xml:space="preserve">
|
||||
<source>or chat with the developers</source>
|
||||
<target>or chat with the developers</target>
|
||||
@@ -3336,6 +3521,11 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>wants to connect to you!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="yes" xml:space="preserve">
|
||||
<source>yes</source>
|
||||
<target>yes</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="you are invited to group" xml:space="preserve">
|
||||
<source>you are invited to group</source>
|
||||
<target>you are invited to group</target>
|
||||
|
||||
@@ -278,6 +278,36 @@
|
||||
<target>Все контакты, которые соединились через этот адрес, сохранятся.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||
<target>Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow to irreversibly delete sent messages.</source>
|
||||
<target>Разрешить необратимо удалять отправленные сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to send voice messages." xml:space="preserve">
|
||||
<source>Allow to send voice messages.</source>
|
||||
<target>Разрешить отправлять голосовые сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow voice messages only if your contact allows them." xml:space="preserve">
|
||||
<source>Allow voice messages only if your contact allows them.</source>
|
||||
<target>Разрешить голосовые сообщения, только если их разрешает ваш контакт.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow your contacts to irreversibly delete sent messages.</source>
|
||||
<target>Разрешить вашим контактам необратимо удалять отправленные сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||
<source>Allow your contacts to send voice messages.</source>
|
||||
<target>Разрешить вашим контактам отправлять голосовые сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Already connected?" xml:space="preserve">
|
||||
<source>Already connected?</source>
|
||||
<target>Соединение уже установлено?</target>
|
||||
@@ -328,6 +358,16 @@
|
||||
<target>Автоматически</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Both you and your contact can irreversibly delete sent messages.</source>
|
||||
<target>Вы и ваш контакт можете необратимо удалять отправленные сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||
<source>Both you and your contact can send voice messages.</source>
|
||||
<target>Вы и ваш контакт можете отправлять голосовые сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Call already ended!" xml:space="preserve">
|
||||
<source>Call already ended!</source>
|
||||
<target>Звонок уже завершен!</target>
|
||||
@@ -428,6 +468,11 @@
|
||||
<target>Чат остановлен</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat preferences" xml:space="preserve">
|
||||
<source>Chat preferences</source>
|
||||
<target>Предпочтения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chats" xml:space="preserve">
|
||||
<source>Chats</source>
|
||||
<target>Чаты</target>
|
||||
@@ -558,6 +603,11 @@
|
||||
<target>Превышено время соединения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact allows" xml:space="preserve">
|
||||
<source>Contact allows</source>
|
||||
<target>Контакт разрешает</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact already exists" xml:space="preserve">
|
||||
<source>Contact already exists</source>
|
||||
<target>Существующий контакт</target>
|
||||
@@ -588,11 +638,21 @@
|
||||
<target>Имена контактов</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact preferences" xml:space="preserve">
|
||||
<source>Contact preferences</source>
|
||||
<target>Предпочтения контакта</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact requests" xml:space="preserve">
|
||||
<source>Contact requests</source>
|
||||
<target>Запросы контактов</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contacts can mark messages for deletion; you will be able to view them." xml:space="preserve">
|
||||
<source>Contacts can mark messages for deletion; you will be able to view them.</source>
|
||||
<target>Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Copy" xml:space="preserve">
|
||||
<source>Copy</source>
|
||||
<target>Скопировать</target>
|
||||
@@ -1221,6 +1281,11 @@
|
||||
<target>Для консоли</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Full deletion" xml:space="preserve">
|
||||
<source>Full deletion</source>
|
||||
<target>Полное удаление</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Full name (optional)" xml:space="preserve">
|
||||
<source>Full name (optional)</source>
|
||||
<target>Полное имя (не обязательно)</target>
|
||||
@@ -1271,11 +1336,26 @@
|
||||
<target>Ссылка группы</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<target>Члены группы могут необратимо удалять отправленные сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<target>Члены группы могут отправлять голосовые сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
<source>Group message:</source>
|
||||
<target>Групповое сообщение:</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group preferences" xml:space="preserve">
|
||||
<source>Group preferences</source>
|
||||
<target>Предпочтения группы</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group profile is stored on members' devices, not on the servers." xml:space="preserve">
|
||||
<source>Group profile is stored on members' devices, not on the servers.</source>
|
||||
<target>Профиль группы хранится на устройствах членов, а не на серверах.</target>
|
||||
@@ -1453,6 +1533,11 @@
|
||||
<target>Пригласить в группу</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<target>Необратимое удаление сообщений запрещено в этом чате.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
|
||||
<target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target>
|
||||
@@ -1768,6 +1853,31 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<target>Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
<source>Only group owners can change group preferences.</source>
|
||||
<target>Только владельцы группы могут изменять предпочтения группы.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can irreversibly delete messages (your contact can mark them for deletion)." xml:space="preserve">
|
||||
<source>Only you can irreversibly delete messages (your contact can mark them for deletion).</source>
|
||||
<target>Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||
<source>Only you can send voice messages.</source>
|
||||
<target>Только вы можете отправлять голосовые сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can irreversibly delete messages (you can mark them for deletion)." xml:space="preserve">
|
||||
<source>Only your contact can irreversibly delete messages (you can mark them for deletion).</source>
|
||||
<target>Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||
<source>Only your contact can send voice messages.</source>
|
||||
<target>Только ваш контакт может отправлять голосовые сообщения.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open Settings" xml:space="preserve">
|
||||
<source>Open Settings</source>
|
||||
<target>Открыть Настройки</target>
|
||||
@@ -1858,6 +1968,11 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Preferences" xml:space="preserve">
|
||||
<source>Preferences</source>
|
||||
<target>Предпочтения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Privacy & security" xml:space="preserve">
|
||||
<source>Privacy & security</source>
|
||||
<target>Конфиденциальность</target>
|
||||
@@ -1873,6 +1988,16 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<target>Аватар</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit irreversible message deletion." xml:space="preserve">
|
||||
<source>Prohibit irreversible message deletion.</source>
|
||||
<target>Запретить необратимое удаление сообщений.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||
<source>Prohibit sending voice messages.</source>
|
||||
<target>Запретить отправлять голосовые сообщений.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Protocol timeout" xml:space="preserve">
|
||||
<source>Protocol timeout</source>
|
||||
<target>Таймаут протокола</target>
|
||||
@@ -1968,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<target>Обязательно</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reset" xml:space="preserve">
|
||||
<source>Reset</source>
|
||||
<target>Сбросить</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reset colors" xml:space="preserve">
|
||||
<source>Reset colors</source>
|
||||
<target>Сбросить цвета</target>
|
||||
@@ -2038,11 +2168,21 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<target>Сохранить</target>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify contact)" xml:space="preserve">
|
||||
<source>Save (and notify contact)</source>
|
||||
<target>Сохранить (и уведомить контакт)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
|
||||
<source>Save (and notify contacts)</source>
|
||||
<target>Сохранить (и уведомить контакты)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save (and notify group members)" xml:space="preserve">
|
||||
<source>Save (and notify group members)</source>
|
||||
<target>Сохранить (и уведомить членов группы)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Save archive" xml:space="preserve">
|
||||
<source>Save archive</source>
|
||||
<target>Сохранить архив</target>
|
||||
@@ -2572,6 +2712,16 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Видеозвонок</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages" xml:space="preserve">
|
||||
<source>Voice messages</source>
|
||||
<target>Голосовые сообщения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this chat." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<target>Голосовые сообщения запрещены в этом чате.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Waiting for file" xml:space="preserve">
|
||||
<source>Waiting for file</source>
|
||||
<target>Ожидается прием файла</target>
|
||||
@@ -2627,6 +2777,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Вы приняли приглашение соединиться</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You allow" xml:space="preserve">
|
||||
<source>You allow</source>
|
||||
<target>Вы разрешаете</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You are already connected to %@." xml:space="preserve">
|
||||
<source>You are already connected to %@.</source>
|
||||
<target>Вы уже соединены с контактом %@.</target>
|
||||
@@ -2844,6 +2999,11 @@ You can cancel this connection and remove the contact (and try later with a new
|
||||
<target>Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your preferences" xml:space="preserve">
|
||||
<source>Your preferences</source>
|
||||
<target>Ваши предпочтения</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your privacy" xml:space="preserve">
|
||||
<source>Your privacy</source>
|
||||
<target>Конфиденциальность</target>
|
||||
@@ -2916,6 +3076,11 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>админ</target>
|
||||
<note>member role</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="always" xml:space="preserve">
|
||||
<source>always</source>
|
||||
<target>всегда</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="audio call (not e2e encrypted)" xml:space="preserve">
|
||||
<source>audio call (not e2e encrypted)</source>
|
||||
<target>аудиозвонок (не e2e зашифрованный)</target>
|
||||
@@ -3056,6 +3221,11 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>создатель</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="default (%@)" xml:space="preserve">
|
||||
<source>default (%@)</source>
|
||||
<target>по умолчанию (%@)</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="deleted" xml:space="preserve">
|
||||
<source>deleted</source>
|
||||
<target>удалено</target>
|
||||
@@ -3206,11 +3376,26 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>новое сообщение</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no" xml:space="preserve">
|
||||
<source>no</source>
|
||||
<target>нет</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="no e2e encryption" xml:space="preserve">
|
||||
<source>no e2e encryption</source>
|
||||
<target>нет e2e шифрования</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="off" xml:space="preserve">
|
||||
<source>off</source>
|
||||
<target>нет</target>
|
||||
<note>group pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="on" xml:space="preserve">
|
||||
<source>on</source>
|
||||
<target>да</target>
|
||||
<note>group pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="or chat with the developers" xml:space="preserve">
|
||||
<source>or chat with the developers</source>
|
||||
<target>или соединитесь с разработчиками</target>
|
||||
@@ -3336,6 +3521,11 @@ SimpleX серверы не могут получить доступ к ваше
|
||||
<target>хочет соединиться с вами!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="yes" xml:space="preserve">
|
||||
<source>yes</source>
|
||||
<target>да</target>
|
||||
<note>pref value</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="you are invited to group" xml:space="preserve">
|
||||
<source>you are invited to group</source>
|
||||
<target>вы приглашены в группу</target>
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
|
||||
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
|
||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
|
||||
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* SMPServersView.swift */; };
|
||||
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* SMPServerView.swift */; };
|
||||
5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293229241CD90090FFF9 /* libffi.a */; };
|
||||
5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */; };
|
||||
5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293429241CD90090FFF9 /* libgmpxx.a */; };
|
||||
5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */; };
|
||||
5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C93293629241CDA0090FFF9 /* libgmp.a */; };
|
||||
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
|
||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
|
||||
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; };
|
||||
@@ -122,11 +129,7 @@
|
||||
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
|
||||
64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */; };
|
||||
6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328565291CDEF200FBE5C8 /* libgmpxx.a */; };
|
||||
6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */; };
|
||||
6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328567291CDEF200FBE5C8 /* libffi.a */; };
|
||||
6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64328568291CDEF200FBE5C8 /* libgmp.a */; };
|
||||
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
|
||||
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
|
||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
|
||||
@@ -250,6 +253,13 @@
|
||||
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
|
||||
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
|
||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
|
||||
5C93292E29239A170090FFF9 /* SMPServersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServersView.swift; sourceTree = "<group>"; };
|
||||
5C93293029239BED0090FFF9 /* SMPServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServerView.swift; sourceTree = "<group>"; };
|
||||
5C93293229241CD90090FFF9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C93293429241CD90090FFF9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a"; sourceTree = "<group>"; };
|
||||
5C93293629241CDA0090FFF9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
|
||||
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
|
||||
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = "<group>"; };
|
||||
@@ -323,11 +333,7 @@
|
||||
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
||||
64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a"; sourceTree = "<group>"; };
|
||||
64328565291CDEF200FBE5C8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
64328567291CDEF200FBE5C8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
64328568291CDEF200FBE5C8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
|
||||
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
|
||||
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
|
||||
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
|
||||
@@ -378,13 +384,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
6432856A291CDEF200FBE5C8 /* libgmpxx.a in Frameworks */,
|
||||
6432856D291CDEF200FBE5C8 /* libgmp.a in Frameworks */,
|
||||
6432856C291CDEF200FBE5C8 /* libffi.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5C93293B29241CDA0090FFF9 /* libgmp.a in Frameworks */,
|
||||
5C93293929241CDA0090FFF9 /* libgmpxx.a in Frameworks */,
|
||||
5C93293729241CDA0090FFF9 /* libffi.a in Frameworks */,
|
||||
5C93293A29241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a in Frameworks */,
|
||||
5C93293829241CDA0090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
64328569291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a in Frameworks */,
|
||||
6432856B291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -440,11 +446,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
64328567291CDEF200FBE5C8 /* libffi.a */,
|
||||
64328568291CDEF200FBE5C8 /* libgmp.a */,
|
||||
64328565291CDEF200FBE5C8 /* libgmpxx.a */,
|
||||
64328566291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD-ghc8.10.7.a */,
|
||||
64328564291CDEF200FBE5C8 /* libHSsimplex-chat-4.2.0-LMeXtL0v5gA4lEDweXVrvD.a */,
|
||||
5C93293229241CD90090FFF9 /* libffi.a */,
|
||||
5C93293629241CDA0090FFF9 /* libgmp.a */,
|
||||
5C93293429241CD90090FFF9 /* libgmpxx.a */,
|
||||
5C93293329241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1-ghc8.10.7.a */,
|
||||
5C93293529241CD90090FFF9 /* libHSsimplex-chat-4.2.1-HrC8bBnv4qm8Sq6Eue57W1.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -585,6 +591,8 @@
|
||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */,
|
||||
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
|
||||
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
|
||||
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
|
||||
5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */,
|
||||
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
|
||||
@@ -686,6 +694,7 @@
|
||||
6440CA01288AEC770062C672 /* Group */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */,
|
||||
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */,
|
||||
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */,
|
||||
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */,
|
||||
@@ -891,12 +900,15 @@
|
||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
|
||||
5C93292F29239A170090FFF9 /* SMPServersView.swift in Sources */,
|
||||
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
||||
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
|
||||
5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */,
|
||||
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
|
||||
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
|
||||
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
|
||||
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
|
||||
5C93293129239BED0090FFF9 /* SMPServerView.swift in Sources */,
|
||||
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
|
||||
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
|
||||
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
|
||||
|
||||
@@ -48,6 +48,7 @@ public enum ChatCommand {
|
||||
case apiGetGroupLink(groupId: Int64)
|
||||
case getUserSMPServers
|
||||
case setUserSMPServers(smpServers: [String])
|
||||
case testSMPServer(smpServer: String)
|
||||
case apiSetChatItemTTL(seconds: Int64?)
|
||||
case apiGetChatItemTTL
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
@@ -63,6 +64,7 @@ public enum ChatCommand {
|
||||
case apiClearChat(type: ChatType, id: Int64)
|
||||
case listContacts
|
||||
case apiUpdateProfile(profile: Profile)
|
||||
case apiSetContactPrefs(contactId: Int64, preferences: Preferences)
|
||||
case apiSetContactAlias(contactId: Int64, localAlias: String)
|
||||
case apiSetConnectionAlias(connId: Int64, localAlias: String)
|
||||
case createMyAddress
|
||||
@@ -124,8 +126,9 @@ public enum ChatCommand {
|
||||
case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)"
|
||||
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
|
||||
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
|
||||
case .getUserSMPServers: return "/smp_servers"
|
||||
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
|
||||
case .getUserSMPServers: return "/smp"
|
||||
case let .setUserSMPServers(smpServers): return "/smp \(smpServersStr(smpServers: smpServers))"
|
||||
case let .testSMPServer(smpServer): return "/smp test \(smpServer)"
|
||||
case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))"
|
||||
case .apiGetChatItemTTL: return "/ttl"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
@@ -141,6 +144,7 @@ public enum ChatCommand {
|
||||
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
|
||||
case .listContacts: return "/contacts"
|
||||
case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
|
||||
case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
|
||||
case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case .createMyAddress: return "/address"
|
||||
@@ -203,6 +207,7 @@ public enum ChatCommand {
|
||||
case .apiGetGroupLink: return "apiGetGroupLink"
|
||||
case .getUserSMPServers: return "getUserSMPServers"
|
||||
case .setUserSMPServers: return "setUserSMPServers"
|
||||
case .testSMPServer: return "testSMPServer"
|
||||
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
||||
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||
@@ -218,6 +223,7 @@ public enum ChatCommand {
|
||||
case .apiClearChat: return "apiClearChat"
|
||||
case .listContacts: return "listContacts"
|
||||
case .apiUpdateProfile: return "apiUpdateProfile"
|
||||
case .apiSetContactPrefs: return "apiSetContactPrefs"
|
||||
case .apiSetContactAlias: return "apiSetContactAlias"
|
||||
case .apiSetConnectionAlias: return "apiSetConnectionAlias"
|
||||
case .createMyAddress: return "createMyAddress"
|
||||
@@ -288,7 +294,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case chatSuspended
|
||||
case apiChats(chats: [ChatData])
|
||||
case apiChat(chat: ChatData)
|
||||
case userSMPServers(smpServers: [String])
|
||||
case userSMPServers(smpServers: [ServerCfg], presetSMPServers: [String])
|
||||
case sMPTestResult(smpTestFailure: SMPTestFailure?)
|
||||
case chatItemTTL(chatItemTTL: Int64?)
|
||||
case networkConfig(networkConfig: NetCfg)
|
||||
case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
|
||||
@@ -303,6 +310,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
|
||||
case contactAliasUpdated(toContact: Contact)
|
||||
case connectionAliasUpdated(toConnection: PendingContactConnection)
|
||||
case contactPrefsUpdated(fromContact: Contact, toContact: Contact)
|
||||
case userContactLink(contactLink: UserContactLink)
|
||||
case userContactLinkUpdated(contactLink: UserContactLink)
|
||||
case userContactLinkCreated(connReqContact: String)
|
||||
@@ -390,6 +398,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .apiChats: return "apiChats"
|
||||
case .apiChat: return "apiChat"
|
||||
case .userSMPServers: return "userSMPServers"
|
||||
case .sMPTestResult: return "smpTestResult"
|
||||
case .chatItemTTL: return "chatItemTTL"
|
||||
case .networkConfig: return "networkConfig"
|
||||
case .contactInfo: return "contactInfo"
|
||||
@@ -404,6 +413,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .userProfileUpdated: return "userProfileUpdated"
|
||||
case .contactAliasUpdated: return "contactAliasUpdated"
|
||||
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
||||
case .contactPrefsUpdated: return "contactPrefsUpdated"
|
||||
case .userContactLink: return "userContactLink"
|
||||
case .userContactLinkUpdated: return "userContactLinkUpdated"
|
||||
case .userContactLinkCreated: return "userContactLinkCreated"
|
||||
@@ -490,7 +500,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatSuspended: return noDetails
|
||||
case let .apiChats(chats): return String(describing: chats)
|
||||
case let .apiChat(chat): return String(describing: chat)
|
||||
case let .userSMPServers(smpServers): return String(describing: smpServers)
|
||||
case let .userSMPServers(smpServers, _): return String(describing: smpServers)
|
||||
case let .sMPTestResult(smpTestFailure): return String(describing: smpTestFailure)
|
||||
case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL)
|
||||
case let .networkConfig(networkConfig): return String(describing: networkConfig)
|
||||
case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))"
|
||||
@@ -505,6 +516,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
|
||||
case let .contactAliasUpdated(toContact): return String(describing: toContact)
|
||||
case let .connectionAliasUpdated(toConnection): return String(describing: toConnection)
|
||||
case let .contactPrefsUpdated(fromContact, toContact): return "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))"
|
||||
case let .userContactLink(contactLink): return contactLink.responseDetails
|
||||
case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails
|
||||
case let .userContactLinkCreated(connReq): return connReq
|
||||
@@ -613,7 +625,7 @@ public struct ArchiveConfig: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct DBEncryptionConfig: Encodable {
|
||||
public struct DBEncryptionConfig: Codable {
|
||||
public init(currentKey: String, newKey: String) {
|
||||
self.currentKey = currentKey
|
||||
self.newKey = newKey
|
||||
@@ -623,6 +635,121 @@ public struct DBEncryptionConfig: Encodable {
|
||||
public var newKey: String
|
||||
}
|
||||
|
||||
public struct ServerCfg: Identifiable, Decodable {
|
||||
public var server: String
|
||||
public var preset: Bool
|
||||
public var tested: Bool?
|
||||
public var enabled: Bool
|
||||
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
|
||||
// Even if we don't see the use case, it's probably better to allow it in the model
|
||||
// In any case, "trusted/known" servers are out of scope of this change
|
||||
|
||||
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
|
||||
self.server = server
|
||||
self.preset = preset
|
||||
self.tested = tested
|
||||
self.enabled = enabled
|
||||
}
|
||||
|
||||
public var id: String { server }
|
||||
|
||||
public static var empty = ServerCfg(server: "", preset: false, tested: false, enabled: true)
|
||||
|
||||
public struct SampleData {
|
||||
public var preset: ServerCfg
|
||||
public var custom: ServerCfg
|
||||
public var untested: ServerCfg
|
||||
}
|
||||
|
||||
public static var sampleData = SampleData(
|
||||
preset: ServerCfg(
|
||||
server: "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
||||
preset: true,
|
||||
tested: true,
|
||||
enabled: true
|
||||
),
|
||||
custom: ServerCfg(
|
||||
server: "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
|
||||
preset: false,
|
||||
tested: false,
|
||||
enabled: false
|
||||
),
|
||||
untested: ServerCfg(
|
||||
server: "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
|
||||
preset: false,
|
||||
tested: nil,
|
||||
enabled: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public enum SMPTestStep: String, Decodable {
|
||||
case connect
|
||||
case createQueue
|
||||
case secureQueue
|
||||
case deleteQueue
|
||||
case disconnect
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .connect: return NSLocalizedString("Connect", comment: "server test step")
|
||||
case .createQueue: return NSLocalizedString("Create queue", comment: "server test step")
|
||||
case .secureQueue: return NSLocalizedString("Secure queue", comment: "server test step")
|
||||
case .deleteQueue: return NSLocalizedString("Delete queue", comment: "server test step")
|
||||
case .disconnect: return NSLocalizedString("Disconnect", comment: "server test step")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SMPTestFailure: Decodable, Error {
|
||||
var testStep: SMPTestStep
|
||||
var testError: AgentErrorType
|
||||
|
||||
var localizedDescription: String {
|
||||
let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@", comment: "server test failure"), testStep.text)
|
||||
switch testError {
|
||||
case .SMP(.AUTH):
|
||||
return err + "," + NSLocalizedString("Server requires authentication to create queues, check password", comment: "server test error")
|
||||
case .BROKER(.NETWORK):
|
||||
return err + "," + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ServerAddress {
|
||||
public var hostnames: [String]
|
||||
public var port: String
|
||||
public var keyHash: String
|
||||
public var basicAuth: String
|
||||
|
||||
public init(hostnames: [String], port: String, keyHash: String, basicAuth: String = "") {
|
||||
self.hostnames = hostnames
|
||||
self.port = port
|
||||
self.keyHash = keyHash
|
||||
self.basicAuth = basicAuth
|
||||
}
|
||||
|
||||
public var uri: String {
|
||||
"smp://\(keyHash)\(basicAuth == "" ? "" : ":" + basicAuth)@\(hostnames.joined(separator: ","))"
|
||||
}
|
||||
|
||||
static public var empty = ServerAddress(
|
||||
hostnames: [],
|
||||
port: "",
|
||||
keyHash: "",
|
||||
basicAuth: ""
|
||||
)
|
||||
|
||||
static public var sampleData = ServerAddress(
|
||||
hostnames: ["smp.simplex.im", "1234.onion"],
|
||||
port: "",
|
||||
keyHash: "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
|
||||
basicAuth: "server_password"
|
||||
)
|
||||
}
|
||||
|
||||
public struct NetCfg: Codable, Equatable {
|
||||
public var socksProxy: String? = nil
|
||||
public var hostMode: HostMode = .publicHost
|
||||
|
||||
@@ -14,6 +14,7 @@ public struct User: Decodable, NamedChat {
|
||||
var userContactId: Int64
|
||||
var localDisplayName: ContactName
|
||||
public var profile: LocalProfile
|
||||
public var fullPreferences: FullPreferences
|
||||
var activeUser: Bool
|
||||
|
||||
public var displayName: String { get { profile.displayName } }
|
||||
@@ -26,6 +27,7 @@ public struct User: Decodable, NamedChat {
|
||||
userContactId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: LocalProfile.sampleData,
|
||||
fullPreferences: FullPreferences.sampleData,
|
||||
activeUser: true
|
||||
)
|
||||
}
|
||||
@@ -35,15 +37,17 @@ public typealias ContactName = String
|
||||
public typealias GroupName = String
|
||||
|
||||
public struct Profile: Codable, NamedChat {
|
||||
public init(displayName: String, fullName: String, image: String? = nil) {
|
||||
public init(displayName: String, fullName: String, image: String? = nil, preferences: Preferences? = nil) {
|
||||
self.displayName = displayName
|
||||
self.fullName = fullName
|
||||
self.image = image
|
||||
self.preferences = preferences
|
||||
}
|
||||
|
||||
public var displayName: String
|
||||
public var fullName: String
|
||||
public var image: String?
|
||||
public var preferences: Preferences?
|
||||
public var localAlias: String { get { "" } }
|
||||
|
||||
var profileViewName: String {
|
||||
@@ -57,11 +61,12 @@ public struct Profile: Codable, NamedChat {
|
||||
}
|
||||
|
||||
public struct LocalProfile: Codable, NamedChat {
|
||||
public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, localAlias: String) {
|
||||
public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, preferences: Preferences? = nil, localAlias: String) {
|
||||
self.profileId = profileId
|
||||
self.displayName = displayName
|
||||
self.fullName = fullName
|
||||
self.image = image
|
||||
self.preferences = preferences
|
||||
self.localAlias = localAlias
|
||||
}
|
||||
|
||||
@@ -69,6 +74,7 @@ public struct LocalProfile: Codable, NamedChat {
|
||||
public var displayName: String
|
||||
public var fullName: String
|
||||
public var image: String?
|
||||
public var preferences: Preferences?
|
||||
public var localAlias: String
|
||||
|
||||
var profileViewName: String {
|
||||
@@ -81,6 +87,7 @@ public struct LocalProfile: Codable, NamedChat {
|
||||
profileId: 1,
|
||||
displayName: "alice",
|
||||
fullName: "Alice",
|
||||
preferences: Preferences.sampleData,
|
||||
localAlias: ""
|
||||
)
|
||||
}
|
||||
@@ -117,7 +124,7 @@ extension NamedChat {
|
||||
|
||||
public typealias ChatId = String
|
||||
|
||||
public struct FullPreferences: Decodable {
|
||||
public struct FullPreferences: Decodable, Equatable {
|
||||
public var fullDelete: Preference
|
||||
public var voice: Preference
|
||||
|
||||
@@ -125,9 +132,27 @@ public struct FullPreferences: Decodable {
|
||||
self.fullDelete = fullDelete
|
||||
self.voice = voice
|
||||
}
|
||||
|
||||
public static let sampleData = FullPreferences(fullDelete: Preference(allow: .no), voice: Preference(allow: .yes))
|
||||
}
|
||||
|
||||
public struct Preference: Codable {
|
||||
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) {
|
||||
@@ -143,6 +168,19 @@ public struct ContactUserPreferences: Decodable {
|
||||
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 {
|
||||
@@ -241,6 +279,36 @@ public enum Feature {
|
||||
: "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 {
|
||||
@@ -274,6 +342,56 @@ public enum ContactFeatureAllowed: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -292,6 +410,58 @@ public enum FeatureAllowed: String, Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct FullGroupPreferences: Decodable, Equatable {
|
||||
public var fullDelete: GroupPreference
|
||||
public var voice: GroupPreference
|
||||
|
||||
public init(fullDelete: GroupPreference, voice: GroupPreference) {
|
||||
self.fullDelete = fullDelete
|
||||
self.voice = voice
|
||||
}
|
||||
|
||||
public static let sampleData = FullGroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
|
||||
}
|
||||
|
||||
public struct GroupPreferences: Codable {
|
||||
public var fullDelete: GroupPreference?
|
||||
public var voice: GroupPreference?
|
||||
|
||||
public init(fullDelete: GroupPreference?, voice: GroupPreference?) {
|
||||
self.fullDelete = fullDelete
|
||||
self.voice = voice
|
||||
}
|
||||
|
||||
public static let sampleData = GroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
|
||||
}
|
||||
|
||||
public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> GroupPreferences {
|
||||
GroupPreferences(fullDelete: fullPreferences.fullDelete, voice: fullPreferences.voice)
|
||||
}
|
||||
|
||||
public struct GroupPreference: Codable, Equatable {
|
||||
public var enable: GroupFeatureEnabled
|
||||
|
||||
public init(enable: GroupFeatureEnabled) {
|
||||
self.enable = enable
|
||||
}
|
||||
}
|
||||
|
||||
public enum GroupFeatureEnabled: String, Codable, Identifiable {
|
||||
case on
|
||||
case off
|
||||
|
||||
public static var values: [GroupFeatureEnabled] { [.on, .off] }
|
||||
|
||||
public var id: Self { self }
|
||||
|
||||
public var text: String {
|
||||
switch self {
|
||||
case .on: return NSLocalizedString("on", comment: "group pref value")
|
||||
case .off: return NSLocalizedString("off", comment: "group pref value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
case direct(contact: Contact)
|
||||
case group(groupInfo: GroupInfo)
|
||||
@@ -496,6 +666,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var activeConn: Connection
|
||||
public var viaGroup: Int64?
|
||||
public var chatSettings: ChatSettings
|
||||
public var userPreferences: Preferences
|
||||
public var mergedPreferences: ContactUserPreferences
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
|
||||
@@ -526,6 +698,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
profile: LocalProfile.sampleData,
|
||||
activeConn: Connection.sampleData,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
userPreferences: Preferences.sampleData,
|
||||
mergedPreferences: ContactUserPreferences.sampleData,
|
||||
createdAt: .now,
|
||||
updatedAt: .now
|
||||
)
|
||||
@@ -731,6 +905,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
public var groupId: Int64
|
||||
var localDisplayName: GroupName
|
||||
public var groupProfile: GroupProfile
|
||||
public var fullGroupPreferences: FullGroupPreferences
|
||||
public var membership: GroupMember
|
||||
public var hostConnCustomUserProfileId: Int64?
|
||||
public var chatSettings: ChatSettings
|
||||
@@ -762,6 +937,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
groupId: 1,
|
||||
localDisplayName: "team",
|
||||
groupProfile: GroupProfile.sampleData,
|
||||
fullGroupPreferences: FullGroupPreferences.sampleData,
|
||||
membership: GroupMember.sampleData,
|
||||
hostConnCustomUserProfileId: nil,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
@@ -771,15 +947,17 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
}
|
||||
|
||||
public struct GroupProfile: Codable, NamedChat {
|
||||
public init(displayName: String, fullName: String, image: String? = nil) {
|
||||
public init(displayName: String, fullName: String, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
|
||||
self.displayName = displayName
|
||||
self.fullName = fullName
|
||||
self.image = image
|
||||
self.groupPreferences = groupPreferences
|
||||
}
|
||||
|
||||
public var displayName: String
|
||||
public var fullName: String
|
||||
public var image: String?
|
||||
public var groupPreferences: GroupPreferences?
|
||||
public var localAlias: String { "" }
|
||||
|
||||
public static let sampleData = GroupProfile(
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
")" = ")";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Beitragen](https://github.com/simplex-chat/simplex-chat#contribute)";
|
||||
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Unterstützen Sie uns](https://github.com/simplex-chat/simplex-chat#contribute)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"[Send us email](mailto:chat@simplex.chat)" = "[Senden Sie uns eine E-Mail](mailto:chat@simplex.chat)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub](https://github.com/simplex-chat/simplex-chat)";
|
||||
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Stern auf GitHub vergeben](https://github.com/simplex-chat/simplex-chat)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Fügen Sie einen neuen Kontakt hinzu**: Erzeugen Sie einen Einmal-QR-Code oder -Link für Ihren Kontakt.";
|
||||
@@ -188,9 +188,30 @@
|
||||
/* No comment provided by engineer. */
|
||||
"All your contacts will remain connected" = "Alle Ihre Kontakte bleiben verbunden.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow irreversible message deletion only if your contact allows it to you." = "***Allow irreversible message deletion only if your contact allows it to you.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow to irreversibly delete sent messages." = "***Allow to irreversibly delete sent messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow to send voice messages." = "***Allow to send voice messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow voice messages only if your contact allows them." = "***Allow voice messages only if your contact allows them.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow your contacts to irreversibly delete sent messages." = "***Allow your contacts to irreversibly delete sent messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow your contacts to send voice messages." = "***Allow your contacts to send voice messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Already connected?" = "Sind Sie bereits verbunden?";
|
||||
|
||||
/* pref value */
|
||||
"always" = "***always";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Answer call" = "Anruf annehmen";
|
||||
|
||||
@@ -230,6 +251,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"bold" = "fett";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Both you and your contact can irreversibly delete sent messages." = "***Both you and your contact can irreversibly delete sent messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Both you and your contact can send voice messages." = "***Both you and your contact can send voice messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Call already ended!" = "Anruf ist bereits beendet!";
|
||||
|
||||
@@ -314,6 +341,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Chat is stopped" = "Der Chat ist beendet";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Chat preferences" = "***Chat preferences";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Chats" = "Chats";
|
||||
|
||||
@@ -431,6 +461,9 @@
|
||||
/* connection information */
|
||||
"connection:%@" = "Verbindung:%@";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact allows" = "***Contact allows";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact already exists" = "Der Kontakt ist bereits vorhanden";
|
||||
|
||||
@@ -455,9 +488,15 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Contact name" = "Kontaktname";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact preferences" = "***Contact preferences";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact requests" = "Kontaktanfragen";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contacts can mark messages for deletion; you will be able to view them." = "***Contacts can mark messages for deletion; you will be able to view them.";
|
||||
|
||||
/* chat item action */
|
||||
"Copy" = "Kopieren";
|
||||
|
||||
@@ -542,6 +581,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Decentralized" = "Dezentral";
|
||||
|
||||
/* pref value */
|
||||
"default (%@)" = "***default (%@)";
|
||||
|
||||
/* chat item action */
|
||||
"Delete" = "Löschen";
|
||||
|
||||
@@ -857,6 +899,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"For console" = "Für Konsole";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Full deletion" = "***Full deletion";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Full name (optional)" = "Vollständiger Name (optional)";
|
||||
|
||||
@@ -890,9 +935,18 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Group link" = "Gruppen-Link";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group members can irreversibly delete sent messages." = "***Group members can irreversibly delete sent messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group members can send voice messages." = "***Group members can send voice messages.";
|
||||
|
||||
/* notification */
|
||||
"Group message:" = "Grppennachricht:";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group preferences" = "***Group preferences";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group profile is stored on members' devices, not on the servers." = "Das Gruppenprofil wird nur auf den Mitglieds-Systemen gespeichtert und nicht auf den Servern.";
|
||||
|
||||
@@ -1034,6 +1088,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Für die sichere Speicherung des Passworts nach dem Neustart der App und dem Wechsel des Passworts wird der iOS Schlüsselbund verwendet - dies erlaubt den Empfang von Push-Benachrichtigungen.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Irreversible message deletion is prohibited in this chat." = "***Irreversible message deletion is prohibited in this chat.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.";
|
||||
|
||||
@@ -1193,6 +1250,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"New passphrase…" = "Neues Passwort…";
|
||||
|
||||
/* pref value */
|
||||
"no" = "***no";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No" = "Nein";
|
||||
|
||||
@@ -1220,6 +1280,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Notifications are disabled!" = "Benachrichtigungen sind deaktiviert!";
|
||||
|
||||
/* group pref value */
|
||||
"off" = "***off";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Off (Local)" = "Aus (Lokal)";
|
||||
|
||||
@@ -1232,6 +1295,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Old database archive" = "Altes Datenbankarchiv";
|
||||
|
||||
/* group pref value */
|
||||
"on" = "***on";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"One-time invitation link" = "Einmal-Einladungslink";
|
||||
|
||||
@@ -1247,6 +1313,21 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine **2-Schichten Ende-zu-Ende-Verschlüsselung** gesendet werden.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only group owners can change group preferences." = "***Only group owners can change group preferences.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "***Only you can irreversibly delete messages (your contact can mark them for deletion).";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only you can send voice messages." = "***Only you can send voice messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "***Only your contact can irreversibly delete messages (you can mark them for deletion).";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only your contact can send voice messages." = "***Only your contact can send voice messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Open chat" = "Chat öffnen";
|
||||
|
||||
@@ -1310,6 +1391,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Bitte bewahren Sie das Passwort sicher auf, Sie können es NICHT mehr ändern, wenn Sie es vergessen haben oder verlieren.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Preferences" = "***Preferences";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Privacy & security" = "Datenschutz & Sicherheit";
|
||||
|
||||
@@ -1319,6 +1403,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Profile image" = "Profilbild";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Prohibit irreversible message deletion." = "***Prohibit irreversible message deletion.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Prohibit sending voice messages." = "***Prohibit sending voice messages.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Protocol timeout" = "Protokollzeitüberschreitung";
|
||||
|
||||
@@ -1326,7 +1416,7 @@
|
||||
"Push notifications" = "Push-Benachrichtigungen";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Rate the app" = "Bewerte die App";
|
||||
"Rate the app" = "Bewerten Sie die App";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Read" = "Lesen";
|
||||
@@ -1394,6 +1484,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Required" = "Erforderlich";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Reset" = "***Reset";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Reset colors" = "Farben zurücksetzen";
|
||||
|
||||
@@ -1430,9 +1523,15 @@
|
||||
/* chat item action */
|
||||
"Save" = "Speichern";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save (and notify contact)" = "***Save (and notify contact)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save (and notify group members)" = "***Save (and notify group members)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save archive" = "Archiv speichern";
|
||||
|
||||
@@ -1578,7 +1677,7 @@
|
||||
"strike" = "durchstreichen";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Support SimpleX Chat" = "Unterstützen SimpleX Chat";
|
||||
"Support SimpleX Chat" = "Unterstützung von SimpleX Chat";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"System" = "System";
|
||||
@@ -1665,7 +1764,7 @@
|
||||
"this contact" = "Dieser Kontakt";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)." = "Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.";
|
||||
"This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)." = "Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"This group no longer exists." = "Diese Gruppe existiert nicht mehr.";
|
||||
@@ -1793,6 +1892,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"video call (not e2e encrypted)" = "Videoanruf (nicht E2E verschlüsselt)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Voice messages" = "***Voice messages";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Voice messages are prohibited in this chat." = "***Voice messages are prohibited in this chat.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"waiting for answer…" = "Warten auf Antwort…";
|
||||
|
||||
@@ -1829,12 +1934,18 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Wrong passphrase!" = "Falsches Passwort!";
|
||||
|
||||
/* pref value */
|
||||
"yes" = "***yes";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You" = "Meine Daten";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You accepted connection" = "Sie haben die Verbindung akzeptiert";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You allow" = "***You allow";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You are already connected to %@." = "Sie sind bereits mit %@ verbunden.";
|
||||
|
||||
@@ -1988,6 +2099,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Your ICE servers" = "Ihre ICE-Server";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your preferences" = "***Your preferences";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your privacy" = "Meine Privatsphäre";
|
||||
|
||||
|
||||
@@ -188,9 +188,30 @@
|
||||
/* No comment provided by engineer. */
|
||||
"All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow irreversible message deletion only if your contact allows it to you." = "Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow to irreversibly delete sent messages." = "Разрешить необратимо удалять отправленные сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow to send voice messages." = "Разрешить отправлять голосовые сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow voice messages only if your contact allows them." = "Разрешить голосовые сообщения, только если их разрешает ваш контакт.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow your contacts to irreversibly delete sent messages." = "Разрешить вашим контактам необратимо удалять отправленные сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Allow your contacts to send voice messages." = "Разрешить вашим контактам отправлять голосовые сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Already connected?" = "Соединение уже установлено?";
|
||||
|
||||
/* pref value */
|
||||
"always" = "всегда";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Answer call" = "Принять звонок";
|
||||
|
||||
@@ -230,6 +251,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"bold" = "жирный";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Both you and your contact can irreversibly delete sent messages." = "Вы и ваш контакт можете необратимо удалять отправленные сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Both you and your contact can send voice messages." = "Вы и ваш контакт можете отправлять голосовые сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Call already ended!" = "Звонок уже завершен!";
|
||||
|
||||
@@ -314,6 +341,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Chat is stopped" = "Чат остановлен";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Chat preferences" = "Предпочтения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Chats" = "Чаты";
|
||||
|
||||
@@ -431,6 +461,9 @@
|
||||
/* connection information */
|
||||
"connection:%@" = "connection:%@";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact allows" = "Контакт разрешает";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact already exists" = "Существующий контакт";
|
||||
|
||||
@@ -455,9 +488,15 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Contact name" = "Имена контактов";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact preferences" = "Предпочтения контакта";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contact requests" = "Запросы контактов";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Contacts can mark messages for deletion; you will be able to view them." = "Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.";
|
||||
|
||||
/* chat item action */
|
||||
"Copy" = "Скопировать";
|
||||
|
||||
@@ -542,6 +581,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Decentralized" = "Децентрализованный";
|
||||
|
||||
/* pref value */
|
||||
"default (%@)" = "по умолчанию (%@)";
|
||||
|
||||
/* chat item action */
|
||||
"Delete" = "Удалить";
|
||||
|
||||
@@ -857,6 +899,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"For console" = "Для консоли";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Full deletion" = "Полное удаление";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Full name (optional)" = "Полное имя (не обязательно)";
|
||||
|
||||
@@ -890,9 +935,18 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Group link" = "Ссылка группы";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group members can irreversibly delete sent messages." = "Члены группы могут необратимо удалять отправленные сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group members can send voice messages." = "Члены группы могут отправлять голосовые сообщения.";
|
||||
|
||||
/* notification */
|
||||
"Group message:" = "Групповое сообщение:";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group preferences" = "Предпочтения группы";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах.";
|
||||
|
||||
@@ -1034,6 +1088,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Irreversible message deletion is prohibited in this chat." = "Необратимое удаление сообщений запрещено в этом чате.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.";
|
||||
|
||||
@@ -1193,6 +1250,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"New passphrase…" = "Новый пароль…";
|
||||
|
||||
/* pref value */
|
||||
"no" = "нет";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No" = "Нет";
|
||||
|
||||
@@ -1220,6 +1280,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Notifications are disabled!" = "Уведомления выключены";
|
||||
|
||||
/* group pref value */
|
||||
"off" = "нет";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Off (Local)" = "Выключить (Локальные)";
|
||||
|
||||
@@ -1232,6 +1295,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Old database archive" = "Старый архив чата";
|
||||
|
||||
/* group pref value */
|
||||
"on" = "да";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"One-time invitation link" = "Одноразовая ссылка";
|
||||
|
||||
@@ -1247,6 +1313,21 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only group owners can change group preferences." = "Только владельцы группы могут изменять предпочтения группы.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only you can irreversibly delete messages (your contact can mark them for deletion)." = "Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only you can send voice messages." = "Только вы можете отправлять голосовые сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only your contact can irreversibly delete messages (you can mark them for deletion)." = "Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Only your contact can send voice messages." = "Только ваш контакт может отправлять голосовые сообщения.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Open chat" = "Открыть чат";
|
||||
|
||||
@@ -1310,6 +1391,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Preferences" = "Предпочтения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Privacy & security" = "Конфиденциальность";
|
||||
|
||||
@@ -1319,6 +1403,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Profile image" = "Аватар";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Prohibit irreversible message deletion." = "Запретить необратимое удаление сообщений.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Prohibit sending voice messages." = "Запретить отправлять голосовые сообщений.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Protocol timeout" = "Таймаут протокола";
|
||||
|
||||
@@ -1394,6 +1484,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Required" = "Обязательно";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Reset" = "Сбросить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Reset colors" = "Сбросить цвета";
|
||||
|
||||
@@ -1430,9 +1523,15 @@
|
||||
/* chat item action */
|
||||
"Save" = "Сохранить";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save (and notify contact)" = "Сохранить (и уведомить контакт)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save (and notify contacts)" = "Сохранить (и уведомить контакты)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save (and notify group members)" = "Сохранить (и уведомить членов группы)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Save archive" = "Сохранить архив";
|
||||
|
||||
@@ -1793,6 +1892,12 @@
|
||||
/* No comment provided by engineer. */
|
||||
"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Voice messages" = "Голосовые сообщения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Voice messages are prohibited in this chat." = "Голосовые сообщения запрещены в этом чате.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"waiting for answer…" = "ожидается ответ…";
|
||||
|
||||
@@ -1829,12 +1934,18 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Wrong passphrase!" = "Неправильный пароль!";
|
||||
|
||||
/* pref value */
|
||||
"yes" = "да";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You" = "Вы";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You accepted connection" = "Вы приняли приглашение соединиться";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You allow" = "Вы разрешаете";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"You are already connected to %@." = "Вы уже соединены с контактом %@.";
|
||||
|
||||
@@ -1988,6 +2099,9 @@
|
||||
/* No comment provided by engineer. */
|
||||
"Your ICE servers" = "Ваши ICE серверы";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your preferences" = "Ваши предпочтения";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Your privacy" = "Конфиденциальность";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 95db734b2d89bdf35e413f0abd4eac4ed3c64fc3
|
||||
tag: c2342cba057fa2333b5936a2254507b5b62e8de2
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
36
docs/CLI.md
36
docs/CLI.md
@@ -85,29 +85,37 @@ move <binary> %APPDATA%/local/bin/simplex-chat.exe
|
||||
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
$ cd simplex-chat
|
||||
$ git checkout stable
|
||||
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
|
||||
git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
cd simplex-chat
|
||||
git checkout stable
|
||||
DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
|
||||
```
|
||||
|
||||
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
|
||||
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.7-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
|
||||
|
||||
#### Using Haskell stack
|
||||
#### In any OS
|
||||
|
||||
Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/):
|
||||
1. Install [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 8.10.7 and cabal:
|
||||
|
||||
```shell
|
||||
curl -sSL https://get.haskellstack.org/ | sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
|
||||
```
|
||||
|
||||
and build the project:
|
||||
2. Build the project:
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
$ cd simplex-chat
|
||||
$ git checkout stable
|
||||
$ stack install
|
||||
git clone git@github.com:simplex-chat/simplex-chat.git
|
||||
cd simplex-chat
|
||||
git checkout stable
|
||||
# on Linux
|
||||
apt-get update && apt-get install -y build-essential libgmp3-dev zlib1g-dev
|
||||
cp scripts/cabal.project.local.linux cabal.project.local
|
||||
# or on MacOS:
|
||||
# brew install openssl@1.1
|
||||
# cp scripts/cabal.project.local.mac cabal.project.local
|
||||
# you may need to amend cabal.project.local to point to the actual openssl location
|
||||
cabal update
|
||||
cabal install
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -140,7 +148,7 @@ You can still talk to people using default or any other server - it only affects
|
||||
|
||||
Run `simplex-chat -h` to see all available options.
|
||||
|
||||
### Access messaging servers via Tor (BETA)
|
||||
### Access messaging servers via Tor
|
||||
|
||||
Install Tor and run it as SOCKS5 proxy on port 9050, e.g. on Mac you can:
|
||||
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode:
|
||||
|
||||
```
|
||||
ignore-project: False
|
||||
|
||||
package direct-sqlcipher
|
||||
extra-include-dirs: /opt/homebrew/opt/openssl@3/include
|
||||
extra-lib-dirs: /opt/homebrew/opt/openssl@3/lib
|
||||
flags: +openssl
|
||||
cp scripts/cabal.project.local.mac cabal.project.local
|
||||
# or
|
||||
# cp scripts/cabal.project.local.linux cabal.project.local
|
||||
```
|
||||
|
||||
OpenSSL can be installed with `brew install openssl`
|
||||
## OpenSSL on MacOS
|
||||
|
||||
MacOS comes with LibreSSL as default, OpenSSL must be installed to compile SimpleX from source.
|
||||
|
||||
OpenSSL can be installed with `brew install openssl@1.1`
|
||||
|
||||
You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order to have things working properly
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simplex-chat",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "SimpleX Chat client",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -249,7 +249,7 @@ export class ChatClient {
|
||||
const r = await this.sendChatCommand({type: "showMyAddress"})
|
||||
switch (r.type) {
|
||||
case "userContactLink":
|
||||
return r.connReqContact
|
||||
return r.contactLink.connReqContact
|
||||
default:
|
||||
if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") {
|
||||
return undefined
|
||||
|
||||
@@ -280,8 +280,7 @@ export interface CRCmdOk extends CR {
|
||||
|
||||
export interface CRUserContactLink extends CR {
|
||||
type: "userContactLink"
|
||||
connReqContact: string
|
||||
autoAccept: boolean
|
||||
contactLink: UserContactLink
|
||||
}
|
||||
|
||||
export interface CRUserContactLinkUpdated extends CR {
|
||||
@@ -913,6 +912,16 @@ interface FileTransferMeta {
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
interface UserContactLink {
|
||||
connReqContact: string
|
||||
autoAccept?: AutoAccept
|
||||
}
|
||||
|
||||
interface AutoAccept {
|
||||
acceptIncognito: boolean
|
||||
autoReply?: MsgContent
|
||||
}
|
||||
|
||||
export interface ChatStats {
|
||||
unreadCount: number
|
||||
minUnreadItemId: number
|
||||
|
||||
9
scripts/cabal.project.local.linux
Normal file
9
scripts/cabal.project.local.linux
Normal file
@@ -0,0 +1,9 @@
|
||||
ignore-project: False
|
||||
|
||||
# amend to point to the actual openssl location
|
||||
package direct-sqlcipher
|
||||
extra-include-dirs: /usr/local/opt/openssl@1.1/include
|
||||
extra-lib-dirs: /usr/local/opt/openssl@1.1/lib
|
||||
flags: +openssl
|
||||
|
||||
test-show-details: direct
|
||||
9
scripts/cabal.project.local.mac
Normal file
9
scripts/cabal.project.local.mac
Normal file
@@ -0,0 +1,9 @@
|
||||
ignore-project: False
|
||||
|
||||
# amend to point to the actual openssl location
|
||||
package direct-sqlcipher
|
||||
extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include
|
||||
extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib
|
||||
flags: +openssl
|
||||
|
||||
test-show-details: direct
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."95db734b2d89bdf35e413f0abd4eac4ed3c64fc3" = "139ixj08y2x4vyf9qj1d9sz35hqxpaiyy9wi5skkncxgzgcm1ksa";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."c2342cba057fa2333b5936a2254507b5b62e8de2" = "0fsi4lgq5x3dgy79g85s7isg3387ppwrqm4v8dndixlxn8cx3pyp";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
|
||||
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
|
||||
|
||||
@@ -62,6 +62,7 @@ library
|
||||
Simplex.Chat.Migrations.M20221025_chat_settings
|
||||
Simplex.Chat.Migrations.M20221029_group_link_id
|
||||
Simplex.Chat.Migrations.M20221112_server_password
|
||||
Simplex.Chat.Migrations.M20221115_server_cfg
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Options
|
||||
Simplex.Chat.ProfileGenerator
|
||||
|
||||
@@ -133,13 +133,14 @@ createChatDatabase filePrefix key yesToMigrations = do
|
||||
|
||||
newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController
|
||||
newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {smpServers, networkConfig, logConnections, logServerHosts} sendToast = do
|
||||
let config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts}
|
||||
servers <- resolveServers defaultServers
|
||||
let servers' = servers {netCfg = networkConfig}
|
||||
config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = servers'}
|
||||
sendNotification = fromMaybe (const $ pure ()) sendToast
|
||||
firstTime = dbNew chatStore
|
||||
activeTo <- newTVarIO ActiveNone
|
||||
currentUser <- newTVarIO user
|
||||
servers <- resolveServers defaultServers
|
||||
smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers {netCfg = networkConfig}
|
||||
smpAgent <- getSMPAgentClient aCfg {database = AgentDB agentStore} servers'
|
||||
agentAsync <- newTVarIO Nothing
|
||||
idsDrg <- newTVarIO =<< drgNew
|
||||
inputQ <- newTBQueueIO tbqSize
|
||||
@@ -157,14 +158,21 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
|
||||
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs}
|
||||
where
|
||||
resolveServers :: InitialAgentServers -> IO InitialAgentServers
|
||||
resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of
|
||||
Just smpServers' -> pure ss {smp = smpServers'}
|
||||
resolveServers ss = case nonEmpty smpServers of
|
||||
Just smpServers' -> pure ss {smp = L.map (\ServerCfg {server} -> server) smpServers'}
|
||||
_ -> case user of
|
||||
Just usr -> do
|
||||
userSmpServers <- withTransaction chatStore (`getSMPServers` usr)
|
||||
pure ss {smp = fromMaybe defaultSMPServers $ nonEmpty userSmpServers}
|
||||
Just user' -> do
|
||||
userSmpServers <- withTransaction chatStore (`getSMPServers` user')
|
||||
pure ss {smp = activeAgentServers cfg userSmpServers}
|
||||
_ -> pure ss
|
||||
|
||||
activeAgentServers :: ChatConfig -> [ServerCfg] -> NonEmpty SMPServerWithAuth
|
||||
activeAgentServers ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} =
|
||||
fromMaybe defaultSMPServers
|
||||
. nonEmpty
|
||||
. map (\ServerCfg {server} -> server)
|
||||
. filter (\ServerCfg {enabled} -> enabled)
|
||||
|
||||
startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ())
|
||||
startChatController user subConns enableExpireCIs = do
|
||||
asks smpAgent >>= resumeAgentClient
|
||||
@@ -276,10 +284,11 @@ processChatCommand = \case
|
||||
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search)
|
||||
CTContactRequest -> pure $ chatCmdError "not implemented"
|
||||
CTContactConnection -> pure $ chatCmdError "not supported"
|
||||
APIGetChatItems _pagination -> pure $ chatCmdError "not implemented"
|
||||
APIGetChatItems pagination search -> withUser $ \user -> withStore $ \db ->
|
||||
CRApiChatItems <$> getAllChatItems db user pagination search
|
||||
APISendMessage (ChatRef cType chatId) (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
|
||||
CTDirect -> do
|
||||
ct@Contact {localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db userId chatId
|
||||
ct@Contact {localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId
|
||||
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
|
||||
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct
|
||||
(msgContainer, quotedItem_) <- prepareMsg fileInvitation_
|
||||
@@ -378,6 +387,8 @@ processChatCommand = \case
|
||||
| otherwise = case qmc of
|
||||
MCImage _ image -> MCImage qTextOrFile image
|
||||
MCFile _ -> MCFile qTextOrFile
|
||||
-- consider same for voice messages
|
||||
-- MCVoice _ voice -> MCVoice qTextOrFile voice
|
||||
_ -> qmc
|
||||
where
|
||||
-- if the message we're quoting with is one of the "large" MsgContents
|
||||
@@ -387,6 +398,7 @@ processChatCommand = \case
|
||||
MCFile _ -> False
|
||||
MCLink {} -> True
|
||||
MCImage {} -> True
|
||||
MCVoice {} -> False
|
||||
MCUnknown {} -> True
|
||||
qText = msgContentText qmc
|
||||
qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_
|
||||
@@ -396,7 +408,7 @@ processChatCommand = \case
|
||||
unzipMaybe3 _ = (Nothing, Nothing, Nothing)
|
||||
APIUpdateChatItem (ChatRef cType chatId) itemId mc -> withUser $ \user@User {userId} -> withChatLock "updateChatItem" $ case cType of
|
||||
CTDirect -> do
|
||||
(ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId
|
||||
(ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db userId chatId itemId
|
||||
case ci of
|
||||
CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do
|
||||
case (ciContent, itemSharedMsgId) of
|
||||
@@ -425,7 +437,7 @@ processChatCommand = \case
|
||||
CTContactConnection -> pure $ chatCmdError "not supported"
|
||||
APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user@User {userId} -> withChatLock "deleteChatItem" $ case cType of
|
||||
CTDirect -> do
|
||||
(ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db userId chatId <*> getDirectChatItem db userId chatId itemId
|
||||
(ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db userId chatId itemId
|
||||
case (mode, msgDir, itemSharedMsgId) of
|
||||
(CIDMInternal, _, _) -> do
|
||||
deleteCIFile user file
|
||||
@@ -468,10 +480,10 @@ processChatCommand = \case
|
||||
CTGroup -> withStore' (\db -> updateGroupChatItemsRead db chatId fromToIds) $> CRCmdOk
|
||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||
CTContactConnection -> pure $ chatCmdError "not supported"
|
||||
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user@User {userId} -> case cType of
|
||||
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of
|
||||
CTDirect -> do
|
||||
withStore $ \db -> do
|
||||
ct <- getContact db userId chatId
|
||||
ct <- getContact db user chatId
|
||||
liftIO $ updateContactUnreadChat db user ct unreadChat
|
||||
pure CRCmdOk
|
||||
CTGroup -> do
|
||||
@@ -482,7 +494,7 @@ processChatCommand = \case
|
||||
_ -> pure $ chatCmdError "not supported"
|
||||
APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
|
||||
CTDirect -> do
|
||||
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db userId chatId
|
||||
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId
|
||||
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
||||
conns <- withStore $ \db -> getContactConnections db userId ct
|
||||
withChatLock "deleteChat direct" . procCmd $ do
|
||||
@@ -516,9 +528,9 @@ processChatCommand = \case
|
||||
withStore' $ \db -> deleteGroup db user gInfo
|
||||
pure $ CRGroupDeletedUser gInfo
|
||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
|
||||
APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of
|
||||
CTDirect -> do
|
||||
ct <- withStore $ \db -> getContact db userId chatId
|
||||
ct <- withStore $ \db -> getContact db user chatId
|
||||
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
||||
maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct
|
||||
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
|
||||
@@ -561,7 +573,7 @@ processChatCommand = \case
|
||||
pure $ CRContactRequestRejected cReq
|
||||
APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do
|
||||
-- party initiating call
|
||||
ct <- withStore $ \db -> getContact db userId contactId
|
||||
ct <- withStore $ \db -> getContact db user contactId
|
||||
calls <- asks currentCalls
|
||||
withChatLock "sendCallInvitation" $ do
|
||||
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
@@ -629,27 +641,27 @@ processChatCommand = \case
|
||||
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId)
|
||||
updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
|
||||
pure Nothing
|
||||
APIGetCallInvitations -> withUser $ \User {userId} -> do
|
||||
APIGetCallInvitations -> withUser $ \user -> do
|
||||
calls <- asks currentCalls >>= readTVarIO
|
||||
let invs = mapMaybe callInvitation $ M.elems calls
|
||||
CRCallInvitations <$> mapM (rcvCallInvitation userId) invs
|
||||
CRCallInvitations <$> mapM (rcvCallInvitation user) invs
|
||||
where
|
||||
callInvitation Call {contactId, callState, callTs} = case callState of
|
||||
CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey)
|
||||
_ -> Nothing
|
||||
rcvCallInvitation userId (contactId, callTs, peerCallType, sharedKey) = do
|
||||
contact <- withStore (\db -> getContact db userId contactId)
|
||||
rcvCallInvitation user (contactId, callTs, peerCallType, sharedKey) = do
|
||||
contact <- withStore (\db -> getContact db user contactId)
|
||||
pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs}
|
||||
APICallStatus contactId receivedStatus ->
|
||||
withCurrentCall contactId $ \userId ct call ->
|
||||
updateCallItemStatus userId ct call receivedStatus Nothing $> Just call
|
||||
APIUpdateProfile profile -> withUser (`updateProfile` profile)
|
||||
APISetContactPrefs contactId prefs' -> withUser $ \user@User {userId} -> do
|
||||
ct <- withStore $ \db -> getContact db userId contactId
|
||||
APISetContactPrefs contactId prefs' -> withUser $ \user -> do
|
||||
ct <- withStore $ \db -> getContact db user contactId
|
||||
updateContactPrefs user ct prefs'
|
||||
APISetContactAlias contactId localAlias -> withUser $ \User {userId} -> do
|
||||
APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do
|
||||
ct' <- withStore $ \db -> do
|
||||
ct <- getContact db userId contactId
|
||||
ct <- getContact db user contactId
|
||||
liftIO $ updateContactAlias db userId ct localAlias
|
||||
pure $ CRContactAliasUpdated ct'
|
||||
APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do
|
||||
@@ -668,12 +680,19 @@ processChatCommand = \case
|
||||
msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta
|
||||
connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing
|
||||
pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages}
|
||||
GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore' (`getSMPServers` user))
|
||||
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
|
||||
withStore $ \db -> overwriteSMPServers db user smpServers
|
||||
GetUserSMPServers -> do
|
||||
ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config
|
||||
withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers))
|
||||
smpServers <- withUser (\user -> withStore' (`getSMPServers` user))
|
||||
let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers
|
||||
pure $ CRUserSMPServers smpServers' defaultSMPServers
|
||||
where
|
||||
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True}
|
||||
SetUserSMPServers (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do
|
||||
withStore $ \db -> overwriteSMPServers db user smpServers
|
||||
cfg <- asks config
|
||||
withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers
|
||||
pure CRCmdOk
|
||||
TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer)
|
||||
APISetChatItemTTL newTTL_ -> withUser' $ \user ->
|
||||
checkStoreNotChanged $
|
||||
withChatLock "setChatItemTTL" $ do
|
||||
@@ -692,10 +711,10 @@ processChatCommand = \case
|
||||
APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user))
|
||||
APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk
|
||||
APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig)
|
||||
APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user@User {userId} -> case cType of
|
||||
APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of
|
||||
CTDirect -> do
|
||||
ct <- withStore $ \db -> do
|
||||
ct <- getContact db userId chatId
|
||||
ct <- getContact db user chatId
|
||||
liftIO $ updateContactSettings db user chatId chatSettings
|
||||
pure ct
|
||||
withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings)
|
||||
@@ -709,9 +728,9 @@ processChatCommand = \case
|
||||
withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError)
|
||||
pure CRCmdOk
|
||||
_ -> pure $ chatCmdError "not supported"
|
||||
APIContactInfo contactId -> withUser $ \User {userId} -> do
|
||||
APIContactInfo contactId -> withUser $ \user@User {userId} -> do
|
||||
-- [incognito] print user's incognito profile for this contact
|
||||
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db userId contactId
|
||||
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId
|
||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
||||
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
|
||||
pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile)
|
||||
@@ -719,8 +738,8 @@ processChatCommand = \case
|
||||
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
|
||||
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
|
||||
pure $ CRGroupMemberInfo g m connectionStats
|
||||
APISwitchContact contactId -> withUser $ \User {userId} -> do
|
||||
ct <- withStore $ \db -> getContact db userId contactId
|
||||
APISwitchContact contactId -> withUser $ \user -> do
|
||||
ct <- withStore $ \db -> getContact db user contactId
|
||||
withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct
|
||||
pure CRCmdOk
|
||||
APISwitchGroupMember gId gMemberId -> withUser $ \user -> do
|
||||
@@ -837,9 +856,9 @@ processChatCommand = \case
|
||||
gVar <- asks idsDrg
|
||||
groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile)
|
||||
pure $ CRGroupCreated groupInfo
|
||||
APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock "addMember" $ do
|
||||
APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do
|
||||
-- TODO for large groups: no need to load all members to determine if contact is a member
|
||||
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId
|
||||
(group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId
|
||||
let Group gInfo@GroupInfo {membership} members = group
|
||||
GroupMember {memberRole = userRole} = membership
|
||||
Contact {localDisplayName = cName} = contact
|
||||
@@ -891,7 +910,7 @@ processChatCommand = \case
|
||||
Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole
|
||||
_ -> throwChatError CEGroupMemberNotFound
|
||||
where
|
||||
changeMemberRole user@User {userId} gInfo@GroupInfo {membership} members m gEvent = do
|
||||
changeMemberRole user gInfo@GroupInfo {membership} members m gEvent = do
|
||||
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m
|
||||
GroupMember {memberRole = userRole} = membership
|
||||
canChangeRole = userRole >= GRAdmin && userRole >= mRole && userRole >= memRole && memberCurrent membership
|
||||
@@ -901,7 +920,7 @@ processChatCommand = \case
|
||||
withStore' $ \db -> updateGroupMemberRole db user m memRole
|
||||
case mStatus of
|
||||
GSMemInvited -> do
|
||||
withStore (\db -> (,) <$> mapM (getContact db userId) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case
|
||||
withStore (\db -> (,) <$> mapM (getContact db user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case
|
||||
(Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq
|
||||
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
||||
_ -> do
|
||||
@@ -1017,9 +1036,9 @@ processChatCommand = \case
|
||||
processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc
|
||||
LastMessages (Just chatName) count search -> withUser $ \user -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
CRLastMessages . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) search)
|
||||
LastMessages Nothing count search -> withUser $ \user -> withStore $ \db ->
|
||||
CRLastMessages <$> getAllChatItems db user (CPLast count) search
|
||||
CRApiChatItems . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) search)
|
||||
LastMessages Nothing count search ->
|
||||
processChatCommand (APIGetChatItems (CPLast count) search)
|
||||
SendFile chatName f -> withUser $ \user -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "")
|
||||
@@ -1051,7 +1070,7 @@ processChatCommand = \case
|
||||
sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId
|
||||
withStore (\db -> getChatRefByFileId db user fileId) >>= \case
|
||||
ChatRef CTDirect contactId -> do
|
||||
contact <- withStore $ \db -> getContact db userId contactId
|
||||
contact <- withStore $ \db -> getContact db user contactId
|
||||
void . sendDirectContactMessage contact $ XFileCancel sharedMsgId
|
||||
ChatRef CTGroup groupId -> do
|
||||
Group gInfo ms <- withStore $ \db -> getGroup db user groupId
|
||||
@@ -1114,7 +1133,7 @@ processChatCommand = \case
|
||||
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse
|
||||
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
|
||||
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
|
||||
withStore' (\db -> getConnReqContactXContactId db userId cReqHash) >>= \case
|
||||
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
|
||||
(Just contact, _) -> pure $ CRContactAlreadyExists contact
|
||||
(_, xContactId_) -> procCmd $ do
|
||||
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
@@ -1165,25 +1184,22 @@ processChatCommand = \case
|
||||
void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError)
|
||||
pure $ CRUserProfileUpdated (fromLocalProfile p) p'
|
||||
updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse
|
||||
updateContactPrefs user@User {userId} ct@Contact {contactId, activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
|
||||
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct $ contactUserPreferences user ct -- nothing changed actually
|
||||
updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
|
||||
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct
|
||||
| otherwise = do
|
||||
withStore' $ \db -> updateContactUserPreferences db userId contactId contactUserPrefs'
|
||||
-- [incognito] filter out contacts with whom user has incognito connections
|
||||
let ct' = (ct :: Contact) {userPreferences = contactUserPrefs'}
|
||||
ct' <- withStore' $ \db -> updateContactUserPreferences db user ct contactUserPrefs'
|
||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId
|
||||
let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct')
|
||||
withChatLock "updateProfile" . procCmd $ do
|
||||
void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError)
|
||||
pure $ CRContactPrefsUpdated ct ct' $ contactUserPreferences user ct'
|
||||
|
||||
pure $ CRContactPrefsUpdated ct ct'
|
||||
isReady :: Contact -> Bool
|
||||
isReady ct =
|
||||
let s = connStatus $ activeConn (ct :: Contact)
|
||||
in s == ConnReady || s == ConnSndReady
|
||||
withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse
|
||||
withCurrentCall ctId action = withUser $ \user@User {userId} -> do
|
||||
ct <- withStore $ \db -> getContact db userId ctId
|
||||
ct <- withStore $ \db -> getContact db user ctId
|
||||
calls <- asks currentCalls
|
||||
withChatLock "currentCall" $
|
||||
atomically (TM.lookup ctId calls) >>= \case
|
||||
@@ -1313,7 +1329,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F
|
||||
chatRef <- withStore $ \db -> getChatRefByFileId db user fileId
|
||||
case (chatRef, grpMemberId) of
|
||||
(ChatRef CTDirect contactId, Nothing) -> do
|
||||
ct <- withStore $ \db -> getContact db userId contactId
|
||||
ct <- withStore $ \db -> getContact db user contactId
|
||||
(msg, ci) <- acceptFile
|
||||
void $ sendDirectContactMessage ct msg
|
||||
pure ci
|
||||
@@ -2057,7 +2073,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
where
|
||||
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
|
||||
profileContactRequest invId p xContactId_ = do
|
||||
withStore (\db -> createOrUpdateContactRequest db userId userContactLinkId invId p xContactId_) >>= \case
|
||||
withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case
|
||||
CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact
|
||||
CORRequest cReq@UserContactRequest {localDisplayName} -> do
|
||||
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
|
||||
@@ -2151,7 +2167,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
if connectedIncognito
|
||||
then withStore' $ \db -> deleteSentProbe db userId probeId
|
||||
else do
|
||||
cs <- withStore' $ \db -> getMatchingContacts db userId ct
|
||||
cs <- withStore' $ \db -> getMatchingContacts db user ct
|
||||
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
|
||||
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
|
||||
where
|
||||
@@ -2466,21 +2482,21 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
|
||||
xInfo :: Contact -> Profile -> m ()
|
||||
xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do
|
||||
c' <- withStore $ \db -> updateContactProfile db userId c p'
|
||||
toView $ CRContactUpdated c c' $ contactUserPreferences user c'
|
||||
c' <- withStore $ \db -> updateContactProfile db user c p'
|
||||
toView $ CRContactUpdated c c'
|
||||
|
||||
xInfoProbe :: Contact -> Probe -> m ()
|
||||
xInfoProbe c2 probe =
|
||||
-- [incognito] unless connected incognito
|
||||
unless (contactConnIncognito c2) $ do
|
||||
r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe
|
||||
r <- withStore' $ \db -> matchReceivedProbe db user c2 probe
|
||||
forM_ r $ \c1 -> probeMatch c1 c2 probe
|
||||
|
||||
xInfoProbeCheck :: Contact -> ProbeHash -> m ()
|
||||
xInfoProbeCheck c1 probeHash =
|
||||
-- [incognito] unless connected incognito
|
||||
unless (contactConnIncognito c1) $ do
|
||||
r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash
|
||||
r <- withStore' $ \db -> matchReceivedProbeHash db user c1 probeHash
|
||||
forM_ r . uncurry $ probeMatch c1
|
||||
|
||||
probeMatch :: Contact -> Contact -> Probe -> m ()
|
||||
@@ -2493,7 +2509,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
|
||||
xInfoProbeOk :: Contact -> Probe -> m ()
|
||||
xInfoProbeOk c1@Contact {contactId = cId1} probe = do
|
||||
r <- withStore' $ \db -> matchSentProbe db userId c1 probe
|
||||
r <- withStore' $ \db -> matchSentProbe db user c1 probe
|
||||
forM_ r $ \c2@Contact {contactId = cId2} ->
|
||||
if cId1 /= cId2
|
||||
then mergeContacts c1 c2
|
||||
@@ -2608,7 +2624,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
ChatMessage {chatMsgEvent} <- parseChatMessage connInfo
|
||||
case chatMsgEvent of
|
||||
XInfo p -> do
|
||||
ct <- withStore $ \db -> createDirectContact db userId activeConn p
|
||||
ct <- withStore $ \db -> createDirectContact db user activeConn p
|
||||
toView $ CRContactConnecting ct
|
||||
-- TODO show/log error, other events in SMP confirmation
|
||||
_ -> pure ()
|
||||
@@ -3167,7 +3183,7 @@ chatCommandP =
|
||||
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
|
||||
"/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
|
||||
"/_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))),
|
||||
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP),
|
||||
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
|
||||
@@ -3202,9 +3218,15 @@ chatCommandP =
|
||||
"/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_leave #" *> (APILeaveGroup <$> A.decimal),
|
||||
"/_members #" *> (APIListMembers <$> A.decimal),
|
||||
"/smp_servers default" $> SetUserSMPServers [],
|
||||
"/smp_servers " *> (SetUserSMPServers <$> smpServersP),
|
||||
-- /smp_servers is deprecated, use /smp and /_smp
|
||||
"/smp_servers default" $> SetUserSMPServers (SMPServersConfig []),
|
||||
"/smp_servers " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP),
|
||||
"/smp_servers" $> GetUserSMPServers,
|
||||
"/smp default" $> SetUserSMPServers (SMPServersConfig []),
|
||||
"/smp test " *> (TestSMPServer <$> strP),
|
||||
"/_smp " *> (SetUserSMPServers <$> jsonP),
|
||||
"/smp " *> (SetUserSMPServers . SMPServersConfig <$> smpServersP),
|
||||
"/smp" $> GetUserSMPServers,
|
||||
"/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal),
|
||||
"/ttl " *> (APISetChatItemTTL <$> ciTTL),
|
||||
"/ttl" $> APIGetChatItemTTL,
|
||||
|
||||
@@ -23,6 +23,7 @@ import Data.ByteString.Char8 (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Char (ord)
|
||||
import Data.Int (Int64)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.Map.Strict (Map)
|
||||
import Data.String
|
||||
import Data.Text (Text)
|
||||
@@ -39,7 +40,7 @@ import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent (AgentClient)
|
||||
import Simplex.Messaging.Agent.Client (AgentLocks)
|
||||
import Simplex.Messaging.Agent.Client (AgentLocks, SMPTestFailure)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig)
|
||||
import Simplex.Messaging.Agent.Lock
|
||||
import Simplex.Messaging.Agent.Protocol
|
||||
@@ -146,7 +147,7 @@ data ChatCommand
|
||||
| ExecAgentStoreSQL Text
|
||||
| APIGetChats {pendingConnections :: Bool}
|
||||
| APIGetChat ChatRef ChatPagination (Maybe String)
|
||||
| APIGetChatItems Int
|
||||
| APIGetChatItems ChatPagination (Maybe String)
|
||||
| APISendMessage ChatRef ComposedMessage
|
||||
| APIUpdateChatItem ChatRef ChatItemId MsgContent
|
||||
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
|
||||
@@ -166,7 +167,7 @@ data ChatCommand
|
||||
| APIGetCallInvitations
|
||||
| APICallStatus ContactId WebRTCCallStatus
|
||||
| APIUpdateProfile Profile
|
||||
| APISetContactPrefs Int64 Preferences
|
||||
| APISetContactPrefs ContactId Preferences
|
||||
| APISetContactAlias ContactId LocalAlias
|
||||
| APISetConnectionAlias Int64 LocalAlias
|
||||
| APIParseMarkdown Text
|
||||
@@ -186,7 +187,8 @@ data ChatCommand
|
||||
| APIDeleteGroupLink GroupId
|
||||
| APIGetGroupLink GroupId
|
||||
| GetUserSMPServers
|
||||
| SetUserSMPServers [SMPServerWithAuth]
|
||||
| SetUserSMPServers SMPServersConfig
|
||||
| TestSMPServer SMPServerWithAuth
|
||||
| APISetChatItemTTL (Maybe Int64)
|
||||
| APIGetChatItemTTL
|
||||
| APISetNetworkConfig NetworkConfig
|
||||
@@ -259,9 +261,10 @@ data ChatResponse
|
||||
| CRChatSuspended
|
||||
| CRApiChats {chats :: [AChat]}
|
||||
| CRApiChat {chat :: AChat}
|
||||
| CRLastMessages {chatItems :: [AChatItem]}
|
||||
| CRApiChatItems {chatItems :: [AChatItem]}
|
||||
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
|
||||
| CRUserSMPServers {smpServers :: [SMPServerWithAuth]}
|
||||
| CRUserSMPServers {smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth}
|
||||
| CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure}
|
||||
| CRChatItemTTL {chatItemTTL :: Maybe Int64}
|
||||
| CRNetworkConfig {networkConfig :: NetworkConfig}
|
||||
| CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
|
||||
@@ -296,7 +299,7 @@ data ChatResponse
|
||||
| CRInvitation {connReqInvitation :: ConnReqInvitation}
|
||||
| CRSentConfirmation
|
||||
| CRSentInvitation {customUserProfile :: Maybe Profile}
|
||||
| CRContactUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences}
|
||||
| CRContactUpdated {fromContact :: Contact, toContact :: Contact}
|
||||
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
|
||||
| CRContactDeleted {contact :: Contact}
|
||||
| CRChatCleared {chatInfo :: AChatInfo}
|
||||
@@ -322,7 +325,7 @@ data ChatResponse
|
||||
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
|
||||
| CRContactAliasUpdated {toContact :: Contact}
|
||||
| CRConnectionAliasUpdated {toConnection :: PendingContactConnection}
|
||||
| CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact, preferences :: ContactUserPreferences}
|
||||
| CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact}
|
||||
| CRContactConnecting {contact :: Contact}
|
||||
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
|
||||
| CRContactAnotherClient {contact :: Contact}
|
||||
@@ -383,6 +386,9 @@ instance ToJSON ChatResponse where
|
||||
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
|
||||
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
|
||||
|
||||
data SMPServersConfig = SMPServersConfig {smpServers :: [ServerCfg]}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
|
||||
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
|
||||
@@ -465,6 +471,24 @@ data SwitchProgress = SwitchProgress
|
||||
|
||||
instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data ParsedServerAddress = ParsedServerAddress
|
||||
{ serverAddress :: Maybe ServerAddress,
|
||||
parseError :: String
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data ServerAddress = ServerAddress
|
||||
{ hostnames :: NonEmpty String,
|
||||
port :: String,
|
||||
keyHash :: String,
|
||||
basicAuth :: String
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data ChatError
|
||||
= ChatError {errorType :: ChatErrorType}
|
||||
| ChatErrorAgent {agentError :: AgentErrorType}
|
||||
|
||||
@@ -199,7 +199,7 @@ settingsInfo =
|
||||
styleMarkdown
|
||||
[ green "Chat settings:",
|
||||
indent <> highlight "/network " <> " - show / set network access options",
|
||||
indent <> highlight "/smp_servers " <> " - show / set custom SMP servers",
|
||||
indent <> highlight "/smp " <> " - show / set custom SMP servers",
|
||||
indent <> highlight "/info <contact> " <> " - information about contact connection",
|
||||
indent <> highlight "/info #<group> <member> " <> " - information about member connection",
|
||||
indent <> highlight "/(un)mute <contact> " <> " - (un)mute contact, the last messages can be printed with /tail command",
|
||||
|
||||
@@ -881,14 +881,9 @@ ciCallInfoText status duration = case status of
|
||||
CISCallRejected -> "rejected"
|
||||
CISCallAccepted -> "accepted"
|
||||
CISCallNegotiated -> "connecting..."
|
||||
CISCallProgress -> "in progress " <> d
|
||||
CISCallEnded -> "ended " <> d
|
||||
CISCallProgress -> "in progress " <> durationText duration
|
||||
CISCallEnded -> "ended " <> durationText duration
|
||||
CISCallError -> "error"
|
||||
where
|
||||
d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")"
|
||||
with0 n
|
||||
| n < 9 = '0' : show n
|
||||
| otherwise = show n
|
||||
|
||||
data SChatType (c :: ChatType) where
|
||||
SCTDirect :: SChatType 'CTDirect
|
||||
|
||||
19
src/Simplex/Chat/Migrations/M20221115_server_cfg.hs
Normal file
19
src/Simplex/Chat/Migrations/M20221115_server_cfg.hs
Normal file
@@ -0,0 +1,19 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20221115_server_cfg where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20221115_server_cfg :: Query
|
||||
m20221115_server_cfg =
|
||||
[sql|
|
||||
PRAGMA ignore_check_constraints=ON;
|
||||
|
||||
ALTER TABLE smp_servers ADD COLUMN preset INTEGER DEFAULT 0 CHECK (preset NOT NULL);
|
||||
ALTER TABLE smp_servers ADD COLUMN tested INTEGER;
|
||||
ALTER TABLE smp_servers ADD COLUMN enabled INTEGER DEFAULT 1 CHECK (enabled NOT NULL);
|
||||
UPDATE smp_servers SET preset = 0, enabled = 1;
|
||||
|
||||
PRAGMA ignore_check_constraints=OFF;
|
||||
|]
|
||||
@@ -385,6 +385,9 @@ CREATE TABLE smp_servers(
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
basic_auth TEXT,
|
||||
preset INTEGER DEFAULT 0 CHECK(preset NOT NULL),
|
||||
tested INTEGER,
|
||||
enabled INTEGER DEFAULT 1 CHECK(enabled NOT NULL),
|
||||
UNIQUE(host, port)
|
||||
);
|
||||
CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id);
|
||||
|
||||
@@ -16,6 +16,7 @@ import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Functor (($>))
|
||||
import Data.List (find)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Database.SQLite.Simple (SQLError (..))
|
||||
import qualified Database.SQLite.Simple as DB
|
||||
@@ -34,8 +35,10 @@ import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore)
|
||||
import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore)
|
||||
import Simplex.Messaging.Client (defaultNetworkConfig)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (CorrId (..))
|
||||
import Simplex.Messaging.Protocol (BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth)
|
||||
import Simplex.Messaging.Util (catchAll, safeDecodeUtf8)
|
||||
import System.Timeout (timeout)
|
||||
|
||||
@@ -58,6 +61,8 @@ foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatCont
|
||||
|
||||
foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString
|
||||
|
||||
foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString
|
||||
|
||||
-- | check / migrate database and initialize chat controller on success
|
||||
cChatMigrateInit :: CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
|
||||
cChatMigrateInit fp key ctrl = do
|
||||
@@ -107,6 +112,10 @@ cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t)
|
||||
cChatParseMarkdown :: CString -> IO CJSONString
|
||||
cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s
|
||||
|
||||
-- | parse server address - returns ParsedServerAddress JSON
|
||||
cChatParseServer :: CString -> IO CJSONString
|
||||
cChatParseServer s = newCAString . chatParseServer =<< peekCAString s
|
||||
|
||||
mobileChatOpts :: ChatOpts
|
||||
mobileChatOpts =
|
||||
ChatOpts
|
||||
@@ -206,6 +215,18 @@ chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc)
|
||||
chatParseMarkdown :: String -> JSONString
|
||||
chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack
|
||||
|
||||
chatParseServer :: String -> JSONString
|
||||
chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack
|
||||
where
|
||||
toServerAddress :: Either String SMPServerWithAuth -> ParsedServerAddress
|
||||
toServerAddress = \case
|
||||
Right (ProtoServerWithAuth ProtocolServer {host, port, keyHash = C.KeyHash kh} auth) ->
|
||||
let basicAuth = maybe "" (\(BasicAuth a) -> enc a) auth
|
||||
in ParsedServerAddress (Just ServerAddress {hostnames = L.map enc host, port, keyHash = enc kh, basicAuth}) ""
|
||||
Left e -> ParsedServerAddress Nothing e
|
||||
enc :: StrEncoding a => a -> String
|
||||
enc = B.unpack . strEncode
|
||||
|
||||
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
|
||||
deriving (Generic)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Options.Applicative
|
||||
import Simplex.Chat.Controller (updateStr, versionStr)
|
||||
import Simplex.Messaging.Agent.Protocol (SMPServerWithAuth)
|
||||
import Simplex.Chat.Types (ServerCfg (..))
|
||||
import Simplex.Messaging.Client (NetworkConfig (..), defaultNetworkConfig)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (parseAll)
|
||||
@@ -26,7 +26,7 @@ import System.FilePath (combine)
|
||||
data ChatOpts = ChatOpts
|
||||
{ dbFilePrefix :: String,
|
||||
dbKey :: String,
|
||||
smpServers :: [SMPServerWithAuth],
|
||||
smpServers :: [ServerCfg],
|
||||
networkConfig :: NetworkConfig,
|
||||
logConnections :: Bool,
|
||||
logServerHosts :: Bool,
|
||||
@@ -155,7 +155,7 @@ fullNetworkConfig socksProxy tcpTimeout =
|
||||
let tcpConnectTimeout = (tcpTimeout * 3) `div` 2
|
||||
in defaultNetworkConfig {socksProxy, tcpTimeout, tcpConnectTimeout}
|
||||
|
||||
parseSMPServers :: ReadM [SMPServerWithAuth]
|
||||
parseSMPServers :: ReadM [ServerCfg]
|
||||
parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
|
||||
|
||||
parseSocksProxy :: ReadM (Maybe SocksProxy)
|
||||
@@ -167,8 +167,10 @@ parseServerPort = eitherReader $ parseAll serverPortP . B.pack
|
||||
serverPortP :: A.Parser (Maybe String)
|
||||
serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit
|
||||
|
||||
smpServersP :: A.Parser [SMPServerWithAuth]
|
||||
smpServersP = strP `A.sepBy1` A.char ';'
|
||||
smpServersP :: A.Parser [ServerCfg]
|
||||
smpServersP = (toServerCfg <$> strP) `A.sepBy1` A.char ';'
|
||||
where
|
||||
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
|
||||
|
||||
getChatOpts :: FilePath -> FilePath -> IO ChatOpts
|
||||
getChatOpts appDir defaultDbFileName =
|
||||
|
||||
@@ -29,6 +29,7 @@ import Data.ByteString.Internal (c2w, w2c)
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Data.Type.Equality
|
||||
@@ -263,7 +264,7 @@ cmToQuotedMsg = \case
|
||||
ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg
|
||||
_ -> Nothing
|
||||
|
||||
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCFile_ | MCUnknown_ Text
|
||||
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVoice_ | MCFile_ | MCUnknown_ Text
|
||||
|
||||
instance StrEncoding MsgContentTag where
|
||||
strEncode = \case
|
||||
@@ -271,11 +272,13 @@ instance StrEncoding MsgContentTag where
|
||||
MCLink_ -> "link"
|
||||
MCImage_ -> "image"
|
||||
MCFile_ -> "file"
|
||||
MCVoice_ -> "voice"
|
||||
MCUnknown_ t -> encodeUtf8 t
|
||||
strDecode = \case
|
||||
"text" -> Right MCText_
|
||||
"link" -> Right MCLink_
|
||||
"image" -> Right MCImage_
|
||||
"voice" -> Right MCVoice_
|
||||
"file" -> Right MCFile_
|
||||
t -> Right . MCUnknown_ $ safeDecodeUtf8 t
|
||||
strP = strDecode <$?> A.takeTill (== ' ')
|
||||
@@ -313,6 +316,7 @@ data MsgContent
|
||||
= MCText Text
|
||||
| MCLink {text :: Text, preview :: LinkPreview}
|
||||
| MCImage {text :: Text, image :: ImageData}
|
||||
| MCVoice {text :: Text, duration :: Int}
|
||||
| MCFile Text
|
||||
| MCUnknown {tag :: Text, text :: Text, json :: J.Object}
|
||||
deriving (Eq, Show)
|
||||
@@ -322,14 +326,27 @@ msgContentText = \case
|
||||
MCText t -> t
|
||||
MCLink {text} -> text
|
||||
MCImage {text} -> text
|
||||
MCVoice {text, duration} ->
|
||||
if T.null text then msg else msg <> "; " <> text
|
||||
where
|
||||
msg = "voice message " <> durationText duration
|
||||
MCFile t -> t
|
||||
MCUnknown {text} -> text
|
||||
|
||||
durationText :: Int -> Text
|
||||
durationText duration =
|
||||
let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")"
|
||||
where
|
||||
with0 n
|
||||
| n <= 9 = '0' : show n
|
||||
| otherwise = show n
|
||||
|
||||
msgContentTag :: MsgContent -> MsgContentTag
|
||||
msgContentTag = \case
|
||||
MCText _ -> MCText_
|
||||
MCLink {} -> MCLink_
|
||||
MCImage {} -> MCImage_
|
||||
MCVoice {} -> MCVoice_
|
||||
MCFile {} -> MCFile_
|
||||
MCUnknown {tag} -> MCUnknown_ tag
|
||||
|
||||
@@ -356,6 +373,10 @@ instance FromJSON MsgContent where
|
||||
text <- v .: "text"
|
||||
image <- v .: "image"
|
||||
pure MCImage {image, text}
|
||||
MCVoice_ -> do
|
||||
text <- v .: "text"
|
||||
duration <- v .: "duration"
|
||||
pure MCVoice {text, duration}
|
||||
MCFile_ -> MCFile <$> v .: "text"
|
||||
MCUnknown_ tag -> do
|
||||
text <- fromMaybe unknownMsgType <$> v .:? "text"
|
||||
@@ -382,12 +403,14 @@ instance ToJSON MsgContent where
|
||||
MCText t -> J.object ["type" .= MCText_, "text" .= t]
|
||||
MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview]
|
||||
MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image]
|
||||
MCVoice {text, duration} -> J.object ["type" .= MCVoice_, "text" .= text, "duration" .= duration]
|
||||
MCFile t -> J.object ["type" .= MCFile_, "text" .= t]
|
||||
toEncoding = \case
|
||||
MCUnknown {json} -> JE.value $ J.Object json
|
||||
MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t
|
||||
MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview
|
||||
MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image
|
||||
MCVoice {text, duration} -> J.pairs $ "type" .= MCVoice_ <> "text" .= text <> "duration" .= duration
|
||||
MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t
|
||||
|
||||
instance ToField MsgContent where
|
||||
|
||||
@@ -297,6 +297,7 @@ import Simplex.Chat.Migrations.M20221024_contact_used
|
||||
import Simplex.Chat.Migrations.M20221025_chat_settings
|
||||
import Simplex.Chat.Migrations.M20221029_group_link_id
|
||||
import Simplex.Chat.Migrations.M20221112_server_password
|
||||
import Simplex.Chat.Migrations.M20221115_server_cfg
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..))
|
||||
@@ -304,7 +305,7 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore
|
||||
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), SMPServerWithAuth, pattern SMPServer)
|
||||
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), pattern SMPServer)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost)
|
||||
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
|
||||
import UnliftIO.STM
|
||||
@@ -344,7 +345,8 @@ schemaMigrations =
|
||||
("20221024_contact_used", m20221024_contact_used),
|
||||
("20221025_chat_settings", m20221025_chat_settings),
|
||||
("20221029_group_link_id", m20221029_group_link_id),
|
||||
("20221112_server_password", m20221112_server_password)
|
||||
("20221112_server_password", m20221112_server_password),
|
||||
("20221115_server_cfg", m20221115_server_cfg)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
@@ -414,7 +416,7 @@ getUsers db =
|
||||
toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User
|
||||
toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
|
||||
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser}
|
||||
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences}
|
||||
|
||||
setActiveUser :: DB.Connection -> UserId -> IO ()
|
||||
setActiveUser db userId = do
|
||||
@@ -438,15 +440,15 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou
|
||||
pccConnId <- insertedRowId db
|
||||
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt}
|
||||
|
||||
getConnReqContactXContactId :: DB.Connection -> UserId -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId)
|
||||
getConnReqContactXContactId db userId cReqHash = do
|
||||
getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId)
|
||||
getConnReqContactXContactId db user@User {userId} cReqHash = do
|
||||
getContact' >>= \case
|
||||
c@(Just _) -> pure (c, Nothing)
|
||||
Nothing -> (Nothing,) <$> getXContactId
|
||||
where
|
||||
getContact' :: IO (Maybe Contact)
|
||||
getContact' =
|
||||
maybeFirstRow toContact $
|
||||
maybeFirstRow (toContact user) $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
@@ -534,11 +536,14 @@ createConnection_ db userId connType entityId acId viaContact viaUserContactLink
|
||||
where
|
||||
ent ct = if connType == ct then entityId else Nothing
|
||||
|
||||
createDirectContact :: DB.Connection -> UserId -> Connection -> Profile -> ExceptT StoreError IO Contact
|
||||
createDirectContact db userId activeConn@Connection {connId, localAlias} profile = do
|
||||
createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
|
||||
createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do
|
||||
createdAt <- liftIO getCurrentTime
|
||||
(localDisplayName, contactId, profileId) <- createContact_ db userId connId profile localAlias Nothing createdAt
|
||||
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile localAlias, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences = emptyChatPrefs, createdAt, updatedAt = createdAt}
|
||||
(localDisplayName, contactId, profileId) <- createContact_ db userId connId p localAlias Nothing createdAt
|
||||
let profile = toLocalProfile profileId p localAlias
|
||||
userPreferences = emptyChatPrefs
|
||||
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt}
|
||||
|
||||
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId)
|
||||
createContact_ db userId connId Profile {displayName, fullName, image, preferences} localAlias viaGroup currentTs =
|
||||
@@ -632,24 +637,32 @@ updateUserProfile db User {userId, userContactId, localDisplayName, profile = Lo
|
||||
updateContactProfile_' db userId profileId p' currentTs
|
||||
updateContact_ db userId userContactId localDisplayName newName currentTs
|
||||
|
||||
updateContactProfile :: DB.Connection -> UserId -> Contact -> Profile -> ExceptT StoreError IO Contact
|
||||
updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} p'@Profile {displayName = newName}
|
||||
| displayName == newName =
|
||||
liftIO $ updateContactProfile_ db userId profileId p' $> (c :: Contact) {profile = toLocalProfile profileId p' localAlias}
|
||||
updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact
|
||||
updateContactProfile db user@User {userId} c p'
|
||||
| displayName == newName = do
|
||||
liftIO $ updateContactProfile_ db userId profileId p'
|
||||
pure $ c {profile, mergedPreferences}
|
||||
| otherwise =
|
||||
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
||||
currentTs <- getCurrentTime
|
||||
updateContactProfile_' db userId profileId p' currentTs
|
||||
updateContact_ db userId contactId localDisplayName ldn currentTs
|
||||
pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p' localAlias}
|
||||
pure . Right $ c {localDisplayName = ldn, profile, mergedPreferences}
|
||||
where
|
||||
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, activeConn, userPreferences} = c
|
||||
Profile {displayName = newName, preferences} = p'
|
||||
profile = toLocalProfile profileId p' localAlias
|
||||
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
|
||||
updateContactUserPreferences :: DB.Connection -> UserId -> Int64 -> Preferences -> IO ()
|
||||
updateContactUserPreferences db userId contactId userPreferences = do
|
||||
updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact
|
||||
updateContactUserPreferences db user@User {userId} c@Contact {contactId, activeConn} userPreferences = do
|
||||
updatedAt <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
|
||||
(userPreferences, updatedAt, userId, contactId)
|
||||
let mergedPreferences = contactUserPreferences user userPreferences (preferences' c) $ connIncognito activeConn
|
||||
pure $ c {mergedPreferences, userPreferences}
|
||||
|
||||
updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact
|
||||
updateContactAlias db userId c@Contact {profile = lp@LocalProfile {profileId}} localAlias = do
|
||||
@@ -722,33 +735,35 @@ updateContact_ db userId contactId displayName newName updatedAt = do
|
||||
|
||||
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime)
|
||||
|
||||
toContact :: ContactRow :. ConnectionRow -> Contact
|
||||
toContact (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
||||
toContact :: User -> ContactRow :. ConnectionRow -> Contact
|
||||
toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||
activeConn = toConnection connRow
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
||||
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||
|
||||
toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact
|
||||
toContactOrError (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
||||
toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact
|
||||
toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)) :. connRow) =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||
in case toMaybeConnection connRow of
|
||||
Just activeConn ->
|
||||
Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
||||
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||
_ -> Left $ SEContactNotReady localDisplayName
|
||||
|
||||
-- TODO return the last connection that is ready, not any last connection
|
||||
-- requires updating connection status
|
||||
getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact
|
||||
getContactByName db user@User {userId} localDisplayName = do
|
||||
getContactByName db user localDisplayName = do
|
||||
cId <- getContactIdByName db user localDisplayName
|
||||
getContact db userId cId
|
||||
getContact db user cId
|
||||
|
||||
getUserContacts :: DB.Connection -> User -> IO [Contact]
|
||||
getUserContacts db User {userId} = do
|
||||
getUserContacts db user@User {userId} = do
|
||||
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId)
|
||||
rights <$> mapM (runExceptT . getContact db userId) contactIds
|
||||
rights <$> mapM (runExceptT . getContact db user) contactIds
|
||||
|
||||
createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO ()
|
||||
createUserContactLink db userId agentConnId cReq =
|
||||
@@ -977,8 +992,8 @@ getGroupLinkId db User {userId} GroupInfo {groupId} =
|
||||
fmap join . maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId)
|
||||
|
||||
createOrUpdateContactRequest :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
|
||||
createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
|
||||
createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
|
||||
createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ =
|
||||
liftIO (maybeM getContact' xContactId_) >>= \case
|
||||
Just contact -> pure $ CORContact contact
|
||||
Nothing -> CORRequest <$> createOrUpdate_
|
||||
@@ -1014,7 +1029,7 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN
|
||||
insertedRowId db
|
||||
getContact' :: XContactId -> IO (Maybe Contact)
|
||||
getContact' xContactId =
|
||||
maybeFirstRow toContact $
|
||||
maybeFirstRow (toContact user) $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
@@ -1133,20 +1148,21 @@ deleteContactRequest db userId contactRequestId = do
|
||||
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
|
||||
|
||||
createAcceptedContact :: DB.Connection -> User -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe IncognitoProfile -> IO Contact
|
||||
createAcceptedContact db User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do
|
||||
createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}} agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do
|
||||
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
createdAt <- getCurrentTime
|
||||
customUserProfileId <- forM incognitoProfile $ \case
|
||||
NewIncognito p -> createIncognitoProfile_ db userId createdAt p
|
||||
ExistingIncognito LocalProfile {profileId = pId} -> pure pId
|
||||
let contactUserPrefs = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
|
||||
let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?,?,?)"
|
||||
(userId, localDisplayName, profileId, True, contactUserPrefs, createdAt, createdAt, xContactId)
|
||||
(userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, xContactId)
|
||||
contactId <- insertedRowId db
|
||||
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt
|
||||
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences = contactUserPrefs, createdAt = createdAt, updatedAt = createdAt}
|
||||
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt}
|
||||
|
||||
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
|
||||
getLiveSndFileTransfers db User {userId} = do
|
||||
@@ -1249,8 +1265,8 @@ toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, v
|
||||
Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt)
|
||||
toMaybeConnection _ = Nothing
|
||||
|
||||
getMatchingContacts :: DB.Connection -> UserId -> Contact -> IO [Contact]
|
||||
getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
|
||||
getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact]
|
||||
getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
|
||||
contactIds <-
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
@@ -1264,7 +1280,7 @@ getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displa
|
||||
AND ((p.image IS NULL AND ? IS NULL) OR p.image = ?)
|
||||
|]
|
||||
(userId, contactId, displayName, fullName, image, image)
|
||||
rights <$> mapM (runExceptT . getContact db userId) contactIds
|
||||
rights <$> mapM (runExceptT . getContact db user) contactIds
|
||||
|
||||
createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> Contact -> ExceptT StoreError IO (Probe, Int64)
|
||||
createSentProbe db gVar userId _to@Contact {contactId} =
|
||||
@@ -1291,8 +1307,8 @@ deleteSentProbe db userId probeId =
|
||||
"DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?"
|
||||
(userId, probeId)
|
||||
|
||||
matchReceivedProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact)
|
||||
matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
|
||||
matchReceivedProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
|
||||
matchReceivedProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
|
||||
let probeHash = C.sha256Hash probe
|
||||
contactIds <-
|
||||
map fromOnly
|
||||
@@ -1312,10 +1328,10 @@ matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do
|
||||
(contactId, probe, probeHash, userId, currentTs, currentTs)
|
||||
case contactIds of
|
||||
[] -> pure Nothing
|
||||
cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId)
|
||||
cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
|
||||
|
||||
matchReceivedProbeHash :: DB.Connection -> UserId -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe))
|
||||
matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash) = do
|
||||
matchReceivedProbeHash :: DB.Connection -> User -> Contact -> ProbeHash -> IO (Maybe (Contact, Probe))
|
||||
matchReceivedProbeHash db user@User {userId} _from@Contact {contactId} (ProbeHash probeHash) = do
|
||||
namesAndProbes <-
|
||||
DB.query
|
||||
db
|
||||
@@ -1335,10 +1351,10 @@ matchReceivedProbeHash db userId _from@Contact {contactId} (ProbeHash probeHash)
|
||||
[] -> pure Nothing
|
||||
(cId, probe) : _ ->
|
||||
either (const Nothing) (Just . (,Probe probe))
|
||||
<$> runExceptT (getContact db userId cId)
|
||||
<$> runExceptT (getContact db user cId)
|
||||
|
||||
matchSentProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact)
|
||||
matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do
|
||||
matchSentProbe :: DB.Connection -> User -> Contact -> Probe -> IO (Maybe Contact)
|
||||
matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = do
|
||||
contactIds <-
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
@@ -1353,7 +1369,7 @@ matchSentProbe db userId _from@Contact {contactId} (Probe probe) = do
|
||||
(userId, probe, contactId)
|
||||
case contactIds of
|
||||
[] -> pure Nothing
|
||||
cId : _ -> eitherToMaybe <$> runExceptT (getContact db userId cId)
|
||||
cId : _ -> eitherToMaybe <$> runExceptT (getContact db user cId)
|
||||
|
||||
mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO ()
|
||||
mergeContactRecords db userId Contact {contactId = toContactId} Contact {contactId = fromContactId, localDisplayName} = do
|
||||
@@ -1438,7 +1454,8 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
|
||||
toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, contactUsed, enableNtfs_) :. (preferences, userPreferences, createdAt, updatedAt)] =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
||||
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
|
||||
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
getGroupAndMember_ groupMemberId c = ExceptT $ do
|
||||
@@ -1590,6 +1607,7 @@ updateConnectionStatus db Connection {connId} connStatus = do
|
||||
createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
||||
createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
|
||||
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
currentTs <- getCurrentTime
|
||||
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
|
||||
groupId <- liftIO $ do
|
||||
@@ -1606,7 +1624,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
|
||||
memberId <- liftIO $ encodedRandomBytes gVar 12
|
||||
membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs
|
||||
let chatSettings = ChatSettings {enableNtfs = True}
|
||||
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs}
|
||||
pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs}
|
||||
|
||||
-- | creates a new group record for the group the current user was invited to, or returns an existing one
|
||||
createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
||||
@@ -1633,6 +1651,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
|
||||
createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
||||
createGroupInvitation_ = do
|
||||
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
ExceptT $
|
||||
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
@@ -1650,7 +1669,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
|
||||
GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs
|
||||
membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs
|
||||
let chatSettings = ChatSettings {enableNtfs = True}
|
||||
pure (GroupInfo {groupId, localDisplayName, groupProfile, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}, groupMemberId)
|
||||
pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}, groupMemberId)
|
||||
|
||||
getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId
|
||||
getHostMemberId_ db User {userId} groupId =
|
||||
@@ -1805,7 +1824,8 @@ toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
|
||||
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) =
|
||||
let membership = toGroupMember userContactId userMemberRow
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
|
||||
|
||||
getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember
|
||||
getGroupMember db user@User {userId} groupId groupMemberId =
|
||||
@@ -1976,8 +1996,8 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co
|
||||
)
|
||||
|
||||
getContactViaMember :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
|
||||
getContactViaMember db User {userId} GroupMember {groupMemberId} =
|
||||
maybeFirstRow toContact $
|
||||
getContactViaMember db user@User {userId} GroupMember {groupMemberId} =
|
||||
maybeFirstRow (toContact user) $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
@@ -2102,7 +2122,7 @@ cleanupMemberContactAndProfile_ :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
cleanupMemberContactAndProfile_ db user@User {userId} m@GroupMember {groupMemberId, localDisplayName, memberContactId, memberContactProfileId, memberProfile = LocalProfile {profileId}} =
|
||||
case memberContactId of
|
||||
Just contactId ->
|
||||
runExceptT (getContact db userId contactId) >>= \case
|
||||
runExceptT (getContact db user contactId) >>= \case
|
||||
Right ct@Contact {activeConn = Connection {connLevel, viaGroupLink}, contactUsed} ->
|
||||
unless ((connLevel == 0 && not viaGroupLink) || contactUsed) $ deleteContact db user ct
|
||||
_ -> pure ()
|
||||
@@ -2320,7 +2340,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
|
||||
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow})
|
||||
|
||||
getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact)
|
||||
getViaGroupContact db User {userId} GroupMember {groupMemberId} =
|
||||
getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
|
||||
maybeFirstRow toContact' $
|
||||
DB.query
|
||||
db
|
||||
@@ -2347,7 +2367,8 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences, localAlias}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||
activeConn = toConnection connRow
|
||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, createdAt, updatedAt}
|
||||
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt}
|
||||
|
||||
createSndDirectFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> Maybe ConnId -> Integer -> IO FileTransferMeta
|
||||
createSndDirectFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize, fileInline} acId_ chunkSize = do
|
||||
@@ -2659,7 +2680,7 @@ getRcvFileTransfer db user@User {userId} fileId = do
|
||||
rfi_ = \case
|
||||
(Just filePath, Just connId, Just agentConnId, _, _, _, _) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId}
|
||||
(Just filePath, Nothing, Nothing, Just contactId, _, _, True) -> do
|
||||
Contact {activeConn = Connection {connId, agentConnId}} <- getContact db userId contactId
|
||||
Contact {activeConn = Connection {connId, agentConnId}} <- getContact db user contactId
|
||||
pure $ Just RcvFileInfo {filePath, connId, agentConnId}
|
||||
(Just filePath, Nothing, Nothing, _, Just groupId, Just groupMemberId, True) -> do
|
||||
getGroupMember db user groupId groupMemberId >>= \case
|
||||
@@ -3194,7 +3215,7 @@ getChatPreviews db user withPCC = do
|
||||
ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo
|
||||
|
||||
getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat]
|
||||
getDirectChatPreviews_ db User {userId} = do
|
||||
getDirectChatPreviews_ db user@User {userId} = do
|
||||
tz <- getCurrentTimeZone
|
||||
currentTs <- getCurrentTime
|
||||
map (toDirectChatPreview tz currentTs)
|
||||
@@ -3233,7 +3254,7 @@ getDirectChatPreviews_ db User {userId} = do
|
||||
WHERE item_status = ? AND item_deleted != 1
|
||||
GROUP BY contact_id
|
||||
) ChatStats ON ChatStats.contact_id = ct.contact_id
|
||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
||||
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||
WHERE ct.user_id = ?
|
||||
AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1)
|
||||
AND c.connection_id = (
|
||||
@@ -3253,7 +3274,7 @@ getDirectChatPreviews_ db User {userId} = do
|
||||
where
|
||||
toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
|
||||
toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) =
|
||||
let contact = toContact $ contactRow :. connRow
|
||||
let contact = toContact user $ contactRow :. connRow
|
||||
ci_ = toDirectChatItemList tz currentTs ciRow_
|
||||
stats = toChatStats statsRow
|
||||
in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats
|
||||
@@ -3310,7 +3331,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
|
||||
) ChatStats ON ChatStats.group_id = g.group_id
|
||||
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
|
||||
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
||||
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id
|
||||
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
|
||||
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
|
||||
WHERE g.user_id = ? AND mu.contact_id = ?
|
||||
@@ -3420,8 +3441,8 @@ getDirectChat db user contactId pagination search_ = do
|
||||
CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search
|
||||
|
||||
getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatLast_ db User {userId} contactId count search = do
|
||||
contact <- getContact db userId contactId
|
||||
getDirectChatLast_ db user@User {userId} contactId count search = do
|
||||
contact <- getContact db user contactId
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItems <- ExceptT getDirectChatItemsLast_
|
||||
pure $ Chat (DirectChat contact) (reverse chatItems) stats
|
||||
@@ -3443,7 +3464,7 @@ getDirectChatLast_ db User {userId} contactId count search = do
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
||||
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
||||
ORDER BY i.chat_item_id DESC
|
||||
LIMIT ?
|
||||
@@ -3451,8 +3472,8 @@ getDirectChatLast_ db User {userId} contactId count search = do
|
||||
(userId, contactId, search, count)
|
||||
|
||||
getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
|
||||
contact <- getContact db userId contactId
|
||||
getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search = do
|
||||
contact <- getContact db user contactId
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItems <- ExceptT getDirectChatItemsAfter_
|
||||
pure $ Chat (DirectChat contact) chatItems stats
|
||||
@@ -3474,7 +3495,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
||||
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
||||
AND i.chat_item_id > ?
|
||||
ORDER BY i.chat_item_id ASC
|
||||
@@ -3483,8 +3504,8 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do
|
||||
(userId, contactId, search, afterChatItemId, count)
|
||||
|
||||
getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
|
||||
getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search = do
|
||||
contact <- getContact db userId contactId
|
||||
getDirectChatBefore_ db user@User {userId} contactId beforeChatItemId count search = do
|
||||
contact <- getContact db user contactId
|
||||
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
|
||||
chatItems <- ExceptT getDirectChatItemsBefore_
|
||||
pure $ Chat (DirectChat contact) (reverse chatItems) stats
|
||||
@@ -3506,7 +3527,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search =
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
||||
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%'
|
||||
AND i.chat_item_id < ?
|
||||
ORDER BY i.chat_item_id DESC
|
||||
@@ -3519,9 +3540,9 @@ getContactIdByName db User {userId} cName =
|
||||
ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $
|
||||
DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName)
|
||||
|
||||
getContact :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO Contact
|
||||
getContact db userId contactId =
|
||||
ExceptT . fmap join . firstRow toContactOrError (SEContactNotFound contactId) $
|
||||
getContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact
|
||||
getContact db user@User {userId} contactId =
|
||||
ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
@@ -3649,17 +3670,18 @@ getGroupInfo db User {userId, userContactId} groupId =
|
||||
(groupId, userId, userContactId)
|
||||
|
||||
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
||||
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, groupPreferences}} p'@GroupProfile {displayName = newName, fullName, image}
|
||||
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, image, groupPreferences}
|
||||
| displayName == newName = liftIO $ do
|
||||
currentTs <- getCurrentTime
|
||||
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p'}
|
||||
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p', fullGroupPreferences}
|
||||
| otherwise =
|
||||
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
|
||||
currentTs <- getCurrentTime
|
||||
updateGroupProfile_ currentTs
|
||||
updateGroup_ ldn currentTs
|
||||
pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p'}
|
||||
pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences}
|
||||
where
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
updateGroupProfile_ currentTs =
|
||||
DB.execute
|
||||
db
|
||||
@@ -3685,8 +3707,8 @@ getAllChatItems db user pagination search_ = do
|
||||
let search = fromMaybe "" search_
|
||||
case pagination of
|
||||
CPLast count -> getAllChatItemsLast_ db user count search
|
||||
CPAfter _afterId _count -> throwError $ SEInternalError "not implemented"
|
||||
CPBefore _beforeId _count -> throwError $ SEInternalError "not implemented"
|
||||
CPAfter afterId count -> getAllChatItemsAfter_ db user afterId count search
|
||||
CPBefore beforeId count -> getAllChatItemsBefore_ db user beforeId count search
|
||||
|
||||
getAllChatItemsLast_ :: DB.Connection -> User -> Int -> String -> ExceptT StoreError IO [AChatItem]
|
||||
getAllChatItemsLast_ db user@User {userId} count search = do
|
||||
@@ -3698,13 +3720,59 @@ getAllChatItemsLast_ db user@User {userId} count search = do
|
||||
[sql|
|
||||
SELECT chat_item_id, contact_id, group_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND item_text LIKE '%' || ? || '%'
|
||||
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
|
||||
ORDER BY item_ts DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, search, count)
|
||||
mapM (uncurry $ getAChatItem_ db user) itemRefs
|
||||
|
||||
getAllChatItemsAfter_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO [AChatItem]
|
||||
getAllChatItemsAfter_ db user@User {userId} afterItemId count search = do
|
||||
AChatItem _ _ _ afterItem <- getAChatItem db user afterItemId
|
||||
itemRefs <- liftIO $ getChatItemRefsAfter_ (chatItemTs' afterItem)
|
||||
mapM (uncurry $ getAChatItem_ db user) itemRefs
|
||||
where
|
||||
getChatItemRefsAfter_ afterItemTs =
|
||||
rights . map toChatItemRef
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id, contact_id, group_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
|
||||
AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?))
|
||||
ORDER BY item_ts ASC, chat_item_id ASC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, search, afterItemTs, afterItemTs, afterItemId, count)
|
||||
|
||||
getAllChatItemsBefore_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO [AChatItem]
|
||||
getAllChatItemsBefore_ db user@User {userId} beforeItemId count search = do
|
||||
AChatItem _ _ _ beforeItem <- getAChatItem db user beforeItemId
|
||||
itemRefs <- liftIO $ getChatItemRefsBefore_ (chatItemTs' beforeItem)
|
||||
mapM (uncurry $ getAChatItem_ db user) itemRefs
|
||||
where
|
||||
getChatItemRefsBefore_ beforeItemTs =
|
||||
reverse . rights . map toChatItemRef
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT chat_item_id, contact_id, group_id
|
||||
FROM chat_items
|
||||
WHERE user_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%'
|
||||
AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?))
|
||||
ORDER BY item_ts DESC, chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(userId, search, beforeItemTs, beforeItemTs, beforeItemId, count)
|
||||
|
||||
getAChatItem :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO AChatItem
|
||||
getAChatItem db user@User {userId} itemId = do
|
||||
afterItemRef <- ExceptT $ firstRow' toChatItemRef (SEChatItemNotFound itemId) $
|
||||
DB.query db "SELECT chat_item_id, contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId)
|
||||
uncurry (getAChatItem_ db user) afterItemRef
|
||||
|
||||
getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId
|
||||
getGroupIdByName db User {userId} gName =
|
||||
ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $
|
||||
@@ -3865,7 +3933,7 @@ getDirectChatItem db userId contactId itemId = ExceptT $ do
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
||||
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.contact_id = i.contact_id
|
||||
WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ?
|
||||
|]
|
||||
(userId, contactId, itemId)
|
||||
@@ -3990,7 +4058,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
|
||||
LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
|
||||
LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id
|
||||
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
|
||||
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id)
|
||||
WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ?
|
||||
@@ -4076,7 +4144,7 @@ getChatItemByGroupId db user@User {userId} groupId = do
|
||||
getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem
|
||||
getAChatItem_ db user@User {userId} itemId = \case
|
||||
ChatRef CTDirect contactId -> do
|
||||
ct <- getContact db userId contactId
|
||||
ct <- getContact db user contactId
|
||||
(CChatItem msgDir ci) <- getDirectChatItem db userId contactId itemId
|
||||
pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci
|
||||
ChatRef CTGroup groupId -> do
|
||||
@@ -4238,35 +4306,38 @@ toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just
|
||||
either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_)
|
||||
toGroupChatItemList _ _ _ _ = []
|
||||
|
||||
getSMPServers :: DB.Connection -> User -> IO [SMPServerWithAuth]
|
||||
getSMPServers :: DB.Connection -> User -> IO [ServerCfg]
|
||||
getSMPServers db User {userId} =
|
||||
map toSmpServer
|
||||
map toServerCfg
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT host, port, key_hash, basic_auth
|
||||
SELECT host, port, key_hash, basic_auth, preset, tested, enabled
|
||||
FROM smp_servers
|
||||
WHERE user_id = ?;
|
||||
|]
|
||||
(Only userId)
|
||||
where
|
||||
toSmpServer :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text) -> SMPServerWithAuth
|
||||
toSmpServer (host, port, keyHash, auth_) = ProtoServerWithAuth (SMPServer host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
|
||||
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg
|
||||
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
|
||||
let server = ProtoServerWithAuth (SMPServer host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
|
||||
in ServerCfg {server, preset, tested, enabled}
|
||||
|
||||
overwriteSMPServers :: DB.Connection -> User -> [SMPServerWithAuth] -> ExceptT StoreError IO ()
|
||||
overwriteSMPServers db User {userId} smpServers =
|
||||
overwriteSMPServers :: DB.Connection -> User -> [ServerCfg] -> ExceptT StoreError IO ()
|
||||
overwriteSMPServers db User {userId} servers =
|
||||
checkConstraint SEUniqueID . ExceptT $ do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId)
|
||||
forM_ smpServers $ \(ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) ->
|
||||
forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do
|
||||
let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO smp_servers
|
||||
(host, port, key_hash, basic_auth, user_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
(host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, userId, currentTs, currentTs)
|
||||
(host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, preset, tested, enabled, userId, currentTs, currentTs)
|
||||
pure $ Right ()
|
||||
|
||||
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
|
||||
|
||||
@@ -44,6 +44,7 @@ import GHC.Generics (Generic)
|
||||
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON)
|
||||
import Simplex.Messaging.Protocol (SMPServerWithAuth)
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
|
||||
|
||||
class IsContact a where
|
||||
@@ -66,6 +67,7 @@ data User = User
|
||||
userContactId :: ContactId,
|
||||
localDisplayName :: ContactName,
|
||||
profile :: LocalProfile,
|
||||
fullPreferences :: FullPreferences,
|
||||
activeUser :: Bool
|
||||
}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
@@ -87,6 +89,7 @@ data Contact = Contact
|
||||
contactUsed :: Bool,
|
||||
chatSettings :: ChatSettings,
|
||||
userPreferences :: Preferences,
|
||||
mergedPreferences :: ContactUserPreferences,
|
||||
createdAt :: UTCTime,
|
||||
updatedAt :: UTCTime
|
||||
}
|
||||
@@ -100,13 +103,10 @@ contactConn :: Contact -> Connection
|
||||
contactConn = activeConn
|
||||
|
||||
contactConnId :: Contact -> ConnId
|
||||
contactConnId Contact {activeConn} = aConnId activeConn
|
||||
contactConnId = aConnId . contactConn
|
||||
|
||||
contactConnIncognito :: Contact -> Bool
|
||||
contactConnIncognito = isJust . customUserProfileId'
|
||||
|
||||
customUserProfileId' :: Contact -> Maybe Int64
|
||||
customUserProfileId' Contact {activeConn} = customUserProfileId (activeConn :: Connection)
|
||||
contactConnIncognito = connIncognito . contactConn
|
||||
|
||||
data ContactRef = ContactRef
|
||||
{ contactId :: ContactId,
|
||||
@@ -207,6 +207,7 @@ data GroupInfo = GroupInfo
|
||||
{ groupId :: GroupId,
|
||||
localDisplayName :: GroupName,
|
||||
groupProfile :: GroupProfile,
|
||||
fullGroupPreferences :: FullGroupPreferences,
|
||||
membership :: GroupMember,
|
||||
hostConnCustomUserProfileId :: Maybe ProfileId,
|
||||
chatSettings :: ChatSettings,
|
||||
@@ -293,6 +294,39 @@ data Preferences = Preferences
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON Preferences where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
instance ToField Preferences where
|
||||
toField = toField . encodeJSON
|
||||
|
||||
instance FromField Preferences where
|
||||
fromField = fromTextField_ decodeJSON
|
||||
|
||||
groupPrefSel :: ChatFeature -> GroupPreferences -> Maybe GroupPreference
|
||||
groupPrefSel = \case
|
||||
CFFullDelete -> fullDelete
|
||||
-- CFReceipts -> receipts
|
||||
CFVoice -> voice
|
||||
|
||||
class GroupPreferenceI p where
|
||||
getGroupPreference :: ChatFeature -> p -> GroupPreference
|
||||
|
||||
instance GroupPreferenceI GroupPreferences where
|
||||
getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt prefs)
|
||||
|
||||
instance GroupPreferenceI (Maybe GroupPreferences) where
|
||||
getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs)
|
||||
|
||||
instance GroupPreferenceI FullGroupPreferences where
|
||||
getGroupPreference = \case
|
||||
CFFullDelete -> fullDelete
|
||||
-- CFReceipts -> receipts
|
||||
CFVoice -> voice
|
||||
{-# INLINE getGroupPreference #-}
|
||||
|
||||
-- collection of optional group preferences
|
||||
data GroupPreferences = GroupPreferences
|
||||
{ fullDelete :: Maybe GroupPreference,
|
||||
-- receipts :: Maybe GroupPreference,
|
||||
@@ -317,7 +351,20 @@ data FullPreferences = FullPreferences
|
||||
-- receipts :: Preference,
|
||||
voice :: Preference
|
||||
}
|
||||
deriving (Eq)
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
-- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing
|
||||
-- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here.
|
||||
data FullGroupPreferences = FullGroupPreferences
|
||||
{ fullDelete :: GroupPreference,
|
||||
-- receipts :: GroupPreference,
|
||||
voice :: GroupPreference
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
-- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences
|
||||
data ContactUserPreferences = ContactUserPreferences
|
||||
@@ -325,17 +372,17 @@ data ContactUserPreferences = ContactUserPreferences
|
||||
-- receipts :: ContactUserPreference,
|
||||
voice :: ContactUserPreference
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
data ContactUserPreference = ContactUserPreference
|
||||
{ enabled :: PrefEnabled,
|
||||
userPreference :: ContactUserPref,
|
||||
contactPreference :: Preference
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
data ContactUserPref = CUPContact {preference :: Preference} | CUPUser {preference :: Preference}
|
||||
deriving (Show, Generic)
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
@@ -364,26 +411,24 @@ defaultChatPrefs =
|
||||
emptyChatPrefs :: Preferences
|
||||
emptyChatPrefs = Preferences Nothing Nothing
|
||||
|
||||
instance ToJSON Preferences where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
instance ToField Preferences where
|
||||
toField = toField . encodeJSON
|
||||
|
||||
instance FromField Preferences where
|
||||
fromField = fromTextField_ decodeJSON
|
||||
defaultGroupPrefs :: FullGroupPreferences
|
||||
defaultGroupPrefs =
|
||||
FullGroupPreferences
|
||||
{ fullDelete = GroupPreference {enable = FEOff},
|
||||
-- receipts = GroupPreference {enable = FEOff},
|
||||
voice = GroupPreference {enable = FEOn}
|
||||
}
|
||||
|
||||
data Preference = Preference
|
||||
{allow :: FeatureAllowed}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data GroupPreference = GroupPreference
|
||||
{enable :: GroupFeatureEnabled}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON Preference where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data FeatureAllowed
|
||||
@@ -392,9 +437,6 @@ data FeatureAllowed
|
||||
| FANo -- do not allow
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
data GroupFeatureEnabled = FEOn | FEOff
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode
|
||||
|
||||
instance ToField FeatureAllowed where toField = toField . strEncode
|
||||
@@ -418,6 +460,9 @@ instance ToJSON FeatureAllowed where
|
||||
toJSON = strToJSON
|
||||
toEncoding = strToJEncoding
|
||||
|
||||
data GroupFeatureEnabled = FEOn | FEOff
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode
|
||||
|
||||
instance ToField GroupFeatureEnabled where toField = toField . strEncode
|
||||
@@ -452,12 +497,25 @@ mergePreferences contactPrefs userPreferences =
|
||||
in fromMaybe (getPreference pt defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel)
|
||||
|
||||
mergeUserChatPrefs :: User -> Contact -> FullPreferences
|
||||
mergeUserChatPrefs user ct =
|
||||
let userPrefs = if contactConnIncognito ct then Nothing else preferences' user
|
||||
in mergePreferences (Just $ userPreferences ct) userPrefs
|
||||
mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct)
|
||||
|
||||
mergeUserChatPrefs' :: User -> Bool -> Preferences -> FullPreferences
|
||||
mergeUserChatPrefs' user connectedIncognito userPreferences =
|
||||
let userPrefs = if connectedIncognito then Nothing else preferences' user
|
||||
in mergePreferences (Just userPreferences) userPrefs
|
||||
|
||||
mergeGroupPreferences :: Maybe GroupPreferences -> FullGroupPreferences
|
||||
mergeGroupPreferences groupPreferences =
|
||||
FullGroupPreferences
|
||||
{ fullDelete = pref CFFullDelete,
|
||||
-- receipts = pref CFReceipts,
|
||||
voice = pref CFVoice
|
||||
}
|
||||
where
|
||||
pref pt = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPreferences >>= groupPrefSel pt)
|
||||
|
||||
data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool}
|
||||
deriving (Show, Generic)
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON PrefEnabled where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
@@ -471,8 +529,8 @@ prefEnabled Preference {allow = user} Preference {allow = contact} = case (user,
|
||||
(FANo, _) -> PrefEnabled False False
|
||||
_ -> PrefEnabled True True
|
||||
|
||||
contactUserPreferences :: User -> Contact -> ContactUserPreferences
|
||||
contactUserPreferences user ct =
|
||||
contactUserPreferences :: User -> Preferences -> Maybe Preferences -> Bool -> ContactUserPreferences
|
||||
contactUserPreferences user userPreferences contactPreferences connectedIncognito =
|
||||
ContactUserPreferences
|
||||
{ fullDelete = pref CFFullDelete,
|
||||
-- receipts = pref CFReceipts,
|
||||
@@ -483,19 +541,19 @@ contactUserPreferences user ct =
|
||||
ContactUserPreference
|
||||
{ enabled = prefEnabled userPref ctPref,
|
||||
-- incognito contact cannot have default user preference used
|
||||
userPreference = if contactConnIncognito ct then CUPContact ctUserPref else maybe (CUPUser userPref) CUPContact ctUserPref_,
|
||||
userPreference = if connectedIncognito then CUPContact ctUserPref else maybe (CUPUser userPref) CUPContact ctUserPref_,
|
||||
contactPreference = ctPref
|
||||
}
|
||||
where
|
||||
ctUserPref = getPreference pt $ userPreferences ct
|
||||
ctUserPref_ = chatPrefSel pt $ userPreferences ct
|
||||
ctUserPref = getPreference pt userPreferences
|
||||
ctUserPref_ = chatPrefSel pt userPreferences
|
||||
userPref = getPreference pt ctUserPrefs
|
||||
ctPref = getPreference pt ctPrefs
|
||||
ctUserPrefs = mergeUserChatPrefs user ct
|
||||
ctPrefs = mergePreferences (preferences' ct) Nothing
|
||||
ctUserPrefs = mergeUserChatPrefs' user connectedIncognito userPreferences
|
||||
ctPrefs = mergePreferences contactPreferences Nothing
|
||||
|
||||
getContactUserPrefefence :: ChatFeature -> ContactUserPreferences -> ContactUserPreference
|
||||
getContactUserPrefefence = \case
|
||||
getContactUserPreference :: ChatFeature -> ContactUserPreferences -> ContactUserPreference
|
||||
getContactUserPreference = \case
|
||||
CFFullDelete -> fullDelete
|
||||
-- CFReceipts -> receipts
|
||||
CFVoice -> voice
|
||||
@@ -1144,6 +1202,9 @@ data Connection = Connection
|
||||
aConnId :: Connection -> ConnId
|
||||
aConnId Connection {agentConnId = AgentConnId cId} = cId
|
||||
|
||||
connIncognito :: Connection -> Bool
|
||||
connIncognito Connection {customUserProfileId} = isJust customUserProfileId
|
||||
|
||||
instance ToJSON Connection where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
@@ -1389,3 +1450,18 @@ encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode
|
||||
|
||||
decodeJSON :: FromJSON a => Text -> Maybe a
|
||||
decodeJSON = J.decode . LB.fromStrict . encodeUtf8
|
||||
|
||||
data ServerCfg = ServerCfg
|
||||
{ server :: SMPServerWithAuth,
|
||||
preset :: Bool,
|
||||
tested :: Maybe Bool,
|
||||
enabled :: Bool
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance ToJSON ServerCfg where
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
instance FromJSON ServerCfg where
|
||||
parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
@@ -18,6 +18,7 @@ import Data.Char (toUpper)
|
||||
import Data.Function (on)
|
||||
import Data.Int (Int64)
|
||||
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Maybe (isJust, isNothing, mapMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
@@ -37,6 +38,7 @@ import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
|
||||
import Simplex.Chat.Styled
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Client (SMPTestFailure (..), SMPTestStep (..))
|
||||
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
|
||||
import Simplex.Messaging.Agent.Protocol
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
@@ -64,7 +66,8 @@ responseToView user_ testView ts = \case
|
||||
CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats]
|
||||
CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat]
|
||||
CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft]
|
||||
CRUserSMPServers smpServers -> viewSMPServers smpServers testView
|
||||
CRUserSMPServers smpServers _ -> viewSMPServers (L.toList smpServers) testView
|
||||
CRSmpTestResult testFailure -> viewSMPTestResult testFailure
|
||||
CRChatItemTTL ttl -> viewChatItemTTL ttl
|
||||
CRNetworkConfig cfg -> viewNetworkConfig cfg
|
||||
CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile
|
||||
@@ -72,7 +75,7 @@ responseToView user_ testView ts = \case
|
||||
CRContactSwitch ct progress -> viewContactSwitch ct progress
|
||||
CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress
|
||||
CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts
|
||||
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems
|
||||
CRApiChatItems chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems
|
||||
CRChatItemStatusUpdated _ -> []
|
||||
CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item ts
|
||||
CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem ts
|
||||
@@ -125,13 +128,13 @@ responseToView user_ testView ts = \case
|
||||
CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts
|
||||
CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft
|
||||
CRUserProfileUpdated p p' -> viewUserProfileUpdated p p'
|
||||
CRContactPrefsUpdated {fromContact, toContact, preferences} -> case user_ of
|
||||
Just user -> viewUserContactPrefsUpdated user fromContact toContact preferences
|
||||
CRContactPrefsUpdated {fromContact, toContact} -> case user_ of
|
||||
Just user -> viewUserContactPrefsUpdated user fromContact toContact
|
||||
_ -> ["unexpected chat event CRContactPrefsUpdated without current user"]
|
||||
CRContactAliasUpdated c -> viewContactAliasUpdated c
|
||||
CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c
|
||||
CRContactUpdated {fromContact = c, toContact = c', preferences} -> case user_ of
|
||||
Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' preferences
|
||||
CRContactUpdated {fromContact = c, toContact = c'} -> case user_ of
|
||||
Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c'
|
||||
_ -> ["unexpected chat event CRContactUpdated without current user"]
|
||||
CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt
|
||||
CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile
|
||||
@@ -620,15 +623,16 @@ viewUserProfile Profile {displayName, fullName} =
|
||||
"(the updated profile will be sent to all your contacts)"
|
||||
]
|
||||
|
||||
viewSMPServers :: [SMPServerWithAuth] -> Bool -> [StyledString]
|
||||
viewSMPServers :: [ServerCfg] -> Bool -> [StyledString]
|
||||
viewSMPServers smpServers testView =
|
||||
if testView
|
||||
then [customSMPServers]
|
||||
else
|
||||
[ customSMPServers,
|
||||
"",
|
||||
"use " <> highlight' "/smp_servers <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
|
||||
"use " <> highlight' "/smp_servers default" <> " to remove custom SMP servers and use default",
|
||||
"use " <> highlight' "/smp test <srv>" <> " to test SMP server connection",
|
||||
"use " <> highlight' "/smp set <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
|
||||
"use " <> highlight' "/smp default" <> " to remove custom SMP servers and use default",
|
||||
"(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"
|
||||
]
|
||||
where
|
||||
@@ -637,6 +641,16 @@ viewSMPServers smpServers testView =
|
||||
then "no custom SMP servers saved"
|
||||
else viewServers smpServers
|
||||
|
||||
viewSMPTestResult :: Maybe SMPTestFailure -> [StyledString]
|
||||
viewSMPTestResult = \case
|
||||
Just SMPTestFailure {testStep, testError} ->
|
||||
result
|
||||
<> ["Server requires authentication to create queues, check password" | testStep == TSCreateQueue && testError == SMP SMP.AUTH]
|
||||
<> ["Possibly, certificate fingerprint in server address is incorrect" | testStep == TSConnect && testError == BROKER NETWORK]
|
||||
where
|
||||
result = ["SMP server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> plain (strEncode testError)]
|
||||
_ -> ["SMP server test passed"]
|
||||
|
||||
viewChatItemTTL :: Maybe Int64 -> [StyledString]
|
||||
viewChatItemTTL = \case
|
||||
Nothing -> ["old messages are not being deleted"]
|
||||
@@ -652,7 +666,7 @@ viewNetworkConfig :: NetworkConfig -> [StyledString]
|
||||
viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
|
||||
[ plain $ maybe "direct network connection" (("using SOCKS5 proxy " <>) . show) socksProxy,
|
||||
"TCP timeout: " <> sShow tcpTimeout,
|
||||
"use `/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]` to change settings"
|
||||
"use " <> highlight' "/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]" <> " to change settings"
|
||||
]
|
||||
|
||||
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
|
||||
@@ -677,8 +691,8 @@ viewConnectionStats ConnectionStats {rcvServers, sndServers} =
|
||||
["receiving messages via: " <> viewServerHosts rcvServers | not $ null rcvServers]
|
||||
<> ["sending messages via: " <> viewServerHosts sndServers | not $ null sndServers]
|
||||
|
||||
viewServers :: [SMPServerWithAuth] -> StyledString
|
||||
viewServers = plain . intercalate ", " . map (B.unpack . strEncode)
|
||||
viewServers :: [ServerCfg] -> StyledString
|
||||
viewServers = plain . intercalate ", " . map (B.unpack . strEncode . (\ServerCfg {server} -> server))
|
||||
|
||||
viewServerHosts :: [SMPServer] -> StyledString
|
||||
viewServerHosts = plain . intercalate ", " . map showSMPServer
|
||||
@@ -710,15 +724,15 @@ viewUserProfileUpdated Profile {displayName = n, fullName, image, preferences} P
|
||||
| otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified]
|
||||
notified = " (your contacts are notified)"
|
||||
|
||||
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString]
|
||||
viewUserContactPrefsUpdated user ct ct' cups
|
||||
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
|
||||
viewUserContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
|
||||
| null prefs = ["your preferences for " <> ttyContact' ct' <> " did not change"]
|
||||
| otherwise = ("you updated preferences for " <> ttyContact' ct' <> ":") : prefs
|
||||
where
|
||||
prefs = viewContactPreferences user ct ct' cups
|
||||
|
||||
viewContactPrefsUpdated :: User -> Contact -> Contact -> ContactUserPreferences -> [StyledString]
|
||||
viewContactPrefsUpdated user ct ct' cups
|
||||
viewContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]
|
||||
viewContactPrefsUpdated user ct ct'@Contact {mergedPreferences = cups}
|
||||
| null prefs = []
|
||||
| otherwise = (ttyContact' ct' <> " updated preferences for you:") : prefs
|
||||
where
|
||||
@@ -736,7 +750,7 @@ viewContactPref userPrefs userPrefs' ctPrefs cups pt
|
||||
userPref = getPreference pt userPrefs
|
||||
userPref' = getPreference pt userPrefs'
|
||||
ctPref = getPreference pt ctPrefs
|
||||
ContactUserPreference {enabled, userPreference, contactPreference} = getContactUserPrefefence pt cups
|
||||
ContactUserPreference {enabled, userPreference, contactPreference} = getContactUserPreference pt cups
|
||||
|
||||
viewPrefsUpdated :: Maybe Preferences -> Maybe Preferences -> [StyledString]
|
||||
viewPrefsUpdated ps ps'
|
||||
@@ -771,15 +785,36 @@ viewPrefEnabled = \case
|
||||
|
||||
viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString]
|
||||
viewGroupUpdated
|
||||
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image}}
|
||||
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image'}}
|
||||
m
|
||||
| n == n' && fullName == fullName' && image == image' = []
|
||||
| n == n' && fullName == fullName' = ["group " <> ttyGroup n <> ": profile image " <> (if isNothing image' then "removed" else "updated") <> byMember]
|
||||
| n == n' = ["group " <> ttyGroup n <> ": full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName' <> byMember]
|
||||
| otherwise = ["group " <> ttyGroup n <> " is changed to " <> ttyFullGroup g' <> byMember]
|
||||
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image, groupPreferences = gps}}
|
||||
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image', groupPreferences = gps'}}
|
||||
m = do
|
||||
let update = groupProfileUpdated <> groupPrefsUpdated
|
||||
if null update
|
||||
then []
|
||||
else memberUpdated <> update
|
||||
where
|
||||
byMember = maybe "" ((" by " <>) . ttyMember) m
|
||||
memberUpdated = maybe [] (\m' -> [ttyMember m' <> " updated group " <> ttyGroup n <> ":"]) m
|
||||
groupProfileUpdated
|
||||
| n == n' && fullName == fullName' && image == image' = []
|
||||
| n == n' && fullName == fullName' = ["profile image " <> (if isNothing image' then "removed" else "updated")]
|
||||
| n == n' = ["full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName']
|
||||
| otherwise = ["changed to " <> ttyFullGroup g']
|
||||
groupPrefsUpdated
|
||||
| null prefs = []
|
||||
| otherwise = "updated group preferences:" : prefs
|
||||
where
|
||||
prefs = mapMaybe viewPref allChatFeatures
|
||||
viewPref pt
|
||||
| pref gps == pref gps' = Nothing
|
||||
| otherwise = Just $ plain (chatPrefName pt) <> " enabled: " <> viewGroupPreference (pref gps')
|
||||
where
|
||||
pref pss = getGroupPreference pt $ mergeGroupPreferences pss
|
||||
|
||||
viewGroupPreference :: GroupPreference -> StyledString
|
||||
viewGroupPreference = \case
|
||||
GroupPreference {enable} -> case enable of
|
||||
FEOn -> "on"
|
||||
FEOff -> "off"
|
||||
|
||||
viewContactAliasUpdated :: Contact -> [StyledString]
|
||||
viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}}
|
||||
|
||||
@@ -49,7 +49,7 @@ extra-deps:
|
||||
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
||||
# - ../simplexmq
|
||||
- github: simplex-chat/simplexmq
|
||||
commit: 95db734b2d89bdf35e413f0abd4eac4ed3c64fc3
|
||||
commit: c2342cba057fa2333b5936a2254507b5b62e8de2
|
||||
# - ../direct-sqlcipher
|
||||
- github: simplex-chat/direct-sqlcipher
|
||||
commit: 34309410eb2069b029b8fc1872deb1e0db123294
|
||||
|
||||
@@ -25,7 +25,7 @@ import Simplex.Chat.Options
|
||||
import Simplex.Chat.Store
|
||||
import Simplex.Chat.Terminal
|
||||
import Simplex.Chat.Terminal.Output (newChatTerminal)
|
||||
import Simplex.Chat.Types (Profile, User (..))
|
||||
import Simplex.Chat.Types (Profile, ServerCfg (..), User (..))
|
||||
import Simplex.Messaging.Agent.Env.SQLite
|
||||
import Simplex.Messaging.Agent.RetryInterval
|
||||
import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig)
|
||||
@@ -51,7 +51,7 @@ testOpts =
|
||||
{ dbFilePrefix = undefined,
|
||||
dbKey = "",
|
||||
-- dbKey = "this is a pass-phrase to encrypt the database",
|
||||
smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001"],
|
||||
smpServers = [ServerCfg "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" False Nothing True],
|
||||
networkConfig = defaultNetworkConfig,
|
||||
logConnections = False,
|
||||
logServerHosts = False,
|
||||
|
||||
@@ -74,7 +74,7 @@ chatTests = do
|
||||
describe "async group connections" $ do
|
||||
xit "create and join group when clients go offline" testGroupAsync
|
||||
describe "user profiles" $ do
|
||||
it "update user profiles and notify contacts" testUpdateProfile
|
||||
it "update user profile and notify contacts" testUpdateProfile
|
||||
it "update user profile with image" testUpdateProfileImage
|
||||
describe "sending and receiving files" $ do
|
||||
describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer
|
||||
@@ -112,12 +112,15 @@ chatTests = do
|
||||
it "join group incognito" testJoinGroupIncognito
|
||||
it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito
|
||||
it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito
|
||||
describe "contact aliases and prefs" $ do
|
||||
describe "contact aliases" $ do
|
||||
it "set contact alias" testSetAlias
|
||||
it "set connection alias" testSetConnectionAlias
|
||||
it "set contact prefs" testSetContactPrefs
|
||||
describe "SMP servers" $
|
||||
describe "preferences" $ do
|
||||
it "set contact preferences" testSetContactPrefs
|
||||
it "update group preferences" testUpdateGroupPrefs
|
||||
describe "SMP servers" $ do
|
||||
it "get and set SMP servers" testGetSetSMPServers
|
||||
it "test SMP server connection" testTestSMPServerConnection
|
||||
describe "async connection handshake" $ do
|
||||
it "connect when initiating client goes offline" testAsyncInitiatingOffline
|
||||
it "connect when accepting client goes offline" testAsyncAcceptingOffline
|
||||
@@ -1297,10 +1300,15 @@ testUpdateGroupProfile =
|
||||
bob ##> "/gp team my_team"
|
||||
bob <## "you have insufficient permissions for this group command"
|
||||
alice ##> "/gp team my_team"
|
||||
alice <## "group #team is changed to #my_team"
|
||||
concurrently_
|
||||
(bob <## "group #team is changed to #my_team by alice")
|
||||
(cath <## "group #team is changed to #my_team by alice")
|
||||
alice <## "changed to #my_team"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "changed to #my_team",
|
||||
do
|
||||
cath <## "alice updated group #team:"
|
||||
cath <## "changed to #my_team"
|
||||
]
|
||||
bob #> "#my_team hi"
|
||||
concurrently_
|
||||
(alice <# "#my_team bob> hi")
|
||||
@@ -2862,19 +2870,63 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $
|
||||
bob <## "alice updated preferences for you:"
|
||||
bob <## "full message deletion: off (you allow: default (yes), contact allows: no)"
|
||||
|
||||
testUpdateGroupPrefs :: IO ()
|
||||
testUpdateGroupPrefs =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
createGroup2 "team" alice bob
|
||||
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"on\"}}}"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "full message deletion enabled: on"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "full message deletion enabled: on"
|
||||
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"off\"}}}"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "full message deletion enabled: off"
|
||||
alice <## "voice messages enabled: off"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "full message deletion enabled: off"
|
||||
bob <## "voice messages enabled: off"
|
||||
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "voice messages enabled: on"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "voice messages enabled: on"
|
||||
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}"
|
||||
-- no update
|
||||
alice #> "#team hey"
|
||||
bob <# "#team alice> hey"
|
||||
bob #> "#team hi"
|
||||
alice <# "#team bob> hi"
|
||||
|
||||
testGetSetSMPServers :: IO ()
|
||||
testGetSetSMPServers =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice _ -> do
|
||||
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
|
||||
alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok")
|
||||
alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im")
|
||||
alice #$> ("/smp_servers smp://1234-w==:password@smp1.example.im", id, "ok")
|
||||
alice #$> ("/smp_servers", id, "smp://1234-w==:password@smp1.example.im")
|
||||
alice #$> ("/smp_servers smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok")
|
||||
alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
|
||||
alice #$> ("/smp_servers default", id, "ok")
|
||||
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
|
||||
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001")
|
||||
alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok")
|
||||
alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im")
|
||||
alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok")
|
||||
alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
|
||||
alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok")
|
||||
alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
|
||||
alice #$> ("/smp default", id, "ok")
|
||||
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001")
|
||||
|
||||
testTestSMPServerConnection :: IO ()
|
||||
testTestSMPServerConnection =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice _ -> do
|
||||
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"
|
||||
alice <## "SMP server test passed"
|
||||
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001"
|
||||
alice <## "SMP server test passed"
|
||||
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZwjI=@localhost:5001"
|
||||
alice <## "SMP server test failed at Connect, error: BROKER NETWORK"
|
||||
alice <## "Possibly, certificate fingerprint in server address is incorrect"
|
||||
|
||||
testAsyncInitiatingOffline :: IO ()
|
||||
testAsyncInitiatingOffline = withTmpFiles $ do
|
||||
|
||||
@@ -32,9 +32,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\"
|
||||
|
||||
activeUser :: String
|
||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}}"
|
||||
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}"
|
||||
#else
|
||||
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"activeUser\":true}}}"
|
||||
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}"
|
||||
#endif
|
||||
|
||||
chatStarted :: String
|
||||
|
||||
Reference in New Issue
Block a user