Merge pull request #379 from simplex-chat/master (v1.3.0 terminal app)

This commit is contained in:
Efim Poberezkin
2022-02-26 17:25:05 +04:00
committed by GitHub
106 changed files with 2305 additions and 900 deletions
+7 -1
View File
@@ -3,6 +3,7 @@
<JetCodeStyleSettings>
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
<option name="WRAP_EXPRESSION_BODY_FUNCTIONS" value="0" />
<option name="WRAP_ELVIS_EXPRESSIONS" value="0" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
@@ -121,10 +122,15 @@
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="RIGHT_MARGIN" value="120" />
<option name="RIGHT_MARGIN" value="140" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
<option name="CALL_PARAMETERS_WRAP" value="0" />
<option name="METHOD_PARAMETERS_WRAP" value="0" />
<option name="EXTENDS_LIST_WRAP" value="0" />
<option name="METHOD_CALL_CHAIN_WRAP" value="0" />
<option name="ASSIGNMENT_WRAP" value="0" />
<option name="METHOD_ANNOTATION_WRAP" value="0" />
<option name="CLASS_ANNOTATION_WRAP" value="0" />
<option name="FIELD_ANNOTATION_WRAP" value="0" />
+2 -2
View File
@@ -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 {
@@ -11,9 +11,8 @@
<application
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -10,10 +10,11 @@ import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.lifecycle.AndroidViewModel
import androidx.navigation.*
import androidx.navigation.compose.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.*
import chat.simplex.app.views.chat.ChatInfoView
@@ -21,12 +22,13 @@ import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.SettingsView
import chat.simplex.app.views.usersettings.UserProfileView
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ExperimentalAnimatedInsets
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.serialization.decodeFromString
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@ExperimentalPermissionsApi
@@ -36,6 +38,7 @@ class MainActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
connectIfOpenedViaUri(intent, vm.chatModel)
setContent {
SimpleXTheme {
@@ -50,6 +53,7 @@ class SimplexViewModel(application: Application): AndroidViewModel(application)
val chatModel = getApplication<SimplexApp>().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<ChatItem>(str))
}
@@ -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(
@@ -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<User?>(null)
var userCreated = mutableStateOf<Boolean?>(null)
var chats = mutableStateListOf<Chat>()
var chatsLoaded = mutableStateOf<Boolean?>(null)
var chatId = mutableStateOf<String?>(null)
var chatItems = mutableStateListOf<ChatItem>()
var connReqInvitation: String? = null
var terminalItems = mutableStateListOf<TerminalItem>()
var userAddress = mutableStateOf<String?>(null)
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf<Uri?>(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<FormattedText>? = 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
@@ -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<String>(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<ContactSubStatus>): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List<MemberSubError>): 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)
@@ -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)
@@ -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)
// )
}
}
@@ -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<TerminalItem>, 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<TerminalItem>, navigate: (String) -> Unit) {
}
@Composable
fun DetailView(identifier: Long, terminalItems: List<TerminalItem>, navController: NavController){
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
fun DetailView(identifier: Long, terminalItems: List<TerminalItem>, 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 ?: "")
}
}
}
}
@@ -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)
@@ -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<ChatItem>) {
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<ChatItem>) {
}
}
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@Preview(showBackground = true)
@Preview(
@@ -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
)
}
@@ -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() {
@@ -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() {
@@ -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)
}
}
@@ -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 = {}
)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
)
}
}
}
@@ -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)
}
@@ -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 {
@@ -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 {
@@ -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
@@ -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 = {}
)
}
}
@@ -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 = {})
}
}
@@ -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()
}
}
@@ -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 = {},
)
}
}
@@ -179,7 +179,6 @@ fun UserProfileLayout(
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@mipmap/icon_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@color/white"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_background">#FFFFFF</color>
</resources>
@@ -2,6 +2,6 @@
<resources>
<style name="Theme.SimpleX" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
<item name="android:statusBarColor">@color/black</item>
</style>
</resources>
</resources>
+2
View File
@@ -65,3 +65,5 @@ fastlane/test_output
iOSInjectionProject/
Libraries/
Shared/MyPlayground.playground/*
+18 -16
View File
@@ -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 {
+55 -1
View File
@@ -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
}
}
}
}
+1 -1
View File
@@ -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
}
+98 -66
View File
@@ -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<Void, Never>?
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<CChar>) -> ChatResponse {
private func chatResponse(_ cjson: UnsafeMutablePointer<CChar>) -> 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<CChar>) -> ChatResponse {
}
json = prettyJSON(j)
}
free(cjson)
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
}
@@ -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")
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
<timeline fileName='timeline.xctimeline'/>
</playground>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Timeline
version = "3.0">
<TimelineItems>
<LoggerValueHistoryTimelineItem
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&amp;CharacterRangeLoc=91&amp;EndingColumnNumber=0&amp;EndingLineNumber=7&amp;StartingColumnNumber=3&amp;StartingLineNumber=6&amp;Timestamp=666087303.155273"
selectedRepresentationIndex = "0"
shouldTrackSuperviewWidth = "NO">
</LoggerValueHistoryTimelineItem>
</TimelineItems>
</Timeline>
+10 -6
View File
@@ -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()
@@ -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)..<s.index(s.endIndex, offsetBy: -1)])
}
private func msgDeliveryError(_ err: String) {
AlertManager.shared.showAlertMsg(
title: "Message delivery error",
@@ -133,6 +69,57 @@ struct TextItemView: View {
}
}
func messageText(_ chatItem: ChatItem, preview: Bool = false) -> 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{
+10 -6
View File
@@ -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)")
}
}
}
}
@@ -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()
)
@@ -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)")
}
}
}
},
@@ -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 {
@@ -33,12 +33,14 @@ struct ConnectContactView: View {
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
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)")
@@ -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)")
}
}
+4 -6
View File
@@ -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
}
}
}
@@ -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()
}
}
@@ -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)")
}
}
}
})
@@ -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)
@@ -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())
}
}
}
@@ -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
}
}
+26 -5
View File
@@ -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 {
+44 -28
View File
@@ -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 = "<group>"; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = "<group>"; };
5C27D00327C7D8B500DD6182 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C27D00427C7D8B500DD6182 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a"; sourceTree = "<group>"; };
5C27D00627C7D8B500DD6182 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
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 = "<group>"; };
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = "<group>"; };
5C2E260927A2C63500F70299 /* MyPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = MyPlayground.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; };
@@ -126,12 +138,8 @@
5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = "<group>"; };
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = "<group>"; };
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
5C499F2827BAF1E300ECB4C5 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C499F2927BAF1E300ECB4C5 /* libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.0-KiHnJvLnz2iELJvTN47xo8.a"; sourceTree = "<group>"; };
5C499F2A27BAF1E300ECB4C5 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
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 = "<group>"; };
5C499F2C27BAF1E300ECB4C5 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = "<group>"; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -371,6 +384,7 @@
5CB924D627A8563F00ACCCDD /* SettingsView.swift */,
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -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;
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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.*
+1 -1
View File
@@ -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";
+5 -1
View File
@@ -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.*
+89 -73
View File
@@ -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 =
+24 -1
View File
@@ -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}
-1
View File
@@ -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)",
+69 -79
View File
@@ -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
+5 -3
View File
@@ -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)
@@ -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;
|]
+2 -1
View File
@@ -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
+9 -3
View File
@@ -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"
+20 -14
View File
@@ -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
-1
View File
@@ -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]

Some files were not shown because too many files have changed in this diff Show More