diff --git a/apps/android/.idea/codeStyles/Project.xml b/apps/android/.idea/codeStyles/Project.xml
index 1901f77e30..9dd7fe45d6 100644
--- a/apps/android/.idea/codeStyles/Project.xml
+++ b/apps/android/.idea/codeStyles/Project.xml
@@ -3,6 +3,7 @@
+
@@ -121,10 +122,15 @@
-
+
+
+
+
+
+
diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
index 940748b83f..b660ad0476 100644
--- a/apps/android/app/build.gradle
+++ b/apps/android/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 26
targetSdk 32
- versionCode 1
- versionName "1.0"
+ versionCode 3
+ versionName "0.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index af887b7f75..c7e7c1e20d 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -11,9 +11,8 @@
().chatModel
}
+@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@@ -62,6 +66,7 @@ fun MainPage(chatModel: ChatModel, nav: NavController) {
}
}
+@ExperimentalTextApi
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@@ -108,12 +113,18 @@ fun Navigation(chatModel: ChatModel) {
}
)
) { entry -> DetailView(entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) }
- composable(route = Pages.Settings.route) {
- SettingsView(chatModel, nav)
- }
composable(route = Pages.UserProfile.route) {
UserProfileView(chatModel, nav)
}
+ composable(route = Pages.UserAddress.route) {
+ UserAddressView(chatModel, nav)
+ }
+ composable(route = Pages.Help.route) {
+ HelpView(chatModel, nav)
+ }
+ composable(route = Pages.Markdown.route) {
+ MarkdownHelpView(nav)
+ }
}
val am = chatModel.alertManager
if (am.presentAlert.value) am.alertView.value?.invoke()
@@ -130,8 +141,10 @@ sealed class Pages(val route: String) {
object AddContact: Pages("add_contact")
object Connect: Pages("connect")
object ChatInfo: Pages("chat_info")
- object Settings: Pages("settings")
object UserProfile: Pages("user_profile")
+ object UserAddress: Pages("user_address")
+ object Help: Pages("help")
+ object Markdown: Pages("markdown")
}
@DelicateCoroutinesApi
@@ -158,3 +171,11 @@ fun connectIfOpenedViaUri(intent: Intent?, chatModel: ChatModel) {
}
}
}
+
+fun testJson() {
+ val str = """
+ {}
+ """.trimIndent()
+
+ println(json.decodeFromString(str))
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
index 2602960e8b..cb8fa644fe 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -58,8 +58,40 @@ class SimplexApp: Application() {
alertView.value = null
}
- fun showAlertMsg(title: String, text: String? = null,
- confirmText: String = "Ok", onConfirm: (() -> Unit)? = null) {
+ fun showAlertDialog(
+ title: String,
+ text: String? = null,
+ confirmText: String = "Ok",
+ onConfirm: (() -> Unit)? = null,
+ dismissText: String = "Cancel",
+ onDismiss: (() -> Unit)? = null
+ ) {
+ val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
+ showAlert {
+ AlertDialog(
+ onDismissRequest = this::hideAlert,
+ title = { Text(title) },
+ text = alertText,
+ confirmButton = {
+ Button(onClick = {
+ onConfirm?.invoke()
+ hideAlert()
+ }) { Text(confirmText) }
+ },
+ dismissButton = {
+ Button(onClick = {
+ onDismiss?.invoke()
+ hideAlert()
+ }) { Text(dismissText) }
+ }
+ )
+ }
+ }
+
+ fun showAlertMsg(
+ title: String, text: String? = null,
+ confirmText: String = "Ok", onConfirm: (() -> Unit)? = null
+ ) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
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 63c36affa2..82b1dbf827 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
@@ -1,22 +1,31 @@
package chat.simplex.app.model
import android.net.Uri
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateOf
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.*
+import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.app.SimplexApp
+import chat.simplex.app.ui.theme.*
+import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.datetime.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+@DelicateCoroutinesApi
class ChatModel(val controller: ChatController, val alertManager: SimplexApp.AlertManager) {
var currentUser = mutableStateOf(null)
var userCreated = mutableStateOf(null)
var chats = mutableStateListOf()
+ var chatsLoaded = mutableStateOf(null)
var chatId = mutableStateOf(null)
var chatItems = mutableStateListOf()
var connReqInvitation: String? = null
var terminalItems = mutableStateListOf()
+ var userAddress = mutableStateOf(null)
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf(null)
@@ -54,14 +63,15 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale
}
}
-// func replaceChat(_ id: String, _ chat: Chat) {
-// if let i = getChatIndex(id) {
-// chats[i] = chat
-// } else {
-// // invalid state, correcting
-// chats.insert(chat, at: 0)
-// }
-// }
+ fun replaceChat(id: String, chat: Chat) {
+ val i = getChatIndex(id)
+ if (i >= 0) {
+ chats[i] = chat
+ } else {
+ // invalid state, correcting
+ chats.add(index = 0, chat)
+ }
+ }
fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
// update previews
@@ -240,6 +250,13 @@ data class Chat (
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
}
+
+ companion object {
+ val sampleData = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = arrayListOf(ChatItem.getSampleData())
+ )
+ }
}
@Serializable
@@ -320,6 +337,12 @@ class Contact(
}
}
+@Serializable
+class ContactSubStatus(
+ val contact: Contact,
+ val contactError: ChatError? = null
+)
+
@Serializable
class Connection(val connStatus: String) {
companion object {
@@ -401,6 +424,12 @@ class GroupMember (
}
}
+@Serializable
+class MemberSubError (
+ val member: GroupMember,
+ val memberError: ChatError
+)
+
@Serializable
class UserContactRequest (
val contactRequestId: Long,
@@ -435,14 +464,21 @@ class AChatItem (
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
- val content: CIContent
+ val content: CIContent,
+ val formattedText: List? = null
) {
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
companion object {
- fun getSampleData(id: Long, dir: CIDirection, ts: Instant, text: String,status: CIStatus = CIStatus.SndNew()) =
+ fun getSampleData(
+ id: Long = 1,
+ dir: CIDirection = CIDirection.DirectSnd(),
+ ts: Instant = Clock.System.now(),
+ text: String = "hello\nthere",
+ status: CIStatus = CIStatus.SndNew()
+ ) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status),
@@ -566,6 +602,66 @@ sealed class MsgContent {
}
@Serializable
-class RcvFileTransfer {
-
+class FormattedText(val text: String, val format: Format? = null) {
+ val link: String? = when (format) {
+ is Format.Uri -> text
+ is Format.Email -> "mailto:$text"
+ is Format.Phone -> "tel:$text"
+ else -> null
+ }
}
+
+@Serializable
+sealed class Format {
+ @Serializable @SerialName("bold") class Bold: Format()
+ @Serializable @SerialName("italic") class Italic: Format()
+ @Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
+ @Serializable @SerialName("snippet") class Snippet: Format()
+ @Serializable @SerialName("secret") class Secret: Format()
+ @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
+ @Serializable @SerialName("uri") class Uri: Format()
+ @Serializable @SerialName("email") class Email: Format()
+ @Serializable @SerialName("phone") class Phone: Format()
+
+ val style: SpanStyle @Composable get() = when (this) {
+ is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
+ is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
+ is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
+ is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
+ is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
+ is Colored -> SpanStyle(color = this.color.uiColor)
+ is Uri -> linkStyle
+ is Email -> linkStyle
+ is Phone -> linkStyle
+ }
+
+ companion object {
+ val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
+ }
+}
+
+@Serializable
+enum class FormatColor(val color: String) {
+ red("red"),
+ green("green"),
+ blue("blue"),
+ yellow("yellow"),
+ cyan("cyan"),
+ magenta("magenta"),
+ black("black"),
+ white("white");
+
+ val uiColor: Color @Composable get() = when (this) {
+ red -> Color.Red
+ green -> Color.Green
+ blue -> Color.Blue
+ yellow -> Color.Yellow
+ cyan -> Color.Cyan
+ magenta -> Color.Magenta
+ black -> MaterialTheme.colors.onBackground
+ white -> MaterialTheme.colors.onBackground
+ }
+}
+
+@Serializable
+class RcvFileTransfer
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 bfb255dad4..c8f09cfe90 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
@@ -3,6 +3,7 @@ package chat.simplex.app.model
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.*
+import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@@ -23,7 +24,9 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
Log.d("SIMPLEX (user)", u.toString())
try {
apiStartChat()
+ chatModel.userAddress.value = apiGetUserAddress()
chatModel.chats.addAll(apiGetChats())
+ chatModel.chatsLoaded.value = true
startReceiver()
Log.d("SIMPLEX", "started chat")
} catch(e: Error) {
@@ -34,14 +37,7 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
fun startReceiver() {
thread(name="receiver") {
-// val chatlog = FifoQueue(500)
- while(true) {
- val json = chatRecvMsg(ctrl)
- val r = APIResponse.decodeStr(json).resp
- Log.d("SIMPLEX", "chatRecvMsg: ${r.responseType}")
- if (r is CR.Response || r is CR.Invalid) Log.d("SIMPLEX", "chatRecvMsg json: $json")
- processReceivedMsg(r)
- }
+ withApi { recvMspLoop() }
}
}
@@ -61,6 +57,21 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
}
}
+ suspend fun recvMsg(): CR {
+ return withContext(Dispatchers.IO) {
+ val json = chatRecvMsg(ctrl)
+ val r = APIResponse.decodeStr(json).resp
+ Log.d("SIMPLEX", "chatRecvMsg: ${r.responseType}")
+ if (r is CR.Response || r is CR.Invalid) Log.d("SIMPLEX", "chatRecvMsg json: $json")
+ r
+ }
+ }
+
+ suspend fun recvMspLoop() {
+ processReceivedMsg(recvMsg())
+ recvMspLoop()
+ }
+
suspend fun apiGetActiveUser(): User? {
val r = sendCmd(CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user
@@ -220,35 +231,30 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
// NtfManager.shared.notifyContactConnected(contact)
}
-// is CR.ReceivedContactRequest -> return
+ is CR.ReceivedContactRequest -> {
+ val contactRequest = r.contactRequest
+ val cInfo = ChatInfo.ContactRequest(contactRequest)
+ chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
+// NtfManager.shared.notifyContactRequest(contactRequest)
+ }
is CR.ContactUpdated -> {
val cInfo = ChatInfo.Direct(r.toContact)
if (chatModel.hasChat(r.toContact.id)) {
chatModel.updateChatInfo(cInfo)
}
}
- is CR.ContactSubscribed -> {
- chatModel.updateContact(r.contact)
- chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
- }
+ is CR.ContactSubscribed -> processContactSubscribed(r.contact)
is CR.ContactDisconnected -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
}
- is CR.ContactSubError -> {
- chatModel.updateContact(r.contact)
- val e = r.chatError
- val err: String =
- if (e is ChatError.ChatErrorAgent) {
- val a = e.agentError
- when {
- a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
- a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
- else -> e.string
- }
- }
- else e.string
- chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Error(err))
+ is CR.ContactSubError -> processContactSubError(r.contact, r.chatError)
+ is CR.ContactSubSummary -> {
+ for (sub in r.contactSubscriptions) {
+ val err = sub.contactError
+ if (err == null) processContactSubscribed(sub.contact)
+ else processContactSubError(sub.contact, sub.contactError)
+ }
}
is CR.NewChatItem -> {
val cInfo = r.chatItem.chatInfo
@@ -256,15 +262,6 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
chatModel.addChatItem(cInfo, cItem)
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
-
-// switch res {
-// case let .receivedContactRequest(contactRequest):
- // chatModel.addChat(Chat(
- // chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
- // chatItems: []
- // ))
-// NtfManager.shared.notifyContactRequest(contactRequest)
-//
// case let .chatItemUpdated(aChatItem):
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
@@ -276,6 +273,27 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
// }
}
}
+
+ fun processContactSubscribed(contact: Contact) {
+ chatModel.updateContact(contact)
+ chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected())
+ }
+
+ fun processContactSubError(contact: Contact, chatError: ChatError) {
+ chatModel.updateContact(contact)
+ val e = chatError
+ val err: String =
+ if (e is ChatError.ChatErrorAgent) {
+ val a = e.agentError
+ when {
+ a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
+ a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
+ else -> e.string
+ }
+ }
+ else e.string
+ chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err))
+ }
}
// ChatCommand
@@ -396,7 +414,9 @@ sealed class CR {
@Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR()
@Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR()
+ @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
+ @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
@@ -430,7 +450,9 @@ sealed class CR {
is ContactSubscribed -> "contactSubscribed"
is ContactDisconnected -> "contactDisconnected"
is ContactSubError -> "contactSubError"
+ is ContactSubSummary -> "contactSubSummary"
is GroupSubscribed -> "groupSubscribed"
+ is MemberSubErrors -> "memberSubErrors"
is GroupEmpty -> "groupEmpty"
is UserContactLinkSubscribed -> "userContactLinkSubscribed"
is NewChatItem -> "newChatItem"
@@ -465,7 +487,9 @@ sealed class CR {
is ContactSubscribed -> json.encodeToString(contact)
is ContactDisconnected -> json.encodeToString(contact)
is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}"
+ is ContactSubSummary -> json.encodeToString(contactSubscriptions)
is GroupSubscribed -> json.encodeToString(group)
+ is MemberSubErrors -> json.encodeToString(memberSubErrors)
is GroupEmpty -> json.encodeToString(group)
is UserContactLinkSubscribed -> noDetails()
is NewChatItem -> json.encodeToString(chatItem)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
index 4e6b8fc805..f2f2c0b486 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
@@ -9,6 +9,7 @@ val Teal200 = Color(0xFF03DAC5)
val Gray = Color(0x22222222)
val SimplexBlue = Color(0, 136, 255, 255)
val SimplexGreen = Color(98, 196, 103, 255)
+val SecretColor = Color(0x40808080)
val LightGray = Color(241, 242, 246, 255)
val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(134, 135, 139, 255)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/SplashView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/SplashView.kt
index 1c43d35d57..503a50e211 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/SplashView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/SplashView.kt
@@ -1,23 +1,29 @@
package chat.simplex.app.views
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@Composable
fun SplashView() {
- Box(modifier = Modifier.fillMaxSize()) {
- Image(
- painter = painterResource(R.drawable.logo),
- contentDescription = "Simplex Icon",
- modifier = Modifier
- .height(230.dp)
- .align(Alignment.Center)
- )
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(color = Color.White)
+ ) {
+// Image(
+// painter = painterResource(R.drawable.logo),
+// contentDescription = "Simplex Icon",
+// modifier = Modifier
+// .height(230.dp)
+// .align(Alignment.Center)
+// )
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
index 586029c957..4d83b38b1d 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
@@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -66,7 +67,10 @@ fun TerminalLog(terminalItems: List, navigate: (String) -> Unit) {
items(terminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .padding(horizontal = 8.dp, vertical = 4.dp)
.clickable { navigate("details/${item.id}") })
}
val len = terminalItems.count()
@@ -79,15 +83,17 @@ fun TerminalLog(terminalItems: List, navigate: (String) -> Unit) {
}
@Composable
-fun DetailView(identifier: Long, terminalItems: List, navController: NavController){
- Column(
- modifier = Modifier.verticalScroll(rememberScrollState())
+fun DetailView(identifier: Long, terminalItems: List, nav: NavController){
+ Surface(
+ Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()
) {
- Button(onClick = { navController.popBackStack() }) {
- Text("Back")
- }
- SelectionContainer {
- Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "")
+ Column {
+ CloseSheetBar(nav::popBackStack)
+ SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "")
+ }
}
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
index 2c09ec0cb8..fcfafb82d0 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
@@ -2,49 +2,153 @@ package chat.simplex.app.views
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.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.views.helpers.withApi
+import com.google.accompanist.insets.ProvideWindowInsets
+import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun WelcomeView(chatModel: ChatModel, routeHome: () -> Unit) {
- Column(
- modifier = Modifier.verticalScroll(rememberScrollState())
- ) {
- Image(
- painter=painterResource(R.drawable.logo), contentDescription = "Simplex Logo",
- )
- Text("You control your chat!")
- Text("The messaging and application platform protecting your privacy and security.")
- Spacer(Modifier.height(8.dp))
- Text("We don't store any of your contacts or messages (once delivered) on the servers.")
- Spacer(Modifier.height(24.dp))
- CreateProfilePanel(chatModel, routeHome)
+ ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = MaterialTheme.colors.background)
+ ) {
+ Column(
+ verticalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize()
+ .background(color = MaterialTheme.colors.background)
+ .padding(12.dp)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.logo),
+ contentDescription = "Simplex Logo",
+ modifier = Modifier.padding(vertical = 15.dp)
+ )
+ Text(
+ "You control your chat!",
+ style = MaterialTheme.typography.h4,
+ color = MaterialTheme.colors.onBackground
+ )
+ Text(
+ "The messaging and application platform protecting your privacy and security.",
+ style = MaterialTheme.typography.body1,
+ color = MaterialTheme.colors.onBackground
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "We don't store any of your contacts or messages (once delivered) on the servers.",
+ style = MaterialTheme.typography.body1,
+ color = MaterialTheme.colors.onBackground
+ )
+ Spacer(Modifier.height(24.dp))
+ CreateProfilePanel(chatModel, routeHome)
+ }
+ }
}
}
+
+fun isValidDisplayName(name: String) : Boolean {
+ return (name.firstOrNull { it.isWhitespace() }) == null
+}
+
@DelicateCoroutinesApi
@Composable
fun CreateProfilePanel(chatModel: ChatModel, routeHome: () -> Unit) {
var displayName by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
- Column {
- Text("Create profile")
- Text("Your profile is stored on your device and shared only with your contacts.")
- Text("Display Name")
- TextField(value = displayName, onValueChange = { value -> displayName = value })
- Text("Full Name (Optional)")
- TextField(value = fullName, onValueChange = { fullName = it })
- Button(onClick={
+ Column(
+ modifier=Modifier.fillMaxSize()
+ ) {
+ Text(
+ "Create profile",
+ style = MaterialTheme.typography.h4,
+ color = MaterialTheme.colors.onBackground,
+ modifier = Modifier.padding(vertical = 5.dp)
+ )
+ Text(
+ "Your profile is stored on your device and shared only with your contacts.",
+ style = MaterialTheme.typography.body1,
+ color = MaterialTheme.colors.onBackground
+ )
+ Spacer(Modifier.height(10.dp))
+ Text(
+ "Display Name",
+ style = MaterialTheme.typography.h6,
+ color = MaterialTheme.colors.onBackground,
+ modifier = Modifier.padding(bottom = 3.dp)
+ )
+ BasicTextField(
+ value = displayName,
+ onValueChange = { displayName = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.secondary)
+ .height(40.dp)
+ .clip(RoundedCornerShape(5.dp))
+ .padding(8.dp)
+ .navigationBarsWithImePadding(),
+ textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ singleLine = true
+ )
+ val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else ""
+
+ Text(
+ errorText,
+ fontSize = 15.sp,
+ color = MaterialTheme.colors.error
+ )
+
+ Spacer(Modifier.height(3.dp))
+ Text(
+ "Full Name (Optional)",
+ style = MaterialTheme.typography.h6,
+ color = MaterialTheme.colors.onBackground,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ BasicTextField(
+ value = fullName,
+ onValueChange = { fullName = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.secondary)
+ .height(40.dp)
+ .clip(RoundedCornerShape(3.dp))
+ .padding(8.dp)
+ .navigationBarsWithImePadding(),
+ textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ singleLine = true
+ )
+ Spacer(Modifier.height(20.dp))
+ Button(onClick = {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index f5369afd12..5b6e3044f5 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -11,6 +11,8 @@ import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -25,8 +27,8 @@ import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.*
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
-import java.util.*
+@ExperimentalTextApi
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@Composable
@@ -34,7 +36,6 @@ fun ChatView(chatModel: ChatModel, nav: NavController) {
if (chatModel.chatId.value != null && chatModel.chats.count() > 0) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat != null) {
-
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
delay(1000L)
@@ -70,6 +71,7 @@ fun ChatView(chatModel: ChatModel, nav: NavController) {
}
}
+@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
@@ -134,15 +136,17 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
}
}
+@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatItemsList(chatItems: List) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
+ val uriHandler = LocalUriHandler.current
LazyColumn(state = listState) {
items(chatItems) { cItem ->
- ChatItemView(cItem)
+ ChatItemView(cItem, uriHandler)
}
val len = chatItems.count()
if (len > 1) {
@@ -153,6 +157,7 @@ fun ChatItemsList(chatItems: List) {
}
}
+@ExperimentalTextApi
@ExperimentalAnimatedInsets
@Preview(showBackground = true)
@Preview(
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
index eb5c59572f..fcbd726a6d 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
@@ -4,6 +4,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.sp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
@@ -14,7 +15,7 @@ fun CIMetaView(chatItem: ChatItem) {
Text(
chatItem.timestampText,
color = HighOrLowlight,
- style = MaterialTheme.typography.body2
+ fontSize = 14.sp
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
index 60ec4ee67e..8a252f714b 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
@@ -11,8 +13,9 @@ import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
+@ExperimentalTextApi
@Composable
-fun ChatItemView(chatItem: ChatItem) {
+fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
@@ -26,10 +29,11 @@ fun ChatItemView(chatItem: ChatItem) {
),
contentAlignment = alignment,
) {
- TextItemView(chatItem)
+ TextItemView(chatItem, uriHandler)
}
}
+@ExperimentalTextApi
@Preview
@Composable
fun PreviewChatItemView() {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
index fdb2d5d80e..5be27d0e57 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
@@ -2,24 +2,31 @@ package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.*
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import chat.simplex.app.model.CIDirection
-import chat.simplex.app.model.ChatItem
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.LightGray
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
// TODO move to theme
val SentColorLight = Color(0x1E45B8FF)
-val ReceivedColorLight = Color(0x1EF1F0F5)
+val ReceivedColorLight = Color(0x1EB1B0B5)
+@ExperimentalTextApi
@Composable
-fun TextItemView(chatItem: ChatItem) {
+fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
@@ -28,14 +35,82 @@ fun TextItemView(chatItem: ChatItem) {
Box(
modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp)
) {
- Column {
- Text(text = chatItem.content.text)
+ Box(contentAlignment = Alignment.BottomEnd) {
+ MarkdownText(chatItem, uriHandler = uriHandler, groupMemberBold = true)
CIMetaView(chatItem)
}
}
}
}
+val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
+val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
+
+fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
+ if (chatItem.chatDir is CIDirection.GroupRcv) {
+ val name = chatItem.chatDir.groupMember.memberProfile.displayName
+ if (groupMemberBold) b.withStyle(boldFont) { append(name) }
+ else b.append(name)
+ b.append(": ")
+ }
+}
+
+@ExperimentalTextApi
+@Composable
+fun MarkdownText (
+ chatItem: ChatItem,
+ style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
+ maxLines: Int = Int.MAX_VALUE,
+ overflow: TextOverflow = TextOverflow.Clip,
+ uriHandler: UriHandler? = null,
+ groupMemberBold: Boolean = false,
+ modifier: Modifier = Modifier
+) {
+ if (chatItem.formattedText == null) {
+ val annotatedText = buildAnnotatedString {
+ appendGroupMember(this, chatItem, groupMemberBold)
+ append(chatItem.content.text)
+ withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
+ }
+ SelectionContainer {
+ Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
+ }
+ } else {
+ val annotatedText = buildAnnotatedString {
+ appendGroupMember(this, chatItem, groupMemberBold)
+ for (ft in chatItem.formattedText) {
+ if (ft.format == null) append(ft.text)
+ else {
+ val link = ft.link
+ if (link != null) {
+ withAnnotation(tag = "URL", annotation = link) {
+ withStyle(ft.format.style) { append(ft.text) }
+ }
+ } else {
+ withStyle(ft.format.style) { append(ft.text) }
+ }
+ }
+ }
+ withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
+ }
+ if (uriHandler != null) {
+ SelectionContainer {
+ ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
+ onClick = { offset ->
+ annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
+ .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
+ }
+ )
+ }
+ } else {
+ SelectionContainer {
+ Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
+ }
+ }
+ }
+}
+
+@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewSnd() {
@@ -48,6 +123,7 @@ fun PreviewTextItemViewSnd() {
}
}
+@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewRcv() {
@@ -60,6 +136,7 @@ fun PreviewTextItemViewRcv() {
}
}
+@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewLong() {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt
new file mode 100644
index 0000000000..8fb912c6d5
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt
@@ -0,0 +1,170 @@
+package chat.simplex.app.views.chat
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.PersonAdd
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.usersettings.simplexTeamUri
+
+@Composable
+fun ChatHelpView(addContact: () -> Unit, doAddContact: Boolean) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.background),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ val uriHandler = LocalUriHandler.current
+
+ Text(
+ "Thank you for installing SimpleX Chat!",
+ color = MaterialTheme.colors.onBackground
+ )
+ Text(
+ buildAnnotatedString {
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append("You can ")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
+ append("connect to SimpleX team")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(".")
+ }
+ },
+ modifier = Modifier
+ .clickable(onClick = { uriHandler.openUri(simplexTeamUri) })
+ )
+
+ Column(
+ Modifier.padding(top = 24.dp),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(
+ "To start a new chat",
+ color = MaterialTheme.colors.onBackground,
+ style = MaterialTheme.typography.h2
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "Tap button",
+ color = MaterialTheme.colors.onBackground
+ )
+ Icon(
+ Icons.Outlined.PersonAdd,
+ "Add Contact",
+ modifier = if (doAddContact) Modifier.clickable(onClick = addContact) else Modifier,
+ tint = MaterialTheme.colors.onBackground,
+ )
+ Text(
+ "above, then:",
+ color = MaterialTheme.colors.onBackground
+ )
+ }
+ Text(
+ buildAnnotatedString {
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
+ append("Add new contact")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(": to create your one-time QR Code for your contact.")
+ }
+ }
+ )
+ Text(
+ buildAnnotatedString {
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
+ append("Scan QR code")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(": to connect to your contact who shows QR code to you.")
+ }
+ }
+ )
+ }
+
+ Column(
+ Modifier.padding(top = 24.dp),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(
+ "To connect via link",
+ color = MaterialTheme.colors.onBackground,
+ style = MaterialTheme.typography.h2
+ )
+ Text(
+ "If you received SimpleX Chat invitation link you can open it in your browser:",
+ color = MaterialTheme.colors.onBackground
+ )
+ Text(
+ buildAnnotatedString {
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
+ }
+ withStyle(
+ SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
+ ) {
+ append("Scan QR code")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(".")
+ }
+ }
+ )
+ Text(
+ buildAnnotatedString {
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append("\uD83D\uDCF1 mobile: tap ")
+ }
+ withStyle(
+ SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
+ ) {
+ append("Open in mobile app")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(", then tap ")
+ }
+ withStyle(
+ SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
+ ) {
+ append("Connect")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(" in the app.")
+ }
+ }
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatHelpLayout() {
+ SimpleXTheme {
+ ChatHelpView({}, false)
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
new file mode 100644
index 0000000000..d13f2863ad
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
@@ -0,0 +1,193 @@
+package chat.simplex.app.views.chatlist
+
+import android.content.res.Configuration
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Divider
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.toMutableStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import chat.simplex.app.Pages
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.withApi
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.datetime.Clock
+
+@ExperimentalTextApi
+@Composable
+fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel, nav: NavController) {
+ ChatListNavLink(
+ chat = chat,
+ action = {
+ when (chat.chatInfo) {
+ is ChatInfo.Direct -> chatNavLink(chat, chatModel, nav)
+ is ChatInfo.Group -> chatNavLink(chat, chatModel, nav)
+ is ChatInfo.ContactRequest -> contactRequestNavLink(chat.chatInfo, chatModel, nav)
+ }
+ }
+ )
+}
+
+@DelicateCoroutinesApi
+fun chatNavLink(chatPreview: Chat, chatModel: ChatModel, navController: NavController) {
+ withApi {
+ val chatInfo = chatPreview.chatInfo
+ val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
+ if (chat != null) {
+ chatModel.chatId.value = chatInfo.id
+ chatModel.chatItems = chat.chatItems.toMutableStateList()
+ navController.navigate(Pages.Chat.route)
+ } else {
+ // TODO show error? or will apiGetChat show it
+ }
+ }
+}
+
+@DelicateCoroutinesApi
+fun contactRequestNavLink(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, navController: NavController) {
+ chatModel.alertManager.showAlertDialog(
+ title = "Accept connection request?",
+ text = "If you choose to reject sender will NOT be notified",
+ confirmText = "Accept",
+ onConfirm = {
+ withApi {
+ val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
+ if (contact != null) {
+ val chat = Chat(ChatInfo.Direct(contact), listOf())
+ chatModel.replaceChat(contactRequest.id, chat)
+ }
+ }
+ },
+ dismissText = "Reject",
+ onDismiss = {
+ withApi {
+ chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
+ chatModel.removeChat(contactRequest.id)
+ }
+ }
+ )
+}
+
+@ExperimentalTextApi
+@Composable
+fun ChatListNavLink(chat: Chat, action: () -> Unit) {
+ ChatListNavLinkLayout(
+ content = {
+ when (chat.chatInfo) {
+ is ChatInfo.Direct -> ChatPreviewView(chat)
+ is ChatInfo.Group -> ChatPreviewView(chat)
+ is ChatInfo.ContactRequest -> ContactRequestView(chat)
+ }
+ },
+ action = action
+ )
+}
+
+@Composable
+fun ChatListNavLinkLayout(content: (@Composable () -> Unit), action: () -> Unit) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = action)
+ .height(88.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ .padding(start = 8.dp)
+ .padding(end = 12.dp),
+ verticalAlignment = Alignment.Top,
+// TODO?
+// verticalAlignment = Alignment.CenterVertically,
+// horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ content.invoke()
+ }
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
+}
+
+@ExperimentalTextApi
+@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatListNavLinkDirect() {
+ SimpleXTheme {
+ ChatListNavLink(
+ chat = Chat(
+ chatInfo = ChatInfo.Direct.sampleData,
+ chatItems = listOf(
+ ChatItem.getSampleData(
+ 1,
+ CIDirection.DirectSnd(),
+ Clock.System.now(),
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
+ )
+ ),
+ chatStats = Chat.ChatStats()
+ ),
+ action = {}
+ )
+ }
+}
+
+@ExperimentalTextApi
+@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatListNavLinkGroup() {
+ SimpleXTheme {
+ ChatListNavLink(
+ chat = Chat(
+ chatInfo = ChatInfo.Group.sampleData,
+ chatItems = listOf(
+ ChatItem.getSampleData(
+ 1,
+ CIDirection.DirectSnd(),
+ Clock.System.now(),
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
+ )
+ ),
+ chatStats = Chat.ChatStats()
+ ),
+ action = {}
+ )
+ }
+}
+
+@ExperimentalTextApi
+@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewChatListNavLinkContactRequest() {
+ SimpleXTheme {
+ ChatListNavLink(
+ chat = Chat(
+ chatInfo = ChatInfo.ContactRequest.sampleData,
+ chatItems = listOf(),
+ chatStats = Chat.ChatStats()
+ ),
+ action = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
index bd8c40a0fe..179ed1919e 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
@@ -14,44 +14,57 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
-import androidx.navigation.NavOptions
-import chat.simplex.app.Pages
-import chat.simplex.app.model.Chat
import chat.simplex.app.model.ChatModel
-import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.usersettings.SettingsView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.*
@ExperimentalMaterialApi
-class ScaffoldController(val state: BottomSheetScaffoldState, val scope: CoroutineScope) {
- fun expand() = scope.launch { state.bottomSheetState.expand() }
- fun collapse() = scope.launch { state.bottomSheetState.collapse() }
- fun toggle() = scope.launch {
- val s = state.bottomSheetState
- if (s.isExpanded) s.collapse() else s.expand()
+class ScaffoldController(val scope: CoroutineScope) {
+ lateinit var state: BottomSheetScaffoldState
+ val expanded = mutableStateOf(false)
+
+ fun expand() {
+ expanded.value = true
+ scope.launch { state.bottomSheetState.expand() }
+ }
+
+ fun collapse() {
+ expanded.value = false
+ scope.launch { state.bottomSheetState.collapse() }
+ }
+
+ fun toggleSheet() {
+ if (state.bottomSheetState.isExpanded ?: false) collapse() else expand()
}
fun toggleDrawer() = scope.launch {
- state.drawerState.apply {
- if (isClosed) open() else close()
- }
+ state.drawerState.apply { if (isClosed) open() else close() }
}
}
@ExperimentalMaterialApi
@Composable
fun scaffoldController(): ScaffoldController {
- return ScaffoldController(
- state = rememberBottomSheetScaffoldState(),
- scope = rememberCoroutineScope()
+ val ctrl = ScaffoldController(scope = rememberCoroutineScope())
+ val bottomSheetState = rememberBottomSheetState(
+ BottomSheetValue.Collapsed,
+ confirmStateChange = {
+ ctrl.expanded.value = it == BottomSheetValue.Expanded
+ true
+ }
)
+ ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ return ctrl
}
+@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@@ -60,41 +73,78 @@ fun ChatListView(chatModel: ChatModel, nav: NavController) {
val scaffoldCtrl = scaffoldController()
BottomSheetScaffold(
scaffoldState = scaffoldCtrl.state,
- topBar = {
- ChatListToolbar(
- scaffoldCtrl,
- settings = { scaffoldCtrl.toggleDrawer() }
- )
- },
- drawerContent = {
- SettingsView(chatModel, nav)
- },
+ drawerContent = { SettingsView(chatModel, nav) },
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl, nav) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
) {
- Column(
- modifier = Modifier
- .padding(vertical = 8.dp)
- .fillMaxSize()
- .background(MaterialTheme.colors.background)
- ) {
- ChatList(chatModel, nav)
- }
- if (scaffoldCtrl.state.bottomSheetState.isExpanded) {
- Surface(
- Modifier
+ Box {
+ Column(
+ modifier = Modifier
+ .padding(vertical = 8.dp)
.fillMaxSize()
- .clickable { scaffoldCtrl.collapse() },
- color = Color.Black.copy(alpha = 0.12F)
- ) {}
+ .background(MaterialTheme.colors.background)
+ ) {
+ ChatListToolbar(scaffoldCtrl)
+ when (chatModel.chatsLoaded.value) {
+ true -> if (chatModel.chats.isNotEmpty()) {
+ ChatList(chatModel, nav)
+ } else {
+ val user = chatModel.currentUser.value
+ Help(scaffoldCtrl, displayName = user?.profile?.displayName)
+ }
+ else -> ChatList(chatModel, nav)
+ }
+ }
+ if (scaffoldCtrl.expanded.value) {
+ Surface(
+ Modifier
+ .fillMaxSize()
+ .clickable { scaffoldCtrl.collapse() },
+ color = Color.Black.copy(alpha = 0.12F)
+ ) {}
+ }
}
}
}
@ExperimentalMaterialApi
@Composable
-fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit) {
+fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ Text(
+ text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
+ Modifier.padding(bottom = 24.dp),
+ style = MaterialTheme.typography.h1,
+ color = MaterialTheme.colors.onBackground
+ )
+ ChatHelpView({ scaffoldCtrl.toggleSheet() }, true)
+ Row(
+ Modifier.padding(top = 30.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "This text is available in settings",
+ color = MaterialTheme.colors.onBackground
+ )
+ Icon(
+ Icons.Outlined.Settings,
+ "Settings",
+ tint = MaterialTheme.colors.onBackground,
+ modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
+ )
+ }
+ }
+}
+
+@ExperimentalMaterialApi
+@Composable
+fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -103,7 +153,7 @@ fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit)
.padding(horizontal = 8.dp)
.height(60.dp)
) {
- IconButton(onClick = settings) {
+ IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
Icons.Outlined.Settings,
"Settings",
@@ -117,7 +167,7 @@ fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit)
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(5.dp)
)
- IconButton(onClick = { newChatSheetCtrl.toggle() }) {
+ IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
@@ -128,29 +178,16 @@ fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit)
}
}
-@DelicateCoroutinesApi
-fun goToChat(chatPreview: Chat, chatModel: ChatModel, navController: NavController) {
- withApi {
- val cInfo = chatPreview.chatInfo
- val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
- if (chat != null) {
- chatModel.chatId.value = cInfo.id
- chatModel.chatItems = chat.chatItems.toMutableStateList()
- navController.navigate(Pages.Chat.route)
- } else {
- // TODO show error? or will apiGetChat show it
- }
- }
-}
-
+@ExperimentalTextApi
@DelicateCoroutinesApi
@Composable
fun ChatList(chatModel: ChatModel, navController: NavController) {
+ Divider(Modifier.padding(horizontal = 8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chatModel.chats) { chat ->
- ChatPreviewView(chat) { goToChat(chat, chatModel, navController) }
+ ChatListNavLinkView(chat, chatModel, navController)
}
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
index c595580858..caad7bdb1d 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt
@@ -1,101 +1,93 @@
package chat.simplex.app.views.chatlist
-import androidx.compose.foundation.*
+import android.content.res.Configuration
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import chat.simplex.app.model.*
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.model.Chat
+import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
-import kotlinx.datetime.Clock
+@ExperimentalTextApi
@Composable
-fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
- Surface(
- border = BorderStroke(0.5.dp, MaterialTheme.colors.secondary),
- modifier = Modifier
- .fillMaxWidth()
- .clickable(onClick = goToChat)
- .height(88.dp)
- ) {
- Row(
+fun ChatPreviewView(chat: Chat) {
+ Row {
+ ChatInfoImage(chat, size = 72.dp)
+ Column(
modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp)
- .padding(start = 8.dp)
- .padding(end = 12.dp),
- verticalAlignment = Alignment.Top
- ) {
- ChatInfoImage(chat, size = 72.dp)
- Column(modifier = Modifier
.padding(horizontal = 8.dp)
- .weight(1F)) {
- Text(
- chat.chatInfo.chatViewName,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.h3,
- fontWeight = FontWeight.Bold
- )
- if (chat.chatItems.count() > 0) {
- Text(
- chat.chatItems.last().content.text,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
- }
- val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
- Column(Modifier.fillMaxHeight(),
- verticalArrangement = Arrangement.Top) {
- Text(ts,
- color = HighOrLowlight,
- style = MaterialTheme.typography.body2,
- modifier = Modifier.padding(bottom=5.dp)
- )
+ .weight(1F)
+ ) {
+ Text(
+ chat.chatInfo.chatViewName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.h3,
+ fontWeight = FontWeight.Bold
+ )
- if (chat.chatStats.unreadCount > 0) {
- Text(
- chat.chatStats.unreadCount.toString(),
- color = MaterialTheme.colors.onPrimary,
- style = MaterialTheme.typography.body2,
- modifier = Modifier
- .background(MaterialTheme.colors.primary, shape = CircleShape)
- .align(Alignment.End)
- .badgeLayout()
- .padding(2.dp)
- )
- }
+ if (chat.chatItems.count() > 0) {
+ MarkdownText(
+ chat.chatItems.last(),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
+ Column(
+ Modifier.fillMaxHeight(),
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(
+ ts,
+ color = HighOrLowlight,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ val n = chat.chatStats.unreadCount
+ if (n > 0) {
+ Text(
+ if (n < 1000) "$n" else "${n / 1000}k",
+ color = MaterialTheme.colors.onPrimary,
+ fontSize = 14.sp,
+ modifier = Modifier
+ .background(MaterialTheme.colors.primary, shape = CircleShape)
+ .align(Alignment.End)
+ .badgeLayout()
+ .padding(horizontal = 3.dp)
+ .padding(vertical = 1.dp)
+ )
}
}
}
}
-@Preview
+@ExperimentalTextApi
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
@Composable
-fun ChatPreviewViewExample() {
+fun PreviewChatPreviewView() {
SimpleXTheme {
- ChatPreviewView(
- chat = Chat(
- chatInfo = ChatInfo.Direct.sampleData,
- chatItems = listOf(ChatItem.getSampleData(
- 1,
- CIDirection.DirectSnd(),
- Clock.System.now(),
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
- )),
- chatStats = Chat.ChatStats()
- ),
- goToChat = {}
- )
+ ChatPreviewView(Chat.sampleData)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt
new file mode 100644
index 0000000000..4cfa5c48c9
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt
@@ -0,0 +1,52 @@
+package chat.simplex.app.views.chatlist
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import chat.simplex.app.model.Chat
+import chat.simplex.app.model.getTimestampText
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.views.helpers.ChatInfoImage
+
+@Composable
+fun ContactRequestView(chat: Chat) {
+ Row {
+ ChatInfoImage(chat, size = 72.dp)
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .weight(1F)
+ ) {
+ Text(
+ chat.chatInfo.chatViewName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.h3,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.primary
+ )
+ Text(
+ "wants to connect to you!",
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ val ts = getTimestampText(chat.chatInfo.createdAt)
+ Column(
+ Modifier.fillMaxHeight(),
+ verticalArrangement = Arrangement.Top
+ ) {
+ Text(
+ ts,
+ color = HighOrLowlight,
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier.padding(bottom = 5.dp)
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
new file mode 100644
index 0000000000..be51412768
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt
@@ -0,0 +1,14 @@
+package chat.simplex.app.views.helpers
+
+import android.content.Context
+import android.content.Intent
+
+fun shareText(cxt: Context, text: String) {
+ val sendIntent: Intent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_TEXT, text)
+ type = "text/plain"
+ }
+ val shareIntent = Intent.createChooser(sendIntent, null)
+ cxt.startActivity(shareIntent)
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
index 7066024bf2..f221c97567 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
@@ -1,7 +1,6 @@
package chat.simplex.app.views.newchat
-import android.content.Context
-import android.content.Intent
+import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
@@ -22,6 +21,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
+import chat.simplex.app.views.helpers.shareText
@Composable
fun AddContactView(chatModel: ChatModel, nav: NavController) {
@@ -40,54 +40,53 @@ fun AddContactView(chatModel: ChatModel, nav: NavController) {
fun AddContactLayout(connReq: String, close: () -> Unit, share: () -> Unit) {
Column(
modifier = Modifier
- .padding(horizontal = 8.dp)
.fillMaxSize()
- .background(MaterialTheme.colors.background),
- horizontalAlignment = Alignment.CenterHorizontally
+ .background(MaterialTheme.colors.background)
+ .padding(horizontal = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloseSheetBar(close)
Text(
"Add contact",
style = MaterialTheme.typography.h1,
- modifier = Modifier.padding(bottom = 8.dp)
+ color = MaterialTheme.colors.onBackground
)
Text(
"Show QR code to your contact\nto scan from the app",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
- modifier = Modifier.padding(bottom = 8.dp)
+ color = MaterialTheme.colors.onBackground
)
QRCode(connReq)
Text(
buildAnnotatedString {
- append("If you cannot meet in person, you can ")
- withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append("If you cannot meet in person, you can ")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
- append(", or you can share the invitation link via any other channel.")
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(", or you can share the invitation link via any other channel.")
+ }
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
- .padding(top = 8.dp)
.padding(bottom = 16.dp)
)
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
}
}
-fun shareText(cxt: Context, text: String) {
- val sendIntent: Intent = Intent().apply {
- action = Intent.ACTION_SEND
- putExtra(Intent.EXTRA_TEXT, text)
- type = "text/plain"
- }
- val shareIntent = Intent.createChooser(sendIntent, null)
- cxt.startActivity(shareIntent)
-}
-
@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
@Composable
fun PreviewAddContactView() {
SimpleXTheme {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
index 2a7274a1be..5a82ea51c9 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
@@ -1,5 +1,6 @@
package chat.simplex.app.views.newchat
+import android.content.res.Configuration
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@@ -30,7 +31,7 @@ fun ConnectContactView(chatModel: ChatModel, nav: NavController) {
withUriAction(chatModel, uri) { action ->
connectViaUri(chatModel, action, uri)
}
- } catch(e: RuntimeException) {
+ } catch (e: RuntimeException) {
chatModel.alertManager.showAlertMsg(
title = "Invalid QR code",
text = "This QR code is not a link!"
@@ -44,8 +45,10 @@ fun ConnectContactView(chatModel: ChatModel, nav: NavController) {
}
@DelicateCoroutinesApi
-fun withUriAction(chatModel: ChatModel, uri: Uri,
- run: suspend (String) -> Unit) {
+fun withUriAction(
+ chatModel: ChatModel, uri: Uri,
+ run: suspend (String) -> Unit
+) {
val action = uri.path?.drop(1)
if (action == "contact" || action == "invitation") {
withApi { run(action) }
@@ -74,46 +77,57 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
Column(
modifier = Modifier
- .padding(horizontal = 8.dp)
.fillMaxSize()
- .background(MaterialTheme.colors.background),
- horizontalAlignment = Alignment.CenterHorizontally
+ .background(MaterialTheme.colors.background)
+ .padding(horizontal = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloseSheetBar(close)
Text(
"Scan QR code",
style = MaterialTheme.typography.h1,
- modifier = Modifier.padding(bottom = 8.dp)
+ color = MaterialTheme.colors.onBackground
)
Text(
"Your chat profile will be sent\nto your contact",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
- modifier = Modifier.padding(bottom = 16.dp)
+ color = MaterialTheme.colors.onBackground,
+ modifier = Modifier.padding(bottom = 4.dp)
)
- Box (
+ Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
buildAnnotatedString {
- append("If you cannot meet in person, you can ")
- withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append("If you cannot meet in person, you can ")
+ }
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
- append(", or you can create the invitation link.")
+ withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
+ append(", or you can create the invitation link.")
+ }
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
- .padding(top = 16.dp)
+ .padding(top = 4.dp)
)
}
}
@Preview
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
@Composable
fun PreviewConnectContactLayout() {
SimpleXTheme {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
index b4f8094224..64db15a0cf 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.ChatModel
-import chat.simplex.app.ui.theme.DarkGray
+import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ScaffoldController
import chat.simplex.app.views.helpers.withApi
@@ -91,7 +91,7 @@ fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boo
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
- val tint = if (disabled) DarkGray else MaterialTheme.colors.primary
+ val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text,
tint = tint,
modifier = Modifier
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt
new file mode 100644
index 0000000000..fcc225912e
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt
@@ -0,0 +1,64 @@
+package chat.simplex.app.views.usersettings
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.chat.ChatHelpView
+import chat.simplex.app.views.helpers.CloseSheetBar
+
+@Composable
+fun HelpView(chatModel: ChatModel, nav: NavController) {
+ val user = chatModel.currentUser.value
+ if (user != null) {
+ HelpLayout(
+ displayName = user.profile.displayName,
+ back = nav::popBackStack
+ )
+ }
+}
+
+@Composable
+fun HelpLayout(displayName: String, back: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colors.background)
+ .padding(horizontal = 8.dp),
+ horizontalAlignment = Alignment.Start
+ ) {
+ CloseSheetBar(back)
+ Text(
+ "Welcome $displayName!",
+ Modifier.padding(bottom = 24.dp),
+ style = MaterialTheme.typography.h1,
+ color = MaterialTheme.colors.onBackground
+ )
+ ChatHelpView({}, false)
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewHelpView() {
+ SimpleXTheme {
+ HelpLayout(
+ displayName = "Alice",
+ back = {}
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
new file mode 100644
index 0000000000..d7e647e042
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
@@ -0,0 +1,110 @@
+package chat.simplex.app.views.usersettings
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import chat.simplex.app.model.Format
+import chat.simplex.app.model.FormatColor
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.CloseSheetBar
+
+@Composable
+fun MarkdownHelpView(nav: NavController) {
+ MarkdownHelpLayout(nav::popBackStack)
+}
+
+@Composable
+fun MarkdownHelpLayout(back: () -> Unit) {
+ Surface(
+ Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()
+ ) {
+ Column {
+ CloseSheetBar(back)
+ Column(Modifier.padding(horizontal = 16.dp)) {
+ Text(
+ "How to use markdown",
+ style = MaterialTheme.typography.h1,
+ )
+ Text(
+ "You can use markdown to format messages:",
+ Modifier.padding(vertical = 16.dp)
+ )
+ MdFormat("*bold*", "bold text", Format.Bold())
+ MdFormat("_italic_", "italic text", Format.Italic())
+ MdFormat("~strike~", "strikethrough text", Format.StrikeThrough())
+ MdFormat("`code`", "a = b + c", Format.Snippet())
+ Row {
+ MdSyntax("!1 colored!")
+ Text(buildAnnotatedString {
+ withStyle(Format.Colored(FormatColor.red).style) { append("red text") }
+ append(" (")
+ appendColor(this, "1", FormatColor.red, ", ")
+ appendColor(this, "2", FormatColor.green, ", ")
+ appendColor(this, "3", FormatColor.blue, ", ")
+ appendColor(this, "4", FormatColor.yellow, ", ")
+ appendColor(this, "5", FormatColor.cyan, ", ")
+ appendColor(this, "6", FormatColor.magenta, ")")
+ })
+ }
+ Row {
+ MdSyntax("#secret")
+ SelectionContainer {
+ Text(buildAnnotatedString {
+ withStyle(Format.Secret().style) { append("secret text") }
+ })
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MdSyntax(markdown: String) {
+ Text(markdown, Modifier
+ .width(100.dp)
+ .padding(bottom = 4.dp))
+}
+
+@Composable
+fun MdFormat(markdown: String, example: String, format: Format) {
+ Row {
+ MdSyntax(markdown)
+ Text(buildAnnotatedString {
+ withStyle(format.style) { append(example) }
+ })
+ }
+}
+
+@Composable
+fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) {
+ b.withStyle(Format.Colored(c).style) { append(s)}
+ b.append(after)
+}
+
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewMarkdownHelpView() {
+ SimpleXTheme {
+ MarkdownHelpLayout(back = {})
+ }
+}
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 a44c3b9664..9581ca62bc 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
@@ -1,6 +1,7 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -11,6 +12,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -20,7 +22,6 @@ import chat.simplex.app.Pages
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
-import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
@@ -43,25 +44,28 @@ fun SettingsLayout(
navigate: (String) -> Unit
) {
val uriHandler = LocalUriHandler.current
- Column(
+ Surface(
Modifier
+ .background(MaterialTheme.colors.background)
.fillMaxSize()
-// .background(MaterialTheme.colors.background)
- .padding(8.dp)
) {
- Text(
- "Your Settings",
- style = MaterialTheme.typography.h1,
- color = MaterialTheme.colors.onBackground
- )
- Spacer(Modifier.height(30.dp))
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colors.background)
+ .padding(8.dp)
+ .padding(top = 16.dp)
+ ) {
+ Text(
+ "Your Settings",
+ style = MaterialTheme.typography.h1,
+ )
+ Spacer(Modifier.height(30.dp))
- SettingsSectionView(
- content = {
+ SettingsSectionView({ navigate(Pages.UserProfile.route) }, 60.dp) {
Icon(
Icons.Outlined.AccountCircle,
contentDescription = "Avatar Placeholder",
- tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
@@ -69,136 +73,102 @@ fun SettingsLayout(
profile.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colors.onBackground
- )
- Text(
- profile.fullName,
- color = MaterialTheme.colors.onBackground
)
+ Text(profile.fullName)
}
- },
- func = { navigate(Pages.UserProfile.route) },
- height = 60.dp
- )
- Divider(Modifier.padding(horizontal = 8.dp))
- SettingsSectionView(
- content = {
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
+ SettingsSectionView({ navigate(Pages.UserAddress.route) }) {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Address",
- tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(
- "Your SimpleX contact address",
- color = HighOrLowlight
- )
- },
- func = { println("navigate to address") }
- )
- Spacer(Modifier.height(24.dp))
+ Text("Your SimpleX contact address")
+ }
+ Spacer(Modifier.height(24.dp))
- SettingsSectionView(
- content = {
+ SettingsSectionView({ navigate(Pages.Help.route) }) {
Icon(
Icons.Outlined.HelpOutline,
- contentDescription = "Help",
- tint = MaterialTheme.colors.onBackground,
+ contentDescription = "Chat help",
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(
- "How to use SimpleX Chat",
- color = MaterialTheme.colors.onBackground
+ Text("How to use SimpleX Chat")
+ }
+ SettingsSectionView({ navigate(Pages.Markdown.route) }) {
+ Icon(
+ Icons.Outlined.TextFormat,
+ contentDescription = "Markdown help",
)
- },
- func = { println("navigate to help") }
- )
- Divider(Modifier.padding(horizontal = 8.dp))
- SettingsSectionView(
- content = {
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text("Markdown in messages")
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
+ SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
Icon(
Icons.Outlined.Tag,
contentDescription = "SimpleX Team",
- tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Get help & advice via chat",
color = MaterialTheme.colors.primary
)
- },
- func = { uriHandler.openUri(simplexTeamUri) }
- )
- Divider(Modifier.padding(horizontal = 8.dp))
- SettingsSectionView(
- content = {
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
+ SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
Icon(
Icons.Outlined.Email,
contentDescription = "Email",
- tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Ask questions via email",
color = MaterialTheme.colors.primary
)
- },
- func = { uriHandler.openUri("mailto:chat@simplex.chat") }
- )
- Spacer(Modifier.height(24.dp))
+ }
+ Spacer(Modifier.height(24.dp))
- SettingsSectionView(
- content = {
+ SettingsSectionView({ navigate(Pages.Terminal.route) }) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = "Chat console",
- tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
- Text(
- "Chat console",
- color = MaterialTheme.colors.onBackground
- )
- },
- func = { navigate(Pages.Terminal.route) }
- )
- Divider(Modifier.padding(horizontal = 8.dp))
- SettingsSectionView(
- content = {
+ Text("Chat console")
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
+ SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
- tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
- "Install ",
- color = MaterialTheme.colors.onBackground
+ buildAnnotatedString {
+ append("Install ")
+ withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
+ append("SimpleX Chat for terminal")
+ }
+ }
)
- Text(
- "SimpleX Chat for terminal",
- color = MaterialTheme.colors.primary
- )
- },
- func = { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }
- )
+ }
+ }
}
}
@Composable
-fun SettingsSectionView(content: (@Composable () -> Unit), func: () -> Unit, height: Dp = 48.dp) {
- Surface(
- modifier = Modifier
+fun SettingsSectionView(func: () -> Unit, height: Dp = 48.dp, content: (@Composable () -> Unit)) {
+ Row(
+ Modifier
+ .padding(start = 8.dp)
.fillMaxWidth()
.clickable(onClick = func)
.height(height),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- Modifier.padding(start = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- content.invoke()
- }
+ content.invoke()
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
new file mode 100644
index 0000000000..d082048f8d
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
@@ -0,0 +1,147 @@
+package chat.simplex.app.views.usersettings
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.SimpleButton
+import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.QRCode
+
+@Composable
+fun UserAddressView(chatModel: ChatModel, nav: NavController) {
+ val cxt = LocalContext.current
+ UserAddressLayout(
+ userAddress = chatModel.userAddress.value,
+ back = { nav.popBackStack() },
+ createAddress = {
+ withApi {
+ chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
+ }
+ },
+ share = { userAddress: String -> shareText(cxt, userAddress) },
+ deleteAddress = {
+ chatModel.alertManager.showAlertMsg(
+ title = "Delete address?",
+ text = "All your contacts will remain connected",
+ confirmText = "Delete",
+ onConfirm = {
+ withApi {
+ chatModel.controller.apiDeleteUserAddress()
+ chatModel.userAddress.value = null
+ }
+ }
+ )
+ }
+ )
+}
+
+@Composable
+fun UserAddressLayout(
+ userAddress: String?,
+ back: () -> Unit,
+ createAddress: () -> Unit,
+ share: (String) -> Unit,
+ deleteAddress: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colors.background)
+ .padding(horizontal = 8.dp),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.Top
+ ) {
+ CloseSheetBar(back)
+ Text(
+ "Your chat address",
+ Modifier.padding(bottom = 24.dp),
+ style = MaterialTheme.typography.h1,
+ color = MaterialTheme.colors.onBackground
+ )
+ Text(
+ "You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
+ "and if you later delete it - you won't lose your contacts.",
+ Modifier.padding(bottom = 24.dp),
+ color = MaterialTheme.colors.onBackground
+ )
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ if (userAddress == null) {
+ SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
+ } else {
+ QRCode(userAddress)
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ SimpleButton(
+ "Share link",
+ icon = Icons.Outlined.Share,
+ click = { share(userAddress) })
+ SimpleButton(
+ "Delete address",
+ icon = Icons.Outlined.Delete,
+ color = Color.Red,
+ click = deleteAddress
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewUserAddressLayoutNoAddress() {
+ SimpleXTheme {
+ UserAddressLayout(
+ userAddress = null,
+ back = {},
+ createAddress = {},
+ share = { _ -> },
+ deleteAddress = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewUserAddressLayoutAddressCreated() {
+ SimpleXTheme {
+ UserAddressLayout(
+ userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
+ back = {},
+ createAddress = {},
+ share = { _ -> },
+ deleteAddress = {},
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
index dffa108ce0..6af1f44db1 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
@@ -179,7 +179,6 @@ fun UserProfileLayout(
showBackground = true,
name = "Dark Mode"
)
-
@Composable
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 03eed2533d..0000000000
--- a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100644
index 03eed2533d..0000000000
--- a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/icon.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/icon.xml
new file mode 100644
index 0000000000..62225ef23d
--- /dev/null
+++ b/apps/android/app/src/main/res/mipmap-anydpi-v26/icon.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/icon_round.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/icon_round.xml
new file mode 100644
index 0000000000..b9826055d4
--- /dev/null
+++ b/apps/android/app/src/main/res/mipmap-anydpi-v26/icon_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78ecd..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d1ba..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-hdpi/icon.png b/apps/android/app/src/main/res/mipmap-hdpi/icon.png
new file mode 100644
index 0000000000..d032a71e43
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/icon.png differ
diff --git a/apps/android/app/src/main/res/mipmap-hdpi/icon_foreground.png b/apps/android/app/src/main/res/mipmap-hdpi/icon_foreground.png
new file mode 100644
index 0000000000..2f948004d1
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/icon_foreground.png differ
diff --git a/apps/android/app/src/main/res/mipmap-hdpi/icon_round.png b/apps/android/app/src/main/res/mipmap-hdpi/icon_round.png
new file mode 100644
index 0000000000..3aa85930e5
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-hdpi/icon_round.png differ
diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d64e5..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611da08..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-mdpi/icon.png b/apps/android/app/src/main/res/mipmap-mdpi/icon.png
new file mode 100644
index 0000000000..fd3787d7f4
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/icon.png differ
diff --git a/apps/android/app/src/main/res/mipmap-mdpi/icon_foreground.png b/apps/android/app/src/main/res/mipmap-mdpi/icon_foreground.png
new file mode 100644
index 0000000000..0a8ea3a4cb
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/icon_foreground.png differ
diff --git a/apps/android/app/src/main/res/mipmap-mdpi/icon_round.png b/apps/android/app/src/main/res/mipmap-mdpi/icon_round.png
new file mode 100644
index 0000000000..c9fcb26696
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-mdpi/icon_round.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a3070fe..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a6956b3..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/icon.png b/apps/android/app/src/main/res/mipmap-xhdpi/icon.png
new file mode 100644
index 0000000000..662cf03b5b
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/icon.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/icon_foreground.png b/apps/android/app/src/main/res/mipmap-xhdpi/icon_foreground.png
new file mode 100644
index 0000000000..4416b9fed0
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/icon_foreground.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/icon_round.png b/apps/android/app/src/main/res/mipmap-xhdpi/icon_round.png
new file mode 100644
index 0000000000..b6425d6da0
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xhdpi/icon_round.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77f9f..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f50836..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/icon.png b/apps/android/app/src/main/res/mipmap-xxhdpi/icon.png
new file mode 100644
index 0000000000..4da696a17e
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/icon.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/icon_foreground.png b/apps/android/app/src/main/res/mipmap-xxhdpi/icon_foreground.png
new file mode 100644
index 0000000000..4f51265654
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/icon_foreground.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/icon_round.png b/apps/android/app/src/main/res/mipmap-xxhdpi/icon_round.png
new file mode 100644
index 0000000000..d625723fa9
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxhdpi/icon_round.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d6427e6..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae37cb..0000000000
Binary files a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/icon.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/icon.png
new file mode 100644
index 0000000000..72087d96a6
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/icon.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/icon_foreground.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/icon_foreground.png
new file mode 100644
index 0000000000..3adb3883b9
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/icon_foreground.png differ
diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/icon_round.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/icon_round.png
new file mode 100644
index 0000000000..6ecbe609ba
Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/icon_round.png differ
diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml
index 09837df62f..2e02f493bd 100644
--- a/apps/android/app/src/main/res/values/colors.xml
+++ b/apps/android/app/src/main/res/values/colors.xml
@@ -1,10 +1,5 @@
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
#FF000000
#FFFFFFFF
\ No newline at end of file
diff --git a/apps/android/app/src/main/res/values/icon_background.xml b/apps/android/app/src/main/res/values/icon_background.xml
new file mode 100644
index 0000000000..4abf4564e3
--- /dev/null
+++ b/apps/android/app/src/main/res/values/icon_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
diff --git a/apps/android/app/src/main/res/values/themes.xml b/apps/android/app/src/main/res/values/themes.xml
index ced725b2b2..f59d099fba 100644
--- a/apps/android/app/src/main/res/values/themes.xml
+++ b/apps/android/app/src/main/res/values/themes.xml
@@ -2,6 +2,6 @@
-
\ No newline at end of file
+
diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore
index 5137305d86..99ef9f7530 100644
--- a/apps/ios/.gitignore
+++ b/apps/ios/.gitignore
@@ -65,3 +65,5 @@ fastlane/test_output
iOSInjectionProject/
Libraries/
+
+Shared/MyPlayground.playground/*
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index 24cd35b187..36f233972c 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -13,24 +13,26 @@ struct ContentView: View {
@State private var showNotificationAlert = false
var body: some View {
- if let user = chatModel.currentUser {
- ChatListView(user: user)
- .onAppear {
- do {
- try apiStartChat()
- chatModel.chats = try apiGetChats()
- } catch {
- fatalError("Failed to start or load chats: \(error)")
+ ZStack {
+ if let user = chatModel.currentUser {
+ ChatListView(user: user)
+ .onAppear {
+ do {
+ try apiStartChat()
+ chatModel.chats = try apiGetChats()
+ } catch {
+ fatalError("Failed to start or load chats: \(error)")
+ }
+ ChatReceiver.shared.start()
+ NtfManager.shared.requestAuthorization(onDeny: {
+ alertManager.showAlert(notificationAlert())
+ })
}
- ChatReceiver.shared.start()
- NtfManager.shared.requestAuthorization(onDeny: {
- alertManager.showAlert(notificationAlert())
- })
- }
- .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
- } else {
- WelcomeView()
+ } else {
+ WelcomeView()
+ }
}
+ .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
}
func notificationAlert() -> Alert {
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index e0caf9d1a6..c8d37b3b65 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -97,7 +97,7 @@ final class ChatModel: ObservableObject {
if case .rcvNew = cItem.meta.itemStatus {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if self.chatId == cInfo.id {
- SimpleX.markChatItemRead(cInfo, cItem)
+ Task { await SimpleX.markChatItemRead(cInfo, cItem) }
}
}
}
@@ -426,6 +426,11 @@ struct Contact: Identifiable, Decodable, NamedChat {
)
}
+struct ContactSubStatus: Decodable {
+ var contact: Contact
+ var contactError: ChatError?
+}
+
struct Connection: Decodable {
var connStatus: String
@@ -503,6 +508,11 @@ struct GroupMember: Decodable {
)
}
+struct MemberSubError: Decodable {
+ var member: GroupMember
+ var memberError: ChatError
+}
+
struct AChatItem: Decodable {
var chatInfo: ChatInfo
var chatItem: ChatItem
@@ -512,6 +522,7 @@ struct ChatItem: Identifiable, Decodable {
var chatDir: CIDirection
var meta: CIMeta
var content: CIContent
+ var formattedText: [FormattedText]?
var id: Int64 { get { meta.itemId } }
@@ -657,3 +668,46 @@ extension MsgContent: Decodable {
}
}
}
+
+struct FormattedText: Decodable {
+ var text: String
+ var format: Format?
+}
+
+enum Format: Decodable {
+ case bold
+ case italic
+ case strikeThrough
+ case snippet
+ case secret
+ case colored(color: FormatColor)
+ case uri
+ case email
+ case phone
+}
+
+enum FormatColor: String, Decodable {
+ case red = "red"
+ case green = "green"
+ case blue = "blue"
+ case yellow = "yellow"
+ case cyan = "cyan"
+ case magenta = "magenta"
+ case black = "black"
+ case white = "white"
+
+ var uiColor: Color {
+ get {
+ switch (self) {
+ case .red: return .red
+ case .green: return .green
+ case .blue: return .blue
+ case .yellow: return .yellow
+ case .cyan: return .cyan
+ case .magenta: return .purple
+ case .black: return .primary
+ case .white: return .primary
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index 073c91c0d2..5459d9b673 100644
--- a/apps/ios/Shared/Model/NtfManager.swift
+++ b/apps/ios/Shared/Model/NtfManager.swift
@@ -37,7 +37,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept,
let chatId = content.userInfo["chatId"] as? String,
case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
- acceptContactRequest(contactRequest)
+ Task { await acceptContactRequest(contactRequest) }
} else {
chatModel.chatId = content.targetContentIdentifier
}
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 0cccd292e4..5717791bb6 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -114,7 +114,9 @@ enum ChatResponse: Decodable, Error {
case contactSubscribed(contact: Contact)
case contactDisconnected(contact: Contact)
case contactSubError(contact: Contact, chatError: ChatError)
+ case contactSubSummary(contactSubscriptions: [ContactSubStatus])
case groupSubscribed(groupInfo: GroupInfo)
+ case memberSubErrors(memberSubErrors: [MemberSubError])
case groupEmpty(groupInfo: GroupInfo)
case userContactLinkSubscribed
case newChatItem(chatItem: AChatItem)
@@ -148,7 +150,9 @@ enum ChatResponse: Decodable, Error {
case .contactSubscribed: return "contactSubscribed"
case .contactDisconnected: return "contactDisconnected"
case .contactSubError: return "contactSubError"
+ case .contactSubSummary: return "contactSubSummary"
case .groupSubscribed: return "groupSubscribed"
+ case .memberSubErrors: return "memberSubErrors"
case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItem: return "newChatItem"
@@ -185,7 +189,9 @@ enum ChatResponse: Decodable, Error {
case let .contactSubscribed(contact): return String(describing: contact)
case let .contactDisconnected(contact): return String(describing: contact)
case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
+ case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions)
case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
+ case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors)
case let .groupEmpty(groupInfo): return String(describing: groupInfo)
case .userContactLinkSubscribed: return noDetails
case let .newChatItem(chatItem): return String(describing: chatItem)
@@ -232,10 +238,10 @@ enum TerminalItem: Identifiable {
}
}
-func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
+func chatSendCmdSync(_ cmd: ChatCommand) -> ChatResponse {
var c = cmd.cmdString.cString(using: .utf8)!
logger.debug("chatSendCmd \(cmd.cmdType)")
- let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
+ let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c))
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
@@ -247,13 +253,22 @@ func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
return resp
}
-func chatRecvMsg() throws -> ChatResponse {
- chatResponse(chat_recv_msg(getChatCtrl())!)
+func chatSendCmd(_ cmd: ChatCommand) async -> ChatResponse {
+ await withCheckedContinuation { cont in
+ cont.resume(returning: chatSendCmdSync(cmd))
+ }
+}
+
+func chatRecvMsg() async -> ChatResponse {
+ await withCheckedContinuation { cont in
+ let resp = chatResponse(chat_recv_msg(getChatCtrl())!)
+ cont.resume(returning: resp)
+ }
}
func apiGetActiveUser() throws -> User? {
let _ = getChatCtrl()
- let r = try chatSendCmd(.showActiveUser)
+ let r = chatSendCmdSync(.showActiveUser)
switch r {
case let .activeUser(user): return user
case .chatCmdError(.error(.noActiveUser)): return nil
@@ -262,43 +277,43 @@ func apiGetActiveUser() throws -> User? {
}
func apiCreateActiveUser(_ p: Profile) throws -> User {
- let r = try chatSendCmd(.createActiveUser(profile: p))
+ let r = chatSendCmdSync(.createActiveUser(profile: p))
if case let .activeUser(user) = r { return user }
throw r
}
func apiStartChat() throws {
- let r = try chatSendCmd(.startChat)
+ let r = chatSendCmdSync(.startChat)
if case .chatStarted = r { return }
throw r
}
func apiGetChats() throws -> [Chat] {
- let r = try chatSendCmd(.apiGetChats)
+ let r = chatSendCmdSync(.apiGetChats)
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
throw r
}
-func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
- let r = try chatSendCmd(.apiGetChat(type: type, id: id))
+func apiGetChat(type: ChatType, id: Int64) async throws -> Chat {
+ let r = await chatSendCmd(.apiGetChat(type: type, id: id))
if case let .apiChat(chat) = r { return Chat.init(chat) }
throw r
}
-func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) throws -> ChatItem {
- let r = try chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
+func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) async throws -> ChatItem {
+ let r = await chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
if case let .newChatItem(aChatItem) = r { return aChatItem.chatItem }
throw r
}
-func apiAddContact() throws -> String {
- let r = try chatSendCmd(.addContact)
+func apiAddContact() async throws -> String {
+ let r = await chatSendCmd(.addContact)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
throw r
}
-func apiConnect(connReq: String) throws {
- let r = try chatSendCmd(.connect(connReq: connReq))
+func apiConnect(connReq: String) async throws {
+ let r = await chatSendCmd(.connect(connReq: connReq))
switch r {
case .sentConfirmation: return
case .sentInvitation: return
@@ -306,14 +321,14 @@ func apiConnect(connReq: String) throws {
}
}
-func apiDeleteChat(type: ChatType, id: Int64) throws {
- let r = try chatSendCmd(.apiDeleteChat(type: type, id: id))
+func apiDeleteChat(type: ChatType, id: Int64) async throws {
+ let r = await chatSendCmd(.apiDeleteChat(type: type, id: id))
if case .contactDeleted = r { return }
throw r
}
-func apiUpdateProfile(profile: Profile) throws -> Profile? {
- let r = try chatSendCmd(.updateProfile(profile: profile))
+func apiUpdateProfile(profile: Profile) async throws -> Profile? {
+ let r = await chatSendCmd(.updateProfile(profile: profile))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(_, toProfile): return toProfile
@@ -321,20 +336,20 @@ func apiUpdateProfile(profile: Profile) throws -> Profile? {
}
}
-func apiCreateUserAddress() throws -> String {
- let r = try chatSendCmd(.createMyAddress)
+func apiCreateUserAddress() async throws -> String {
+ let r = await chatSendCmd(.createMyAddress)
if case let .userContactLinkCreated(connReq) = r { return connReq }
throw r
}
-func apiDeleteUserAddress() throws {
- let r = try chatSendCmd(.deleteMyAddress)
+func apiDeleteUserAddress() async throws {
+ let r = await chatSendCmd(.deleteMyAddress)
if case .userContactLinkDeleted = r { return }
throw r
}
-func apiGetUserAddress() throws -> String? {
- let r = try chatSendCmd(.showMyAddress)
+func apiGetUserAddress() async throws -> String? {
+ let r = await chatSendCmd(.showMyAddress)
switch r {
case let .userContactLink(connReq):
return connReq
@@ -344,59 +359,59 @@ func apiGetUserAddress() throws -> String? {
}
}
-func apiAcceptContactRequest(contactReqId: Int64) throws -> Contact {
- let r = try chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
+func apiAcceptContactRequest(contactReqId: Int64) async throws -> Contact {
+ let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
if case let .acceptingContactRequest(contact) = r { return contact }
throw r
}
-func apiRejectContactRequest(contactReqId: Int64) throws {
- let r = try chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
+func apiRejectContactRequest(contactReqId: Int64) async throws {
+ let r = await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
if case .contactRequestRejected = r { return }
throw r
}
-func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) throws {
- let r = try chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
+func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
+ let r = await chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
if case .cmdOk = r { return }
throw r
}
-func acceptContactRequest(_ contactRequest: UserContactRequest) {
+func acceptContactRequest(_ contactRequest: UserContactRequest) async {
do {
- let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
+ let contact = try await apiAcceptContactRequest(contactReqId: contactRequest.apiId)
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
- ChatModel.shared.replaceChat(contactRequest.id, chat)
+ DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
} catch let error {
logger.error("acceptContactRequest error: \(error.localizedDescription)")
}
}
-func rejectContactRequest(_ contactRequest: UserContactRequest) {
+func rejectContactRequest(_ contactRequest: UserContactRequest) async {
do {
- try apiRejectContactRequest(contactReqId: contactRequest.apiId)
- ChatModel.shared.removeChat(contactRequest.id)
+ try await apiRejectContactRequest(contactReqId: contactRequest.apiId)
+ DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) }
} catch let error {
logger.error("rejectContactRequest: \(error.localizedDescription)")
}
}
-func markChatRead(_ chat: Chat) {
+func markChatRead(_ chat: Chat) async {
do {
let minItemId = chat.chatStats.minUnreadItemId
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
- try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
- ChatModel.shared.markChatItemsRead(cInfo)
+ try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
+ DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) }
} catch {
logger.error("markChatRead apiChatRead error: \(error.localizedDescription)")
}
}
-func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
+func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
- try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
- ChatModel.shared.markChatItemRead(cInfo, cItem)
+ try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
+ DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch {
logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)")
}
@@ -411,7 +426,7 @@ func initializeChat() {
}
class ChatReceiver {
- private var receiveLoop: DispatchWorkItem?
+ private var receiveLoop: Task?
private var receiveMessages = true
private var _lastMsgTime = Date.now
@@ -424,18 +439,16 @@ class ChatReceiver {
receiveMessages = true
_lastMsgTime = .now
if receiveLoop != nil { return }
- let loop = DispatchWorkItem(qos: .default, flags: []) {
- while self.receiveMessages {
- do {
- processReceivedMsg(try chatRecvMsg())
- self._lastMsgTime = .now
- } catch {
- logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)")
- }
- }
+ receiveLoop = Task { await receiveMsgLoop() }
+ }
+
+ func receiveMsgLoop() async {
+ let msg = await chatRecvMsg()
+ self._lastMsgTime = .now
+ processReceivedMsg(msg)
+ if self.receiveMessages {
+ await receiveMsgLoop()
}
- receiveLoop = loop
- DispatchQueue.global().async(execute: loop)
}
func stop() {
@@ -468,20 +481,20 @@ func processReceivedMsg(_ res: ChatResponse) {
chatModel.updateChatInfo(cInfo)
}
case let .contactSubscribed(contact):
- chatModel.updateContact(contact)
- chatModel.updateNetworkStatus(contact, .connected)
+ processContactSubscribed(contact)
case let .contactDisconnected(contact):
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, .disconnected)
case let .contactSubError(contact, chatError):
- chatModel.updateContact(contact)
- var err: String
- switch chatError {
- case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
- case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
- default: err = String(describing: chatError)
+ processContactSubError(contact, chatError)
+ case let .contactSubSummary(contactSubscriptions):
+ for sub in contactSubscriptions {
+ if let err = sub.contactError {
+ processContactSubError(sub.contact, err)
+ } else {
+ processContactSubscribed(sub.contact)
+ }
}
- chatModel.updateNetworkStatus(contact, .error(err))
case let .newChatItem(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
@@ -499,12 +512,30 @@ func processReceivedMsg(_ res: ChatResponse) {
}
}
+func processContactSubscribed(_ contact: Contact) {
+ let m = ChatModel.shared
+ m.updateContact(contact)
+ m.updateNetworkStatus(contact, .connected)
+}
+
+func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
+ let m = ChatModel.shared
+ m.updateContact(contact)
+ var err: String
+ switch chatError {
+ case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
+ case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
+ default: err = String(describing: chatError)
+ }
+ m.updateNetworkStatus(contact, .error(err))
+}
+
private struct UserResponse: Decodable {
var user: User?
var error: String?
}
-private func chatResponse(_ cjson: UnsafePointer) -> ChatResponse {
+private func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
// TODO is there a way to do it without copying the data? e.g:
@@ -528,6 +559,7 @@ private func chatResponse(_ cjson: UnsafePointer) -> ChatResponse {
}
json = prettyJSON(j)
}
+ free(cjson)
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
}
diff --git a/apps/ios/Shared/MyPlayground.playground/Contents.swift b/apps/ios/Shared/MyPlayground.playground/Contents.swift
deleted file mode 100644
index eb0b6af432..0000000000
--- a/apps/ios/Shared/MyPlayground.playground/Contents.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-import Foundation
-
-var greeting = "Hello, playground"
-
-let jsonEncoder = JSONEncoder()
-
-//jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!)
-
-
-var a = [1, 2, 3]
-
-a.removeAll(where: { $0 == 1} )
-
-print(a)
-
-let input = "This is a test with the привет 🙂 URL https://www.hackingwithswift.com to be detected."
-let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
-let matches = detector.matches(in: input, options: [], range: NSRange(location: 0, length: input.count))
-
-print(matches)
-
-for match in matches {
- guard let range = Range(match.range, in: input) else { continue }
- let url = input[range]
- print(url)
-}
-
-let r = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
-
-print(r.firstMatch(in: "+44(0)7448-736-790", options: [], range: NSRange(location: 0, length: "+44(0)7448-736-790".count)) == nil)
-
-let action: NtfAction? = NtfAction(rawValue: "NTF_ACT_ACCEPT")
diff --git a/apps/ios/Shared/MyPlayground.playground/contents.xcplayground b/apps/ios/Shared/MyPlayground.playground/contents.xcplayground
deleted file mode 100644
index cf026f2286..0000000000
--- a/apps/ios/Shared/MyPlayground.playground/contents.xcplayground
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline b/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline
deleted file mode 100644
index f62b952eff..0000000000
--- a/apps/ios/Shared/MyPlayground.playground/timeline.xctimeline
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index 078f05c530..7e0905913f 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
@@ -63,12 +63,16 @@ struct ChatInfoView: View {
title: Text("Delete contact?"),
message: Text("Contact and all messages will be deleted"),
primaryButton: .destructive(Text("Delete")) {
- do {
- try apiDeleteChat(type: .direct, id: contact.apiId)
- chatModel.removeChat(contact.id)
- showChatInfo = false
- } catch let error {
- logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
+ Task {
+ do {
+ try await apiDeleteChat(type: .direct, id: contact.apiId)
+ DispatchQueue.main.async {
+ chatModel.removeChat(contact.id)
+ showChatInfo = false
+ }
+ } catch let error {
+ logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
+ }
}
},
secondaryButton: .cancel()
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift
index be3cfdaada..e8f9b5b551 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift
@@ -8,13 +8,10 @@
import SwiftUI
-private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$", options: .caseInsensitive)
-
-private let phoneRegex = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
-
private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
-private let linkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
+private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
+private let linkColor = Color(uiColor: uiLinkColor)
struct TextItemView: View {
@Environment(\.colorScheme) var colorScheme
@@ -58,73 +55,12 @@ struct TextItemView: View {
}
}
- private func messageText(_ chatItem: ChatItem) -> Text {
- let s = chatItem.content.text
- var res: Text
- if s == "" {
- res = Text("")
- } else {
- let parts = s.split(separator: " ")
- res = wordToText(parts[0])
- var i = 1
- while i < parts.count {
- res = res + Text(" ") + wordToText(parts[i])
- i = i + 1
- }
- }
- if case let .groupRcv(groupMember) = chatItem.chatDir {
- let member = Text(groupMember.memberProfile.displayName).font(.headline)
- return member + Text(": ") + res
- } else {
- return res
- }
- }
-
private func reserveSpaceForMeta(_ meta: String) -> Text {
Text(" \(meta)")
.font(.caption)
.foregroundColor(.clear)
}
- private func wordToText(_ s: String.SubSequence) -> Text {
- let str = String(s)
- switch true {
- case s.starts(with: "http://") || s.starts(with: "https://"):
- return linkText(str, prefix: "")
- case match(str, emailRegex):
- return linkText(str, prefix: "mailto:")
- case match(str, phoneRegex):
- return linkText(str, prefix: "tel:")
- default:
- if (s.count > 1) {
- switch true {
- case s.first == "*" && s.last == "*": return mdText(s).bold()
- case s.first == "_" && s.last == "_": return mdText(s).italic()
- case s.first == "+" && s.last == "+": return mdText(s).underline()
- case s.first == "~" && s.last == "~": return mdText(s).strikethrough()
- default: return Text(s)
- }
- } else {
- return Text(s)
- }
- }
- }
-
- private func match(_ s: String, _ regex: NSRegularExpression) -> Bool {
- regex.firstMatch(in: s, options: [], range: NSRange(location: 0, length: s.count)) != nil
- }
-
- private func linkText(_ s: String, prefix: String) -> Text {
- Text(AttributedString(s, attributes: AttributeContainer([
- .link: NSURL(string: prefix + s) as Any,
- .foregroundColor: linkColor as Any
- ]))).underline()
- }
-
- private func mdText(_ s: String.SubSequence) -> Text {
- Text(s[s.index(s.startIndex, offsetBy: 1).. Text {
+ let s = chatItem.content.text
+ var res: Text
+ if let ft = chatItem.formattedText, ft.count > 0 {
+ res = formattedText(ft[0], preview)
+ var i = 1
+ while i < ft.count {
+ res = res + formattedText(ft[i], preview)
+ i = i + 1
+ }
+ } else {
+ res = Text(s)
+ }
+
+ if case let .groupRcv(groupMember) = chatItem.chatDir {
+ let m = Text(groupMember.memberProfile.displayName)
+ return (preview ? m : m.font(.headline)) + Text(": ") + res
+ } else {
+ return res
+ }
+}
+
+private func formattedText(_ ft: FormattedText, _ preview: Bool) -> Text {
+ let t = ft.text
+ if let f = ft.format {
+ switch (f) {
+ case .bold: return Text(t).bold()
+ case .italic: return Text(t).italic()
+ case .strikeThrough: return Text(t).strikethrough()
+ case .snippet: return Text(t).font(.body.monospaced())
+ case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
+ case let .colored(color): return Text(t).foregroundColor(color.uiColor)
+ case .uri: return linkText(t, t, preview, prefix: "")
+ case .email: return linkText(t, t, preview, prefix: "mailto:")
+ case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
+ }
+ } else {
+ return Text(t)
+ }
+}
+
+private func linkText(_ s: String, _ link: String,
+ _ preview: Bool, prefix: String) -> Text {
+ preview
+ ? Text(s).foregroundColor(linkColor).underline(color: linkColor)
+ : Text(AttributedString(s, attributes: AttributeContainer([
+ .link: NSURL(string: prefix + link) as Any,
+ .foregroundColor: uiLinkColor as Any
+ ]))).underline()
+}
+
struct TextItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index cdaa1905da..540a88af62 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -107,17 +107,21 @@ struct ChatView: View {
func markAllRead() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if chatModel.chatId == chat.id {
- markChatRead(chat)
+ Task { await markChatRead(chat) }
}
}
}
func sendMessage(_ msg: String) {
- do {
- let chatItem = try apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg))
- chatModel.addChatItem(chat.chatInfo, chatItem)
- } catch {
- logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)")
+ Task {
+ do {
+ let chatItem = try await apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg))
+ DispatchQueue.main.async {
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
+ } catch {
+ logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)")
+ }
}
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index 75da1fea98..e5f2ed3718 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -27,13 +27,17 @@ struct ChatListNavLink: View {
private func chatView() -> some View {
ChatView(chat: chat)
.onAppear {
- do {
- let cInfo = chat.chatInfo
- let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
- chatModel.updateChatInfo(chat.chatInfo)
- chatModel.chatItems = chat.chatItems
- } catch {
- logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)")
+ Task {
+ do {
+ let cInfo = chat.chatInfo
+ let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
+ DispatchQueue.main.async {
+ chatModel.updateChatInfo(chat.chatInfo)
+ chatModel.chatItems = chat.chatItems
+ }
+ } catch {
+ logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)")
+ }
}
}
}
@@ -86,7 +90,7 @@ struct ChatListNavLink: View {
private func markReadButton() -> some View {
Button {
- markChatRead(chat)
+ Task { await markChatRead(chat) }
} label: {
Label("Read", systemImage: "checkmark")
}
@@ -96,7 +100,7 @@ struct ChatListNavLink: View {
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
- Button { acceptContactRequest(contactRequest) }
+ Button { Task { await acceptContactRequest(contactRequest) } }
label: { Label("Accept", systemImage: "checkmark") }
.tint(Color.accentColor)
Button(role: .destructive) {
@@ -108,8 +112,8 @@ struct ChatListNavLink: View {
.frame(height: 80)
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
- Button("Accept contact") { acceptContactRequest(contactRequest) }
- Button("Reject contact (sender NOT notified)") { rejectContactRequest(contactRequest) }
+ Button("Accept contact") { Task { await acceptContactRequest(contactRequest) } }
+ Button("Reject contact (sender NOT notified)") { Task { await rejectContactRequest(contactRequest) } }
}
}
@@ -118,11 +122,15 @@ struct ChatListNavLink: View {
title: Text("Delete contact?"),
message: Text("Contact and all messages will be deleted"),
primaryButton: .destructive(Text("Delete")) {
- do {
- try apiDeleteChat(type: .direct, id: contact.apiId)
- chatModel.removeChat(contact.id)
- } catch let error {
- logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
+ Task {
+ do {
+ try await apiDeleteChat(type: .direct, id: contact.apiId)
+ DispatchQueue.main.async {
+ chatModel.removeChat(contact.id)
+ }
+ } catch let error {
+ logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
+ }
}
},
secondaryButton: .cancel()
@@ -141,7 +149,7 @@ struct ChatListNavLink: View {
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
- rejectContactRequest(contactRequest)
+ Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index 4f5d044716..fe1e24ea39 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -80,19 +80,22 @@ struct ChatListView: View {
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
+ let action = path
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
return Alert(
- title: Text("Connect via \(path) link?"),
+ title: Text("Connect via \(action) link?"),
message: Text("Your profile will be sent to the contact that you received this link from: \(link)"),
primaryButton: .default(Text("Connect")) {
DispatchQueue.main.async {
- do {
- try apiConnect(connReq: link)
- connectionReqSentAlert(path == "contact" ? .contact : .invitation)
- } catch {
- let err = error.localizedDescription
- AlertManager.shared.showAlertMsg(title: "Connection error", message: err)
- logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)")
+ Task {
+ do {
+ try await apiConnect(connReq: link)
+ connectionReqSentAlert(action == "contact" ? .contact : .invitation)
+ } catch {
+ let err = error.localizedDescription
+ AlertManager.shared.showAlertMsg(title: "Connection error", message: err)
+ logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)")
+ }
}
}
},
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 0d7d2608ee..39f076b5b5 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -51,7 +51,7 @@ struct ChatPreviewView: View {
if let cItem = cItem {
ZStack(alignment: .topTrailing) {
- (itemStatusMark(cItem) + Text(chatItemText(cItem)))
+ (itemStatusMark(cItem) + messageText(cItem, preview: true))
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
@@ -91,14 +91,6 @@ struct ChatPreviewView: View {
default: return Text("")
}
}
-
- private func chatItemText(_ cItem: ChatItem) -> String {
- let t = cItem.content.text
- if case let .groupRcv(groupMember) = cItem.chatDir {
- return groupMember.memberProfile.displayName + ": " + t
- }
- return t
- }
}
struct ChatPreviewView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift
index 024c310434..b6a118b10f 100644
--- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift
+++ b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift
@@ -33,12 +33,14 @@ struct ConnectContactView: View {
func processQRCode(_ resp: Result) {
switch resp {
case let .success(r):
- do {
- try apiConnect(connReq: r.string)
- completed(nil)
- } catch {
- logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)")
- completed(error)
+ Task {
+ do {
+ try await apiConnect(connReq: r.string)
+ completed(nil)
+ } catch {
+ logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)")
+ completed(error)
+ }
}
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
index b389f9c47a..db8e1d9f39 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
@@ -35,14 +35,16 @@ struct NewChatButton: View {
}
func addContactAction() {
- do {
- connReqInvitation = try apiAddContact()
- addContact = true
- } catch {
- DispatchQueue.global().async {
- connectionErrorAlert(error)
+ Task {
+ do {
+ connReqInvitation = try await apiAddContact()
+ addContact = true
+ } catch {
+ DispatchQueue.global().async {
+ connectionErrorAlert(error)
+ }
+ logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
}
- logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
}
}
diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift
index 9d561a9c26..60e54807a9 100644
--- a/apps/ios/Shared/Views/TerminalView.swift
+++ b/apps/ios/Shared/Views/TerminalView.swift
@@ -73,13 +73,11 @@ struct TerminalView: View {
func sendMessage(_ cmdStr: String) {
let cmd = ChatCommand.string(cmdStr)
DispatchQueue.global().async {
- inProgress = true
- do {
- let _ = try chatSendCmd(cmd)
- } catch {
- logger.error("TerminalView.sendMessage chatSendCmd error: \(error.localizedDescription)")
+ Task {
+ inProgress = true
+ _ = await chatSendCmd(cmd)
+ inProgress = false
}
- inProgress = false
}
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift
new file mode 100644
index 0000000000..d9a73372dd
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift
@@ -0,0 +1,48 @@
+//
+// MarkdownHelp.swift
+// SimpleX
+//
+// Created by Evgeny on 24/02/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct MarkdownHelp: View {
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("You can use markdown to format messages:")
+ .padding(.bottom)
+ mdFormat("*bold*", Text("bold text").bold())
+ mdFormat("_italic_", Text("italic text").italic())
+ mdFormat("~strike~", Text("strikethrough text").strikethrough())
+ mdFormat("`code`", Text("`a = b + c`").font(.body.monospaced()))
+ mdFormat("!1 colored!", Text("red text").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")"))
+ (
+ mdFormat("#secret#", Text("secret text")
+ .foregroundColor(.clear)
+ .underline(color: .primary) + Text(" (can be copied)"))
+ )
+ .textSelection(.enabled)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ }
+}
+
+private func mdFormat(_ format: String, _ example: Text) -> some View {
+ HStack {
+ Text(format).frame(width: 88, alignment: .leading)
+ example
+ }
+}
+
+private func color(_ s: String, _ c: Color) -> Text {
+ Text(s).foregroundColor(c) + Text(", ")
+}
+
+struct MarkdownHelp_Previews: PreviewProvider {
+ static var previews: some View {
+ MarkdownHelp()
+ }
+}
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift
index 7bfc2eec49..4d9818278d 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift
@@ -19,10 +19,15 @@ struct SettingsButton: View {
.sheet(isPresented: $showSettings, content: {
SettingsView(showSettings: $showSettings)
.onAppear {
- do {
- chatModel.userAddress = try apiGetUserAddress()
- } catch {
- logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)")
+ Task {
+ do {
+ let userAddress = try await apiGetUserAddress()
+ DispatchQueue.main.async {
+ chatModel.userAddress = userAddress
+ }
+ } catch {
+ logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)")
+ }
}
}
})
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index e48dececc7..0dd85b7aae 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -50,14 +50,9 @@ struct SettingsView: View {
Section("Help") {
NavigationLink {
- VStack(alignment: .leading, spacing: 10) {
- Text("Welcome \(user.displayName)!")
- .font(.largeTitle)
- .padding(.leading)
- Divider()
- ChatHelp(showSettings: $showSettings)
- }
- .frame(maxHeight: .infinity, alignment: .top)
+ ChatHelp(showSettings: $showSettings)
+ .navigationTitle("Welcome \(user.displayName)!")
+ .frame(maxHeight: .infinity, alignment: .top)
} label: {
HStack {
Image(systemName: "questionmark.circle")
@@ -65,6 +60,17 @@ struct SettingsView: View {
Text("How to use SimpleX Chat")
}
}
+ NavigationLink {
+ MarkdownHelp()
+ .navigationTitle("How to use markdown")
+ .frame(maxHeight: .infinity, alignment: .top)
+ } label: {
+ HStack {
+ Image(systemName: "textformat")
+ .padding(.trailing, 4)
+ Text("Markdown in messages")
+ }
+ }
HStack {
Image(systemName: "number")
.padding(.trailing, 8)
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift
index 6ed2d03744..e15bd167c1 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift
@@ -35,11 +35,15 @@ struct UserAddress: View {
title: Text("Delete address?"),
message: Text("All your contacts will remain connected"),
primaryButton: .destructive(Text("Delete")) {
- do {
- try apiDeleteUserAddress()
- chatModel.userAddress = nil
- } catch let error {
- logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
+ Task {
+ do {
+ try await apiDeleteUserAddress()
+ DispatchQueue.main.async {
+ chatModel.userAddress = nil
+ }
+ } catch let error {
+ logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
+ }
}
}, secondaryButton: .cancel()
)
@@ -48,10 +52,15 @@ struct UserAddress: View {
.frame(maxWidth: .infinity)
} else {
Button {
- do {
- chatModel.userAddress = try apiCreateUserAddress()
- } catch let error {
- logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)")
+ Task {
+ do {
+ let userAddress = try await apiCreateUserAddress()
+ DispatchQueue.main.async {
+ chatModel.userAddress = userAddress
+ }
+ } catch let error {
+ logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)")
+ }
}
} label: { Label("Create address", systemImage: "qrcode") }
.frame(maxWidth: .infinity)
@@ -66,7 +75,11 @@ struct UserAddress_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
- return UserAddress()
- .environmentObject(chatModel)
+ return Group {
+ UserAddress()
+ .environmentObject(chatModel)
+ UserAddress()
+ .environmentObject(ChatModel())
+ }
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 2dd5575033..79b33d03bc 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -62,15 +62,19 @@ struct UserProfile: View {
}
func saveProfile() {
- do {
- if let newProfile = try apiUpdateProfile(profile: profile) {
- chatModel.currentUser?.profile = newProfile
- profile = newProfile
+ Task {
+ do {
+ if let newProfile = try await apiUpdateProfile(profile: profile) {
+ DispatchQueue.main.async {
+ chatModel.currentUser?.profile = newProfile
+ profile = newProfile
+ }
+ }
+ } catch {
+ logger.error("UserProfile apiUpdateProfile error: \(error.localizedDescription)")
}
- } catch {
- logger.error("UserProfile apiUpdateProfile error: \(error.localizedDescription)")
+ editProfile = false
}
- editProfile = false
}
}
diff --git a/apps/ios/Shared/Views/WelcomeView.swift b/apps/ios/Shared/Views/WelcomeView.swift
index 02ab3d1653..65e356f31f 100644
--- a/apps/ios/Shared/Views/WelcomeView.swift
+++ b/apps/ios/Shared/Views/WelcomeView.swift
@@ -26,19 +26,35 @@ struct WelcomeView: View {
Text("The messaging and application platform protecting your privacy and security.")
.padding(.bottom, 8)
Text("We don't store any of your contacts or messages (once delivered) on the servers.")
- .padding(.bottom, 24)
+ .padding(.bottom, 32)
Text("Create profile")
.font(.largeTitle)
.padding(.bottom)
Text("Your profile is stored on your device and shared only with your contacts.")
.padding(.bottom)
- TextField("Display name", text: $displayName)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
- .padding(.bottom)
+ ZStack(alignment: .topLeading) {
+ if !validDisplayName(displayName) {
+ Button {
+ AlertManager.shared.showAlertMsg(
+ title: "Display name",
+ message: "Display name can't contain spaces"
+ )
+ } label: {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ .padding(.top, 4)
+ }
+ }
+ TextField("Display name", text: $displayName)
+ .textInputAutocapitalization(.never)
+ .disableAutocorrection(true)
+ .padding(.leading, 28)
+ }
+ .padding(.bottom)
TextField("Full name (optional)", text: $fullName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
+ .padding(.leading, 28)
.padding(.bottom)
Button("Create") {
let profile = Profile(
@@ -52,10 +68,15 @@ struct WelcomeView: View {
fatalError("Failed to create user: \(error)")
}
}
+ .disabled(!validDisplayName(displayName))
}
}
.padding()
}
+
+ func validDisplayName(_ name: String) -> Bool {
+ name.firstIndex(of: " ") == nil
+ }
}
struct WelcomeView_Previews: PreviewProvider {
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 365311f057..dafbc7e23d 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -13,6 +13,16 @@
5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
+ 5C27D00827C7D8B500DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00327C7D8B500DD6182 /* libgmpxx.a */; };
+ 5C27D00927C7D8B500DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00327C7D8B500DD6182 /* libgmpxx.a */; };
+ 5C27D00A27C7D8B500DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00427C7D8B500DD6182 /* libgmp.a */; };
+ 5C27D00B27C7D8B500DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00427C7D8B500DD6182 /* libgmp.a */; };
+ 5C27D00C27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */; };
+ 5C27D00D27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */; };
+ 5C27D00E27C7D8B500DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00627C7D8B500DD6182 /* libffi.a */; };
+ 5C27D00F27C7D8B500DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00627C7D8B500DD6182 /* libffi.a */; };
+ 5C27D01027C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */; };
+ 5C27D01127C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */; };
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; };
@@ -25,13 +35,10 @@
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; };
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
- 5C499F2D27BAF1E300ECB4C5 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2827BAF1E300ECB4C5 /* libffi.a */; };
- 5C499F2E27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2927BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a */; };
- 5C499F2F27BAF1E300ECB4C5 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2A27BAF1E300ECB4C5 /* libgmpxx.a */; };
- 5C499F3027BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2B27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a */; };
- 5C499F3127BAF1E300ECB4C5 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C499F2C27BAF1E300ECB4C5 /* libgmp.a */; };
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
+ 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
+ 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
@@ -118,6 +125,11 @@
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; };
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; };
+ 5C27D00327C7D8B500DD6182 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
+ 5C27D00427C7D8B500DD6182 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
+ 5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a"; sourceTree = ""; };
+ 5C27D00627C7D8B500DD6182 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a"; sourceTree = ""; };
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; };
5C2E260927A2C63500F70299 /* MyPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = MyPlayground.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; };
@@ -126,12 +138,8 @@
5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = ""; };
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; };
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; };
- 5C499F2827BAF1E300ECB4C5 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
- 5C499F2927BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a"; sourceTree = ""; };
- 5C499F2A27BAF1E300ECB4C5 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
- 5C499F2B27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a"; sourceTree = ""; };
- 5C499F2C27BAF1E300ECB4C5 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; };
+ 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; };
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; };
@@ -178,14 +186,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 5C499F3027BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a in Frameworks */,
- 5C499F2F27BAF1E300ECB4C5 /* libgmpxx.a in Frameworks */,
- 5C499F3127BAF1E300ECB4C5 /* libgmp.a in Frameworks */,
+ 5C27D00827C7D8B500DD6182 /* libgmpxx.a in Frameworks */,
+ 5C27D01027C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */,
+ 5C27D00A27C7D8B500DD6182 /* libgmp.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
- 5C499F2E27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
+ 5C27D00E27C7D8B500DD6182 /* libffi.a in Frameworks */,
+ 5C27D00C27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
- 5C499F2D27BAF1E300ECB4C5 /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -193,7 +201,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 5C27D00927C7D8B500DD6182 /* libgmpxx.a in Frameworks */,
+ 5C27D00F27C7D8B500DD6182 /* libffi.a in Frameworks */,
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */,
+ 5C27D00B27C7D8B500DD6182 /* libgmp.a in Frameworks */,
+ 5C27D01127C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */,
+ 5C27D00D27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */,
5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -246,11 +259,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
- 5C499F2827BAF1E300ECB4C5 /* libffi.a */,
- 5C499F2C27BAF1E300ECB4C5 /* libgmp.a */,
- 5C499F2A27BAF1E300ECB4C5 /* libgmpxx.a */,
- 5C499F2B27BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8-ghc8.10.7.a */,
- 5C499F2927BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a */,
+ 5C27D00627C7D8B500DD6182 /* libffi.a */,
+ 5C27D00427C7D8B500DD6182 /* libgmp.a */,
+ 5C27D00327C7D8B500DD6182 /* libgmpxx.a */,
+ 5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */,
+ 5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */,
);
path = Libraries;
sourceTree = "";
@@ -371,6 +384,7 @@
5CB924D627A8563F00ACCCDD /* SettingsView.swift */,
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
+ 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
);
path = UserSettings;
sourceTree = "";
@@ -590,6 +604,7 @@
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
+ 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
@@ -633,6 +648,7 @@
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
+ 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */,
5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
@@ -799,7 +815,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 10;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -809,6 +825,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -816,10 +833,9 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- LIBRARY_SEARCH_PATHS = "";
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
- MARKETING_VERSION = 0.3.1;
+ MARKETING_VERSION = 0.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -827,7 +843,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (iOS)-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@@ -839,7 +855,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 10;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -849,6 +865,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -856,17 +873,16 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- LIBRARY_SEARCH_PATHS = "";
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
- MARKETING_VERSION = 0.3.1;
+ MARKETING_VERSION = 0.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (iOS)-Bridging-Header.h";
SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
+ TARGETED_DEVICE_FAMILY = 1;
VALIDATE_PRODUCT = YES;
};
name = Release;
diff --git a/cabal.project b/cabal.project
index dcee0f71a5..0ae6c0efd7 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: dff5cad1bef67376e82c3dc15cccdb5ba9e675ab
+ tag: d1e6147adfbd46f5e3e996cc6365d8f3f0f7669c
source-repository-package
type: git
diff --git a/package.yaml b/package.yaml
index 19f30227d0..111b557f0d 100644
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
name: simplex-chat
-version: 1.2.1
+version: 1.3.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -23,6 +23,7 @@ dependencies:
- containers == 0.6.*
- cryptonite >= 0.27 && < 0.30
- directory == 1.3.*
+ - email-validate == 2.3.*
- exceptions == 0.10.*
- filepath == 1.4.*
- mtl == 2.2.*
diff --git a/sha256map.nix b/sha256map.nix
index e77e104a95..8b428b97eb 100644
--- a/sha256map.nix
+++ b/sha256map.nix
@@ -1,5 +1,5 @@
{
- "git://github.com/simplex-chat/simplexmq.git"."dff5cad1bef67376e82c3dc15cccdb5ba9e675ab" = "06291v6vw7i00r0j13qx5apkz794jak68n1yr875gi32dxx5lhnp";
+ "git://github.com/simplex-chat/simplexmq.git"."d1e6147adfbd46f5e3e996cc6365d8f3f0f7669c" = "11wny0ivhrrp36757i074ml18k6nv7hq6a5dvv4rg3npqf19y3r7";
"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 ced24d54c9..053d771f31 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 1.2.1
+version: 1.3.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -28,6 +28,7 @@ library
Simplex.Chat.Migrations.M20220122_v1_1
Simplex.Chat.Migrations.M20220205_chat_item_status
Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
+ Simplex.Chat.Migrations.M20220224_messages_fks
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.Protocol
@@ -57,6 +58,7 @@ library
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, directory ==1.3.*
+ , email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, mtl ==2.2.*
@@ -92,6 +94,7 @@ executable simplex-chat
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, directory ==1.3.*
+ , email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, mtl ==2.2.*
@@ -134,6 +137,7 @@ test-suite simplex-chat-test
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, directory ==1.3.*
+ , email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, hspec ==2.7.*
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index fc163dc3dd..d12a5cfa6e 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -58,7 +58,7 @@ import System.Exit (exitFailure, exitSuccess)
import System.FilePath (combine, splitExtensions, takeFileName)
import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout)
import Text.Read (readMaybe)
-import UnliftIO.Async (Async, async, race_)
+import UnliftIO.Async
import UnliftIO.Concurrent (forkIO, threadDelay)
import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory)
import qualified UnliftIO.Exception as E
@@ -78,8 +78,10 @@ defaultChatConfig =
},
dbPoolSize = 1,
yesToMigrations = False,
- tbqSize = 16,
+ tbqSize = 64,
fileChunkSize = 15780,
+ subscriptionConcurrency = 16,
+ subscriptionEvents = False,
testView = False
}
@@ -87,12 +89,13 @@ logCfg :: LogConfig
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> (Notification -> IO ()) -> IO ChatController
-newChatController chatStore user config@ChatConfig {agentConfig = cfg, tbqSize} ChatOpts {dbFilePrefix, smpServers} sendNotification = do
+newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} ChatOpts {dbFilePrefix, smpServers, logConnections} sendNotification = do
let f = chatStoreFile dbFilePrefix
+ let config = cfg {subscriptionEvents = logConnections}
activeTo <- newTVarIO ActiveNone
firstTime <- not <$> doesFileExist f
currentUser <- newTVarIO user
- smpAgent <- getSMPAgentClient cfg {dbFile = dbFilePrefix <> "_agent.db", smpServers}
+ smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", smpServers}
agentAsync <- newTVarIO Nothing
idsDrg <- newTVarIO =<< drgNew
inputQ <- newTBQueueIO tbqSize
@@ -280,14 +283,14 @@ processChatCommand = \case
let userRole = memberRole (membership :: GroupMember)
when (userRole < GRAdmin || userRole < mRole) $ throwChatError CEGroupUserRole
withChatLock . procCmd $ do
- when (mStatus /= GSMemInvited) . void . sendGroupMessage members $ XGrpMemDel mId
+ when (mStatus /= GSMemInvited) . void . sendGroupMessage gInfo members $ XGrpMemDel mId
deleteMemberConnection m
withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved
pure $ CRUserDeletedMember gInfo m
LeaveGroup gName -> withUser $ \user@User {userId} -> do
Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName
withChatLock . procCmd $ do
- void $ sendGroupMessage members XGrpLeave
+ void $ sendGroupMessage gInfo members XGrpLeave
mapM_ deleteMemberConnection members
withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft
pure $ CRLeftMemberUser gInfo
@@ -299,7 +302,7 @@ processChatCommand = \case
|| (s == GSMemRemoved || s == GSMemLeft || s == GSMemGroupDeleted || s == GSMemInvited)
unless canDelete $ throwChatError CEGroupUserRole
withChatLock . procCmd $ do
- when (memberActive membership) . void $ sendGroupMessage members XGrpDel
+ when (memberActive membership) . void $ sendGroupMessage gInfo members XGrpDel
mapM_ deleteMemberConnection members
withStore $ \st -> deleteGroup st user g
pure $ CRGroupDeletedUser gInfo
@@ -322,7 +325,7 @@ processChatCommand = \case
pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci
SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do
(fileSize, chSize) <- checkSndFile f
- Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName
+ Group gInfo@GroupInfo {groupId, membership} members <- withStore $ \st -> getGroupByName st user gName
unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
let fileName = takeFileName f
ms <- forM (filter memberActive members) $ \m -> do
@@ -331,7 +334,7 @@ processChatCommand = \case
fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize
-- TODO sendGroupChatItem - same file invitation to all
forM_ ms $ \(m, _, fileInv) ->
- traverse (`sendDirectMessage` XFile fileInv) $ memberConn m
+ traverse (\conn -> sendDirectMessage conn (XFile fileInv) (GroupId groupId)) $ memberConn m
setActive $ ActiveG gName
-- this is a hack as we have multiple direct messages instead of one per group
let ciContent = CISndFileInvitation fileId f
@@ -462,36 +465,49 @@ agentSubscriber user = do
processAgentMessage u connId msg `catchError` (toView . CRChatError)
subscribeUserConnections :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
-subscribeUserConnections user@User {userId} = void . runExceptT $ do
- subscribeContacts
- subscribeGroups
- subscribeFiles
- subscribePendingConnections
- subscribeUserContactLink
+subscribeUserConnections user@User {userId} = do
+ n <- asks $ subscriptionConcurrency . config
+ ce <- asks $ subscriptionEvents . config
+ void . runExceptT $ do
+ catchErr $ subscribeContacts n ce
+ catchErr $ subscribeUserContactLink n
+ catchErr $ subscribeGroups n ce
+ catchErr $ subscribeFiles n
+ catchErr $ subscribePendingConnections n
where
- subscribeContacts = do
+ catchErr a = a `catchError` \_ -> pure ()
+ subscribeContacts n ce = do
contacts <- withStore (`getUserContacts` user)
- forM_ contacts $ \ct ->
- (subscribe (contactConnId ct) >> toView (CRContactSubscribed ct)) `catchError` (toView . CRContactSubError ct)
- subscribeGroups = do
+ toView . CRContactSubSummary =<< pooledForConcurrentlyN n contacts (\ct -> ContactSubStatus ct <$> subscribeContact ce ct)
+ subscribeContact ce ct =
+ (subscribe (contactConnId ct) >> when ce (toView $ CRContactSubscribed ct) $> Nothing)
+ `catchError` (\e -> when ce (toView $ CRContactSubError ct e) $> Just e)
+ subscribeGroups n ce = do
groups <- withStore (`getUserGroups` user)
- forM_ groups $ \(Group g@GroupInfo {membership} members) -> do
- let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members
- if memberStatus membership == GSMemInvited
- then toView $ CRGroupInvitation g
- else
- if null connectedMembers
- then
- if memberActive membership
- then toView $ CRGroupEmpty g
- else toView $ CRGroupRemoved g
- else do
- forM_ connectedMembers $ \(GroupMember {localDisplayName = c}, cId) ->
- subscribe cId `catchError` (toView . CRMemberSubError g c)
- toView $ CRGroupSubscribed g
- subscribeFiles = do
- withStore (`getLiveSndFileTransfers` user) >>= mapM_ subscribeSndFile
- withStore (`getLiveRcvFileTransfers` user) >>= mapM_ subscribeRcvFile
+ toView . CRMemberSubErrors . mconcat =<< forM groups (subscribeGroup n ce)
+ subscribeGroup n ce (Group g@GroupInfo {membership} members) = do
+ let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members
+ if memberStatus membership == GSMemInvited
+ then do
+ toView $ CRGroupInvitation g
+ pure []
+ else
+ if null connectedMembers
+ then do
+ if memberActive membership
+ then toView $ CRGroupEmpty g
+ else toView $ CRGroupRemoved g
+ pure []
+ else do
+ ms <- pooledForConcurrentlyN n connectedMembers $ \(m@GroupMember {localDisplayName = c}, cId) ->
+ (m,) <$> ((subscribe cId $> Nothing) `catchError` (\e -> when ce (toView $ CRMemberSubError g c e) $> Just e))
+ toView $ CRGroupSubscribed g
+ pure $ mapMaybe (\(m, e) -> maybe Nothing (Just . MemberSubError m) e) ms
+ subscribeFiles n = do
+ sndFileTransfers <- withStore (`getLiveSndFileTransfers` user)
+ pooledForConcurrentlyN_ n sndFileTransfers $ \sft -> subscribeSndFile sft
+ rcvFileTransfers <- withStore (`getLiveRcvFileTransfers` user)
+ pooledForConcurrentlyN_ n rcvFileTransfers $ \rft -> subscribeRcvFile rft
where
subscribeSndFile ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId cId} = do
subscribe cId `catchError` (toView . CRSndFileSubError ft)
@@ -510,17 +526,17 @@ subscribeUserConnections user@User {userId} = void . runExceptT $ do
where
resume RcvFileInfo {agentConnId = AgentConnId cId} =
subscribe cId `catchError` (toView . CRRcvFileSubError ft)
- subscribePendingConnections = do
+ subscribePendingConnections n = do
cs <- withStore (`getPendingConnections` user)
- subscribeConns cs `catchError` \_ -> pure ()
- subscribeUserContactLink = do
+ subscribeConns n cs `catchError` \_ -> pure ()
+ subscribeUserContactLink n = do
cs <- withStore (`getUserContactLinkConnections` userId)
- (subscribeConns cs >> toView CRUserContactLinkSubscribed)
+ (subscribeConns n cs >> toView CRUserContactLinkSubscribed)
`catchError` (toView . CRUserContactLinkSubError)
subscribe cId = withAgent (`subscribeConnection` cId)
- subscribeConns conns =
+ subscribeConns n conns =
withAgent $ \a ->
- forM_ conns $ subscribeConnection a . aConnId
+ pooledForConcurrentlyN_ n conns $ \c -> subscribeConnection a (aConnId c)
processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m ()
processAgentMessage Nothing _ _ = throwChatError CENoActiveUser
@@ -565,7 +581,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
INFO connInfo ->
saveConnInfo conn connInfo
MSG meta msgBody -> do
- _ <- saveRcvMSG conn meta msgBody
+ _ <- saveRcvMSG conn meta msgBody (ConnectionId connId)
withAckMessage agentConnId meta $ pure ()
ackMsgDeliveryEvent conn meta
SENT msgId ->
@@ -578,7 +594,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
_ -> pure ()
Just ct@Contact {localDisplayName = c} -> case agentMsg of
MSG msgMeta msgBody -> do
- (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody
+ (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody (ConnectionId connId)
withAckMessage agentConnId msgMeta $
case chatMsgEvent of
XMsgNew mc -> newContentMessage ct mc msgId msgMeta
@@ -653,7 +669,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
_ -> pure ()
processGroupMessage :: ACommand 'Agent -> Connection -> GroupInfo -> GroupMember -> m ()
- processGroupMessage agentMsg conn gInfo@GroupInfo {localDisplayName = gName, membership} m = case agentMsg of
+ processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership} m = case agentMsg of
CONF confId connInfo -> do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo
case memberCategory m of
@@ -701,9 +717,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
setActive $ ActiveG gName
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
intros <- withStore $ \st -> createIntroductions st members m
- void . sendGroupMessage members . XGrpMemNew $ memberInfo m
+ void . sendGroupMessage gInfo members . XGrpMemNew $ memberInfo m
forM_ intros $ \intro@GroupMemberIntro {introId} -> do
- void . sendDirectMessage conn . XGrpMemIntro . memberInfo $ reMember intro
+ void $ sendDirectMessage conn (XGrpMemIntro . memberInfo $ reMember intro) (GroupId groupId)
withStore $ \st -> updateIntroStatus st introId GMIntroSent
_ -> do
-- TODO send probe and decide whether to use existing contact connection or the new contact connection
@@ -717,7 +733,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
notifyMemberConnected gInfo m
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct
MSG msgMeta msgBody -> do
- (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody
+ (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody (GroupId groupId)
withAckMessage agentConnId msgMeta $
case chatMsgEvent of
XMsgNew mc -> newGroupContentMessage gInfo m mc msgId msgMeta
@@ -999,7 +1015,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
toView $ CRJoinedGroupMemberConnecting gInfo m newMember
xGrpMemIntro :: Connection -> GroupInfo -> GroupMember -> MemberInfo -> m ()
- xGrpMemIntro conn gInfo m memInfo@(MemberInfo memId _ _) = do
+ xGrpMemIntro conn gInfo@GroupInfo {groupId} m memInfo@(MemberInfo memId _ _) = do
case memberCategory m of
GCHostMember -> do
members <- withStore $ \st -> getGroupMembers st user gInfo
@@ -1010,7 +1026,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
(directConnId, directConnReq) <- withAgent (`createConnection` SCMInvitation)
newMember <- withStore $ \st -> createIntroReMember st user gInfo m memInfo groupConnId directConnId
let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq}
- void $ sendDirectMessage conn msg
+ void $ sendDirectMessage conn msg (GroupId groupId)
withStore $ \st -> updateGroupMemberStatus st userId newMember GSMemIntroInvited
_ -> messageError "x.grp.mem.intro can be only sent by host member"
@@ -1023,7 +1039,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
Nothing -> messageError "x.grp.mem.inv error: referenced member does not exists"
Just reMember -> do
GroupMemberIntro {introId} <- withStore $ \st -> saveIntroInvitation st reMember m introInv
- void $ sendXGrpMemInv reMember (XGrpMemFwd (memberInfo m) introInv) introId
+ void $ sendXGrpMemInv gInfo reMember (XGrpMemFwd (memberInfo m) introInv) introId
_ -> messageError "x.grp.mem.inv can be only sent by invitee member"
xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m ()
@@ -1200,22 +1216,22 @@ deleteMemberConnection m@GroupMember {activeConn} = do
forM_ activeConn $ \conn -> withStore $ \st -> updateConnectionStatus st conn ConnDeleted
sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m MessageId
-sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connStatus}} chatMsgEvent = do
+sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent = do
if connStatus == ConnReady || connStatus == ConnSndReady
- then sendDirectMessage conn chatMsgEvent
+ then sendDirectMessage conn chatMsgEvent (ConnectionId connId)
else throwChatError $ CEContactNotReady ct
-sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> m MessageId
-sendDirectMessage conn chatMsgEvent = do
- (msgId, msgBody) <- createSndMessage chatMsgEvent
+sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> ConnOrGroupId -> m MessageId
+sendDirectMessage conn chatMsgEvent connOrGroupId = do
+ (msgId, msgBody) <- createSndMessage chatMsgEvent connOrGroupId
deliverMessage conn msgBody msgId
pure msgId
-createSndMessage :: ChatMonad m => ChatMsgEvent -> m (MessageId, MsgBody)
-createSndMessage chatMsgEvent = do
+createSndMessage :: ChatMonad m => ChatMsgEvent -> ConnOrGroupId -> m (MessageId, MsgBody)
+createSndMessage chatMsgEvent connOrGroupId = do
let msgBody = directMessage chatMsgEvent
newMsg = NewMessage {direction = MDSnd, cmEventTag = toCMEventTag chatMsgEvent, msgBody}
- msgId <- withStore $ \st -> createNewMessage st newMsg
+ msgId <- withStore $ \st -> createNewMessage st newMsg connOrGroupId
pure (msgId, msgBody)
directMessage :: ChatMsgEvent -> ByteString
@@ -1227,18 +1243,18 @@ deliverMessage conn@Connection {connId} msgBody msgId = do
let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId}
withStore $ \st -> createSndMsgDelivery st sndMsgDelivery msgId
-sendGroupMessage :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> m MessageId
-sendGroupMessage members chatMsgEvent =
- sendGroupMessage' members chatMsgEvent Nothing $ pure ()
+sendGroupMessage :: ChatMonad m => GroupInfo -> [GroupMember] -> ChatMsgEvent -> m MessageId
+sendGroupMessage GroupInfo {groupId} members chatMsgEvent =
+ sendGroupMessage' members chatMsgEvent groupId Nothing $ pure ()
-sendXGrpMemInv :: ChatMonad m => GroupMember -> ChatMsgEvent -> Int64 -> m MessageId
-sendXGrpMemInv reMember chatMsgEvent introId =
- sendGroupMessage' [reMember] chatMsgEvent (Just introId) $
+sendXGrpMemInv :: ChatMonad m => GroupInfo -> GroupMember -> ChatMsgEvent -> Int64 -> m MessageId
+sendXGrpMemInv GroupInfo {groupId} reMember chatMsgEvent introId =
+ sendGroupMessage' [reMember] chatMsgEvent groupId (Just introId) $
withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded)
-sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Maybe Int64 -> m () -> m MessageId
-sendGroupMessage' members chatMsgEvent introId_ postDeliver = do
- (msgId, msgBody) <- createSndMessage chatMsgEvent
+sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Int64 -> Maybe Int64 -> m () -> m MessageId
+sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do
+ (msgId, msgBody) <- createSndMessage chatMsgEvent (GroupId groupId)
-- TODO collect failed deliveries into a single error
forM_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} ->
case memberConn m of
@@ -1260,14 +1276,14 @@ sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do
Nothing -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName
Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded)
-saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> m (MessageId, ChatMsgEvent)
-saveRcvMSG Connection {connId} agentMsgMeta msgBody = do
+saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> ConnOrGroupId -> m (MessageId, ChatMsgEvent)
+saveRcvMSG Connection {connId} agentMsgMeta msgBody connOrGroupId = do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody
let agentMsgId = fst $ recipient agentMsgMeta
cmEventTag = toCMEventTag chatMsgEvent
newMsg = NewMessage {direction = MDRcv, cmEventTag, msgBody}
rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta}
- msgId <- withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery
+ msgId <- withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg connOrGroupId rcvMsgDelivery
pure (msgId, chatMsgEvent)
sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd)
@@ -1277,7 +1293,7 @@ sendDirectChatItem userId ct chatMsgEvent ciContent = do
sendGroupChatItem :: ChatMonad m => UserId -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTGroup 'MDSnd)
sendGroupChatItem userId (Group g ms) chatMsgEvent ciContent = do
- msgId <- sendGroupMessage ms chatMsgEvent
+ msgId <- sendGroupMessage g ms chatMsgEvent
saveSndChatItem userId (CDGroupSnd g) msgId ciContent
saveSndChatItem :: ChatMonad m => UserId -> ChatDirection c 'MDSnd -> MessageId -> CIContent 'MDSnd -> m (ChatItem c 'MDSnd)
@@ -1295,7 +1311,7 @@ saveChatItem userId cd ci@NewChatItem {itemContent, itemTs, itemText, createdAt}
tz <- liftIO getCurrentTimeZone
ciId <- withStore $ \st -> createNewChatItem st userId cd ci
let ciMeta = mkCIMeta ciId itemText ciStatusNew tz itemTs createdAt
- pure $ ChatItem (toCIDirection cd) ciMeta itemContent $ parseMarkdownList itemText
+ pure $ ChatItem (toCIDirection cd) ciMeta itemContent $ parseMaybeMarkdownList itemText
mkNewChatItem :: forall d. MsgDirectionI d => CIContent d -> MessageId -> UTCTime -> UTCTime -> NewChatItem d
mkNewChatItem itemContent msgId itemTs createdAt =
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index fb17796cda..25c7a10270 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -36,7 +36,7 @@ import System.IO (Handle)
import UnliftIO.STM
versionNumber :: String
-versionNumber = "1.2.1"
+versionNumber = "1.3.0"
versionStr :: String
versionStr = "SimpleX Chat v" <> versionNumber
@@ -50,6 +50,8 @@ data ChatConfig = ChatConfig
yesToMigrations :: Bool,
tbqSize :: Natural,
fileChunkSize :: Integer,
+ subscriptionConcurrency :: Int,
+ subscriptionEvents :: Bool,
testView :: Bool
}
@@ -186,6 +188,7 @@ data ChatResponse
| CRContactDisconnected {contact :: Contact}
| CRContactSubscribed {contact :: Contact}
| CRContactSubError {contact :: Contact, chatError :: ChatError}
+ | CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]}
| CRGroupInvitation {groupInfo :: GroupInfo}
| CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole}
| CRUserJoinedGroup {groupInfo :: GroupInfo}
@@ -199,6 +202,7 @@ data ChatResponse
| CRGroupRemoved {groupInfo :: GroupInfo}
| CRGroupDeleted {groupInfo :: GroupInfo, member :: GroupMember}
| CRMemberSubError {groupInfo :: GroupInfo, contactName :: ContactName, chatError :: ChatError} -- TODO Contact? or GroupMember?
+ | CRMemberSubErrors {memberSubErrors :: [MemberSubError]}
| CRGroupSubscribed {groupInfo :: GroupInfo}
| CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError}
| CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError}
@@ -213,6 +217,25 @@ instance ToJSON ChatResponse where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
+data ContactSubStatus = ContactSubStatus
+ { contact :: Contact,
+ contactError :: Maybe ChatError
+ }
+ deriving (Show, Generic)
+
+instance ToJSON ContactSubStatus where
+ toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
+ toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
+
+data MemberSubError = MemberSubError
+ { member :: GroupMember,
+ memberError :: ChatError
+ }
+ deriving (Show, Generic)
+
+instance ToJSON MemberSubError where
+ toEncoding = J.genericToEncoding J.defaultOptions
+
data ChatError
= ChatError {errorType :: ChatErrorType}
| ChatErrorAgent {agentError :: AgentErrorType}
diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs
index aa9f7b9a36..482e480003 100644
--- a/src/Simplex/Chat/Help.hs
+++ b/src/Simplex/Chat/Help.hs
@@ -152,7 +152,6 @@ markdownInfo =
[ green "Markdown:",
indent <> highlight "*bold* " <> " - " <> markdown Bold "bold text",
indent <> highlight "_italic_ " <> " - " <> markdown Italic "italic text" <> " (shown as underlined)",
- indent <> highlight "+underlined+ " <> " - " <> markdown Underline "underlined text",
indent <> highlight "~strikethrough~" <> " - " <> markdown StrikeThrough "strikethrough text" <> " (shown as inverse)",
indent <> highlight "`code snippet` " <> " - " <> markdown Snippet "a + b // no *markdown* here",
indent <> highlight "!1 text! " <> " - " <> markdown (colored Red) "red text" <> " (1-6: red, green, blue, yellow, cyan, magenta)",
diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs
index 1384610fbd..fe5d0e0d38 100644
--- a/src/Simplex/Chat/Markdown.hs
+++ b/src/Simplex/Chat/Markdown.hs
@@ -4,22 +4,23 @@
module Simplex.Chat.Markdown where
-import Control.Applicative ((<|>))
+import Control.Applicative (optional, (<|>))
import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
-import Data.Bifunctor (second)
+import Data.Char (isDigit)
import Data.Either (fromRight)
import Data.Functor (($>))
-import Data.Map.Strict (Map)
-import qualified Data.Map.Strict as M
+import Data.Maybe (fromMaybe, isNothing)
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
+import Data.Text.Encoding (encodeUtf8)
import GHC.Generics
import Simplex.Messaging.Parsers (fstToLower, sumTypeJSON)
import System.Console.ANSI.Types
+import qualified Text.Email.Validate as Email
data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown
deriving (Eq, Show)
@@ -27,12 +28,13 @@ data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown
data Format
= Bold
| Italic
- | Underline
| StrikeThrough
| Snippet
| Secret
- | Colored FormatColor
+ | Colored {color :: FormatColor}
| Uri
+ | Email
+ | Phone
deriving (Eq, Show, Generic)
colored :: Color -> Format
@@ -86,46 +88,10 @@ type MarkdownList = [FormattedText]
unmarked :: Text -> Markdown
unmarked = Markdown Nothing
-colorMD :: Char
-colorMD = '!'
-
-secretMD :: Char
-secretMD = '#'
-
-formats :: Map Char Format
-formats =
- M.fromList
- [ ('*', Bold),
- ('_', Italic),
- ('+', Underline),
- ('~', StrikeThrough),
- ('`', Snippet),
- (secretMD, Secret),
- (colorMD, colored White)
- ]
-
-colors :: Map Text FormatColor
-colors =
- M.fromList . map (second FormatColor) $
- [ ("red", Red),
- ("green", Green),
- ("blue", Blue),
- ("yellow", Yellow),
- ("cyan", Cyan),
- ("magenta", Magenta),
- ("r", Red),
- ("g", Green),
- ("b", Blue),
- ("y", Yellow),
- ("c", Cyan),
- ("m", Magenta),
- ("1", Red),
- ("2", Green),
- ("3", Blue),
- ("4", Yellow),
- ("5", Cyan),
- ("6", Magenta)
- ]
+parseMaybeMarkdownList :: Text -> Maybe MarkdownList
+parseMaybeMarkdownList s =
+ let m = markdownToList $ parseMarkdown s
+ in if all (isNothing . format) m then Nothing else Just m
parseMarkdownList :: Text -> MarkdownList
parseMarkdownList = markdownToList . parseMarkdown
@@ -143,52 +109,76 @@ markdownP = mconcat <$> A.many' fragmentP
fragmentP :: Parser Markdown
fragmentP =
A.peekChar >>= \case
- Just ' ' -> unmarked <$> A.takeWhile (== ' ')
- Just c -> case M.lookup c formats of
- Just Secret -> A.char secretMD *> secretP
- Just (Colored (FormatColor White)) -> A.char colorMD *> coloredP
- Just f -> A.char c *> formattedP c "" f
- Nothing -> wordsP
+ Just c -> case c of
+ ' ' -> unmarked <$> A.takeWhile (== ' ')
+ '+' -> phoneP <|> wordP
+ '*' -> formattedP '*' Bold
+ '_' -> formattedP '_' Italic
+ '~' -> formattedP '~' StrikeThrough
+ '`' -> formattedP '`' Snippet
+ '#' -> A.char '#' *> secretP
+ '!' -> coloredP <|> wordP
+ _
+ | isDigit c -> phoneP <|> wordP
+ | otherwise -> wordP
Nothing -> fail ""
- formattedP :: Char -> Text -> Format -> Parser Markdown
- formattedP c p f = do
- s <- A.takeTill (== c)
- (A.char c $> md c p f s) <|> noFormat (c `T.cons` p <> s)
- md :: Char -> Text -> Format -> Text -> Markdown
- md c p f s
+ formattedP :: Char -> Format -> Parser Markdown
+ formattedP c f = do
+ s <- A.char c *> A.takeTill (== c)
+ (A.char c $> md c f s) <|> noFormat (c `T.cons` s)
+ md :: Char -> Format -> Text -> Markdown
+ md c f s
| T.null s || T.head s == ' ' || T.last s == ' ' =
- unmarked $ c `T.cons` p <> s `T.snoc` c
+ unmarked $ c `T.cons` s `T.snoc` c
| otherwise = markdown f s
secretP :: Parser Markdown
- secretP = secret <$> A.takeWhile (== secretMD) <*> A.takeTill (== secretMD) <*> A.takeWhile (== secretMD)
+ secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#')
secret :: Text -> Text -> Text -> Markdown
secret b s a
| T.null a || T.null s || T.head s == ' ' || T.last s == ' ' =
- unmarked $ secretMD `T.cons` ss
+ unmarked $ '#' `T.cons` ss
| otherwise = markdown Secret $ T.init ss
where
ss = b <> s <> a
coloredP :: Parser Markdown
coloredP = do
- color <- A.takeWhile (\c -> c /= ' ' && c /= colorMD)
- case M.lookup color colors of
- Just c ->
- let f = Colored c
- in (A.char ' ' *> formattedP colorMD (color `T.snoc` ' ') f)
- <|> noFormat (colorMD `T.cons` color)
- _ -> noFormat (colorMD `T.cons` color)
- wordsP :: Parser Markdown
- wordsP = do
- word <- wordMD <$> A.takeTill (== ' ')
- s <- (word <>) <$> (unmarked <$> A.takeWhile (== ' '))
- A.peekChar >>= \case
- Nothing -> pure s
- Just c -> case M.lookup c formats of
- Just _ -> pure s
- Nothing -> (s <>) <$> wordsP
+ clr <- A.char '!' *> colorP <* A.space
+ s <- ((<>) <$> A.takeWhile1 (\c -> c /= ' ' && c /= '!') <*> A.takeTill (== '!')) <* A.char '!'
+ if T.null s || T.last s == ' '
+ then fail "not colored"
+ else pure $ markdown (colored clr) s
+ colorP =
+ A.anyChar >>= \case
+ 'r' -> "ed" $> Red <|> pure Red
+ 'g' -> "reen" $> Green <|> pure Green
+ 'b' -> "lue" $> Blue <|> pure Blue
+ 'y' -> "ellow" $> Yellow <|> pure Yellow
+ 'c' -> "yan" $> Cyan <|> pure Cyan
+ 'm' -> "agenta" $> Magenta <|> pure Magenta
+ '1' -> pure Red
+ '2' -> pure Green
+ '3' -> pure Blue
+ '4' -> pure Yellow
+ '5' -> pure Cyan
+ '6' -> pure Magenta
+ _ -> fail "not color"
+ phoneP = do
+ country <- optional $ T.cons <$> A.char '+' <*> A.takeWhile1 isDigit
+ code <- optional $ conc4 <$> phoneSep <*> "(" <*> A.takeWhile1 isDigit <*> ")"
+ segments <- mconcat <$> A.many' ((<>) <$> phoneSep <*> A.takeWhile1 isDigit)
+ let s = fromMaybe "" country <> fromMaybe "" code <> segments
+ len = T.length s
+ if 7 <= len && len <= 22 then pure $ markdown Phone s else fail "not phone"
+ conc4 s1 s2 s3 s4 = s1 <> s2 <> s3 <> s4
+ phoneSep = " " <|> "-" <|> "." <|> ""
+ wordP :: Parser Markdown
+ wordP = wordMD <$> A.takeTill (== ' ')
wordMD :: Text -> Markdown
wordMD s
- | "http://" `T.isPrefixOf` s || "https://" `T.isPrefixOf` s || "simplex:/" `T.isPrefixOf` s = markdown Uri s
+ | T.null s = unmarked s
+ | isUri s = markdown Uri s
+ | isEmail s = markdown Email s
| otherwise = unmarked s
- noFormat :: Text -> Parser Markdown
+ isUri s = "http://" `T.isPrefixOf` s || "https://" `T.isPrefixOf` s || "simplex:/" `T.isPrefixOf` s
+ isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s)
noFormat = pure . unmarked
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index 40c5fb4af4..1a136d5efb 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -78,13 +78,13 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
{ chatDir :: CIDirection c d,
meta :: CIMeta d,
content :: CIContent d,
- formattedText :: [FormattedText]
+ formattedText :: Maybe [FormattedText]
}
deriving (Show, Generic)
instance ToJSON (ChatItem c d) where
- toJSON = J.genericToJSON J.defaultOptions
- toEncoding = J.genericToEncoding J.defaultOptions
+ toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
+ toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data CIDirection (c :: ChatType) (d :: MsgDirection) where
CIDirectSnd :: CIDirection 'CTDirect 'MDSnd
@@ -422,6 +422,8 @@ data PendingGroupMessage = PendingGroupMessage
type MessageId = Int64
+data ConnOrGroupId = ConnectionId Int64 | GroupId Int64
+
data MsgDirection = MDRcv | MDSnd
deriving (Show, Generic)
diff --git a/src/Simplex/Chat/Migrations/M20220224_messages_fks.hs b/src/Simplex/Chat/Migrations/M20220224_messages_fks.hs
new file mode 100644
index 0000000000..9bb5db57a5
--- /dev/null
+++ b/src/Simplex/Chat/Migrations/M20220224_messages_fks.hs
@@ -0,0 +1,13 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Migrations.M20220224_messages_fks where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+m20220224_messages_fks :: Query
+m20220224_messages_fks =
+ [sql|
+ALTER TABLE messages ADD COLUMN connection_id INTEGER DEFAULT NULL REFERENCES connections ON DELETE CASCADE;
+ALTER TABLE messages ADD COLUMN group_id INTEGER DEFAULT NULL REFERENCES groups ON DELETE CASCADE;
+|]
diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs
index 577cdf8ca3..8b829241ab 100644
--- a/src/Simplex/Chat/Mobile.hs
+++ b/src/Simplex/Chat/Mobile.hs
@@ -49,7 +49,8 @@ mobileChatOpts =
ChatOpts
{ dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db
smpServers = defaultSMPServers,
- logging = False
+ logConnections = False,
+ logAgent = False
}
defaultMobileConfig :: ChatConfig
diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs
index a75909c368..1d4f570b8f 100644
--- a/src/Simplex/Chat/Options.hs
+++ b/src/Simplex/Chat/Options.hs
@@ -21,7 +21,8 @@ import System.FilePath (combine)
data ChatOpts = ChatOpts
{ dbFilePrefix :: String,
smpServers :: NonEmpty SMPServer,
- logging :: Bool
+ logConnections :: Bool,
+ logAgent :: Bool
}
defaultSMPServers :: NonEmpty SMPServer
@@ -55,9 +56,14 @@ chatOpts appDir =
<> value defaultSMPServers
)
<*> switch
- ( long "log"
+ ( long "connections"
+ <> short 'c'
+ <> help "Log every contact and group connection on start"
+ )
+ <*> switch
+ ( long "log-agent"
<> short 'l'
- <> help "Enable logging"
+ <> help "Enable logs from SMP agent"
)
where
defaultDbFilePath = combine appDir "simplex_v1"
diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs
index 5c749dd5f1..909dbaba97 100644
--- a/src/Simplex/Chat/Store.hs
+++ b/src/Simplex/Chat/Store.hs
@@ -157,6 +157,7 @@ import Simplex.Chat.Migrations.M20220101_initial
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.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe)
@@ -174,7 +175,8 @@ schemaMigrations =
[ ("20220101_initial", m20220101_initial),
("20220122_v1_1", m20220122_v1_1),
("20220205_chat_item_status", m20220205_chat_item_status),
- ("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests)
+ ("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests),
+ ("20220224_messages_fks", m20220224_messages_fks)
]
-- | The list of migrations in ascending order by date
@@ -2010,11 +2012,11 @@ getSndFileTransfers_ db userId fileId =
Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, recipientDisplayName, connId, agentConnId}
Nothing -> Left $ SESndFileInvalid fileId
-createNewMessage :: MonadUnliftIO m => SQLiteStore -> NewMessage -> m MessageId
-createNewMessage st newMsg =
+createNewMessage :: MonadUnliftIO m => SQLiteStore -> NewMessage -> ConnOrGroupId -> m MessageId
+createNewMessage st newMsg connOrGroupId =
liftIO . withTransaction st $ \db -> do
currentTs <- getCurrentTime
- createNewMessage_ db newMsg currentTs
+ createNewMessage_ db newMsg connOrGroupId currentTs
createSndMsgDelivery :: MonadUnliftIO m => SQLiteStore -> SndMsgDelivery -> MessageId -> m ()
createSndMsgDelivery st sndMsgDelivery messageId =
@@ -2023,11 +2025,11 @@ createSndMsgDelivery st sndMsgDelivery messageId =
msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId currentTs
createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent currentTs
-createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> RcvMsgDelivery -> m MessageId
-createNewMessageAndRcvMsgDelivery st newMsg rcvMsgDelivery =
+createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> ConnOrGroupId -> RcvMsgDelivery -> m MessageId
+createNewMessageAndRcvMsgDelivery st newMsg connOrGroupId rcvMsgDelivery =
liftIO . withTransaction st $ \db -> do
currentTs <- getCurrentTime
- messageId <- createNewMessage_ db newMsg currentTs
+ messageId <- createNewMessage_ db newMsg connOrGroupId currentTs
msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery messageId currentTs
createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs
pure messageId
@@ -2048,17 +2050,21 @@ createRcvMsgDeliveryEvent st connId agentMsgId rcvMsgDeliveryStatus =
currentTs <- getCurrentTime
createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus currentTs
-createNewMessage_ :: DB.Connection -> NewMessage -> UTCTime -> IO MessageId
-createNewMessage_ db NewMessage {direction, cmEventTag, msgBody} createdAt = do
+createNewMessage_ :: DB.Connection -> NewMessage -> ConnOrGroupId -> UTCTime -> IO MessageId
+createNewMessage_ db NewMessage {direction, cmEventTag, msgBody} connOrGroupId createdAt = do
DB.execute
db
[sql|
INSERT INTO messages
- (msg_sent, chat_msg_event, msg_body, created_at, updated_at)
- VALUES (?,?,?,?,?)
+ (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id)
+ VALUES (?,?,?,?,?,?,?)
|]
- (direction, cmEventTag, msgBody, createdAt, createdAt)
+ (direction, cmEventTag, msgBody, createdAt, createdAt, connId_, groupId_)
insertedRowId db
+ where
+ (connId_, groupId_) = case connOrGroupId of
+ ConnectionId connId -> (Just connId, Nothing)
+ GroupId groupId -> (Nothing, Just groupId)
createSndMsgDelivery_ :: DB.Connection -> SndMsgDelivery -> MessageId -> UTCTime -> IO Int64
createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId createdAt = do
@@ -2709,7 +2715,7 @@ toDirectChatItem tz (itemId, itemTs, itemContent, itemText, itemStatus, createdA
_ -> badItem
where
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection c d -> CIStatus d -> CIContent d -> CChatItem c
- cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMarkdownList itemText)
+ cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMaybeMarkdownList itemText)
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIStatus d -> CIMeta d
ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt
@@ -2732,7 +2738,7 @@ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemSt
_ -> badItem
where
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection c d -> CIStatus d -> CIContent d -> CChatItem c
- cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMarkdownList itemText)
+ cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMaybeMarkdownList itemText)
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIStatus d -> CIMeta d
ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt
diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs
index a7814bb079..af11dc0177 100644
--- a/src/Simplex/Chat/Styled.hs
+++ b/src/Simplex/Chat/Styled.hs
@@ -70,7 +70,6 @@ sgr :: Format -> [SGR]
sgr = \case
Bold -> [SetConsoleIntensity BoldIntensity]
Italic -> [SetUnderlining SingleUnderline, SetItalicized True]
- Underline -> [SetUnderlining SingleUnderline]
StrikeThrough -> [SetSwapForegroundBackground True]
Colored (FormatColor c) -> [SetColor Foreground Vivid c]
Secret -> [SetColor Foreground Dull Black, SetColor Background Dull Black]
diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs
index d8e14b3422..1daab90035 100644
--- a/src/Simplex/Chat/Terminal.hs
+++ b/src/Simplex/Chat/Terminal.hs
@@ -20,7 +20,7 @@ import UnliftIO (async, waitEither_)
simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO ()
simplexChat cfg@ChatConfig {dbPoolSize, yesToMigrations} opts t
- | logging opts = do
+ | logAgent opts = do
setLogLevel LogInfo -- LogError
withGlobalLogging logCfg initRun
| otherwise = initRun
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index 474365270b..f0286d7251 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -10,7 +10,8 @@ module Simplex.Chat.View where
import qualified Data.Aeson as J
import Data.Function (on)
import Data.Int (Int64)
-import Data.List (groupBy, intersperse, sortOn)
+import Data.List (groupBy, intersperse, partition, sortOn)
+import Data.Maybe (isJust)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (DiffTime)
@@ -101,6 +102,10 @@ responseToView testView = \case
CRContactDisconnected c -> [ttyContact' c <> ": disconnected from server (messages will be queued)"]
CRContactSubscribed c -> [ttyContact' c <> ": connected to server"]
CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e]
+ CRContactSubSummary summary ->
+ (if null connected then [] else [sShow (length connected) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)"]) <> viewErrorsSummary errors " contact errors"
+ where
+ (errors, connected) = partition (isJust . contactError) summary
CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} ->
[groupInvitation ldn fullName]
CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role
@@ -115,6 +120,7 @@ responseToView testView = \case
CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"]
CRGroupDeleted g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"]
CRMemberSubError g c e -> [ttyGroup' g <> " member " <> ttyContact c <> " error: " <> sShow e]
+ CRMemberSubErrors summary -> viewErrorsSummary summary " group member errors"
CRGroupSubscribed g -> [ttyFullGroup g <> ": connected to server(s)"]
CRSndFileSubError SndFileTransfer {fileId, fileName} e ->
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
@@ -140,6 +146,8 @@ responseToView testView = \case
where
toChatView :: CChatItem c -> (Int, Text)
toChatView (CChatItem dir ChatItem {meta}) = (msgDirectionInt $ toMsgDirection dir, itemText meta)
+ viewErrorsSummary :: [a] -> StyledString -> [StyledString]
+ viewErrorsSummary summary s = if null summary then [] else [styled (colored Red) (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)"]
viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString]
viewChatItem chat (ChatItem cd meta content _) = case (chat, cd) of
diff --git a/stack.yaml b/stack.yaml
index 86a9c6ce42..131d1766de 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: dff5cad1bef67376e82c3dc15cccdb5ba9e675ab
+ commit: d1e6147adfbd46f5e3e996cc6365d8f3f0f7669c
# - 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 b16a74daba..cd65edf869 100644
--- a/tests/ChatClient.hs
+++ b/tests/ChatClient.hs
@@ -43,7 +43,8 @@ opts =
ChatOpts
{ dbFilePrefix = undefined,
smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"],
- logging = False
+ logConnections = False,
+ logAgent = False
}
termSettings :: VirtualTerminalSettings
diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs
index 8443567f1a..168c1aeb75 100644
--- a/tests/MarkdownTests.hs
+++ b/tests/MarkdownTests.hs
@@ -14,6 +14,8 @@ markdownTests = do
secretText
textColor
textWithUri
+ textWithEmail
+ textWithPhone
textFormat :: Spec
textFormat = describe "text format (bold)" do
@@ -141,3 +143,40 @@ textWithUri = describe "text with Uri" do
it "ignored as markdown" do
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
+
+email :: Text -> Markdown
+email = Markdown $ Just Email
+
+textWithEmail :: Spec
+textWithEmail = describe "text with Email" do
+ it "correct markdown" do
+ parseMarkdown "chat@simplex.chat" `shouldBe` email "chat@simplex.chat"
+ parseMarkdown "test chat@simplex.chat" `shouldBe` "test " <> email "chat@simplex.chat"
+ parseMarkdown "test chat+123@simplex.chat" `shouldBe` "test " <> email "chat+123@simplex.chat"
+ parseMarkdown "test chat.chat+123@simplex.chat" `shouldBe` "test " <> email "chat.chat+123@simplex.chat"
+ parseMarkdown "chat@simplex.chat test" `shouldBe` email "chat@simplex.chat" <> " test"
+ parseMarkdown "test1 chat@simplex.chat test2" `shouldBe` "test1 " <> email "chat@simplex.chat" <> " test2"
+ it "ignored as markdown" do
+ parseMarkdown "chat @simplex.chat" `shouldBe` "chat @simplex.chat"
+ parseMarkdown "this is chat @simplex.chat" `shouldBe` "this is chat @simplex.chat"
+
+phone :: Text -> Markdown
+phone = Markdown $ Just Phone
+
+textWithPhone :: Spec
+textWithPhone = describe "text with Phone" do
+ it "correct markdown" do
+ parseMarkdown "07777777777" `shouldBe` phone "07777777777"
+ parseMarkdown "test 07777777777" `shouldBe` "test " <> phone "07777777777"
+ parseMarkdown "07777777777 test" `shouldBe` phone "07777777777" <> " test"
+ parseMarkdown "test1 07777777777 test2" `shouldBe` "test1 " <> phone "07777777777" <> " test2"
+ parseMarkdown "test 07777 777 777 test" `shouldBe` "test " <> phone "07777 777 777" <> " test"
+ parseMarkdown "test +447777777777 test" `shouldBe` "test " <> phone "+447777777777" <> " test"
+ parseMarkdown "test +44 (0) 7777 777 777 test" `shouldBe` "test " <> phone "+44 (0) 7777 777 777" <> " test"
+ parseMarkdown "test +44-7777-777-777 test" `shouldBe` "test " <> phone "+44-7777-777-777" <> " test"
+ parseMarkdown "test +44 (0) 7777.777.777 https://simplex.chat test"
+ `shouldBe` "test " <> phone "+44 (0) 7777.777.777" <> " " <> uri "https://simplex.chat" <> " test"
+ it "ignored as markdown (too short)" $
+ parseMarkdown "test 077777 test" `shouldBe` "test 077777 test"
+ it "ignored as markdown (double spaces)" $
+ parseMarkdown "test 07777 777 777 test" `shouldBe` "test 07777 777 777 test"
diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs
index 02e1f7f4a4..ce9add0e8a 100644
--- a/tests/MobileTests.hs
+++ b/tests/MobileTests.hs
@@ -1,5 +1,4 @@
{-# LANGUAGE CPP #-}
-{-# LANGUAGE NamedFieldPuns #-}
module MobileTests where