diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 92c96064c..e8202f5c0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -11,11 +11,10 @@ import chat.simplex.app.ui.theme.SecretColor import chat.simplex.app.ui.theme.SimplexBlue import kotlinx.datetime.* import kotlinx.serialization.* -import kotlinx.serialization.builtins.IntArraySerializer import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* -import kotlinx.serialization.modules.SerializersModule class ChatModel(val controller: ChatController) { var currentUser = mutableStateOf(null) @@ -27,6 +26,7 @@ class ChatModel(val controller: ChatController) { var connReqInvitation: String? = null var terminalItems = mutableStateListOf() var userAddress = mutableStateOf(null) + var userSMPServers = mutableStateOf<(List)?>(null) // set when app is opened via contact or invitation URI var appOpenUrl = mutableStateOf(null) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index aac062974..147cf888b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -27,6 +27,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap try { apiStartChat() chatModel.userAddress.value = apiGetUserAddress() + chatModel.userSMPServers.value = getUserSMPServers() chatModel.chats.addAll(apiGetChats()) chatModel.currentUser = mutableStateOf(u) chatModel.userCreated.value = true @@ -128,6 +129,28 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap return null } + suspend fun getUserSMPServers(): List? { + val r = sendCmd(CC.GetUserSMPServers()) + if (r is CR.UserSMPServers) return r.smpServers + Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun setUserSMPServers(smpServers: List): Boolean { + val r = sendCmd(CC.SetUserSMPServers(smpServers)) + return when (r) { + is CR.CmdOk -> true + else -> { + Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg( + "Error saving SMP servers", + "Make sure SMP server addresses are in correct format, line separated and are not duplicated" + ) + false + } + } + } + suspend fun apiAddContact(): String? { val r = sendCmd(CC.AddContact()) if (r is CR.Invitation) return r.connReqInvitation @@ -320,6 +343,8 @@ sealed class CC { class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long): CC() class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC() + class GetUserSMPServers(): CC() + class SetUserSMPServers(val smpServers: List): CC() class AddContact: CC() class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() @@ -339,6 +364,8 @@ sealed class CC { is ApiGetChats -> "/_get chats" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}" + is GetUserSMPServers -> "/smp_servers" + is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" is AddContact -> "/connect" is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" @@ -359,6 +386,8 @@ sealed class CC { is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" + is GetUserSMPServers -> "getUserSMPServers" + is SetUserSMPServers -> "setUserSMPServers" is AddContact -> "addContact" is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" @@ -375,6 +404,8 @@ sealed class CC { companion object { fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" + + fun smpServersStr(smpServers: List) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ",") } } @@ -412,6 +443,7 @@ sealed class CR { @Serializable @SerialName("chatRunning") class ChatRunning: CR() @Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() + @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR() @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR() @Serializable @SerialName("sentInvitation") class SentInvitation: CR() @@ -449,6 +481,7 @@ sealed class CR { is ChatRunning -> "chatRunning" is ApiChats -> "apiChats" is ApiChat -> "apiChat" + is UserSMPServers -> "userSMPServers" is Invitation -> "invitation" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" @@ -487,6 +520,7 @@ sealed class CR { is ChatRunning -> noDetails() is ApiChats -> json.encodeToString(chats) is ApiChat -> json.encodeToString(chat) + is UserSMPServers -> json.encodeToString(smpServers) is Invitation -> connReqInvitation is SentConfirmation -> noDetails() is SentInvitation -> noDetails() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt new file mode 100644 index 000000000..548762886 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -0,0 +1,300 @@ +package chat.simplex.app.views.usersettings + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.AlertManager +import chat.simplex.app.views.helpers.withApi + +@Composable +fun SMPServersView(chatModel: ChatModel) { + val userSMPServers = chatModel.userSMPServers.value + if (userSMPServers != null) { + var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) } + var editSMPServers by remember { mutableStateOf(!isUserSMPServers) } + var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") } + fun saveSMPServers(smpServers: List) { + withApi { + val r = chatModel.controller.setUserSMPServers(smpServers = smpServers) + if (r) { + chatModel.userSMPServers.value = smpServers + if (smpServers.isEmpty()) { + isUserSMPServers = false + editSMPServers = true + } else { + editSMPServers = false + } + } + } + } + + SMPServersLayout( + isUserSMPServers = isUserSMPServers, + editSMPServers = editSMPServers, + userSMPServersStr = userSMPServersStr, + isUserSMPServersOnOff = { switch -> + if (switch) { + isUserSMPServers = true + } else { + val userSMPServers = chatModel.userSMPServers.value + if (userSMPServers != null) { + if (userSMPServers.isNotEmpty()) { + AlertManager.shared.showAlertMsg( + title = "Use SimpleX Chat servers?", + text = "Saved SMP servers will be removed", + confirmText = "Confirm", + onConfirm = { + saveSMPServers(listOf()) + isUserSMPServers = false + userSMPServersStr = "" + } + ) + } else { + isUserSMPServers = false + userSMPServersStr = "" + } + } + } + }, + editUserSMPServersStr = { userSMPServersStr = it }, + cancelEdit = { + val userSMPServers = chatModel.userSMPServers.value + if (userSMPServers != null) { + isUserSMPServers = userSMPServers.isNotEmpty() + editSMPServers = !isUserSMPServers + userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "" + } + }, + saveSMPServers = { saveSMPServers(it) }, + editOn = { editSMPServers = true }, + ) + } +} + +@Composable +fun SMPServersLayout( + isUserSMPServers: Boolean, + editSMPServers: Boolean, + userSMPServersStr: String, + isUserSMPServersOnOff: (Boolean) -> Unit, + editUserSMPServersStr: (String) -> Unit, + cancelEdit: () -> Unit, + saveSMPServers: (List) -> Unit, + editOn: () -> Unit, +) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Your SMP servers", + Modifier.padding(bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text("Configure SMP servers", Modifier.padding(end = 24.dp)) + Switch( + checked = isUserSMPServers, + onCheckedChange = isUserSMPServersOnOff, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + ) + } + + if (!isUserSMPServers) { + Text("Using SimpleX Chat servers.") + } else { + Text("Enter one SMP server per line:") + if (editSMPServers) { + BasicTextField( + value = userSMPServersStr, + onValueChange = editUserSMPServersStr, + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, fontSize = 14.sp, + color = MaterialTheme.colors.onBackground + ), + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + autoCorrect = false + ), + modifier = Modifier.height(160.dp), + cursorBrush = SolidColor(HighOrLowlight), + decorationBox = { innerTextField -> + Surface( + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.secondary) + ) { + Row( + Modifier.background(MaterialTheme.colors.background), + verticalAlignment = Alignment.Top + ) { + Box( + Modifier + .weight(1f) + .padding(vertical = 5.dp, horizontal = 7.dp) + ) { + innerTextField() + } + } + } + } + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(horizontalAlignment = Alignment.Start) { + Row { + Text( + "Cancel", + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable(onClick = cancelEdit) + ) + Spacer(Modifier.padding(horizontal = 8.dp)) + Text( + "Save", + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable(onClick = { + val servers = userSMPServersStr.split("\n") + saveSMPServers(servers) + }) + ) + } + } + Column(horizontalAlignment = Alignment.End) { + howToButton() + } + } + } else { + Surface( + modifier = Modifier + .height(160.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.secondary) + ) { + SelectionContainer( + Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + userSMPServersStr, + Modifier + .padding(vertical = 5.dp, horizontal = 7.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp), + ) + } + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(horizontalAlignment = Alignment.Start) { + Text( + "Edit", + color = MaterialTheme.colors.primary, + modifier = Modifier + .clickable(onClick = editOn) + ) + } + Column(horizontalAlignment = Alignment.End) { + howToButton() + } + } + } + } + } +} + +@Composable +fun howToButton() { + val uriHandler = LocalUriHandler.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") } + ) { + Text("How to", color = MaterialTheme.colors.primary) + Icon( + Icons.Outlined.OpenInNew, "How to", tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(horizontal = 5.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewSMPServersLayoutDefaultServers() { + SimpleXTheme { + SMPServersLayout( + isUserSMPServers = false, + editSMPServers = true, + userSMPServersStr = "", + isUserSMPServersOnOff = {}, + editUserSMPServersStr = {}, + cancelEdit = {}, + saveSMPServers = {}, + editOn = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewSMPServersLayoutUserServersEditOn() { + SimpleXTheme { + SMPServersLayout( + isUserSMPServers = true, + editSMPServers = true, + userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + isUserSMPServersOnOff = {}, + editUserSMPServersStr = {}, + cancelEdit = {}, + saveSMPServers = {}, + editOn = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewSMPServersLayoutUserServersEditOff() { + SimpleXTheme { + SMPServersLayout( + isUserSMPServers = true, + editSMPServers = false, + userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", + isUserSMPServersOnOff = {}, + editUserSMPServersStr = {}, + cancelEdit = {}, + saveSMPServers = {}, + editOn = {}, + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index b9b4e69cc..0013f2df3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -99,6 +99,7 @@ fun SettingsLayout( Spacer(Modifier.padding(horizontal = 4.dp)) Text("How to use SimpleX Chat") } + Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showModal { MarkdownHelpView() }) { Icon( Icons.Outlined.TextFormat, @@ -133,6 +134,15 @@ fun SettingsLayout( } Spacer(Modifier.height(24.dp)) + SettingsSectionView(showModal { SMPServersView(it) }) { + Icon( + Icons.Outlined.Dns, + contentDescription = "SMP servers", + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text("SMP servers") + } + Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 3d38c4c7e..fd9a70e0e 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { .onAppear { do { try apiStartChat() + chatModel.userSMPServers = try getUserSMPServers() chatModel.chats = try apiGetChats() } catch { fatalError("Failed to start or load chats: \(error)") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 021110e99..8e7c6090a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -21,6 +21,7 @@ final class ChatModel: ObservableObject { // items in the terminal view @Published var terminalItems: [TerminalItem] = [] @Published var userAddress: String? + @Published var userSMPServers: [String]? @Published var appOpenUrl: URL? var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a6a45a8f1..0893c1c8a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -22,6 +22,8 @@ enum ChatCommand { case apiGetChats case apiGetChat(type: ChatType, id: Int64) case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) + case getUserSMPServers + case setUserSMPServers(smpServers: [String]) case addContact case connect(connReq: String) case apiDeleteChat(type: ChatType, id: Int64) @@ -43,6 +45,8 @@ enum ChatCommand { case .apiGetChats: return "/_get chats" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)" + case .getUserSMPServers: return "/smp_servers" + case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" case let .connect(connReq): return "/connect \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" @@ -67,6 +71,8 @@ enum ChatCommand { case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" + case .getUserSMPServers: return "getUserSMPServers" + case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" case .connect: return "connect" case .apiDeleteChat: return "apiDeleteChat" @@ -85,6 +91,10 @@ enum ChatCommand { func ref(_ type: ChatType, _ id: Int64) -> String { "\(type.rawValue)\(id)" } + + func smpServersStr(smpServers: [String]) -> String { + smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") + } } struct APIResponse: Decodable { @@ -98,6 +108,7 @@ enum ChatResponse: Decodable, Error { case chatRunning case apiChats(chats: [ChatData]) case apiChat(chat: ChatData) + case userSMPServers(smpServers: [String]) case invitation(connReqInvitation: String) case sentConfirmation case sentInvitation @@ -135,6 +146,7 @@ enum ChatResponse: Decodable, Error { case .chatRunning: return "chatRunning" case .apiChats: return "apiChats" case .apiChat: return "apiChat" + case .userSMPServers: return "userSMPServers" case .invitation: return "invitation" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" @@ -175,6 +187,7 @@ enum ChatResponse: Decodable, Error { case .chatRunning: return noDetails case let .apiChats(chats): return String(describing: chats) case let .apiChat(chat): return String(describing: chat) + case let .userSMPServers(smpServers): return String(describing: smpServers) case let .invitation(connReqInvitation): return connReqInvitation case .sentConfirmation: return noDetails case .sentInvitation: return noDetails @@ -372,6 +385,18 @@ func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) async throws -> throw r } +func getUserSMPServers() throws -> [String] { + let r = chatSendCmdSync(.getUserSMPServers) + if case let .userSMPServers(smpServers) = r { return smpServers } + throw r +} + +func setUserSMPServers(smpServers: [String]) async throws { + let r = await chatSendCmd(.setUserSMPServers(smpServers: smpServers)) + if case .cmdOk = r { return } + throw r +} + func apiAddContact() throws -> String { let r = chatSendCmdSync(.addContact, bgTask: false) if case let .invitation(connReqInvitation) = r { return connReqInvitation } diff --git a/apps/ios/Shared/Views/UserSettings/SMPServers.swift b/apps/ios/Shared/Views/UserSettings/SMPServers.swift new file mode 100644 index 000000000..d2a544b87 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SMPServers.swift @@ -0,0 +1,180 @@ +// +// SMPServers.swift +// SimpleX +// +// Created by Efim Poberezkin on 02.03.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private let serversFont = Font.custom("Menlo", size: 14) + +private let howToUrl = URL(string: "https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent")! + +struct SMPServers: View { + @EnvironmentObject var chatModel: ChatModel + @State var isUserSMPServers = false + @State var isUserSMPServersToggle = false + @State var editSMPServers = true + @State var userSMPServersStr = "" + @State var showBadServersAlert = false + @State var showResetServersAlert = false + @FocusState private var keyboardVisible: Bool + + var body: some View { + return VStack(alignment: .leading) { + Toggle("Configure SMP servers", isOn: $isUserSMPServersToggle) + .onChange(of: isUserSMPServersToggle) { _ in + if (isUserSMPServersToggle) { + isUserSMPServers = true + } else { + let servers = chatModel.userSMPServers ?? [] + if (!servers.isEmpty) { + showResetServersAlert = true + } else { + isUserSMPServers = false + userSMPServersStr = "" + } + } + } + .padding(.bottom) + .alert(isPresented: $showResetServersAlert) { + Alert( + title: Text("Use SimpleX Chat servers?"), + message: Text("Saved SMP servers will be removed"), + primaryButton: .destructive(Text("Confirm")) { + saveSMPServers(smpServers: []) + isUserSMPServers = false + userSMPServersStr = "" + }, secondaryButton: .cancel() { + withAnimation() { + isUserSMPServersToggle = true + } + } + ) + } + + if !isUserSMPServers { + Text("Using SimpleX Chat servers.") + .frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(alignment: .leading) { + Text("Enter one SMP server per line:") + if editSMPServers { + TextEditor(text: $userSMPServersStr) + .focused($keyboardVisible) + .font(serversFont) + .disableAutocorrection(true) + .textInputAutocapitalization(.never) + .padding(.horizontal, 5) + .padding(.top, 2) + .frame(height: 160) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) + ) + HStack(spacing: 20) { + Button("Cancel") { + initialize() + } + Button("Save") { + saveUserSMPServers() + } + .alert(isPresented: $showBadServersAlert) { + Alert(title: Text("Error saving SMP servers"), message: Text("Make sure SMP server addresses are in correct format, line separated and are not duplicated")) + } + Spacer() + howToButton() + } + } else { + ScrollView { + Text(userSMPServersStr) + .font(serversFont) + .padding(10) + .frame(minHeight: 0, alignment: .topLeading) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 160) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) + ) + HStack { + Button("Edit") { + editSMPServers = true + } + Spacer() + howToButton() + } + } + } + .frame(maxWidth: .infinity) + } + } + .padding() + .frame(maxHeight: .infinity, alignment: .top) + .onAppear { initialize() } + } + + func initialize() { + let userSMPServers = chatModel.userSMPServers ?? [] + isUserSMPServers = !userSMPServers.isEmpty + isUserSMPServersToggle = isUserSMPServers + editSMPServers = !isUserSMPServers + userSMPServersStr = isUserSMPServers ? userSMPServers.joined(separator: "\n") : "" + } + + func saveUserSMPServers() { + let userSMPServers = userSMPServersStr.components(separatedBy: "\n") + saveSMPServers(smpServers: userSMPServers) + } + + func saveSMPServers(smpServers: [String]) { + Task { + do { + try await setUserSMPServers(smpServers: smpServers) + DispatchQueue.main.async { + chatModel.userSMPServers = smpServers + if smpServers.isEmpty { + isUserSMPServers = false + editSMPServers = true + } else { + editSMPServers = false + } + } + } catch { + let err = error.localizedDescription + logger.error("SMPServers.saveServers setUserSMPServers error: \(err)") + DispatchQueue.main.async { + showBadServersAlert = true + } + } + } + } + + func howToButton() -> some View { + Button { + DispatchQueue.main.async { + UIApplication.shared.open(howToUrl) + } + } label: { + HStack{ + Text("How to") + Image(systemName: "arrow.up.right.circle") + } + } + } +} + +// TODO preview doesn't work +struct SMPServers_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.currentUser = User.sampleData + chatModel.userSMPServers = [] + return SMPServers() + .environmentObject(chatModel) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 0dd85b7aa..193e014db 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -47,6 +47,19 @@ struct SettingsView: View { } } } + + Section("Settings") { + NavigationLink { + SMPServers() + .navigationTitle("Your SMP servers") + } label: { + HStack { + Image(systemName: "server.rack") + .padding(.trailing, 4) + Text("SMP servers") + } + } + } Section("Help") { NavigationLink { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 87016793d..11d2e58f2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -92,16 +92,18 @@ 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 */; }; + 640F50D827CF8872001E05C2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D327CF8872001E05C2 /* libgmp.a */; }; + 640F50D927CF8872001E05C2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D327CF8872001E05C2 /* libgmp.a */; }; + 640F50DA27CF8872001E05C2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D427CF8872001E05C2 /* libffi.a */; }; + 640F50DB27CF8872001E05C2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D427CF8872001E05C2 /* libffi.a */; }; + 640F50DC27CF8872001E05C2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D527CF8872001E05C2 /* libgmpxx.a */; }; + 640F50DD27CF8872001E05C2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D527CF8872001E05C2 /* libgmpxx.a */; }; + 640F50DE27CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D627CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a */; }; + 640F50DF27CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D627CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a */; }; + 640F50E027CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D727CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a */; }; + 640F50E127CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 640F50D727CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.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 */ @@ -174,11 +176,12 @@ 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407527ADB66A007B033A /* TextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextItemView.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; - 5CF60A4727D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a"; sourceTree = ""; }; - 5CF60A4827D401D6009F2C98 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF60A4927D401D6009F2C98 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a"; sourceTree = ""; }; - 5CF60A4A27D401D6009F2C98 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF60A4B27D401D7009F2C98 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 640F50D327CF8872001E05C2 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 640F50D427CF8872001E05C2 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 640F50D527CF8872001E05C2 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 640F50D627CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a"; sourceTree = ""; }; + 640F50D727CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a"; sourceTree = ""; }; + 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -186,13 +189,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 640F50DC27CF8872001E05C2 /* libgmpxx.a in Frameworks */, 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 */, + 640F50DE27CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a in Frameworks */, + 640F50E027CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, + 640F50D827CF8872001E05C2 /* libgmp.a in Frameworks */, + 640F50DA27CF8872001E05C2 /* libffi.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -201,13 +204,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CF60A4F27D401D7009F2C98 /* libgmpxx.a in Frameworks */, 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, + 640F50DD27CF8872001E05C2 /* libgmpxx.a in Frameworks */, + 640F50DF27CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a in Frameworks */, + 640F50E127CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a in Frameworks */, + 640F50D927CF8872001E05C2 /* 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 */, + 640F50DB27CF8872001E05C2 /* libffi.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 */, + 640F50D427CF8872001E05C2 /* libffi.a */, + 640F50D327CF8872001E05C2 /* libgmp.a */, + 640F50D527CF8872001E05C2 /* libgmpxx.a */, + 640F50D727CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla-ghc8.10.7.a */, + 640F50D627CF8872001E05C2 /* libHSsimplex-chat-1.3.0-5IozqhzNoFs59GB71w8Qla.a */, ); path = Libraries; sourceTree = ""; @@ -385,6 +388,7 @@ 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, + 640F50E227CF991C001E05C2 /* SMPServers.swift */, ); path = UserSettings; sourceTree = ""; @@ -587,6 +591,7 @@ 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, + 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, @@ -631,6 +636,7 @@ 5C764E81279C7276000C6508 /* dummy.m in Sources */, 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, + 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, diff --git a/cabal.project b/cabal.project index 609b8ef40..f2fe863ec 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: git://github.com/simplex-chat/simplexmq.git - tag: 7a19ab224bdd1122f0761704b6ca1eb4e1e26eb7 + tag: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc source-repository-package type: git diff --git a/rfcs/2022-02-24-servers-configuration.md b/rfcs/2022-02-24-servers-configuration.md new file mode 100644 index 000000000..f7f255fb8 --- /dev/null +++ b/rfcs/2022-02-24-servers-configuration.md @@ -0,0 +1,29 @@ +# Server configuration + +- in agent: + - Agent.Env.SQLite - move smpServers from AgentConfig to Env, make it TVar; keep "initialSmpServers" in AgentConfig? + - Agent - getSMPServer to read servers from Env and choose a random server + - Agent - new functional api - "useServers" + - ~~Agent.Protocol - new ACommand?~~ +- chat core: + - db: + - new table `smp_servers`, server per row, same columns as for agent. Have rowid for future + - getServers method + - update - truncate and rewrite + - ChatCommand GetServers - new ChatResponse with list of user SMPServers, it may be empty if default are used + - ChatCommand SetServers - ChatResponse Ok (restore default servers is empty set servers list) + - agent config is populated using getServers, if it's empty default are used +- mobile chat: + - mobileChatOpts to be populated with initial servers on init (getServers or default if empty) + - in ui: + - view in settings + - GetServers on view open to populate + - Confirm buttons, Restore button - destructive - clears user servers and default are used + - validation + - validation on submit, error with server's string + - ~~TBD real-time validation~~ + - ~~fastest is validation on submit without detailed error?~~ + - ~~maybe even faster - alternatively have 3 fields for entry per server - fingerprint, host, port - and build server strings (still validate to avoid hard crash?)?~~ +- terminal chat: + - if -s option is given, these servers are used and getServers is not used for populating agentConfig + - if -s option is not provided - same as in mobile - getServers or default if empty diff --git a/rfcs/2022-03-02-avatars.md b/rfcs/2022-03-02-avatars.md new file mode 100644 index 000000000..c0666131f --- /dev/null +++ b/rfcs/2022-03-02-avatars.md @@ -0,0 +1,14 @@ +# Include (Optional) Images in User Profiles + +1. Add SQL migration for database in `src/Simplex/Chat/Migrations` + - This will touch `contact_profiles` and `group_profiles` + +2. Add field to `User` in `Types.hs` allowing for null entry using `Maybe` + +3. Extend parsing in `Chat.hs` under `chatCommandP :: Parser ChatCommand` + +4. Update `UpdateProfile` in `Chat.hs` to accept possible display picture and implement an `APIUpdateProfile` command which accepts a JSON string `/_profile{...}` which will add the image to a profile. + +5. Connect up to Android and iOS apps (new PRs) + +Profile images will be base 64 encoded images. We can use the `base64P` parser to process them and pass them as JSON. diff --git a/sha256map.nix b/sha256map.nix index 6f6c57d8b..c5c5c0050 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "git://github.com/simplex-chat/simplexmq.git"."7a19ab224bdd1122f0761704b6ca1eb4e1e26eb7" = "1sn2bzz5v2r6wxf1p2k9578zwp0vlb42lb6xjqwpl4acr47wcx0g"; + "git://github.com/simplex-chat/simplexmq.git"."5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc" = "0qjmldlrxl5waqfbsckjhxkd3zn25bkbyqwf9l0r4gq3c7l6k358"; "git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ee2cf0b4b..0c8916060 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -29,6 +29,8 @@ library Simplex.Chat.Migrations.M20220205_chat_item_status Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests Simplex.Chat.Migrations.M20220224_messages_fks + Simplex.Chat.Migrations.M20220301_smp_servers + Simplex.Chat.Migrations.M20220302_profile_images Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d5afd8003..d9bcd9416 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -22,15 +22,18 @@ import Crypto.Random (drgNew) import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (first) +import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find) +import Data.List.NonEmpty (NonEmpty, nonEmpty) +import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (isJust, mapMaybe) +import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) @@ -39,7 +42,7 @@ import Data.Word (Word32) import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages -import Simplex.Chat.Options (ChatOpts (..)) +import Simplex.Chat.Options (ChatOpts (..), smpServersP) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Types @@ -50,7 +53,7 @@ import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (parseAll) +import Simplex.Messaging.Parsers (base64P, parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Util (tryError) @@ -71,8 +74,8 @@ defaultChatConfig = { agentConfig = defaultAgentConfig { tcpPort = undefined, -- agent does not listen to TCP - smpServers = undefined, -- filled in from options - dbFile = undefined, -- filled in from options + initialSMPServers = undefined, -- filled in newChatController + dbFile = undefined, -- filled in newChatController dbPoolSize = 1, yesToMigrations = False }, @@ -85,6 +88,14 @@ defaultChatConfig = testView = False } +defaultSMPServers :: NonEmpty SMPServer +defaultSMPServers = + L.fromList + [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im", + "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im", + "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im" + ] + logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} @@ -95,7 +106,8 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch activeTo <- newTVarIO ActiveNone firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user - smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", smpServers} + initialSMPServers <- resolveServers + smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", initialSMPServers} agentAsync <- newTVarIO Nothing idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO tbqSize @@ -105,6 +117,13 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} + where + resolveServers :: IO (NonEmpty SMPServer) + resolveServers = case user of + Nothing -> pure $ if null smpServers then defaultSMPServers else L.fromList smpServers + Just usr -> do + userSmpServers <- getSMPServers chatStore usr + pure . fromMaybe defaultSMPServers . nonEmpty $ if null smpServers then userSmpServers else smpServers runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () runChatController = race_ notificationSubscriber . agentSubscriber @@ -196,6 +215,11 @@ processChatCommand = \case `E.finally` deleteContactRequest st userId connReqId withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq + GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) + SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do + withStore $ \st -> overwriteSMPServers st user smpServers + withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers)) + pure CRCmdOk ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do @@ -370,17 +394,12 @@ processChatCommand = \case FileStatus fileId -> CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile - UpdateProfile p@Profile {displayName} -> withUser $ \user@User {profile} -> - if p == profile - then pure CRUserProfileNoChange - else do - withStore $ \st -> updateUserProfile st user p - let user' = (user :: User) {localDisplayName = displayName, profile = p} - asks currentUser >>= atomically . (`writeTVar` Just user') - contacts <- withStore (`getUserContacts` user) - withChatLock . procCmd $ do - forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p - pure $ CRUserProfileUpdated profile p + UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do + let p = (profile :: Profile) {displayName = displayName, fullName = fullName} + updateProfile user p + UpdateProfileImage image -> withUser $ \user@User {profile} -> do + let p = (profile :: Profile) {image = Just image} + updateProfile user p QuitChat -> liftIO exitSuccess ShowVersion -> pure $ CRVersionInfo versionNumber where @@ -419,6 +438,18 @@ processChatCommand = \case checkSndFile f = do unlessM (doesFileExist f) . throwChatError $ CEFileNotFound f (,) <$> getFileSize f <*> asks (fileChunkSize . config) + updateProfile :: User -> Profile -> m ChatResponse + updateProfile user@User {profile = p} p'@Profile {displayName} = do + if p' == p + then pure CRUserProfileNoChange + else do + withStore $ \st -> updateUserProfile st user p' + let user' = (user :: User) {localDisplayName = displayName, profile = p'} + asks currentUser >>= atomically . (`writeTVar` Just user') + contacts <- withStore (`getUserContacts` user) + withChatLock . procCmd $ do + forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p' + pure $ CRUserProfileUpdated p p' getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath getRcvFilePath fileId filePath fileName = case filePath of Nothing -> do @@ -1356,7 +1387,7 @@ getCreateActiveUser st = do loop = do displayName <- getContactName fullName <- T.pack <$> getWithPrompt "full name (optional)" - liftIO (runExceptT $ createUser st Profile {displayName, fullName} True) >>= \case + liftIO (runExceptT $ createUser st Profile {displayName, fullName, image = Nothing} True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop @@ -1426,6 +1457,8 @@ withStore :: withStore action = asks chatStore >>= runExceptT . action + -- use this line instead of above to log query errors + -- >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e) >>= liftEither . first ChatErrorStore chatCommandP :: Parser ChatCommand @@ -1441,6 +1474,9 @@ chatCommandP = <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) + <|> "/smp_servers default" $> SetUserSMPServers [] + <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP) + <|> "/smp_servers" $> GetUserSMPServers <|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles <|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups <|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress @@ -1473,11 +1509,14 @@ chatCommandP = <|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName) <|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown <|> ("/welcome" <|> "/w") $> Welcome - <|> ("/profile " <|> "/p ") *> (UpdateProfile <$> userProfile) + <|> "/profile_image " *> (UpdateProfileImage . ProfileImage <$> imageP) + <|> ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames) <|> ("/profile" <|> "/p") $> ShowProfile <|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat <|> ("/version" <|> "/v") $> ShowVersion where + imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") + imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P)) chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup chatPaginationP = (CPLast <$ "count=" <*> A.decimal) @@ -1487,14 +1526,17 @@ chatCommandP = displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) refChar c = c > ' ' && c /= '#' && c /= '@' onOffP = ("on" $> True) <|> ("off" $> False) - userProfile = do + userNames = do cName <- displayName fullName <- fullNameP cName - pure Profile {displayName = cName, fullName} + pure (cName, fullName) + userProfile = do + (cName, fullName) <- userNames + pure Profile {displayName = cName, fullName, image = Nothing} groupProfile = do gName <- displayName fullName <- fullNameP gName - pure GroupProfile {displayName = gName, fullName} + pure GroupProfile {displayName = gName, fullName, image = Nothing} fullNameP name = do n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6b44a6e47..a9d04f87b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -95,6 +95,8 @@ data ChatCommand | APIDeleteChat ChatType Int64 | APIAcceptContact Int64 | APIRejectContact Int64 + | GetUserSMPServers + | SetUserSMPServers [SMPServer] | ChatHelp HelpSection | Welcome | AddContact @@ -125,7 +127,8 @@ data ChatCommand | CancelFile FileTransferId | FileStatus FileTransferId | ShowProfile - | UpdateProfile Profile + | UpdateProfile ContactName Text + | UpdateProfileImage ProfileImage | QuitChat | ShowVersion deriving (Show) @@ -136,6 +139,7 @@ data ChatResponse | CRChatRunning | CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} + | CRUserSMPServers {smpServers :: [SMPServer]} | CRNewChatItem {chatItem :: AChatItem} | CRChatItemUpdated {chatItem :: AChatItem} | CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 482e48000..3f17074dd 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -86,10 +86,11 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: files, groups, address", + indent <> highlight "/help " <> " - help on: files, groups, address, smp_servers", indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/contacts " <> " - list contacts", + indent <> highlight "/smp_servers " <> " - show / set custom SMP servers", indent <> highlight "/markdown " <> " - supported markdown syntax", indent <> highlight "/version " <> " - SimpleX Chat version", indent <> highlight "/quit " <> " - quit chat", diff --git a/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs b/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs new file mode 100644 index 000000000..774f2e016 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220301_smp_servers where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220301_smp_servers :: Query +m20220301_smp_servers = + [sql| +CREATE TABLE smp_servers ( + smp_server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (host, port) +); +|] diff --git a/src/Simplex/Chat/Migrations/M20220302_profile_images.hs b/src/Simplex/Chat/Migrations/M20220302_profile_images.hs new file mode 100644 index 000000000..72c22b89c --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220302_profile_images.hs @@ -0,0 +1,13 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220302_profile_images where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220302_profile_images :: Query +m20220302_profile_images = + [sql| + ALTER TABLE contact_profiles ADD COLUMN image TEXT; + ALTER TABLE group_profiles ADD COLUMN image TEXT; +|] diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index be007c19e..e4f15dd0d 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -49,7 +49,7 @@ mobileChatOpts :: ChatOpts mobileChatOpts = ChatOpts { dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db - smpServers = defaultSMPServers, + smpServers = [], logConnections = False, logAgent = False } diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 06b90edaa..6fd25a8d3 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -3,14 +3,12 @@ module Simplex.Chat.Options ( ChatOpts (..), getChatOpts, - defaultSMPServers, + smpServersP, ) where import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B -import Data.List.NonEmpty (NonEmpty) -import qualified Data.List.NonEmpty as L import Options.Applicative import Simplex.Chat.Controller (updateStr, versionStr) import Simplex.Messaging.Agent.Protocol (SMPServer (..)) @@ -20,19 +18,11 @@ import System.FilePath (combine) data ChatOpts = ChatOpts { dbFilePrefix :: String, - smpServers :: NonEmpty SMPServer, + smpServers :: [SMPServer], logConnections :: Bool, logAgent :: Bool } -defaultSMPServers :: NonEmpty SMPServer -defaultSMPServers = - L.fromList - [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im", - "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im", - "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im" - ] - chatOpts :: FilePath -> Parser ChatOpts chatOpts appDir = ChatOpts @@ -45,13 +35,13 @@ chatOpts appDir = <> showDefault ) <*> option - parseSMPServer + parseSMPServers ( long "server" <> short 's' <> metavar "SERVER" <> help "Comma separated list of SMP server(s) to use" - <> value defaultSMPServers + <> value [] ) <*> switch ( long "connections" @@ -66,10 +56,11 @@ chatOpts appDir = where defaultDbFilePath = combine appDir "simplex_v1" -parseSMPServer :: ReadM (NonEmpty SMPServer) -parseSMPServer = eitherReader $ parseAll servers . B.pack - where - servers = L.fromList <$> strP `A.sepBy1` A.char ',' +parseSMPServers :: ReadM [SMPServer] +parseSMPServers = eitherReader $ parseAll smpServersP . B.pack + +smpServersP :: A.Parser [SMPServer] +smpServersP = strP `A.sepBy1` A.char ',' getChatOpts :: FilePath -> IO ChatOpts getChatOpts appDir = diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 39d09d544..784418eaa 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -121,6 +121,8 @@ module Simplex.Chat.Store updateDirectChatItem, updateDirectChatItemsRead, updateGroupChatItemsRead, + getSMPServers, + overwriteSMPServers, ) where @@ -158,10 +160,12 @@ import Simplex.Chat.Migrations.M20220122_v1_1 import Simplex.Chat.Migrations.M20220205_chat_item_status import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests import Simplex.Chat.Migrations.M20220224_messages_fks +import Simplex.Chat.Migrations.M20220301_smp_servers +import Simplex.Chat.Migrations.M20220302_profile_images import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..)) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C @@ -176,7 +180,9 @@ schemaMigrations = ("20220122_v1_1", m20220122_v1_1), ("20220205_chat_item_status", m20220205_chat_item_status), ("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests), - ("20220224_messages_fks", m20220224_messages_fks) + ("20220224_messages_fks", m20220224_messages_fks), + ("20220301_smp_servers", m20220301_smp_servers), + ("20220302_profile_images", m20220302_profile_images) ] -- | The list of migrations in ascending order by date @@ -205,7 +211,7 @@ insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" type StoreMonad m = (MonadUnliftIO m, MonadError StoreError m) createUser :: StoreMonad m => SQLiteStore -> Profile -> Bool -> m User -createUser st Profile {displayName, fullName} activeUser = +createUser st Profile {displayName, fullName, image} activeUser = liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute @@ -219,8 +225,8 @@ createUser st Profile {displayName, fullName} activeUser = (displayName, displayName, userId, currentTs, currentTs) DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" - (displayName, fullName, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, fullName, image, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -228,7 +234,7 @@ createUser st Profile {displayName, fullName} activeUser = (profileId, displayName, userId, True, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName) + pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName, image) getUsers :: SQLiteStore -> IO [User] getUsers st = @@ -237,15 +243,15 @@ getUsers st = <$> DB.query_ db [sql| - SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name + SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name, p.image FROM users u JOIN contacts c ON u.contact_id = c.contact_id JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id |] -toUser :: (UserId, Int64, Bool, ContactName, Text) -> User -toUser (userId, userContactId, activeUser, displayName, fullName) = - let profile = Profile {displayName, fullName} +toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ProfileImage) -> User +toUser (userId, userContactId, activeUser, displayName, fullName, image) = + let profile = Profile {displayName, fullName, image} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m () @@ -283,7 +289,7 @@ getConnReqContactXContactId st userId cReqHash = do [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at @@ -338,12 +344,12 @@ createDirectContact st userId activeConn@Connection {connId} profile = pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt} createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> IO (Either StoreError (Text, Int64, Int64)) -createContact_ db userId connId Profile {displayName, fullName} viaGroup currentTs = +createContact_ db userId connId Profile {displayName, fullName, image} viaGroup currentTs = withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" - (displayName, fullName, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, fullName, image, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -420,13 +426,14 @@ updateContactProfile_ db userId contactId profile = do updateContactProfile_' db userId contactId profile currentTs updateContactProfile_' :: DB.Connection -> UserId -> Int64 -> Profile -> UTCTime -> IO () -updateContactProfile_' db userId contactId Profile {displayName, fullName} updatedAt = do +updateContactProfile_' db userId contactId Profile {displayName, fullName, image} updatedAt = do DB.executeNamed db [sql| UPDATE contact_profiles SET display_name = :display_name, full_name = :full_name, + image = :image, updated_at = :updated_at WHERE contact_profile_id IN ( SELECT contact_profile_id @@ -437,6 +444,7 @@ updateContactProfile_' db userId contactId Profile {displayName, fullName} updat |] [ ":display_name" := displayName, ":full_name" := fullName, + ":image" := image, ":updated_at" := updatedAt, ":user_id" := userId, ":contact_id" := contactId @@ -454,17 +462,17 @@ updateContact_ db userId contactId displayName newName updatedAt = do (newName, updatedAt, userId, contactId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) -type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, UTCTime) +type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage, UTCTime) toContact :: ContactRow :. ConnectionRow -> Contact -toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) = - let profile = Profile {displayName, fullName} +toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) = + let profile = Profile {displayName, fullName, image} activeConn = toConnection connRow in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact -toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) = - let profile = Profile {displayName, fullName} +toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) = + let profile = Profile {displayName, fullName, image} in case toMaybeConnection connRow of Just activeConn -> Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} @@ -601,7 +609,7 @@ createOrUpdateContactRequest st userId userContactLinkId invId profile xContactI createOrUpdateContactRequest_ db userId userContactLinkId invId profile xContactId_ createOrUpdateContactRequest_ :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> IO (Either StoreError (Either Contact UserContactRequest)) -createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName} xContactId_ = +createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName, image} xContactId_ = maybeM getContact' xContactId_ >>= \case Just contact -> pure . Right $ Left contact Nothing -> Right <$$> createOrUpdate_ @@ -621,8 +629,8 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display createContactRequest_ currentTs ldn = do DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" - (displayName, fullName, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, fullName, image, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -642,7 +650,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at @@ -662,7 +670,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -685,6 +693,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display UPDATE contact_profiles SET display_name = ?, full_name = ?, + image = ?, updated_at = ? WHERE contact_profile_id IN ( SELECT contact_profile_id @@ -693,7 +702,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display AND contact_request_id = ? ) |] - (ldn, fullName, updatedAt, userId, cReqId) + (ldn, fullName, image, updatedAt, userId, cReqId) DB.execute db [sql| @@ -720,7 +729,7 @@ getContactRequest_ db userId contactRequestId = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -729,11 +738,11 @@ getContactRequest_ db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime, Maybe XContactId) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ProfileImage, UTCTime, Maybe XContactId) toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt, xContactId) = do - let profile = Profile {displayName, fullName} +toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, createdAt, xContactId) = do + let profile = Profile {displayName, fullName, image} in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt, xContactId} getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64 @@ -875,7 +884,7 @@ toMaybeConnection (Just connId, Just agentConnId, Just connLevel, viaContact, Ju toMaybeConnection _ = Nothing getMatchingContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [Contact] -getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName}} = +getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName, image}} = liftIO . withTransaction st $ \db -> do contactIds <- map fromOnly @@ -887,11 +896,13 @@ getMatchingContacts st userId Contact {contactId, profile = Profile {displayName JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = :user_id AND ct.contact_id != :contact_id AND p.display_name = :display_name AND p.full_name = :full_name + AND ((p.image IS NULL AND :image IS NULL) OR p.image = :image) |] [ ":user_id" := userId, ":contact_id" := contactId, ":display_name" := displayName, - ":full_name" := fullName + ":full_name" := fullName, + ":image" := image ] rights <$> mapM (getContact_ db userId) contactIds @@ -1055,15 +1066,15 @@ getConnectionEntity st User {userId, userContactId} agentConnId = <$> DB.query db [sql| - SELECT c.local_display_name, p.display_name, p.full_name, c.via_group, c.created_at + SELECT c.local_display_name, p.display_name, p.full_name, p.image, c.via_group, c.created_at FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe Int64, UTCTime)] -> Either StoreError Contact - toContact' contactId activeConn [(localDisplayName, displayName, fullName, viaGroup, createdAt)] = - let profile = Profile {displayName, fullName} + toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime)] -> Either StoreError Contact + toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt)] = + let profile = Profile {displayName, fullName, image} in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) @@ -1074,15 +1085,15 @@ getConnectionEntity st User {userId, userContactId} agentConnId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, + pu.display_name, pu.full_name, pu.image, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id JOIN groups g ON g.group_id = m.group_id @@ -1144,7 +1155,7 @@ updateConnectionStatus st Connection {connId} connStatus = createNewGroup :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m GroupInfo createNewGroup st gVar user groupProfile = liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do - let GroupProfile {displayName, fullName} = groupProfile + let GroupProfile {displayName, fullName, image} = groupProfile uId = userId user currentTs <- getCurrentTime DB.execute @@ -1153,8 +1164,8 @@ createNewGroup st gVar user groupProfile = (displayName, displayName, uId, currentTs, currentTs) DB.execute db - "INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" - (displayName, fullName, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, fullName, image, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -1181,13 +1192,13 @@ createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInv <$> DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) createGroupInvitation_ :: DB.Connection -> IO (Either StoreError GroupInfo) createGroupInvitation_ db = do - let GroupProfile {displayName, fullName} = groupProfile + let GroupProfile {displayName, fullName, image} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> do currentTs <- getCurrentTime DB.execute db - "INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" - (displayName, fullName, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, fullName, image, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -1238,9 +1249,9 @@ getUserGroupDetails st User {userId, userContactId} = <$> DB.query db [sql| - SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name + m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name, mp.image FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members m USING (group_id) @@ -1255,12 +1266,12 @@ getGroupInfoByName st user gName = gId <- ExceptT $ getGroupIdByName_ db user gName ExceptT $ getGroupInfo_ db user gId -type GroupInfoRow = (Int64, GroupName, GroupName, Text, UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ProfileImage, UTCTime) :. GroupMemberRow toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo -toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, createdAt) :. userMemberRow) = +toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt) :. userMemberRow) = let membership = toGroupMember userContactId userMemberRow - in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership, createdAt} + in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, createdAt} getGroupMembers :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> m [GroupMember] getGroupMembers st user gInfo = liftIO . withTransaction st $ \db -> getGroupMembers_ db user gInfo @@ -1273,7 +1284,7 @@ getGroupMembers_ db User {userId, userContactId} GroupInfo {groupId} = do [sql| SELECT m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM group_members m @@ -1313,20 +1324,20 @@ getGroupInvitation st user localDisplayName = findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId) findFromContact _ = const Nothing -type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text) +type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage) -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ProfileImage) toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) = - let memberProfile = Profile {displayName, fullName} +toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) = + let memberProfile = Profile {displayName, fullName, image} invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName) = - Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) +toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName, image) = + Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) toMaybeGroupMember _ _ = Nothing createContactMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> m GroupMember @@ -1369,14 +1380,14 @@ updateGroupMemberStatus st userId GroupMember {groupMemberId} memStatus = -- | add new member with profile createNewGroupMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> m GroupMember -createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName}) memCategory memStatus = +createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image}) memCategory memStatus = liftIOEither . withTransaction st $ \db -> withLocalDisplayName db userId displayName $ \localDisplayName -> do currentTs <- getCurrentTime DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)" - (displayName, fullName, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)" + (displayName, fullName, image, currentTs, currentTs) memProfileId <- insertedRowId db let newMember = NewGroupMember @@ -1635,15 +1646,15 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, + pu.display_name, pu.full_name, pu.image, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM group_members m @@ -1677,7 +1688,7 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = db [sql| SELECT - ct.contact_id, ct.local_display_name, p.display_name, p.full_name, ct.via_group, ct.created_at, + ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct @@ -1693,9 +1704,9 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: [(Int64, ContactName, Text, Text, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact - toContact' [(contactId, localDisplayName, displayName, fullName, viaGroup, createdAt) :. connRow] = - let profile = Profile {displayName, fullName} + toContact' :: [(Int64, ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact + toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt) :. connRow] = + let profile = Profile {displayName, fullName, image} activeConn = toConnection connRow in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} toContact' _ = Nothing @@ -2208,7 +2219,7 @@ getDirectChatPreviews_ db User {userId} = do [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, @@ -2265,11 +2276,11 @@ getGroupChatPreviews_ db User {userId, userContactId} = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, - pu.display_name, pu.full_name, + pu.display_name, pu.full_name, pu.image, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem @@ -2277,7 +2288,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name + p.display_name, p.full_name, p.image FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -2318,7 +2329,7 @@ getContactRequestChatPreviews_ db User {userId} = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id FROM contact_requests cr JOIN connections c USING (user_contact_link_id) JOIN contact_profiles p USING (contact_profile_id) @@ -2453,7 +2464,7 @@ getContact_ db userId contactId = [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at, + ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at @@ -2504,7 +2515,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name + p.display_name, p.full_name, p.image FROM chat_items ci LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id @@ -2534,7 +2545,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name + p.display_name, p.full_name, p.image FROM chat_items ci LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id @@ -2564,7 +2575,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name + p.display_name, p.full_name, p.image FROM chat_items ci LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id @@ -2604,11 +2615,11 @@ getGroupInfo_ db User {userId, userContactId} groupId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, - pu.display_name, pu.full_name + pu.display_name, pu.full_name, pu.image FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -2748,6 +2759,38 @@ toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemConten either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_) toGroupChatItemList _ _ _ = [] +getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] +getSMPServers st User {userId} = + liftIO . withTransaction st $ \db -> + map toSmpServer + <$> DB.query + db + [sql| + SELECT host, port, key_hash + FROM smp_servers + WHERE user_id = ?; + |] + (Only userId) + where + toSmpServer :: (String, String, C.KeyHash) -> SMPServer + toSmpServer (host, port, keyHash) = SMPServer host port keyHash + +overwriteSMPServers :: StoreMonad m => SQLiteStore -> User -> [SMPServer] -> m () +overwriteSMPServers st User {userId} smpServers = do + liftIOEither . checkConstraint SEUniqueID . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId) + forM_ smpServers $ \SMPServer {host, port, keyHash} -> + DB.execute + db + [sql| + INSERT INTO smp_servers + (host, port, key_hash, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + |] + (host, port, keyHash, userId, currentTs, currentTs) + pure $ Right () + -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b342f3077..84883ab55 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -170,19 +170,39 @@ groupName' GroupInfo {localDisplayName = g} = g data Profile = Profile { displayName :: ContactName, - fullName :: Text + fullName :: Text, + image :: Maybe ProfileImage } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON Profile where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data GroupProfile = GroupProfile { displayName :: GroupName, - fullName :: Text + fullName :: Text, + image :: Maybe ProfileImage } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON GroupProfile where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +newtype ProfileImage = ProfileImage Text + deriving (Eq, Show) + +instance FromJSON ProfileImage where + parseJSON = fmap ProfileImage . J.parseJSON + +instance ToJSON ProfileImage where + toJSON (ProfileImage t) = J.toJSON t + toEncoding (ProfileImage t) = J.toEncoding t + +instance ToField ProfileImage where toField (ProfileImage t) = toField t + +instance FromField ProfileImage where fromField = fmap ProfileImage . fromField data GroupInvitation = GroupInvitation { fromMember :: MemberIdRole, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ecfb033a6..af40b1a5a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -8,10 +8,11 @@ module Simplex.Chat.View where import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B import Data.Function (on) import Data.Int (Int64) -import Data.List (groupBy, intersperse, partition, sortOn) -import Data.Maybe (isJust) +import Data.List (groupBy, intercalate, intersperse, partition, sortOn) +import Data.Maybe (isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (DiffTime) @@ -42,6 +43,7 @@ responseToView testView = \case CRChatRunning -> [] CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] + CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRChatItemUpdated _ -> [] CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr @@ -316,9 +318,27 @@ viewUserProfile Profile {displayName, fullName} = "(the updated profile will be sent to all your contacts)" ] +viewSMPServers :: [SMPServer] -> Bool -> [StyledString] +viewSMPServers smpServers testView = + if testView + then [customSMPServers] + else + [ customSMPServers, + "", + "use " <> highlight' "/smp_servers " <> " to switch to custom SMP servers", + "use " <> highlight' "/smp_servers default" <> " to remove custom SMP servers and use default", + "(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)" + ] + where + customSMPServers = + if null smpServers + then "no custom SMP servers saved" + else plain $ intercalate ", " (map (B.unpack . strEncode) smpServers) + viewUserProfileUpdated :: Profile -> Profile -> [StyledString] -viewUserProfileUpdated Profile {displayName = n, fullName} Profile {displayName = n', fullName = fullName'} - | n == n' && fullName == fullName' = [] +viewUserProfileUpdated Profile {displayName = n, fullName, image} Profile {displayName = n', fullName = fullName', image = image'} + | n == n' && fullName == fullName' && image == image' = [] + | n == n' && fullName == fullName' = [if isNothing image' then "profile image removed" else "profile image updated"] | n == n' = ["user full name " <> (if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName') <> notified] | otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified] where diff --git a/stack.yaml b/stack.yaml index 221d206e5..922f7b490 100644 --- a/stack.yaml +++ b/stack.yaml @@ -48,7 +48,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 7a19ab224bdd1122f0761704b6ca1eb4e1e26eb7 + commit: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index cd65edf86..dd996011c 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -132,6 +132,12 @@ testChatN ps test = withTmpFiles $ do getTermLine :: TestCC -> IO String getTermLine = atomically . readTQueue . termQ +-- Use below to echo virtual terminal +-- getTermLine cc = do +-- s <- atomically . readTQueue $ termQ cc +-- putStrLn s +-- pure s + testChat2 :: Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO () testChat2 p1 p2 test = testChatN [p1, p2] test_ where diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index a90ea50e3..dcce72827 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -14,27 +14,29 @@ import Data.Char (isDigit) import Data.Maybe (fromJust) import qualified Data.Text as T import Simplex.Chat.Controller (ChatController (..)) -import Simplex.Chat.Types (Profile (..), User (..)) +import Simplex.Chat.Types (Profile (..), ProfileImage (..), User (..)) import Simplex.Chat.Util (unlessM) import System.Directory (doesFileExist) import Test.Hspec aliceProfile :: Profile -aliceProfile = Profile {displayName = "alice", fullName = "Alice"} +aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing} bobProfile :: Profile -bobProfile = Profile {displayName = "bob", fullName = "Bob"} +bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ProfileImage "data:image/png;base64,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")} cathProfile :: Profile -cathProfile = Profile {displayName = "cath", fullName = "Catherine"} +cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing} danProfile :: Profile -danProfile = Profile {displayName = "dan", fullName = "Daniel"} +danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing} chatTests :: Spec chatTests = do describe "direct messages" $ it "add contact and send/receive message" testAddContact + describe "SMP servers" $ + it "get and set SMP servers" testGetSetSMPServers describe "chat groups" $ do it "add contacts, create group and send/receive messages" testGroup it "create and join group with 4 members" testGroup2 @@ -43,8 +45,9 @@ chatTests = do it "re-add member in status invited" testGroupReAddInvited it "remove contact from group and add again" testGroupRemoveAdd it "list groups containing group invitations" testGroupList - describe "user profiles" $ + describe "user profiles" $ do it "update user profiles and notify contacts" testUpdateProfile + it "update user profile with image" testUpdateProfileImage describe "sending and receiving files" $ do it "send and receive file" testFileTransfer it "send and receive a small file" testSmallFileTransfer @@ -52,12 +55,12 @@ chatTests = do it "recipient cancelled file transfer" testFileRcvCancel it "send and receive file to group" testGroupFileTransfer describe "user contact link" $ do - it "should create and connect via contact link" testUserContactLink - it "should auto accept contact requests" testUserContactLinkAutoAccept - it "should deduplicate contact requests" testDeduplicateContactRequests - it "should deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange - it "should reject contact and delete contact link" testRejectContactAndDeleteUserContact - it "should delete connection requests when contact link deleted" testDeleteConnectionRequests + it "create and connect via contact link" testUserContactLink + it "auto accept contact requests" testUserContactLinkAutoAccept + it "deduplicate contact requests" testDeduplicateContactRequests + it "deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange + it "reject contact and delete contact link" testRejectContactAndDeleteUserContact + it "delete connection requests when contact link deleted" testDeleteConnectionRequests testAddContact :: IO () testAddContact = @@ -117,6 +120,18 @@ testAddContact = alice #$$> ("/_get chats", [("@bob", "hi")]) bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) +testGetSetSMPServers :: IO () +testGetSetSMPServers = + testChat2 aliceProfile bobProfile $ + \alice _ -> do + alice #$> ("/smp_servers", id, "no custom SMP servers saved") + alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok") + alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im") + alice #$> ("/smp_servers smp://2345-w==@smp2.example.im,smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + alice #$> ("/smp_servers default", id, "ok") + alice #$> ("/smp_servers", id, "no custom SMP servers saved") + testGroup :: IO () testGroup = testChat3 aliceProfile bobProfile cathProfile $ @@ -570,6 +585,16 @@ testUpdateProfile = bob <## "use @cat to send messages" ] +testUpdateProfileImage :: IO () +testUpdateProfileImage = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + -- Note we currently don't support removing profile image. + alice ##> "/profile_image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" + alice <## "profile image updated" + (bob