Merge pull request #379 from simplex-chat/master (v1.3.0 terminal app)
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
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>
|
||||
|
||||
@@ -65,3 +65,5 @@ fastlane/test_output
|
||||
iOSInjectionProject/
|
||||
|
||||
Libraries/
|
||||
|
||||
Shared/MyPlayground.playground/*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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&CharacterRangeLoc=91&EndingColumnNumber=0&EndingLineNumber=7&StartingColumnNumber=3&StartingLineNumber=6&Timestamp=666087303.155273"
|
||||
selectedRepresentationIndex = "0"
|
||||
shouldTrackSuperviewWidth = "NO">
|
||||
</LoggerValueHistoryTimelineItem>
|
||||
</TimelineItems>
|
||||
</Timeline>
|
||||
@@ -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{
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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.*
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||