diff --git a/README.md b/README.md
index 78050b2969..28f741a31d 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
[](https://github.com/simplex-chat/simplex-chat/releases)
[](https://github.com/simplex-chat/simplex-chat/releases)
-[](https://twitter.com/simplexchat)
+[](https://twitter.com/SimpleXChat)
[](https://www.reddit.com/r/SimpleXChat)
[
](https://apps.apple.com/us/app/simplex-chat/id1605771084)
diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
index ac8b055c95..7f61f6c7ac 100644
--- a/apps/android/app/build.gradle
+++ b/apps/android/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
- versionCode 9
- versionName "0.4.2"
+ versionCode 11
+ versionName "1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
index 28e5fb91a4..a8f0ada2be 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -1,10 +1,8 @@
package chat.simplex.app
import android.app.Application
-import android.net.*
+import android.net.LocalServerSocket
import android.util.Log
-import androidx.lifecycle.*
-import androidx.work.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.withApi
import java.io.BufferedReader
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
index 92c96064c8..e8202f5c0b 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -11,11 +11,10 @@ import chat.simplex.app.ui.theme.SecretColor
import chat.simplex.app.ui.theme.SimplexBlue
import kotlinx.datetime.*
import kotlinx.serialization.*
-import kotlinx.serialization.builtins.IntArraySerializer
import kotlinx.serialization.descriptors.*
-import kotlinx.serialization.encoding.*
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
-import kotlinx.serialization.modules.SerializersModule
class ChatModel(val controller: ChatController) {
var currentUser = mutableStateOf(null)
@@ -27,6 +26,7 @@ class ChatModel(val controller: ChatController) {
var connReqInvitation: String? = null
var terminalItems = mutableStateListOf()
var userAddress = mutableStateOf(null)
+ var userSMPServers = mutableStateOf<(List)?>(null)
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf(null)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
index aac062974b..147cf888b0 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -27,6 +27,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
try {
apiStartChat()
chatModel.userAddress.value = apiGetUserAddress()
+ chatModel.userSMPServers.value = getUserSMPServers()
chatModel.chats.addAll(apiGetChats())
chatModel.currentUser = mutableStateOf(u)
chatModel.userCreated.value = true
@@ -128,6 +129,28 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
return null
}
+ suspend fun getUserSMPServers(): List? {
+ val r = sendCmd(CC.GetUserSMPServers())
+ if (r is CR.UserSMPServers) return r.smpServers
+ Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun setUserSMPServers(smpServers: List): Boolean {
+ val r = sendCmd(CC.SetUserSMPServers(smpServers))
+ return when (r) {
+ is CR.CmdOk -> true
+ else -> {
+ Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}")
+ AlertManager.shared.showAlertMsg(
+ "Error saving SMP servers",
+ "Make sure SMP server addresses are in correct format, line separated and are not duplicated"
+ )
+ false
+ }
+ }
+ }
+
suspend fun apiAddContact(): String? {
val r = sendCmd(CC.AddContact())
if (r is CR.Invitation) return r.connReqInvitation
@@ -320,6 +343,8 @@ sealed class CC {
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
+ class GetUserSMPServers(): CC()
+ class SetUserSMPServers(val smpServers: List): CC()
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
@@ -339,6 +364,8 @@ sealed class CC {
is ApiGetChats -> "/_get chats"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
+ is GetUserSMPServers -> "/smp_servers"
+ is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
@@ -359,6 +386,8 @@ sealed class CC {
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
+ is GetUserSMPServers -> "getUserSMPServers"
+ is SetUserSMPServers -> "setUserSMPServers"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
@@ -375,6 +404,8 @@ sealed class CC {
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
+
+ fun smpServersStr(smpServers: List) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ",")
}
}
@@ -412,6 +443,7 @@ sealed class CR {
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
@Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
+ @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR()
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
@@ -449,6 +481,7 @@ sealed class CR {
is ChatRunning -> "chatRunning"
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
+ is UserSMPServers -> "userSMPServers"
is Invitation -> "invitation"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
@@ -487,6 +520,7 @@ sealed class CR {
is ChatRunning -> noDetails()
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
+ is UserSMPServers -> json.encodeToString(smpServers)
is Invitation -> connReqInvitation
is SentConfirmation -> noDetails()
is SentInvitation -> noDetails()
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
index 35c3661734..52b9948c13 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
@@ -21,12 +21,12 @@ val Typography = Typography(
h3 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
- fontSize = 20.sp
+ fontSize = 19.sp
),
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
- fontSize = 18.sp
+ fontSize = 17.sp
),
body2 = TextStyle(
fontFamily = FontFamily.Default,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index d509cd97b3..c53f83d552 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -11,6 +11,8 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.mapSaver
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
@@ -22,8 +24,7 @@ import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
-import chat.simplex.app.views.helpers.ChatInfoImage
-import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ModalManager
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
@@ -80,7 +81,10 @@ fun ChatLayout(
info: () -> Unit,
sendMessage: (String) -> Unit
) {
- Surface(Modifier.fillMaxWidth().background(MaterialTheme.colors.background)) {
+ Surface(
+ Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.background)) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
@@ -135,9 +139,25 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
}
}
+data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
+
+val CIListStateSaver = run {
+ val scrolledKey = "scrolled"
+ val countKey = "itemCount"
+ val keyboardKey = "keyboardState"
+ mapSaver(
+ save = { mapOf(scrolledKey to it.scrolled, countKey to it.itemCount, keyboardKey to it.keyboardState) },
+ restore = { CIListState(it[scrolledKey] as Boolean, it[countKey] as Int, it[keyboardKey] as KeyboardState) }
+ )
+}
+
@Composable
fun ChatItemsList(chatItems: List) {
val listState = rememberLazyListState()
+ val keyboardState by getKeyboardState()
+ val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
+ mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
+ }
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
LazyColumn(state = listState) {
@@ -145,8 +165,9 @@ fun ChatItemsList(chatItems: List) {
ChatItemView(cItem, uriHandler)
}
val len = chatItems.count()
- if (len > 1) {
+ if (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount) {
scope.launch {
+ ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
index df2528dd25..d21d0749ee 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -58,7 +59,7 @@ fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMembe
@Composable
fun MarkdownText (
chatItem: ChatItem,
- style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface),
+ style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
index 8f148fbd3b..454e880837 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt
@@ -1,6 +1,40 @@
package chat.simplex.app.views.helpers
+import android.graphics.Rect
+import android.view.ViewTreeObserver
+import androidx.compose.runtime.*
+import androidx.compose.ui.platform.LocalView
import kotlinx.coroutines.*
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch { withContext(Dispatchers.Main, action) }
+
+enum class KeyboardState {
+ Opened, Closed
+}
+
+@Composable
+fun getKeyboardState(): State {
+ val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
+ val view = LocalView.current
+ DisposableEffect(view) {
+ val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
+ val rect = Rect()
+ view.getWindowVisibleDisplayFrame(rect)
+ val screenHeight = view.rootView.height
+ val keypadHeight = screenHeight - rect.bottom
+ keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
+ KeyboardState.Opened
+ } else {
+ KeyboardState.Closed
+ }
+ }
+ view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
+
+ onDispose {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
+ }
+ }
+
+ return keyboardState
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
index be27d1d6e0..d3a416c0d4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
@@ -15,6 +15,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -34,35 +35,44 @@ fun AddContactView(chatModel: ChatModel) {
@Composable
fun AddContactLayout(connReq: String, share: () -> Unit) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Text(
- "Add contact",
- style = MaterialTheme.typography.h1,
- )
- Text(
- "Show QR code to your contact\nto scan from the app",
- style = MaterialTheme.typography.h2,
- textAlign = TextAlign.Center,
- )
- QRCode(connReq)
- Text(
- buildAnnotatedString {
- append("If you cannot meet in person, you can ")
- withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
- append("scan QR code in the video call")
- }
- append(", or you can share the invitation link via any other channel.")
- },
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.caption,
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .padding(bottom = 16.dp)
- )
- SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
+ BoxWithConstraints {
+ val screenHeight = maxHeight
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ "Add contact",
+ style = MaterialTheme.typography.h1,
+ )
+ Text(
+ "Show QR code to your contact\nto scan from the app",
+ style = MaterialTheme.typography.h2.copy(fontSize = if(screenHeight > 600.dp) 26.sp else 20.sp),
+ textAlign = TextAlign.Center,
+ )
+ QRCode(
+ connReq, Modifier
+ .weight(1f, fill = false)
+ .aspectRatio(1f)
+ .padding(vertical = 3.dp)
+ )
+ Text(
+ buildAnnotatedString {
+ append("If you cannot meet in person, you can ")
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ append("scan QR code in the video call")
+ }
+ append(", or you can share the invitation link via any other channel.")
+ },
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.caption.copy(fontSize=if(screenHeight > 600.dp) 20.sp else 16.sp),
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp)
+ )
+ SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
+ Spacer(Modifier.height(10.dp))
+ }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt
index 6dee26d107..cbc56268f4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -12,10 +13,11 @@ import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
@Composable
-fun QRCode(connReq: String) {
+fun QRCode(connReq: String, modifier: Modifier = Modifier) {
Image(
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
- contentDescription = "QR Code"
+ contentDescription = "QR Code",
+ modifier = modifier
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
new file mode 100644
index 0000000000..548762886b
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
@@ -0,0 +1,300 @@
+package chat.simplex.app.views.usersettings
+
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.OpenInNew
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.*
+import chat.simplex.app.views.helpers.AlertManager
+import chat.simplex.app.views.helpers.withApi
+
+@Composable
+fun SMPServersView(chatModel: ChatModel) {
+ val userSMPServers = chatModel.userSMPServers.value
+ if (userSMPServers != null) {
+ var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
+ var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
+ var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
+ fun saveSMPServers(smpServers: List) {
+ withApi {
+ val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
+ if (r) {
+ chatModel.userSMPServers.value = smpServers
+ if (smpServers.isEmpty()) {
+ isUserSMPServers = false
+ editSMPServers = true
+ } else {
+ editSMPServers = false
+ }
+ }
+ }
+ }
+
+ SMPServersLayout(
+ isUserSMPServers = isUserSMPServers,
+ editSMPServers = editSMPServers,
+ userSMPServersStr = userSMPServersStr,
+ isUserSMPServersOnOff = { switch ->
+ if (switch) {
+ isUserSMPServers = true
+ } else {
+ val userSMPServers = chatModel.userSMPServers.value
+ if (userSMPServers != null) {
+ if (userSMPServers.isNotEmpty()) {
+ AlertManager.shared.showAlertMsg(
+ title = "Use SimpleX Chat servers?",
+ text = "Saved SMP servers will be removed",
+ confirmText = "Confirm",
+ onConfirm = {
+ saveSMPServers(listOf())
+ isUserSMPServers = false
+ userSMPServersStr = ""
+ }
+ )
+ } else {
+ isUserSMPServers = false
+ userSMPServersStr = ""
+ }
+ }
+ }
+ },
+ editUserSMPServersStr = { userSMPServersStr = it },
+ cancelEdit = {
+ val userSMPServers = chatModel.userSMPServers.value
+ if (userSMPServers != null) {
+ isUserSMPServers = userSMPServers.isNotEmpty()
+ editSMPServers = !isUserSMPServers
+ userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
+ }
+ },
+ saveSMPServers = { saveSMPServers(it) },
+ editOn = { editSMPServers = true },
+ )
+ }
+}
+
+@Composable
+fun SMPServersLayout(
+ isUserSMPServers: Boolean,
+ editSMPServers: Boolean,
+ userSMPServersStr: String,
+ isUserSMPServersOnOff: (Boolean) -> Unit,
+ editUserSMPServersStr: (String) -> Unit,
+ cancelEdit: () -> Unit,
+ saveSMPServers: (List) -> Unit,
+ editOn: () -> Unit,
+) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "Your SMP servers",
+ Modifier.padding(bottom = 24.dp),
+ style = MaterialTheme.typography.h1
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Configure SMP servers", Modifier.padding(end = 24.dp))
+ Switch(
+ checked = isUserSMPServers,
+ onCheckedChange = isUserSMPServersOnOff,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colors.primary,
+ uncheckedThumbColor = HighOrLowlight
+ ),
+ )
+ }
+
+ if (!isUserSMPServers) {
+ Text("Using SimpleX Chat servers.")
+ } else {
+ Text("Enter one SMP server per line:")
+ if (editSMPServers) {
+ BasicTextField(
+ value = userSMPServersStr,
+ onValueChange = editUserSMPServersStr,
+ textStyle = TextStyle(
+ fontFamily = FontFamily.Monospace, fontSize = 14.sp,
+ color = MaterialTheme.colors.onBackground
+ ),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ modifier = Modifier.height(160.dp),
+ cursorBrush = SolidColor(HighOrLowlight),
+ decorationBox = { innerTextField ->
+ Surface(
+ shape = RoundedCornerShape(10.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
+ ) {
+ Row(
+ Modifier.background(MaterialTheme.colors.background),
+ verticalAlignment = Alignment.Top
+ ) {
+ Box(
+ Modifier
+ .weight(1f)
+ .padding(vertical = 5.dp, horizontal = 7.dp)
+ ) {
+ innerTextField()
+ }
+ }
+ }
+ }
+ )
+
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(horizontalAlignment = Alignment.Start) {
+ Row {
+ Text(
+ "Cancel",
+ color = MaterialTheme.colors.primary,
+ modifier = Modifier
+ .clickable(onClick = cancelEdit)
+ )
+ Spacer(Modifier.padding(horizontal = 8.dp))
+ Text(
+ "Save",
+ color = MaterialTheme.colors.primary,
+ modifier = Modifier.clickable(onClick = {
+ val servers = userSMPServersStr.split("\n")
+ saveSMPServers(servers)
+ })
+ )
+ }
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ howToButton()
+ }
+ }
+ } else {
+ Surface(
+ modifier = Modifier
+ .height(160.dp)
+ .fillMaxWidth(),
+ shape = RoundedCornerShape(10.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
+ ) {
+ SelectionContainer(
+ Modifier.verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ userSMPServersStr,
+ Modifier
+ .padding(vertical = 5.dp, horizontal = 7.dp),
+ style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
+ )
+ }
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(horizontalAlignment = Alignment.Start) {
+ Text(
+ "Edit",
+ color = MaterialTheme.colors.primary,
+ modifier = Modifier
+ .clickable(onClick = editOn)
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ howToButton()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun howToButton() {
+ val uriHandler = LocalUriHandler.current
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
+ ) {
+ Text("How to", color = MaterialTheme.colors.primary)
+ Icon(
+ Icons.Outlined.OpenInNew, "How to", tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(horizontal = 5.dp)
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewSMPServersLayoutDefaultServers() {
+ SimpleXTheme {
+ SMPServersLayout(
+ isUserSMPServers = false,
+ editSMPServers = true,
+ userSMPServersStr = "",
+ isUserSMPServersOnOff = {},
+ editUserSMPServersStr = {},
+ cancelEdit = {},
+ saveSMPServers = {},
+ editOn = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewSMPServersLayoutUserServersEditOn() {
+ SimpleXTheme {
+ SMPServersLayout(
+ isUserSMPServers = true,
+ editSMPServers = true,
+ userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
+ isUserSMPServersOnOff = {},
+ editUserSMPServersStr = {},
+ cancelEdit = {},
+ saveSMPServers = {},
+ editOn = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewSMPServersLayoutUserServersEditOff() {
+ SimpleXTheme {
+ SMPServersLayout(
+ isUserSMPServers = true,
+ editSMPServers = false,
+ userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
+ isUserSMPServersOnOff = {},
+ editUserSMPServersStr = {},
+ cancelEdit = {},
+ saveSMPServers = {},
+ editOn = {},
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
index b9b4e69cc5..0013f2df35 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
@@ -99,6 +99,7 @@ fun SettingsLayout(
Spacer(Modifier.padding(horizontal = 4.dp))
Text("How to use SimpleX Chat")
}
+ Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { MarkdownHelpView() }) {
Icon(
Icons.Outlined.TextFormat,
@@ -133,6 +134,15 @@ fun SettingsLayout(
}
Spacer(Modifier.height(24.dp))
+ SettingsSectionView(showModal { SMPServersView(it) }) {
+ Icon(
+ Icons.Outlined.Dns,
+ contentDescription = "SMP servers",
+ )
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text("SMP servers")
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
index a6a36bf16f..7c40fad557 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
@@ -59,27 +59,27 @@ fun UserAddressLayout(
) {
Text(
"Your chat address",
- Modifier.padding(bottom = 24.dp),
+ Modifier.padding(bottom = 16.dp),
style = MaterialTheme.typography.h1,
)
Text(
"You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
"and if you later delete it - you won't lose your contacts.",
- Modifier.padding(bottom = 24.dp),
+ Modifier.padding(bottom = 12.dp),
)
Column(
- Modifier
- .fillMaxWidth()
- .padding(top = 12.dp),
+ Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(24.dp)
+ verticalArrangement = Arrangement.SpaceEvenly
) {
if (userAddress == null) {
SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
} else {
- QRCode(userAddress)
+ QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
- horizontalArrangement = Arrangement.spacedBy(12.dp)
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 10.dp)
) {
SimpleButton(
"Share link",
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index 3d38c4c7ec..fd9a70e0ee 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -18,6 +18,7 @@ struct ContentView: View {
.onAppear {
do {
try apiStartChat()
+ chatModel.userSMPServers = try getUserSMPServers()
chatModel.chats = try apiGetChats()
} catch {
fatalError("Failed to start or load chats: \(error)")
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 3361c9b710..8e7c6090a4 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -21,6 +21,7 @@ final class ChatModel: ObservableObject {
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: String?
+ @Published var userSMPServers: [String]?
@Published var appOpenUrl: URL?
var messageDelivery: Dictionary Void> = [:]
@@ -529,7 +530,7 @@ struct ChatItem: Identifiable, Decodable {
var id: Int64 { get { meta.itemId } }
- var timestampText: String { get { meta.timestampText } }
+ var timestampText: Text { get { meta.timestampText } }
func isRcvNew() -> Bool {
if case .rcvNew = meta.itemStatus { return true }
@@ -570,7 +571,7 @@ struct CIMeta: Decodable {
var itemStatus: CIStatus
var createdAt: Date
- var timestampText: String { get { SimpleX.timestampText(itemTs) } }
+ var timestampText: Text { get { SimpleX.timestampText(itemTs) } }
static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta {
CIMeta(
@@ -583,13 +584,14 @@ struct CIMeta: Decodable {
}
}
+let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
+let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
-func timestampText(_ date: Date) -> String {
+func timestampText(_ date: Date) -> Text {
let now = Calendar.current.dateComponents([.day, .hour], from: .now)
let dc = Calendar.current.dateComponents([.day, .hour], from: date)
- return now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12)
- ? date.formatted(date: .omitted, time: .shortened)
- : String(date.formatted(date: .numeric, time: .omitted).prefix(5))
+ let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12)
+ return Text(date, format: recent ? msgTimeFormat : msgDateFormat)
}
enum CIStatus: Decodable {
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index a6a45a8f1c..0893c1c8a3 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -22,6 +22,8 @@ enum ChatCommand {
case apiGetChats
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
+ case getUserSMPServers
+ case setUserSMPServers(smpServers: [String])
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
@@ -43,6 +45,8 @@ enum ChatCommand {
case .apiGetChats: return "/_get chats"
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)"
+ case .getUserSMPServers: return "/smp_servers"
+ case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
@@ -67,6 +71,8 @@ enum ChatCommand {
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
+ case .getUserSMPServers: return "getUserSMPServers"
+ case .setUserSMPServers: return "setUserSMPServers"
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
@@ -85,6 +91,10 @@ enum ChatCommand {
func ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
}
+
+ func smpServersStr(smpServers: [String]) -> String {
+ smpServers.isEmpty ? "default" : smpServers.joined(separator: ",")
+ }
}
struct APIResponse: Decodable {
@@ -98,6 +108,7 @@ enum ChatResponse: Decodable, Error {
case chatRunning
case apiChats(chats: [ChatData])
case apiChat(chat: ChatData)
+ case userSMPServers(smpServers: [String])
case invitation(connReqInvitation: String)
case sentConfirmation
case sentInvitation
@@ -135,6 +146,7 @@ enum ChatResponse: Decodable, Error {
case .chatRunning: return "chatRunning"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
+ case .userSMPServers: return "userSMPServers"
case .invitation: return "invitation"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
@@ -175,6 +187,7 @@ enum ChatResponse: Decodable, Error {
case .chatRunning: 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 .invitation(connReqInvitation): return connReqInvitation
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
@@ -372,6 +385,18 @@ func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) async throws ->
throw r
}
+func getUserSMPServers() throws -> [String] {
+ let r = chatSendCmdSync(.getUserSMPServers)
+ if case let .userSMPServers(smpServers) = r { return smpServers }
+ throw r
+}
+
+func setUserSMPServers(smpServers: [String]) async throws {
+ let r = await chatSendCmd(.setUserSMPServers(smpServers: smpServers))
+ if case .cmdOk = r { return }
+ throw r
+}
+
func apiAddContact() throws -> String {
let r = chatSendCmdSync(.addContact, bgTask: false)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index 5aa6b98eab..08112976fc 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -25,7 +25,7 @@ struct CIMetaView: View {
default: EmptyView()
}
- Text(chatItem.timestampText)
+ chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift
index e8f9b5b551..4a79c1f423 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift
@@ -55,8 +55,8 @@ struct TextItemView: View {
}
}
- private func reserveSpaceForMeta(_ meta: String) -> Text {
- Text(" \(meta)")
+ private func reserveSpaceForMeta(_ meta: Text) -> Text {
+ (Text(" ") + meta)
.font(.caption)
.foregroundColor(.clear)
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 39f076b5b5..385c909dfd 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -39,7 +39,7 @@ struct ChatPreviewView: View {
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
- Text(cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt))
+ (cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
index d66af40c6b..2eedbdeb49 100644
--- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
@@ -28,7 +28,7 @@ struct ContactRequestView: View {
.padding(.top, 4)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
- Text(timestampText(contactRequest.createdAt))
+ timestampText(contactRequest.createdAt)
.font(.subheadline)
.padding(.trailing, 8)
.padding(.top, 4)
diff --git a/apps/ios/Shared/Views/UserSettings/SMPServers.swift b/apps/ios/Shared/Views/UserSettings/SMPServers.swift
new file mode 100644
index 0000000000..861a934980
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/SMPServers.swift
@@ -0,0 +1,180 @@
+//
+// SMPServers.swift
+// SimpleX
+//
+// Created by Efim Poberezkin on 02.03.2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+private let serversFont = Font.custom("Menlo", size: 14)
+
+private let howToUrl = URL(string: "https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent")!
+
+struct SMPServers: View {
+ @EnvironmentObject var chatModel: ChatModel
+ @State var isUserSMPServers = false
+ @State var isUserSMPServersToggle = false
+ @State var editSMPServers = true
+ @State var userSMPServersStr = ""
+ @State var showBadServersAlert = false
+ @State var showResetServersAlert = false
+ @FocusState private var keyboardVisible: Bool
+
+ var body: some View {
+ return VStack(alignment: .leading) {
+ Toggle("Configure SMP servers", isOn: $isUserSMPServersToggle)
+ .onChange(of: isUserSMPServersToggle) { _ in
+ if (isUserSMPServersToggle) {
+ isUserSMPServers = true
+ } else {
+ let servers = chatModel.userSMPServers ?? []
+ if (!servers.isEmpty) {
+ showResetServersAlert = true
+ } else {
+ isUserSMPServers = false
+ userSMPServersStr = ""
+ }
+ }
+ }
+ .padding(.bottom)
+ .alert(isPresented: $showResetServersAlert) {
+ Alert(
+ title: Text("Use SimpleX Chat servers?"),
+ message: Text("Saved SMP servers will be removed"),
+ primaryButton: .destructive(Text("Confirm")) {
+ saveSMPServers(smpServers: [])
+ isUserSMPServers = false
+ userSMPServersStr = ""
+ }, secondaryButton: .cancel() {
+ withAnimation() {
+ isUserSMPServersToggle = true
+ }
+ }
+ )
+ }
+
+ if !isUserSMPServers {
+ Text("Using SimpleX Chat servers.")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ VStack(alignment: .leading) {
+ Text("Enter one SMP server per line:")
+ if editSMPServers {
+ TextEditor(text: $userSMPServersStr)
+ .focused($keyboardVisible)
+ .font(serversFont)
+ .disableAutocorrection(true)
+ .textInputAutocapitalization(.never)
+ .padding(.horizontal, 5)
+ .padding(.top, 2)
+ .frame(height: 112)
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
+ )
+ HStack(spacing: 20) {
+ Button("Cancel") {
+ initialize()
+ }
+ Button("Save") {
+ saveUserSMPServers()
+ }
+ .alert(isPresented: $showBadServersAlert) {
+ Alert(title: Text("Error saving SMP servers"), message: Text("Make sure SMP server addresses are in correct format, line separated and are not duplicated"))
+ }
+ Spacer()
+ howToButton()
+ }
+ } else {
+ ScrollView {
+ Text(userSMPServersStr)
+ .font(serversFont)
+ .padding(10)
+ .frame(minHeight: 0, alignment: .topLeading)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(height: 160)
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
+ )
+ HStack {
+ Button("Edit") {
+ editSMPServers = true
+ }
+ Spacer()
+ howToButton()
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding()
+ .frame(maxHeight: .infinity, alignment: .top)
+ .onAppear { initialize() }
+ }
+
+ func initialize() {
+ let userSMPServers = chatModel.userSMPServers ?? []
+ isUserSMPServers = !userSMPServers.isEmpty
+ isUserSMPServersToggle = isUserSMPServers
+ editSMPServers = !isUserSMPServers
+ userSMPServersStr = isUserSMPServers ? userSMPServers.joined(separator: "\n") : ""
+ }
+
+ func saveUserSMPServers() {
+ let userSMPServers = userSMPServersStr.components(separatedBy: "\n")
+ saveSMPServers(smpServers: userSMPServers)
+ }
+
+ func saveSMPServers(smpServers: [String]) {
+ Task {
+ do {
+ try await setUserSMPServers(smpServers: smpServers)
+ DispatchQueue.main.async {
+ chatModel.userSMPServers = smpServers
+ if smpServers.isEmpty {
+ isUserSMPServers = false
+ editSMPServers = true
+ } else {
+ editSMPServers = false
+ }
+ }
+ } catch {
+ let err = error.localizedDescription
+ logger.error("SMPServers.saveServers setUserSMPServers error: \(err)")
+ DispatchQueue.main.async {
+ showBadServersAlert = true
+ }
+ }
+ }
+ }
+
+ func howToButton() -> some View {
+ Button {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(howToUrl)
+ }
+ } label: {
+ HStack{
+ Text("How to")
+ Image(systemName: "arrow.up.right.circle")
+ }
+ }
+ }
+}
+
+// TODO preview doesn't work
+struct SMPServers_Previews: PreviewProvider {
+ static var previews: some View {
+ let chatModel = ChatModel()
+ chatModel.currentUser = User.sampleData
+ chatModel.userSMPServers = []
+ return SMPServers()
+ .environmentObject(chatModel)
+ }
+}
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index 0dd85b7aae..193e014db6 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -47,6 +47,19 @@ struct SettingsView: View {
}
}
}
+
+ Section("Settings") {
+ NavigationLink {
+ SMPServers()
+ .navigationTitle("Your SMP servers")
+ } label: {
+ HStack {
+ Image(systemName: "server.rack")
+ .padding(.trailing, 4)
+ Text("SMP servers")
+ }
+ }
+ }
Section("Help") {
NavigationLink {
diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift
index fc57cca555..3f2d80000f 100644
--- a/apps/ios/Shared/Views/WelcomeView.swift
+++ b/apps/ios/Shared/Views/WelcomeView.swift
@@ -23,14 +23,14 @@ struct WelcomeView: View {
Text("You control your chat!")
.font(.title)
.padding(.bottom)
- Text("The messaging and application platform protecting your privacy and security.")
+ Text("The messaging and application platform 100% private by design!")
+ .padding(.bottom, 8)
+ Text("Your profile, contacts and messages (once delivered) are only stored locally on your device.")
.padding(.bottom, 8)
- Text("We don't store any of your contacts or messages (once delivered) on the servers.")
- .padding(.bottom, 32)
Text("Create profile")
.font(.largeTitle)
- .padding(.bottom)
- Text("Your profile is stored on your device and shared only with your contacts.")
+ .padding(.bottom, 4)
+ Text("(shared only with your contacts)")
.padding(.bottom)
ZStack(alignment: .topLeading) {
if !validDisplayName(displayName) {
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 87016793d4..28f9c46b5e 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -45,6 +45,16 @@
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
+ 5C79C23E27DB673900C829D6 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23927DB673800C829D6 /* libffi.a */; };
+ 5C79C23F27DB673900C829D6 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23927DB673800C829D6 /* libffi.a */; };
+ 5C79C24027DB673900C829D6 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23A27DB673800C829D6 /* libgmp.a */; };
+ 5C79C24127DB673900C829D6 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23A27DB673800C829D6 /* libgmp.a */; };
+ 5C79C24227DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; };
+ 5C79C24327DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; };
+ 5C79C24427DB673900C829D6 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23C27DB673900C829D6 /* libgmpxx.a */; };
+ 5C79C24527DB673900C829D6 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23C27DB673900C829D6 /* libgmpxx.a */; };
+ 5C79C24627DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; };
+ 5C79C24727DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; };
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
@@ -92,16 +102,8 @@
5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; };
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
- 5CF60A4C27D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4727D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; };
- 5CF60A4D27D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4727D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; };
- 5CF60A4E27D401D7009F2C98 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4827D401D6009F2C98 /* libgmpxx.a */; };
- 5CF60A4F27D401D7009F2C98 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4827D401D6009F2C98 /* libgmpxx.a */; };
- 5CF60A5027D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4927D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; };
- 5CF60A5127D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4927D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; };
- 5CF60A5227D401D7009F2C98 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4A27D401D6009F2C98 /* libgmp.a */; };
- 5CF60A5327D401D7009F2C98 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4A27D401D6009F2C98 /* libgmp.a */; };
- 5CF60A5427D401D7009F2C98 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4B27D401D7009F2C98 /* libffi.a */; };
- 5CF60A5527D401D7009F2C98 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF60A4B27D401D7009F2C98 /* libffi.a */; };
+ 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
+ 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -145,6 +147,11 @@
5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (macOS)-Bridging-Header.h"; sourceTree = ""; };
5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; };
+ 5C79C23927DB673800C829D6 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 5C79C23A27DB673800C829D6 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
+ 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a"; sourceTree = ""; };
+ 5C79C23C27DB673900C829D6 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
+ 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a"; sourceTree = ""; };
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; };
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; };
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; };
@@ -174,11 +181,7 @@
5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; };
5CE4407527ADB66A007B033A /* TextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextItemView.swift; sourceTree = ""; };
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; };
- 5CF60A4727D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a"; sourceTree = ""; };
- 5CF60A4827D401D6009F2C98 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
- 5CF60A4927D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a"; sourceTree = ""; };
- 5CF60A4A27D401D6009F2C98 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
- 5CF60A4B27D401D7009F2C98 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -187,13 +190,13 @@
buildActionMask = 2147483647;
files = (
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
- 5CF60A5227D401D7009F2C98 /* libgmp.a in Frameworks */,
- 5CF60A4E27D401D7009F2C98 /* libgmpxx.a in Frameworks */,
- 5CF60A5427D401D7009F2C98 /* libffi.a in Frameworks */,
- 5CF60A5027D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */,
- 5CF60A4C27D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */,
+ 5C79C24027DB673900C829D6 /* libgmp.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
+ 5C79C23E27DB673900C829D6 /* libffi.a in Frameworks */,
+ 5C79C24227DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */,
+ 5C79C24627DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */,
+ 5C79C24427DB673900C829D6 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -201,13 +204,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 5CF60A4F27D401D7009F2C98 /* libgmpxx.a in Frameworks */,
+ 5C79C24527DB673900C829D6 /* libgmpxx.a in Frameworks */,
+ 5C79C24727DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */,
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */,
+ 5C79C23F27DB673900C829D6 /* libffi.a in Frameworks */,
+ 5C79C24127DB673900C829D6 /* libgmp.a in Frameworks */,
5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */,
- 5CF60A4D27D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */,
- 5CF60A5527D401D7009F2C98 /* libffi.a in Frameworks */,
- 5CF60A5327D401D7009F2C98 /* libgmp.a in Frameworks */,
- 5CF60A5127D401D7009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */,
+ 5C79C24327DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -259,11 +262,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
- 5CF60A4B27D401D7009F2C98 /* libffi.a */,
- 5CF60A4A27D401D6009F2C98 /* libgmp.a */,
- 5CF60A4827D401D6009F2C98 /* libgmpxx.a */,
- 5CF60A4927D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */,
- 5CF60A4727D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */,
+ 5C79C23927DB673800C829D6 /* libffi.a */,
+ 5C79C23A27DB673800C829D6 /* libgmp.a */,
+ 5C79C23C27DB673900C829D6 /* libgmpxx.a */,
+ 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */,
+ 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */,
);
path = Libraries;
sourceTree = "";
@@ -385,6 +388,7 @@
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
+ 640F50E227CF991C001E05C2 /* SMPServers.swift */,
);
path = UserSettings;
sourceTree = "";
@@ -587,6 +591,7 @@
5C764E80279C7276000C6508 /* dummy.m in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
+ 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */,
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -631,6 +636,7 @@
5C764E81279C7276000C6508 /* dummy.m in Sources */,
5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */,
+ 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */,
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -815,7 +821,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 24;
+ CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -835,7 +841,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
- MARKETING_VERSION = 1.1;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -855,7 +861,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 24;
+ CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -875,7 +881,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
- MARKETING_VERSION = 1.1;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
diff --git a/cabal.project b/cabal.project
index 609b8ef40b..f2fe863ece 100644
--- a/cabal.project
+++ b/cabal.project
@@ -3,7 +3,7 @@ packages: .
source-repository-package
type: git
location: git://github.com/simplex-chat/simplexmq.git
- tag: 7a19ab224bdd1122f0761704b6ca1eb4e1e26eb7
+ tag: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc
source-repository-package
type: git
diff --git a/rfcs/2022-02-24-servers-configuration.md b/rfcs/2022-02-24-servers-configuration.md
new file mode 100644
index 0000000000..f7f255fb80
--- /dev/null
+++ b/rfcs/2022-02-24-servers-configuration.md
@@ -0,0 +1,29 @@
+# Server configuration
+
+- in agent:
+ - Agent.Env.SQLite - move smpServers from AgentConfig to Env, make it TVar; keep "initialSmpServers" in AgentConfig?
+ - Agent - getSMPServer to read servers from Env and choose a random server
+ - Agent - new functional api - "useServers"
+ - ~~Agent.Protocol - new ACommand?~~
+- chat core:
+ - db:
+ - new table `smp_servers`, server per row, same columns as for agent. Have rowid for future
+ - getServers method
+ - update - truncate and rewrite
+ - ChatCommand GetServers - new ChatResponse with list of user SMPServers, it may be empty if default are used
+ - ChatCommand SetServers - ChatResponse Ok (restore default servers is empty set servers list)
+ - agent config is populated using getServers, if it's empty default are used
+- mobile chat:
+ - mobileChatOpts to be populated with initial servers on init (getServers or default if empty)
+ - in ui:
+ - view in settings
+ - GetServers on view open to populate
+ - Confirm buttons, Restore button - destructive - clears user servers and default are used
+ - validation
+ - validation on submit, error with server's string
+ - ~~TBD real-time validation~~
+ - ~~fastest is validation on submit without detailed error?~~
+ - ~~maybe even faster - alternatively have 3 fields for entry per server - fingerprint, host, port - and build server strings (still validate to avoid hard crash?)?~~
+- terminal chat:
+ - if -s option is given, these servers are used and getServers is not used for populating agentConfig
+ - if -s option is not provided - same as in mobile - getServers or default if empty
diff --git a/rfcs/2022-03-02-avatars.md b/rfcs/2022-03-02-avatars.md
new file mode 100644
index 0000000000..c0666131f7
--- /dev/null
+++ b/rfcs/2022-03-02-avatars.md
@@ -0,0 +1,14 @@
+# Include (Optional) Images in User Profiles
+
+1. Add SQL migration for database in `src/Simplex/Chat/Migrations`
+ - This will touch `contact_profiles` and `group_profiles`
+
+2. Add field to `User` in `Types.hs` allowing for null entry using `Maybe`
+
+3. Extend parsing in `Chat.hs` under `chatCommandP :: Parser ChatCommand`
+
+4. Update `UpdateProfile` in `Chat.hs` to accept possible display picture and implement an `APIUpdateProfile` command which accepts a JSON string `/_profile{...}` which will add the image to a profile.
+
+5. Connect up to Android and iOS apps (new PRs)
+
+Profile images will be base 64 encoded images. We can use the `base64P` parser to process them and pass them as JSON.
diff --git a/sha256map.nix b/sha256map.nix
index 6f6c57d8b4..c5c5c00503 100644
--- a/sha256map.nix
+++ b/sha256map.nix
@@ -1,5 +1,5 @@
{
- "git://github.com/simplex-chat/simplexmq.git"."7a19ab224bdd1122f0761704b6ca1eb4e1e26eb7" = "1sn2bzz5v2r6wxf1p2k9578zwp0vlb42lb6xjqwpl4acr47wcx0g";
+ "git://github.com/simplex-chat/simplexmq.git"."5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc" = "0qjmldlrxl5waqfbsckjhxkd3zn25bkbyqwf9l0r4gq3c7l6k358";
"git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
"git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index ee2cf0b4b4..0c89160605 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -29,6 +29,8 @@ library
Simplex.Chat.Migrations.M20220205_chat_item_status
Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
Simplex.Chat.Migrations.M20220224_messages_fks
+ Simplex.Chat.Migrations.M20220301_smp_servers
+ Simplex.Chat.Migrations.M20220302_profile_images
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.Protocol
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index d5afd8003a..d9bcd94167 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -22,15 +22,18 @@ import Crypto.Random (drgNew)
import Data.Attoparsec.ByteString.Char8 (Parser)
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bifunctor (first)
+import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (isSpace)
import Data.Functor (($>))
import Data.Int (Int64)
import Data.List (find)
+import Data.List.NonEmpty (NonEmpty, nonEmpty)
+import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
-import Data.Maybe (isJust, mapMaybe)
+import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, getCurrentTime)
@@ -39,7 +42,7 @@ import Data.Word (Word32)
import Simplex.Chat.Controller
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
-import Simplex.Chat.Options (ChatOpts (..))
+import Simplex.Chat.Options (ChatOpts (..), smpServersP)
import Simplex.Chat.Protocol
import Simplex.Chat.Store
import Simplex.Chat.Types
@@ -50,7 +53,7 @@ import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
-import Simplex.Messaging.Parsers (parseAll)
+import Simplex.Messaging.Parsers (base64P, parseAll)
import Simplex.Messaging.Protocol (ErrorType (..), MsgBody)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Util (tryError)
@@ -71,8 +74,8 @@ defaultChatConfig =
{ agentConfig =
defaultAgentConfig
{ tcpPort = undefined, -- agent does not listen to TCP
- smpServers = undefined, -- filled in from options
- dbFile = undefined, -- filled in from options
+ initialSMPServers = undefined, -- filled in newChatController
+ dbFile = undefined, -- filled in newChatController
dbPoolSize = 1,
yesToMigrations = False
},
@@ -85,6 +88,14 @@ defaultChatConfig =
testView = False
}
+defaultSMPServers :: NonEmpty SMPServer
+defaultSMPServers =
+ L.fromList
+ [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im",
+ "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im",
+ "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im"
+ ]
+
logCfg :: LogConfig
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
@@ -95,7 +106,8 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch
activeTo <- newTVarIO ActiveNone
firstTime <- not <$> doesFileExist f
currentUser <- newTVarIO user
- smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", smpServers}
+ initialSMPServers <- resolveServers
+ smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", initialSMPServers}
agentAsync <- newTVarIO Nothing
idsDrg <- newTVarIO =<< drgNew
inputQ <- newTBQueueIO tbqSize
@@ -105,6 +117,13 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch
sndFiles <- newTVarIO M.empty
rcvFiles <- newTVarIO M.empty
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification}
+ where
+ resolveServers :: IO (NonEmpty SMPServer)
+ resolveServers = case user of
+ Nothing -> pure $ if null smpServers then defaultSMPServers else L.fromList smpServers
+ Just usr -> do
+ userSmpServers <- getSMPServers chatStore usr
+ pure . fromMaybe defaultSMPServers . nonEmpty $ if null smpServers then userSmpServers else smpServers
runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
runChatController = race_ notificationSubscriber . agentSubscriber
@@ -196,6 +215,11 @@ processChatCommand = \case
`E.finally` deleteContactRequest st userId connReqId
withAgent $ \a -> rejectContact a connId invId
pure $ CRContactRequestRejected cReq
+ GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user))
+ SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do
+ withStore $ \st -> overwriteSMPServers st user smpServers
+ withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers))
+ pure CRCmdOk
ChatHelp section -> pure $ CRChatHelp section
Welcome -> withUser $ pure . CRWelcome
AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do
@@ -370,17 +394,12 @@ processChatCommand = \case
FileStatus fileId ->
CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId)
ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile
- UpdateProfile p@Profile {displayName} -> withUser $ \user@User {profile} ->
- if p == profile
- then pure CRUserProfileNoChange
- else do
- withStore $ \st -> updateUserProfile st user p
- let user' = (user :: User) {localDisplayName = displayName, profile = p}
- asks currentUser >>= atomically . (`writeTVar` Just user')
- contacts <- withStore (`getUserContacts` user)
- withChatLock . procCmd $ do
- forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p
- pure $ CRUserProfileUpdated profile p
+ UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do
+ let p = (profile :: Profile) {displayName = displayName, fullName = fullName}
+ updateProfile user p
+ UpdateProfileImage image -> withUser $ \user@User {profile} -> do
+ let p = (profile :: Profile) {image = Just image}
+ updateProfile user p
QuitChat -> liftIO exitSuccess
ShowVersion -> pure $ CRVersionInfo versionNumber
where
@@ -419,6 +438,18 @@ processChatCommand = \case
checkSndFile f = do
unlessM (doesFileExist f) . throwChatError $ CEFileNotFound f
(,) <$> getFileSize f <*> asks (fileChunkSize . config)
+ updateProfile :: User -> Profile -> m ChatResponse
+ updateProfile user@User {profile = p} p'@Profile {displayName} = do
+ if p' == p
+ then pure CRUserProfileNoChange
+ else do
+ withStore $ \st -> updateUserProfile st user p'
+ let user' = (user :: User) {localDisplayName = displayName, profile = p'}
+ asks currentUser >>= atomically . (`writeTVar` Just user')
+ contacts <- withStore (`getUserContacts` user)
+ withChatLock . procCmd $ do
+ forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p'
+ pure $ CRUserProfileUpdated p p'
getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath
getRcvFilePath fileId filePath fileName = case filePath of
Nothing -> do
@@ -1356,7 +1387,7 @@ getCreateActiveUser st = do
loop = do
displayName <- getContactName
fullName <- T.pack <$> getWithPrompt "full name (optional)"
- liftIO (runExceptT $ createUser st Profile {displayName, fullName} True) >>= \case
+ liftIO (runExceptT $ createUser st Profile {displayName, fullName, image = Nothing} True) >>= \case
Left SEDuplicateName -> do
putStrLn "chosen display name is already used by another profile on this device, choose another one"
loop
@@ -1426,6 +1457,8 @@ withStore ::
withStore action =
asks chatStore
>>= runExceptT . action
+ -- use this line instead of above to log query errors
+ -- >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e)
>>= liftEither . first ChatErrorStore
chatCommandP :: Parser ChatCommand
@@ -1441,6 +1474,9 @@ chatCommandP =
<|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal)
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
<|> "/_reject " *> (APIRejectContact <$> A.decimal)
+ <|> "/smp_servers default" $> SetUserSMPServers []
+ <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP)
+ <|> "/smp_servers" $> GetUserSMPServers
<|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles
<|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups
<|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress
@@ -1473,11 +1509,14 @@ chatCommandP =
<|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName)
<|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown
<|> ("/welcome" <|> "/w") $> Welcome
- <|> ("/profile " <|> "/p ") *> (UpdateProfile <$> userProfile)
+ <|> "/profile_image " *> (UpdateProfileImage . ProfileImage <$> imageP)
+ <|> ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames)
<|> ("/profile" <|> "/p") $> ShowProfile
<|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat
<|> ("/version" <|> "/v") $> ShowVersion
where
+ imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,")
+ imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P))
chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup
chatPaginationP =
(CPLast <$ "count=" <*> A.decimal)
@@ -1487,14 +1526,17 @@ chatCommandP =
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
refChar c = c > ' ' && c /= '#' && c /= '@'
onOffP = ("on" $> True) <|> ("off" $> False)
- userProfile = do
+ userNames = do
cName <- displayName
fullName <- fullNameP cName
- pure Profile {displayName = cName, fullName}
+ pure (cName, fullName)
+ userProfile = do
+ (cName, fullName) <- userNames
+ pure Profile {displayName = cName, fullName, image = Nothing}
groupProfile = do
gName <- displayName
fullName <- fullNameP gName
- pure GroupProfile {displayName = gName, fullName}
+ pure GroupProfile {displayName = gName, fullName, image = Nothing}
fullNameP name = do
n <- (A.space *> A.takeByteString) <|> pure ""
pure $ if B.null n then name else safeDecodeUtf8 n
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index 6b44a6e477..a9d04f87bd 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -95,6 +95,8 @@ data ChatCommand
| APIDeleteChat ChatType Int64
| APIAcceptContact Int64
| APIRejectContact Int64
+ | GetUserSMPServers
+ | SetUserSMPServers [SMPServer]
| ChatHelp HelpSection
| Welcome
| AddContact
@@ -125,7 +127,8 @@ data ChatCommand
| CancelFile FileTransferId
| FileStatus FileTransferId
| ShowProfile
- | UpdateProfile Profile
+ | UpdateProfile ContactName Text
+ | UpdateProfileImage ProfileImage
| QuitChat
| ShowVersion
deriving (Show)
@@ -136,6 +139,7 @@ data ChatResponse
| CRChatRunning
| CRApiChats {chats :: [AChat]}
| CRApiChat {chat :: AChat}
+ | CRUserSMPServers {smpServers :: [SMPServer]}
| CRNewChatItem {chatItem :: AChatItem}
| CRChatItemUpdated {chatItem :: AChatItem}
| CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile
diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs
index 482e480003..3f17074dd3 100644
--- a/src/Simplex/Chat/Help.hs
+++ b/src/Simplex/Chat/Help.hs
@@ -86,10 +86,11 @@ chatHelpInfo =
green "Create your address: " <> highlight "/address",
"",
green "Other commands:",
- indent <> highlight "/help " <> " - help on: files, groups, address",
+ indent <> highlight "/help " <> " - help on: files, groups, address, smp_servers",
indent <> highlight "/profile " <> " - show / update user profile",
indent <> highlight "/delete " <> " - delete contact and all messages with them",
indent <> highlight "/contacts " <> " - list contacts",
+ indent <> highlight "/smp_servers " <> " - show / set custom SMP servers",
indent <> highlight "/markdown " <> " - supported markdown syntax",
indent <> highlight "/version " <> " - SimpleX Chat version",
indent <> highlight "/quit " <> " - quit chat",
diff --git a/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs b/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs
new file mode 100644
index 0000000000..774f2e0168
--- /dev/null
+++ b/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs
@@ -0,0 +1,21 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Migrations.M20220301_smp_servers where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+m20220301_smp_servers :: Query
+m20220301_smp_servers =
+ [sql|
+CREATE TABLE smp_servers (
+ smp_server_id INTEGER PRIMARY KEY,
+ host TEXT NOT NULL,
+ port TEXT NOT NULL,
+ key_hash BLOB NOT NULL,
+ user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE (host, port)
+);
+|]
diff --git a/src/Simplex/Chat/Migrations/M20220302_profile_images.hs b/src/Simplex/Chat/Migrations/M20220302_profile_images.hs
new file mode 100644
index 0000000000..72c22b89cb
--- /dev/null
+++ b/src/Simplex/Chat/Migrations/M20220302_profile_images.hs
@@ -0,0 +1,13 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Migrations.M20220302_profile_images where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+m20220302_profile_images :: Query
+m20220302_profile_images =
+ [sql|
+ ALTER TABLE contact_profiles ADD COLUMN image TEXT;
+ ALTER TABLE group_profiles ADD COLUMN image TEXT;
+|]
diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs
index be007c19e3..e4f15dd0d1 100644
--- a/src/Simplex/Chat/Mobile.hs
+++ b/src/Simplex/Chat/Mobile.hs
@@ -49,7 +49,7 @@ mobileChatOpts :: ChatOpts
mobileChatOpts =
ChatOpts
{ dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db
- smpServers = defaultSMPServers,
+ smpServers = [],
logConnections = False,
logAgent = False
}
diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs
index 06b90edaa0..6fd25a8d33 100644
--- a/src/Simplex/Chat/Options.hs
+++ b/src/Simplex/Chat/Options.hs
@@ -3,14 +3,12 @@
module Simplex.Chat.Options
( ChatOpts (..),
getChatOpts,
- defaultSMPServers,
+ smpServersP,
)
where
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B
-import Data.List.NonEmpty (NonEmpty)
-import qualified Data.List.NonEmpty as L
import Options.Applicative
import Simplex.Chat.Controller (updateStr, versionStr)
import Simplex.Messaging.Agent.Protocol (SMPServer (..))
@@ -20,19 +18,11 @@ import System.FilePath (combine)
data ChatOpts = ChatOpts
{ dbFilePrefix :: String,
- smpServers :: NonEmpty SMPServer,
+ smpServers :: [SMPServer],
logConnections :: Bool,
logAgent :: Bool
}
-defaultSMPServers :: NonEmpty SMPServer
-defaultSMPServers =
- L.fromList
- [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im",
- "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im",
- "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im"
- ]
-
chatOpts :: FilePath -> Parser ChatOpts
chatOpts appDir =
ChatOpts
@@ -45,13 +35,13 @@ chatOpts appDir =
<> showDefault
)
<*> option
- parseSMPServer
+ parseSMPServers
( long "server"
<> short 's'
<> metavar "SERVER"
<> help
"Comma separated list of SMP server(s) to use"
- <> value defaultSMPServers
+ <> value []
)
<*> switch
( long "connections"
@@ -66,10 +56,11 @@ chatOpts appDir =
where
defaultDbFilePath = combine appDir "simplex_v1"
-parseSMPServer :: ReadM (NonEmpty SMPServer)
-parseSMPServer = eitherReader $ parseAll servers . B.pack
- where
- servers = L.fromList <$> strP `A.sepBy1` A.char ','
+parseSMPServers :: ReadM [SMPServer]
+parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
+
+smpServersP :: A.Parser [SMPServer]
+smpServersP = strP `A.sepBy1` A.char ','
getChatOpts :: FilePath -> IO ChatOpts
getChatOpts appDir =
diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs
index 39d09d544a..784418eaac 100644
--- a/src/Simplex/Chat/Store.hs
+++ b/src/Simplex/Chat/Store.hs
@@ -121,6 +121,8 @@ module Simplex.Chat.Store
updateDirectChatItem,
updateDirectChatItemsRead,
updateGroupChatItemsRead,
+ getSMPServers,
+ overwriteSMPServers,
)
where
@@ -158,10 +160,12 @@ import Simplex.Chat.Migrations.M20220122_v1_1
import Simplex.Chat.Migrations.M20220205_chat_item_status
import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
import Simplex.Chat.Migrations.M20220224_messages_fks
+import Simplex.Chat.Migrations.M20220301_smp_servers
+import Simplex.Chat.Migrations.M20220302_profile_images
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe)
-import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..))
+import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..))
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
import qualified Simplex.Messaging.Crypto as C
@@ -176,7 +180,9 @@ schemaMigrations =
("20220122_v1_1", m20220122_v1_1),
("20220205_chat_item_status", m20220205_chat_item_status),
("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests),
- ("20220224_messages_fks", m20220224_messages_fks)
+ ("20220224_messages_fks", m20220224_messages_fks),
+ ("20220301_smp_servers", m20220301_smp_servers),
+ ("20220302_profile_images", m20220302_profile_images)
]
-- | The list of migrations in ascending order by date
@@ -205,7 +211,7 @@ insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()"
type StoreMonad m = (MonadUnliftIO m, MonadError StoreError m)
createUser :: StoreMonad m => SQLiteStore -> Profile -> Bool -> m User
-createUser st Profile {displayName, fullName} activeUser =
+createUser st Profile {displayName, fullName, image} activeUser =
liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do
currentTs <- getCurrentTime
DB.execute
@@ -219,8 +225,8 @@ createUser st Profile {displayName, fullName} activeUser =
(displayName, displayName, userId, currentTs, currentTs)
DB.execute
db
- "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
- (displayName, fullName, currentTs, currentTs)
+ "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
+ (displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -228,7 +234,7 @@ createUser st Profile {displayName, fullName} activeUser =
(profileId, displayName, userId, True, currentTs, currentTs)
contactId <- insertedRowId db
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
- pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName)
+ pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName, image)
getUsers :: SQLiteStore -> IO [User]
getUsers st =
@@ -237,15 +243,15 @@ getUsers st =
<$> DB.query_
db
[sql|
- SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name
+ SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name, p.image
FROM users u
JOIN contacts c ON u.contact_id = c.contact_id
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
|]
-toUser :: (UserId, Int64, Bool, ContactName, Text) -> User
-toUser (userId, userContactId, activeUser, displayName, fullName) =
- let profile = Profile {displayName, fullName}
+toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ProfileImage) -> User
+toUser (userId, userContactId, activeUser, displayName, fullName, image) =
+ let profile = Profile {displayName, fullName, image}
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser}
setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m ()
@@ -283,7 +289,7 @@ getConnReqContactXContactId st userId cReqHash = do
[sql|
SELECT
-- Contact
- ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
+ ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -338,12 +344,12 @@ createDirectContact st userId activeConn@Connection {connId} profile =
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt}
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> IO (Either StoreError (Text, Int64, Int64))
-createContact_ db userId connId Profile {displayName, fullName} viaGroup currentTs =
+createContact_ db userId connId Profile {displayName, fullName, image} viaGroup currentTs =
withLocalDisplayName db userId displayName $ \ldn -> do
DB.execute
db
- "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
- (displayName, fullName, currentTs, currentTs)
+ "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
+ (displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -420,13 +426,14 @@ updateContactProfile_ db userId contactId profile = do
updateContactProfile_' db userId contactId profile currentTs
updateContactProfile_' :: DB.Connection -> UserId -> Int64 -> Profile -> UTCTime -> IO ()
-updateContactProfile_' db userId contactId Profile {displayName, fullName} updatedAt = do
+updateContactProfile_' db userId contactId Profile {displayName, fullName, image} updatedAt = do
DB.executeNamed
db
[sql|
UPDATE contact_profiles
SET display_name = :display_name,
full_name = :full_name,
+ image = :image,
updated_at = :updated_at
WHERE contact_profile_id IN (
SELECT contact_profile_id
@@ -437,6 +444,7 @@ updateContactProfile_' db userId contactId Profile {displayName, fullName} updat
|]
[ ":display_name" := displayName,
":full_name" := fullName,
+ ":image" := image,
":updated_at" := updatedAt,
":user_id" := userId,
":contact_id" := contactId
@@ -454,17 +462,17 @@ updateContact_ db userId contactId displayName newName updatedAt = do
(newName, updatedAt, userId, contactId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId)
-type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, UTCTime)
+type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage, UTCTime)
toContact :: ContactRow :. ConnectionRow -> Contact
-toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) =
- let profile = Profile {displayName, fullName}
+toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) =
+ let profile = Profile {displayName, fullName, image}
activeConn = toConnection connRow
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact
-toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) =
- let profile = Profile {displayName, fullName}
+toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) =
+ let profile = Profile {displayName, fullName, image}
in case toMaybeConnection connRow of
Just activeConn ->
Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
@@ -601,7 +609,7 @@ createOrUpdateContactRequest st userId userContactLinkId invId profile xContactI
createOrUpdateContactRequest_ db userId userContactLinkId invId profile xContactId_
createOrUpdateContactRequest_ :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> IO (Either StoreError (Either Contact UserContactRequest))
-createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName} xContactId_ =
+createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName, image} xContactId_ =
maybeM getContact' xContactId_ >>= \case
Just contact -> pure . Right $ Left contact
Nothing -> Right <$$> createOrUpdate_
@@ -621,8 +629,8 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
createContactRequest_ currentTs ldn = do
DB.execute
db
- "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
- (displayName, fullName, currentTs, currentTs)
+ "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
+ (displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -642,7 +650,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
[sql|
SELECT
-- Contact
- ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
+ ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -662,7 +670,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
- c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
+ c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
@@ -685,6 +693,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
UPDATE contact_profiles
SET display_name = ?,
full_name = ?,
+ image = ?,
updated_at = ?
WHERE contact_profile_id IN (
SELECT contact_profile_id
@@ -693,7 +702,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
AND contact_request_id = ?
)
|]
- (ldn, fullName, updatedAt, userId, cReqId)
+ (ldn, fullName, image, updatedAt, userId, cReqId)
DB.execute
db
[sql|
@@ -720,7 +729,7 @@ getContactRequest_ db userId contactRequestId =
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
- c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
+ c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
@@ -729,11 +738,11 @@ getContactRequest_ db userId contactRequestId =
|]
(userId, contactRequestId)
-type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime, Maybe XContactId)
+type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ProfileImage, UTCTime, Maybe XContactId)
toContactRequest :: ContactRequestRow -> UserContactRequest
-toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt, xContactId) = do
- let profile = Profile {displayName, fullName}
+toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, createdAt, xContactId) = do
+ let profile = Profile {displayName, fullName, image}
in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt, xContactId}
getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64
@@ -875,7 +884,7 @@ toMaybeConnection (Just connId, Just agentConnId, Just connLevel, viaContact, Ju
toMaybeConnection _ = Nothing
getMatchingContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [Contact]
-getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName}} =
+getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName, image}} =
liftIO . withTransaction st $ \db -> do
contactIds <-
map fromOnly
@@ -887,11 +896,13 @@ getMatchingContacts st userId Contact {contactId, profile = Profile {displayName
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
WHERE ct.user_id = :user_id AND ct.contact_id != :contact_id
AND p.display_name = :display_name AND p.full_name = :full_name
+ AND ((p.image IS NULL AND :image IS NULL) OR p.image = :image)
|]
[ ":user_id" := userId,
":contact_id" := contactId,
":display_name" := displayName,
- ":full_name" := fullName
+ ":full_name" := fullName,
+ ":image" := image
]
rights <$> mapM (getContact_ db userId) contactIds
@@ -1055,15 +1066,15 @@ getConnectionEntity st User {userId, userContactId} agentConnId =
<$> DB.query
db
[sql|
- SELECT c.local_display_name, p.display_name, p.full_name, c.via_group, c.created_at
+ SELECT c.local_display_name, p.display_name, p.full_name, p.image, c.via_group, c.created_at
FROM contacts c
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
WHERE c.user_id = ? AND c.contact_id = ?
|]
(userId, contactId)
- toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe Int64, UTCTime)] -> Either StoreError Contact
- toContact' contactId activeConn [(localDisplayName, displayName, fullName, viaGroup, createdAt)] =
- let profile = Profile {displayName, fullName}
+ toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime)] -> Either StoreError Contact
+ toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt)] =
+ let profile = Profile {displayName, fullName, image}
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
@@ -1074,15 +1085,15 @@ getConnectionEntity st User {userId, userContactId} agentConnId =
[sql|
SELECT
-- GroupInfo
- g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
+ g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
- pu.display_name, pu.full_name,
+ pu.display_name, pu.full_name, pu.image,
-- from GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
- m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name
+ m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
JOIN groups g ON g.group_id = m.group_id
@@ -1144,7 +1155,7 @@ updateConnectionStatus st Connection {connId} connStatus =
createNewGroup :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m GroupInfo
createNewGroup st gVar user groupProfile =
liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do
- let GroupProfile {displayName, fullName} = groupProfile
+ let GroupProfile {displayName, fullName, image} = groupProfile
uId = userId user
currentTs <- getCurrentTime
DB.execute
@@ -1153,8 +1164,8 @@ createNewGroup st gVar user groupProfile =
(displayName, displayName, uId, currentTs, currentTs)
DB.execute
db
- "INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
- (displayName, fullName, currentTs, currentTs)
+ "INSERT INTO group_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
+ (displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -1181,13 +1192,13 @@ createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInv
<$> DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId)
createGroupInvitation_ :: DB.Connection -> IO (Either StoreError GroupInfo)
createGroupInvitation_ db = do
- let GroupProfile {displayName, fullName} = groupProfile
+ let GroupProfile {displayName, fullName, image} = groupProfile
withLocalDisplayName db userId displayName $ \localDisplayName -> do
currentTs <- getCurrentTime
DB.execute
db
- "INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
- (displayName, fullName, currentTs, currentTs)
+ "INSERT INTO group_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
+ (displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -1238,9 +1249,9 @@ getUserGroupDetails st User {userId, userContactId} =
<$> DB.query
db
[sql|
- SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
+ SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
- m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name
+ m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name, mp.image
FROM groups g
JOIN group_profiles gp USING (group_profile_id)
JOIN group_members m USING (group_id)
@@ -1255,12 +1266,12 @@ getGroupInfoByName st user gName =
gId <- ExceptT $ getGroupIdByName_ db user gName
ExceptT $ getGroupInfo_ db user gId
-type GroupInfoRow = (Int64, GroupName, GroupName, Text, UTCTime) :. GroupMemberRow
+type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ProfileImage, UTCTime) :. GroupMemberRow
toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
-toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, createdAt) :. userMemberRow) =
+toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt) :. userMemberRow) =
let membership = toGroupMember userContactId userMemberRow
- in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership, createdAt}
+ in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, createdAt}
getGroupMembers :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> m [GroupMember]
getGroupMembers st user gInfo = liftIO . withTransaction st $ \db -> getGroupMembers_ db user gInfo
@@ -1273,7 +1284,7 @@ getGroupMembers_ db User {userId, userContactId} GroupInfo {groupId} = do
[sql|
SELECT
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
- m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name,
+ m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1313,20 +1324,20 @@ getGroupInvitation st user localDisplayName =
findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId)
findFromContact _ = const Nothing
-type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text)
+type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage)
-type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text)
+type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ProfileImage)
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
-toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) =
- let memberProfile = Profile {displayName, fullName}
+toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) =
+ let memberProfile = Profile {displayName, fullName, image}
invitedBy = toInvitedBy userContactId invitedById
activeConn = Nothing
in GroupMember {..}
toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember
-toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName) =
- Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName)
+toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName, image) =
+ Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image)
toMaybeGroupMember _ _ = Nothing
createContactMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> m GroupMember
@@ -1369,14 +1380,14 @@ updateGroupMemberStatus st userId GroupMember {groupMemberId} memStatus =
-- | add new member with profile
createNewGroupMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> m GroupMember
-createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName}) memCategory memStatus =
+createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image}) memCategory memStatus =
liftIOEither . withTransaction st $ \db ->
withLocalDisplayName db userId displayName $ \localDisplayName -> do
currentTs <- getCurrentTime
DB.execute
db
- "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
- (displayName, fullName, currentTs, currentTs)
+ "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
+ (displayName, fullName, image, currentTs, currentTs)
memProfileId <- insertedRowId db
let newMember =
NewGroupMember
@@ -1635,15 +1646,15 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} =
[sql|
SELECT
-- GroupInfo
- g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
+ g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
- pu.display_name, pu.full_name,
+ pu.display_name, pu.full_name, pu.image,
-- via GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
- m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name,
+ m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1677,7 +1688,7 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} =
db
[sql|
SELECT
- ct.contact_id, ct.local_display_name, p.display_name, p.full_name, ct.via_group, ct.created_at,
+ ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM contacts ct
@@ -1693,9 +1704,9 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} =
|]
(userId, groupMemberId)
where
- toContact' :: [(Int64, ContactName, Text, Text, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact
- toContact' [(contactId, localDisplayName, displayName, fullName, viaGroup, createdAt) :. connRow] =
- let profile = Profile {displayName, fullName}
+ toContact' :: [(Int64, ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact
+ toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt) :. connRow] =
+ let profile = Profile {displayName, fullName, image}
activeConn = toConnection connRow
in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
toContact' _ = Nothing
@@ -2208,7 +2219,7 @@ getDirectChatPreviews_ db User {userId} = do
[sql|
SELECT
-- Contact
- ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
+ ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at,
@@ -2265,11 +2276,11 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
[sql|
SELECT
-- GroupInfo
- g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
+ g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
- pu.display_name, pu.full_name,
+ pu.display_name, pu.full_name, pu.image,
-- ChatStats
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0),
-- ChatItem
@@ -2277,7 +2288,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
-- Maybe GroupMember - sender
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
- p.display_name, p.full_name
+ p.display_name, p.full_name, p.image
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
@@ -2318,7 +2329,7 @@ getContactRequestChatPreviews_ db User {userId} =
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
- c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
+ c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
@@ -2453,7 +2464,7 @@ getContact_ db userId contactId =
[sql|
SELECT
-- Contact
- ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
+ ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -2504,7 +2515,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
- p.display_name, p.full_name
+ p.display_name, p.full_name, p.image
FROM chat_items ci
LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
@@ -2534,7 +2545,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
- p.display_name, p.full_name
+ p.display_name, p.full_name, p.image
FROM chat_items ci
LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
@@ -2564,7 +2575,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
- p.display_name, p.full_name
+ p.display_name, p.full_name, p.image
FROM chat_items ci
LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
@@ -2604,11 +2615,11 @@ getGroupInfo_ db User {userId, userContactId} groupId =
[sql|
SELECT
-- GroupInfo
- g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
+ g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
- pu.display_name, pu.full_name
+ pu.display_name, pu.full_name, pu.image
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
@@ -2748,6 +2759,38 @@ toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemConten
either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_)
toGroupChatItemList _ _ _ = []
+getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer]
+getSMPServers st User {userId} =
+ liftIO . withTransaction st $ \db ->
+ map toSmpServer
+ <$> DB.query
+ db
+ [sql|
+ SELECT host, port, key_hash
+ FROM smp_servers
+ WHERE user_id = ?;
+ |]
+ (Only userId)
+ where
+ toSmpServer :: (String, String, C.KeyHash) -> SMPServer
+ toSmpServer (host, port, keyHash) = SMPServer host port keyHash
+
+overwriteSMPServers :: StoreMonad m => SQLiteStore -> User -> [SMPServer] -> m ()
+overwriteSMPServers st User {userId} smpServers = do
+ liftIOEither . checkConstraint SEUniqueID . withTransaction st $ \db -> do
+ currentTs <- getCurrentTime
+ DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId)
+ forM_ smpServers $ \SMPServer {host, port, keyHash} ->
+ DB.execute
+ db
+ [sql|
+ INSERT INTO smp_servers
+ (host, port, key_hash, user_id, created_at, updated_at)
+ VALUES (?,?,?,?,?,?)
+ |]
+ (host, port, keyHash, userId, currentTs, currentTs)
+ pure $ Right ()
+
-- | Saves unique local display name based on passed displayName, suffixed with _N if required.
-- This function should be called inside transaction.
withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a)
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index b342f30771..84883ab558 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -170,19 +170,39 @@ groupName' GroupInfo {localDisplayName = g} = g
data Profile = Profile
{ displayName :: ContactName,
- fullName :: Text
+ fullName :: Text,
+ image :: Maybe ProfileImage
}
deriving (Eq, Show, Generic, FromJSON)
-instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions
+instance ToJSON Profile where
+ toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
+ toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data GroupProfile = GroupProfile
{ displayName :: GroupName,
- fullName :: Text
+ fullName :: Text,
+ image :: Maybe ProfileImage
}
deriving (Eq, Show, Generic, FromJSON)
-instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions
+instance ToJSON GroupProfile where
+ toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
+ toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
+
+newtype ProfileImage = ProfileImage Text
+ deriving (Eq, Show)
+
+instance FromJSON ProfileImage where
+ parseJSON = fmap ProfileImage . J.parseJSON
+
+instance ToJSON ProfileImage where
+ toJSON (ProfileImage t) = J.toJSON t
+ toEncoding (ProfileImage t) = J.toEncoding t
+
+instance ToField ProfileImage where toField (ProfileImage t) = toField t
+
+instance FromField ProfileImage where fromField = fmap ProfileImage . fromField
data GroupInvitation = GroupInvitation
{ fromMember :: MemberIdRole,
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index ecfb033a6e..af40b1a5a0 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -8,10 +8,11 @@
module Simplex.Chat.View where
import qualified Data.Aeson as J
+import qualified Data.ByteString.Char8 as B
import Data.Function (on)
import Data.Int (Int64)
-import Data.List (groupBy, intersperse, partition, sortOn)
-import Data.Maybe (isJust)
+import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
+import Data.Maybe (isJust, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (DiffTime)
@@ -42,6 +43,7 @@ responseToView testView = \case
CRChatRunning -> []
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]
+ CRUserSMPServers smpServers -> viewSMPServers smpServers testView
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item
CRChatItemUpdated _ -> []
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
@@ -316,9 +318,27 @@ viewUserProfile Profile {displayName, fullName} =
"(the updated profile will be sent to all your contacts)"
]
+viewSMPServers :: [SMPServer] -> Bool -> [StyledString]
+viewSMPServers smpServers testView =
+ if testView
+ then [customSMPServers]
+ else
+ [ customSMPServers,
+ "",
+ "use " <> highlight' "/smp_servers " <> " to switch to custom SMP servers",
+ "use " <> highlight' "/smp_servers 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
+ customSMPServers =
+ if null smpServers
+ then "no custom SMP servers saved"
+ else plain $ intercalate ", " (map (B.unpack . strEncode) smpServers)
+
viewUserProfileUpdated :: Profile -> Profile -> [StyledString]
-viewUserProfileUpdated Profile {displayName = n, fullName} Profile {displayName = n', fullName = fullName'}
- | n == n' && fullName == fullName' = []
+viewUserProfileUpdated Profile {displayName = n, fullName, image} Profile {displayName = n', fullName = fullName', image = image'}
+ | n == n' && fullName == fullName' && image == image' = []
+ | n == n' && fullName == fullName' = [if isNothing image' then "profile image removed" else "profile image updated"]
| n == n' = ["user full name " <> (if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName') <> notified]
| otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified]
where
diff --git a/stack.yaml b/stack.yaml
index 221d206e58..922f7b4902 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -48,7 +48,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
- commit: 7a19ab224bdd1122f0761704b6ca1eb4e1e26eb7
+ commit: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
- github: simplex-chat/aeson
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs
index cd65edf869..dd996011cb 100644
--- a/tests/ChatClient.hs
+++ b/tests/ChatClient.hs
@@ -132,6 +132,12 @@ testChatN ps test = withTmpFiles $ do
getTermLine :: TestCC -> IO String
getTermLine = atomically . readTQueue . termQ
+-- Use below to echo virtual terminal
+-- getTermLine cc = do
+-- s <- atomically . readTQueue $ termQ cc
+-- putStrLn s
+-- pure s
+
testChat2 :: Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO ()
testChat2 p1 p2 test = testChatN [p1, p2] test_
where
diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs
index a90ea50e3b..dcce728279 100644
--- a/tests/ChatTests.hs
+++ b/tests/ChatTests.hs
@@ -14,27 +14,29 @@ import Data.Char (isDigit)
import Data.Maybe (fromJust)
import qualified Data.Text as T
import Simplex.Chat.Controller (ChatController (..))
-import Simplex.Chat.Types (Profile (..), User (..))
+import Simplex.Chat.Types (Profile (..), ProfileImage (..), User (..))
import Simplex.Chat.Util (unlessM)
import System.Directory (doesFileExist)
import Test.Hspec
aliceProfile :: Profile
-aliceProfile = Profile {displayName = "alice", fullName = "Alice"}
+aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing}
bobProfile :: Profile
-bobProfile = Profile {displayName = "bob", fullName = "Bob"}
+bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ProfileImage "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC")}
cathProfile :: Profile
-cathProfile = Profile {displayName = "cath", fullName = "Catherine"}
+cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing}
danProfile :: Profile
-danProfile = Profile {displayName = "dan", fullName = "Daniel"}
+danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing}
chatTests :: Spec
chatTests = do
describe "direct messages" $
it "add contact and send/receive message" testAddContact
+ describe "SMP servers" $
+ it "get and set SMP servers" testGetSetSMPServers
describe "chat groups" $ do
it "add contacts, create group and send/receive messages" testGroup
it "create and join group with 4 members" testGroup2
@@ -43,8 +45,9 @@ chatTests = do
it "re-add member in status invited" testGroupReAddInvited
it "remove contact from group and add again" testGroupRemoveAdd
it "list groups containing group invitations" testGroupList
- describe "user profiles" $
+ describe "user profiles" $ do
it "update user profiles and notify contacts" testUpdateProfile
+ it "update user profile with image" testUpdateProfileImage
describe "sending and receiving files" $ do
it "send and receive file" testFileTransfer
it "send and receive a small file" testSmallFileTransfer
@@ -52,12 +55,12 @@ chatTests = do
it "recipient cancelled file transfer" testFileRcvCancel
it "send and receive file to group" testGroupFileTransfer
describe "user contact link" $ do
- it "should create and connect via contact link" testUserContactLink
- it "should auto accept contact requests" testUserContactLinkAutoAccept
- it "should deduplicate contact requests" testDeduplicateContactRequests
- it "should deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange
- it "should reject contact and delete contact link" testRejectContactAndDeleteUserContact
- it "should delete connection requests when contact link deleted" testDeleteConnectionRequests
+ it "create and connect via contact link" testUserContactLink
+ it "auto accept contact requests" testUserContactLinkAutoAccept
+ it "deduplicate contact requests" testDeduplicateContactRequests
+ it "deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange
+ it "reject contact and delete contact link" testRejectContactAndDeleteUserContact
+ it "delete connection requests when contact link deleted" testDeleteConnectionRequests
testAddContact :: IO ()
testAddContact =
@@ -117,6 +120,18 @@ testAddContact =
alice #$$> ("/_get chats", [("@bob", "hi")])
bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")])
+testGetSetSMPServers :: IO ()
+testGetSetSMPServers =
+ testChat2 aliceProfile bobProfile $
+ \alice _ -> do
+ alice #$> ("/smp_servers", id, "no custom SMP servers saved")
+ alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok")
+ alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im")
+ alice #$> ("/smp_servers smp://2345-w==@smp2.example.im,smp://3456-w==@smp3.example.im:5224", id, "ok")
+ alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
+ alice #$> ("/smp_servers default", id, "ok")
+ alice #$> ("/smp_servers", id, "no custom SMP servers saved")
+
testGroup :: IO ()
testGroup =
testChat3 aliceProfile bobProfile cathProfile $
@@ -570,6 +585,16 @@ testUpdateProfile =
bob <## "use @cat to send messages"
]
+testUpdateProfileImage :: IO ()
+testUpdateProfileImage =
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+ -- Note we currently don't support removing profile image.
+ alice ##> "/profile_image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
+ alice <## "profile image updated"
+ (bob )
+
testFileTransfer :: IO ()
testFileTransfer =
testChat2 aliceProfile bobProfile $
diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs
index 9491e1fb68..a94f0d66b9 100644
--- a/tests/ProtocolTests.hs
+++ b/tests/ProtocolTests.hs
@@ -70,10 +70,10 @@ s #==# msg = do
s ==# msg
testProfile :: Profile
-testProfile = Profile {displayName = "alice", fullName = "Alice"}
+testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ProfileImage "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=")}
testGroupProfile :: GroupProfile
-testGroupProfile = GroupProfile {displayName = "team", fullName = "Team"}
+testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image = Nothing}
decodeChatMessageTest :: Spec
decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
@@ -82,19 +82,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = testConnReq}
it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg"
- it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" #==# XInfo testProfile
- it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = ""}
+ it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile
+ it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing}
it "x.contact with xContactId" $
- "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
+ "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XContact testProfile (Just $ XContactId "\1\2\3\4")
it "x.contact without XContactId" $
- "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
+ "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XContact testProfile Nothing
it "x.contact with content null" $
- "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
+ "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing
it "x.contact with content (ignored)" $
- "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
+ "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
@@ -102,19 +102,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4")
it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4")
it "x.grp.mem.new" $
- "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}}"
+ "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
it "x.grp.mem.intro" $
- "{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}}"
+ "{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
it "x.grp.mem.inv" $
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.fwd" $
- "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}}"
+ "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.info" $
- "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
+ "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile
it "x.grp.mem.con" $ "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemCon (MemberId "\1\2\3\4")
it "x.grp.mem.con.all" $ "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemConAll (MemberId "\1\2\3\4")