Merge PR #434 - v1.2 release
This commit is contained in:
@@ -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)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<User?>(null)
|
||||
@@ -27,6 +26,7 @@ class ChatModel(val controller: ChatController) {
|
||||
var connReqInvitation: String? = null
|
||||
var terminalItems = mutableStateListOf<TerminalItem>()
|
||||
var userAddress = mutableStateOf<String?>(null)
|
||||
var userSMPServers = mutableStateOf<(List<String>)?>(null)
|
||||
// set when app is opened via contact or invitation URI
|
||||
var appOpenUrl = mutableStateOf<Uri?>(null)
|
||||
|
||||
|
||||
@@ -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<String>? {
|
||||
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<String>): 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<String>): 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<String>) = 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<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
|
||||
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): 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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChatItem>) {
|
||||
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<ChatItem>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<KeyboardState> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>) {
|
||||
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<String>) -> 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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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<Int64, () -> 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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -25,7 +25,7 @@ struct CIMetaView: View {
|
||||
default: EmptyView()
|
||||
}
|
||||
|
||||
Text(chatItem.timestampText)
|
||||
chatItem.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
180
apps/ios/Shared/Views/UserSettings/SMPServers.swift
Normal file
180
apps/ios/Shared/Views/UserSettings/SMPServers.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = "<group>"; };
|
||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
|
||||
5C79C23927DB673800C829D6 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C79C23A27DB673800C829D6 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a"; sourceTree = "<group>"; };
|
||||
5C79C23C27DB673900C829D6 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
|
||||
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
|
||||
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = "<group>"; };
|
||||
@@ -174,11 +181,7 @@
|
||||
5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
|
||||
5CE4407527ADB66A007B033A /* TextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextItemView.swift; sourceTree = "<group>"; };
|
||||
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = "<group>"; };
|
||||
5CF60A4727D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a"; sourceTree = "<group>"; };
|
||||
5CF60A4827D401D6009F2C98 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
5CF60A4A27D401D6009F2C98 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CF60A4B27D401D7009F2C98 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@@ -385,6 +388,7 @@
|
||||
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
|
||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */,
|
||||
);
|
||||
path = UserSettings;
|
||||
sourceTree = "<group>";
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
29
rfcs/2022-02-24-servers-configuration.md
Normal file
29
rfcs/2022-02-24-servers-configuration.md
Normal file
@@ -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
|
||||
14
rfcs/2022-03-02-avatars.md
Normal file
14
rfcs/2022-03-02-avatars.md
Normal file
@@ -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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,10 +86,11 @@ chatHelpInfo =
|
||||
green "Create your address: " <> highlight "/address",
|
||||
"",
|
||||
green "Other commands:",
|
||||
indent <> highlight "/help <topic> " <> " - help on: files, groups, address",
|
||||
indent <> highlight "/help <topic> " <> " - help on: files, groups, address, smp_servers",
|
||||
indent <> highlight "/profile " <> " - show / update user profile",
|
||||
indent <> highlight "/delete <contact>" <> " - 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",
|
||||
|
||||
21
src/Simplex/Chat/Migrations/M20220301_smp_servers.hs
Normal file
21
src/Simplex/Chat/Migrations/M20220301_smp_servers.hs
Normal file
@@ -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)
|
||||
);
|
||||
|]
|
||||
13
src/Simplex/Chat/Migrations/M20220302_profile_images.hs
Normal file
13
src/Simplex/Chat/Migrations/M20220302_profile_images.hs
Normal file
@@ -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;
|
||||
|]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <srv1[,srv2,...]>" <> " 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <message> 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 $
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user