diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
index 7ae480de72..4a377eba51 100644
--- a/apps/android/app/build.gradle
+++ b/apps/android/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
- versionCode 16
- versionName "1.3"
+ versionCode 17
+ versionName "1.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index c7e7c1e20d..284544089a 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
@@ -33,6 +34,15 @@
+
+
+
-
\ No newline at end of file
+
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
index d91262a249..7bdf80eede 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -102,6 +102,37 @@ class ChatModel(val controller: ChatController) {
}
}
+ fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
+ // update previews
+ val i = getChatIndex(cInfo.id)
+ val chat: Chat
+ val res: Boolean
+ if (i >= 0) {
+ chat = chats[i]
+ val pItem = chat.chatItems.last()
+ if (pItem.id == cItem.id) {
+ chats[i] = chat.copy(chatItems = arrayListOf(cItem))
+ }
+ res = false
+ } else {
+ addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
+ res = true
+ }
+ // update current chat
+ if (chatId.value == cInfo.id) {
+ val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
+ if (itemIndex >= 0) {
+ chatItems[itemIndex] = cItem
+ return false
+ } else {
+ chatItems.add(cItem)
+ return true
+ }
+ } else {
+ return res
+ }
+ }
+
fun markChatItemsRead(cInfo: ChatInfo) {
val chatIdx = getChatIndex(cInfo.id)
// update current chat
@@ -122,42 +153,13 @@ class ChatModel(val controller: ChatController) {
}
}
-//
-// func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
-// // update previews
-// var res: Bool
-// if let chat = getChat(cInfo.id) {
-// if let pItem = chat.chatItems.last, pItem.id == cItem.id {
-// chat.chatItems = [cItem]
-// }
-// res = false
-// } else {
-// addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
-// res = true
-// }
-// // update current chat
-// if chatId == cInfo.id {
-// if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
-// withAnimation(.default) {
-// self.chatItems[i] = cItem
-// }
-// return false
-// } else {
-// withAnimation { chatItems.append(cItem) }
-// return true
-// }
-// } else {
-// return res
-// }
-// }
-//
-//
+
// func popChat(_ id: String) {
// if let i = getChatIndex(id) {
// popChat_(i)
// }
// }
-//
+
private fun popChat_(i: Int) {
val chat = chats.removeAt(i)
chats.add(index = 0, chat)
@@ -191,6 +193,7 @@ data class User(
): NamedChat {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
+ override val image: String? get() = profile.image
companion object {
val sampleData = User(
@@ -208,6 +211,7 @@ typealias ChatId = String
interface NamedChat {
val displayName: String
val fullName: String
+ val image: String?
val chatViewName: String
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
}
@@ -272,6 +276,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = contact.createdAt
override val displayName get() = contact.displayName
override val fullName get() = contact.fullName
+ override val image get() = contact.image
companion object {
val sampleData = Direct(Contact.sampleData)
@@ -288,6 +293,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = groupInfo.createdAt
override val displayName get() = groupInfo.displayName
override val fullName get() = groupInfo.fullName
+ override val image get() = groupInfo.image
companion object {
val sampleData = Group(GroupInfo.sampleData)
@@ -304,6 +310,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = contactRequest.createdAt
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
+ override val image get() = contactRequest.image
companion object {
val sampleData = ContactRequest(UserContactRequest.sampleData)
@@ -326,6 +333,7 @@ class Contact(
override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready"
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
+ override val image get() = profile.image
companion object {
val sampleData = Contact(
@@ -354,7 +362,8 @@ class Connection(val connStatus: String) {
@Serializable
class Profile(
val displayName: String,
- val fullName: String
+ val fullName: String,
+ val image: String? = null
) {
companion object {
val sampleData = Profile(
@@ -377,6 +386,7 @@ class GroupInfo (
override val ready get() = true
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
+ override val image get() = groupProfile.image
companion object {
val sampleData = GroupInfo(
@@ -391,7 +401,8 @@ class GroupInfo (
@Serializable
class GroupProfile (
override val displayName: String,
- override val fullName: String
+ override val fullName: String,
+ override val image: String? = null
): NamedChat {
companion object {
val sampleData = GroupProfile(
@@ -444,6 +455,7 @@ class UserContactRequest (
override val ready get() = true
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
+ override val image get() = profile.image
companion object {
val sampleData = UserContactRequest(
@@ -484,11 +496,14 @@ data class ChatItem (
ts: Instant = Clock.System.now(),
text: String = "hello\nthere",
status: CIStatus = CIStatus.SndNew(),
- quotedItem: CIQuote? = null
+ quotedItem: CIQuote? = null,
+ itemDeleted: Boolean = false,
+ itemEdited: Boolean = false,
+ editable: Boolean = true
) =
ChatItem(
chatDir = dir,
- meta = CIMeta.getSample(id, ts, text, status),
+ meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem
)
@@ -526,18 +541,27 @@ data class CIMeta (
val itemTs: Instant,
val itemText: String,
val itemStatus: CIStatus,
- val createdAt: Instant
+ val createdAt: Instant,
+ val itemDeleted: Boolean,
+ val itemEdited: Boolean,
+ val editable: Boolean
) {
val timestampText: String get() = getTimestampText(itemTs)
companion object {
- fun getSample(id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew()): CIMeta =
+ fun getSample(
+ id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
+ itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true
+ ): CIMeta =
CIMeta(
itemId = id,
itemTs = ts,
itemText = text,
itemStatus = status,
- createdAt = ts
+ createdAt = ts,
+ itemDeleted = itemDeleted,
+ itemEdited = itemEdited,
+ editable = editable
)
}
}
@@ -641,6 +665,7 @@ sealed class MsgContent {
}
object MsgContentSerializer : KSerializer {
+ @OptIn(InternalSerializationApi::class)
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
element("MCText", buildClassSerialDescriptor("MCText") {
element("text")
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
index 820a955304..c2ce6ad329 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -131,6 +131,20 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
return null
}
+ suspend fun apiUpdateMessage(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
+ val r = sendCmd(CC.ApiUpdateMessage(type, id, itemId, mc))
+ if (r is CR.ChatItemUpdated) return r.chatItem
+ Log.e(TAG, "apiUpdateMessage bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
+ suspend fun apiDeleteMessage(type: ChatType, id: Long, itemId: Long, mode: MsgDeleteMode): AChatItem? {
+ val r = sendCmd(CC.ApiDeleteMessage(type, id, itemId, mode))
+ if (r is CR.ChatItemDeleted) return r.chatItem
+ Log.e(TAG, "apiDeleteMessage bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
suspend fun getUserSMPServers(): List? {
val r = sendCmd(CC.GetUserSMPServers())
if (r is CR.UserSMPServers) return r.smpServers
@@ -203,7 +217,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
- val r = sendCmd(CC.UpdateProfile(profile))
+ val r = sendCmd(CC.ApiUpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
@@ -303,12 +317,23 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
-// case let .chatItemUpdated(aChatItem):
- // let cInfo = aChatItem.chatInfo
- // let cItem = aChatItem.chatItem
- // if chatModel.upsertChatItem(cInfo, cItem) {
- // NtfManager.shared.notifyMessageReceived(cInfo, cItem)
- // }
+ is CR.ChatItemStatusUpdated -> {
+ val cInfo = r.chatItem.chatInfo
+ val cItem = r.chatItem.chatItem
+ if (chatModel.upsertChatItem(cInfo, cItem)) {
+ ntfManager.notifyMessageReceived(cInfo, cItem)
+ }
+ }
+ is CR.ChatItemUpdated -> {
+ val cInfo = r.chatItem.chatInfo
+ val cItem = r.chatItem.chatItem
+ if (chatModel.upsertChatItem(cInfo, cItem)) {
+ ntfManager.notifyMessageReceived(cInfo, cItem)
+ }
+ }
+ is CR.ChatItemDeleted -> {
+ // TODO
+ }
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
@@ -336,6 +361,11 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
}
}
+enum class MsgDeleteMode(val mode: String) {
+ Broadcast("broadcast"),
+ Internal("internal");
+}
+
// ChatCommand
sealed class CC {
class Console(val cmd: String): CC()
@@ -346,12 +376,14 @@ sealed class CC {
class ApiGetChat(val type: ChatType, val id: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
+ class ApiUpdateMessage(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
+ class ApiDeleteMessage(val type: ChatType, val id: Long, val itemId: Long, val mode: MsgDeleteMode): CC()
class GetUserSMPServers(): CC()
class SetUserSMPServers(val smpServers: List): CC()
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
- class UpdateProfile(val profile: Profile): CC()
+ class ApiUpdateProfile(val profile: Profile): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
@@ -368,12 +400,14 @@ sealed class CC {
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}"
+ is ApiUpdateMessage -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
+ is ApiDeleteMessage -> "/_delete item ${chatRef(type, id)} $itemId $mode"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
- is UpdateProfile -> "/profile ${profile.displayName} ${profile.fullName}"
+ is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
@@ -391,12 +425,14 @@ sealed class CC {
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
is ApiSendMessageQuote -> "apiSendMessageQuote"
+ is ApiUpdateMessage -> "apiUpdateMessage"
+ is ApiDeleteMessage -> "apiDeleteMessage"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
- is UpdateProfile -> "updateProfile"
+ is ApiUpdateProfile -> "updateProfile"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
@@ -474,7 +510,9 @@ sealed class 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()
+ @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val chatItem: AChatItem): CR()
@Serializable @SerialName("cmdOk") class CmdOk: CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR()
@@ -512,7 +550,9 @@ sealed class CR {
is GroupEmpty -> "groupEmpty"
is UserContactLinkSubscribed -> "userContactLinkSubscribed"
is NewChatItem -> "newChatItem"
+ is ChatItemStatusUpdated -> "chatItemStatusUpdated"
is ChatItemUpdated -> "chatItemUpdated"
+ is ChatItemDeleted -> "chatItemDeleted"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
@@ -551,7 +591,9 @@ sealed class CR {
is GroupEmpty -> json.encodeToString(group)
is UserContactLinkSubscribed -> noDetails()
is NewChatItem -> json.encodeToString(chatItem)
+ is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
+ is ChatItemDeleted -> json.encodeToString(chatItem)
is CmdOk -> noDetails()
is ChatCmdError -> chatError.string
is ChatRespError -> chatError.string
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
index 5a9e3c0411..3ce907afc7 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
@@ -1,5 +1,6 @@
package chat.simplex.app.views
+import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -8,8 +9,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -40,11 +40,11 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
}
@Composable
-fun TerminalLayout(terminalItems: List , close: () -> Unit, sendCommand: (String) -> Unit) {
+fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCommand: (String) -> Unit) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
- bottomBar = { SendMsgView(sendCommand) },
+ bottomBar = { SendMsgView(msg = remember { mutableStateOf("") }, sendCommand) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
index 721553f2c0..3c2e33adbc 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
@@ -148,7 +148,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
Button(onClick = {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
- Profile(displayName, fullName)
+ Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index 0d0e2b146d..a72ac162da 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -3,18 +3,18 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.*
+import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
@@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.TAG
import chat.simplex.app.model.*
-import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.helpers.*
@@ -42,6 +41,8 @@ fun ChatView(chatModel: ChatModel) {
chatModel.chatId.value = null
} else {
val quotedItem = remember { mutableStateOf(null) }
+ val editingItem = remember { mutableStateOf(null) }
+ var msg = remember { mutableStateOf("") }
BackHandler { chatModel.chatId.value = null }
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
@@ -58,24 +59,37 @@ fun ChatView(chatModel: ChatModel) {
}
}
}
- ChatLayout(user, chat, chatModel.chatItems, quotedItem,
+ ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem,
back = { chatModel.chatId.value = null },
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
- val newItem = chatModel.controller.apiSendMessage(
- type = cInfo.chatType,
- id = cInfo.apiId,
- quotedItemId = quotedItem.value?.meta?.itemId,
- mc = MsgContent.MCText(msg)
- )
- quotedItem.value = null
+ val ei = editingItem.value
+ if (ei != null) {
+ val updatedItem = chatModel.controller.apiUpdateMessage(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ itemId = ei.meta.itemId,
+ mc = MsgContent.MCText(msg)
+ )
+ if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
+ } else {
+ val newItem = chatModel.controller.apiSendMessage(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ quotedItemId = quotedItem.value?.meta?.itemId,
+ mc = MsgContent.MCText(msg)
+ )
+ if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
+ }
// hide "in progress"
- if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
+ editingItem.value = null
+ quotedItem.value = null
}
- }
+ },
+ resetMessage = { msg.value = "" }
)
}
}
@@ -85,23 +99,27 @@ fun ChatLayout(
user: User,
chat: Chat,
chatItems: List,
+ msg: MutableState,
quotedItem: MutableState,
+ editingItem: MutableState,
back: () -> Unit,
info: () -> Unit,
- sendMessage: (String) -> Unit
+ sendMessage: (String) -> Unit,
+ resetMessage: () -> Unit
) {
Surface(
Modifier
.fillMaxWidth()
- .background(MaterialTheme.colors.background)) {
+ .background(MaterialTheme.colors.background)
+ ) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
- bottomBar = { ComposeView(quotedItem, sendMessage) },
+ bottomBar = { ComposeView(msg, quotedItem, editingItem, sendMessage, resetMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
- ChatItemsList(user, chatItems, quotedItem)
+ ChatItemsList(user, chatItems, msg, quotedItem, editingItem)
}
}
}
@@ -134,14 +152,19 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
) {
val cInfo = chat.chatInfo
ChatInfoImage(chat, size = 40.dp)
- Column(Modifier.padding(start = 8.dp),
+ Column(
+ Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
- Text(cInfo.displayName, fontWeight = FontWeight.Bold,
- maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ cInfo.displayName, fontWeight = FontWeight.Bold,
+ maxLines = 1, overflow = TextOverflow.Ellipsis
+ )
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
- Text(cInfo.fullName,
- maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ cInfo.fullName,
+ maxLines = 1, overflow = TextOverflow.Ellipsis
+ )
}
}
}
@@ -161,7 +184,13 @@ val CIListStateSaver = run {
}
@Composable
-fun ChatItemsList(user: User, chatItems: List, quotedItem: MutableState) {
+fun ChatItemsList(
+ user: User,
+ chatItems: List,
+ msg: MutableState,
+ quotedItem: MutableState,
+ editingItem: MutableState
+) {
val listState = rememberLazyListState()
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
@@ -172,7 +201,7 @@ fun ChatItemsList(user: User, chatItems: List, quotedItem: MutableStat
val cxt = LocalContext.current
LazyColumn(state = listState) {
items(chatItems) { cItem ->
- ChatItemView(user, cItem, quotedItem, cxt, uriHandler)
+ ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler)
}
val len = chatItems.count()
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
@@ -218,10 +247,13 @@ fun PreviewChatLayout() {
chatStats = Chat.ChatStats()
),
chatItems = chatItems,
+ msg = remember { mutableStateOf("") },
quotedItem = remember { mutableStateOf(null) },
+ editingItem = remember { mutableStateOf(null) },
back = {},
info = {},
- sendMessage = {}
+ sendMessage = {},
+ resetMessage = {}
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
index ebea19ef94..790b34924d 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
@@ -1,14 +1,29 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.layout.Column
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.*
import chat.simplex.app.model.ChatItem
+// TODO ComposeState
+
@Composable
-fun ComposeView(quotedItem: MutableState, sendMessage: (String) -> Unit) {
+fun ComposeView(
+ msg: MutableState,
+ quotedItem: MutableState,
+ editingItem: MutableState,
+ sendMessage: (String) -> Unit,
+ resetMessage: () -> Unit
+) {
Column {
- QuotedItemView(quotedItem)
- SendMsgView(sendMessage)
+ when {
+ quotedItem.value != null -> {
+ ContextItemView(quotedItem)
+ }
+ editingItem.value != null -> {
+ ContextItemView(editingItem, editing = editingItem.value != null, resetMessage)
+ }
+ else -> {}
+ }
+ SendMsgView(msg, sendMessage, editing = editingItem.value != null)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
similarity index 60%
rename from apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt
rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
index 5f8f1ac54f..3690e79883 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt
@@ -19,27 +19,38 @@ import chat.simplex.app.views.chat.item.*
import kotlinx.datetime.Clock
@Composable
-fun QuotedItemView(quotedItem: MutableState) {
- val qi = quotedItem.value
- if (qi != null) {
- val sent = qi.chatDir.sent
+fun ContextItemView(
+ contextItem: MutableState,
+ editing: Boolean = false,
+ resetMessage: () -> Unit = {}
+) {
+ val cxtItem = contextItem.value
+ if (cxtItem != null) {
+ val sent = cxtItem.chatDir.sent
Row(
- Modifier.padding(top = 8.dp)
+ Modifier
+ .padding(top = 8.dp)
.background(if (sent) SentColorLight else ReceivedColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Box(
- Modifier.padding(start = 16.dp)
+ Modifier
+ .padding(start = 16.dp)
.padding(vertical = 12.dp)
.fillMaxWidth()
.weight(1F)
) {
- QuoteText(qi)
+ ContextItemText(cxtItem)
}
- IconButton(onClick = { quotedItem.value = null }) {
+ IconButton(onClick = {
+ contextItem.value = null
+ if (editing) {
+ resetMessage()
+ }
+ }) {
Icon(
Icons.Outlined.Close,
- "Remove quote",
+ contentDescription = "Cancel",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
@@ -49,14 +60,14 @@ fun QuotedItemView(quotedItem: MutableState) {
}
@Composable
-private fun QuoteText(qi: ChatItem) {
- val member = qi.memberDisplayName
+private fun ContextItemText(cxtItem: ChatItem) {
+ val member = cxtItem.memberDisplayName
if (member == null) {
- Text(qi.content.text, maxLines = 3)
+ Text(cxtItem.content.text, maxLines = 3)
} else {
val annotatedText = buildAnnotatedString {
withStyle(boldFont) { append(member) }
- append(": ${qi.content.text}")
+ append(": ${cxtItem.content.text}")
}
Text(annotatedText, maxLines = 3)
}
@@ -64,13 +75,15 @@ private fun QuoteText(qi: ChatItem) {
@Preview
@Composable
-fun PreviewTextItemViewEmoji() {
+fun PreviewContextItemView() {
SimpleXTheme {
- QuotedItemView(
- quotedItem = remember {
- mutableStateOf(ChatItem.getSampleData(
- 1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
- ))
+ ContextItemView(
+ contextItem = remember {
+ mutableStateOf(
+ ChatItem.getSampleData(
+ 1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
+ )
+ )
}
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
index 90248d8818..89b5851f89 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -24,15 +25,14 @@ import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
@Composable
-fun SendMsgView(sendMessage: (String) -> Unit) {
- var msg by remember { mutableStateOf("") }
+fun SendMsgView(msg: MutableState, sendMessage: (String) -> Unit, editing: Boolean = false) {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
var textStyle by remember { mutableStateOf(smallFont) }
BasicTextField(
- value = msg,
+ value = msg.value,
onValueChange = {
- msg = it
- textStyle = if(isShortEmoji(it)) {
+ msg.value = it
+ textStyle = if (isShortEmoji(it)) {
if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
} else {
smallFont
@@ -64,9 +64,9 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
) {
innerTextField()
}
- val color = if (msg.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
+ val color = if (msg.value.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
Icon(
- Icons.Outlined.ArrowUpward,
+ if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward,
"Send Message",
tint = Color.White,
modifier = Modifier
@@ -75,9 +75,9 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
.clip(CircleShape)
.background(color)
.clickable {
- if (msg.isNotEmpty()) {
- sendMessage(msg)
- msg = ""
+ if (msg.value.isNotEmpty()) {
+ sendMessage(msg.value)
+ msg.value = ""
textStyle = smallFont
}
}
@@ -98,7 +98,25 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
fun PreviewSendMsgView() {
SimpleXTheme {
SendMsgView(
+ msg = remember { mutableStateOf("") },
sendMessage = { msg -> println(msg) }
)
}
}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ showBackground = true,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewSendMsgViewEditing() {
+ SimpleXTheme {
+ SendMsgView(
+ msg = remember { mutableStateOf("") },
+ sendMessage = { msg -> println(msg) },
+ editing = true
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
index aafcb7d041..0517b0ab10 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
@@ -1,8 +1,15 @@
package chat.simplex.app.views.chat.item
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Icon
import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
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.compose.ui.unit.sp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
@@ -11,11 +18,24 @@ import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem) {
- Text(
- chatItem.timestampText,
- color = HighOrLowlight,
- fontSize = 14.sp
- )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (chatItem.meta.itemEdited) {
+ Icon(
+ Icons.Filled.Edit,
+ modifier = Modifier.height(12.dp),
+ contentDescription = "Edited",
+ tint = HighOrLowlight,
+ )
+ }
+ Text(
+ chatItem.timestampText,
+ color = HighOrLowlight,
+ fontSize = 14.sp
+ )
+ }
}
@Preview
@@ -27,3 +47,14 @@ fun PreviewCIMetaView() {
)
)
}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewEdited() {
+ CIMetaView(
+ chatItem = ChatItem.getSampleData(
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
+ itemEdited = true
+ )
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
index acc249bdf7..e5cfad3a34 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -22,7 +23,15 @@ import chat.simplex.app.views.helpers.shareText
import kotlinx.datetime.Clock
@Composable
-fun ChatItemView(user: User, cItem: ChatItem, quotedItem: MutableState, cxt: Context, uriHandler: UriHandler? = null) {
+fun ChatItemView(
+ user: User,
+ cItem: ChatItem,
+ msg: MutableState,
+ quotedItem: MutableState,
+ editingItem: MutableState,
+ cxt: Context,
+ uriHandler: UriHandler? = null
+) {
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
var showMenu by remember { mutableStateOf(false) }
@@ -44,6 +53,7 @@ fun ChatItemView(user: User, cItem: ChatItem, quotedItem: MutableState Unit) {
DropdownMenuItem(onClick) {
Row {
- Text(text, modifier = Modifier
- .fillMaxWidth()
- .weight(1F))
+ Text(
+ text, modifier = Modifier
+ .fillMaxWidth()
+ .weight(1F)
+ )
Icon(icon, text, tint = HighOrLowlight)
}
}
@@ -81,7 +101,9 @@ fun PreviewChatItemView() {
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
+ msg = remember { mutableStateOf("") },
quotedItem = remember { mutableStateOf(null) },
+ editingItem = remember { mutableStateOf(null) },
cxt = LocalContext.current
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
index abdc15effc..d5b2d9c24b 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
@@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
@@ -48,7 +48,9 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
if (ci.formattedText == null && isShortEmoji(ci.content.text)) {
Column(
- Modifier.padding(bottom = 2.dp).fillMaxWidth(),
+ Modifier
+ .padding(bottom = 2.dp)
+ .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(ci.content.text)
@@ -57,7 +59,7 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) {
} else {
MarkdownText(
ci.content, ci.formattedText, ci.memberDisplayName,
- metaText = ci.timestampText, uriHandler = uriHandler, senderBold = true
+ metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true
)
}
}
@@ -69,14 +71,18 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) {
}
}
+class EditedProvider: PreviewParameterProvider {
+ override val values = listOf(false, true).asSequence()
+}
+
@Preview
@Composable
-fun PreviewTextItemViewSnd() {
+fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Boolean) {
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatItem.getSampleData(
- 1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited
)
)
}
@@ -84,12 +90,12 @@ fun PreviewTextItemViewSnd() {
@Preview
@Composable
-fun PreviewTextItemViewRcv() {
+fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Boolean) {
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatItem.getSampleData(
- 1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
+ 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
)
)
}
@@ -97,7 +103,7 @@ fun PreviewTextItemViewRcv() {
@Preview
@Composable
-fun PreviewTextItemViewLong() {
+fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boolean) {
SimpleXTheme {
FramedItemView(
User.sampleData,
@@ -105,7 +111,8 @@ fun PreviewTextItemViewLong() {
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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
+ "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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+ itemEdited = edited
)
)
}
@@ -113,7 +120,7 @@ fun PreviewTextItemViewLong() {
@Preview
@Composable
-fun PreviewTextItemViewQuote() {
+fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Boolean) {
SimpleXTheme {
FramedItemView(
User.sampleData,
@@ -122,7 +129,8 @@ fun PreviewTextItemViewQuote() {
Clock.System.now(),
"https://simplex.chat",
CIStatus.SndSent(),
- quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv())
+ quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
+ itemEdited = edited
)
)
}
@@ -130,7 +138,7 @@ fun PreviewTextItemViewQuote() {
@Preview
@Composable
-fun PreviewTextItemViewEmoji() {
+fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Boolean) {
SimpleXTheme {
FramedItemView(
User.sampleData,
@@ -139,7 +147,8 @@ fun PreviewTextItemViewEmoji() {
Clock.System.now(),
"👍",
CIStatus.SndSent(),
- quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv())
+ quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
+ itemEdited = edited
)
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
index 6789833fe2..8d25881786 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt
@@ -39,6 +39,7 @@ fun MarkdownText (
formattedText: List? = null,
sender: String? = null,
metaText: String? = null,
+ edited: Boolean = false,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
@@ -46,11 +47,12 @@ fun MarkdownText (
senderBold: Boolean = false,
modifier: Modifier = Modifier
) {
+ val reserve = if (edited) " " else " "
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendSender(this, sender, senderBold)
append(content.text)
- if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") }
+ if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
@@ -71,7 +73,7 @@ fun MarkdownText (
}
}
}
- if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") }
+ if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
index cdc1f86039..d5d582e9ed 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt
@@ -1,6 +1,8 @@
package chat.simplex.app.views.helpers
+import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
@@ -8,6 +10,10 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -20,12 +26,32 @@ fun ChatInfoImage(chat: Chat, size: Dp) {
val icon =
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
+ ProfileImage(size, chat.chatInfo.image, icon)
+}
+
+@Composable
+fun ProfileImage(
+ size: Dp,
+ image: String? = null,
+ icon: ImageVector = Icons.Filled.AccountCircle
+) {
Box(Modifier.size(size)) {
- Icon(icon,
- contentDescription = "Avatar Placeholder",
- tint = MaterialTheme.colors.secondary,
- modifier = Modifier.fillMaxSize()
- )
+ if (image == null) {
+ Icon(
+ icon,
+ contentDescription = "profile image placeholder",
+ tint = MaterialTheme.colors.secondary,
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ val imageBitmap = base64ToBitmap(image).asImageBitmap()
+ Image(
+ imageBitmap,
+ "profile image",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
+ )
+ }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
new file mode 100644
index 0000000000..6ae0c384a6
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt
@@ -0,0 +1,187 @@
+package chat.simplex.app.views.helpers
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.*
+import android.net.Uri
+import android.provider.MediaStore
+import android.util.Base64
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.CallSuper
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Collections
+import androidx.compose.material.icons.outlined.PhotoCamera
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import chat.simplex.app.BuildConfig
+import chat.simplex.app.TAG
+import chat.simplex.app.views.newchat.ActionButton
+import java.io.ByteArrayOutputStream
+import java.io.File
+
+// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
+
+fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String {
+ val size = 104
+ var height = size
+ var width = size
+ var xOffset = 0
+ var yOffset = 0
+ if (bitmap.height < bitmap.width) {
+ width = height * bitmap.width / bitmap.height
+ xOffset = (width - height) / 2
+ } else {
+ height = width * bitmap.height / bitmap.width
+ yOffset = (height - width) / 2
+ }
+ var image = bitmap
+ while (image.width / 2 > width) {
+ image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true)
+ }
+ image = Bitmap.createScaledBitmap(image, width, height, true)
+ if (squareCrop) {
+ image = Bitmap.createBitmap(image, xOffset, yOffset, size, size)
+ }
+ val stream = ByteArrayOutputStream()
+ image.compress(Bitmap.CompressFormat.JPEG, 85, stream)
+ return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
+}
+
+fun base64ToBitmap(base64ImageString: String) : Bitmap {
+ val imageString = base64ImageString
+ .removePrefix("data:image/png;base64,")
+ .removePrefix("data:image/jpg;base64,")
+ val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
+ return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
+}
+
+class CustomTakePicturePreview : ActivityResultContract() {
+ private var uri: Uri? = null
+ private var tmpFile: File? = null
+ lateinit var externalContext: Context
+
+ @CallSuper
+ override fun createIntent(context: Context, input: Void?): Intent {
+ externalContext = context
+ tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
+ uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
+ return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+ .putExtra(MediaStore.EXTRA_OUTPUT, uri)
+ }
+
+ override fun getSynchronousResult(
+ context: Context,
+ input: Void?
+ ): SynchronousResult? = null
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
+ return if (resultCode == Activity.RESULT_OK && uri != null) {
+ val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ tmpFile?.delete()
+ bitmap
+ } else {
+ Log.e( TAG, "Getting image from camera cancelled or failed.")
+ tmpFile?.delete()
+ null
+ }
+ }
+}
+
+@Composable
+fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
+
+@Composable
+fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
+
+@Composable
+fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
+
+@Composable
+fun GetImageBottomSheet(
+ profileImageStr: MutableState,
+ hideBottomSheet: () -> Unit
+) {
+ val context = LocalContext.current
+ val isCameraSelected = remember { mutableStateOf (false) }
+
+ val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
+ if (uri != null) {
+ val source = ImageDecoder.createSource(context.contentResolver, uri)
+ val bitmap = ImageDecoder.decodeBitmap(source)
+ profileImageStr.value = bitmapToBase64(bitmap)
+ }
+ }
+
+ val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
+ if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap)
+ }
+
+ val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
+ if (isGranted) {
+ if (isCameraSelected.value) cameraLauncher.launch(null)
+ else galleryLauncher.launch("image/*")
+ hideBottomSheet()
+ } else {
+ Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .onFocusChanged { focusState ->
+ if (!focusState.hasFocus) hideBottomSheet()
+ }
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 30.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) {
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
+ cameraLauncher.launch(null)
+ hideBottomSheet()
+ }
+ else -> {
+ isCameraSelected.value = true
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+ }
+ ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) {
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
+ galleryLauncher.launch("image/*")
+ hideBottomSheet()
+ }
+ else -> {
+ isCameraSelected.value = false
+ permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
index 65d882ae71..61d2400a01 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt
@@ -83,7 +83,7 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
}
@Composable
-fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boolean = false,
+fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
Column(
Modifier
@@ -97,16 +97,22 @@ fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boo
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
- Text(text,
- textAlign = TextAlign.Center,
- fontWeight = FontWeight.Bold,
- color = tint,
- modifier = Modifier.padding(bottom = 4.dp)
- )
- Text(comment,
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.body2
- )
+ if (text != null) {
+ Text(
+ text,
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.Bold,
+ color = tint,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ }
+ if (comment != null) {
+ Text(
+ comment,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.body2
+ )
+ }
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
index 0f0a649f1e..ef6e29a6d4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt
@@ -16,7 +16,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun MarkdownHelpView() {
- Column(Modifier.padding(horizontal = 16.dp)) {
+ Column {
Text(
"How to use markdown",
style = MaterialTheme.typography.h1,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
index 0734965898..581c9cf271 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
@@ -23,6 +23,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
+import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.newchat.ModalManager
@Composable
@@ -32,6 +33,7 @@ fun SettingsView(chatModel: ChatModel) {
SettingsLayout(
profile = user.profile,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
+ showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
)
}
@@ -44,6 +46,7 @@ val simplexTeamUri =
fun SettingsLayout(
profile: Profile,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
+ showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit
) {
val uriHandler = LocalUriHandler.current
@@ -66,11 +69,8 @@ fun SettingsLayout(
)
Spacer(Modifier.height(30.dp))
- SettingsSectionView(showModal { UserProfileView(it) }, 60.dp) {
- Icon(
- Icons.Outlined.AccountCircle,
- contentDescription = "Avatar Placeholder",
- )
+ SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
+ ProfileImage(size = 60.dp, profile.image)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
@@ -186,7 +186,7 @@ fun SettingsSectionView(click: () -> Unit, height: Dp = 48.dp, content: (@Compos
.height(height),
verticalAlignment = Alignment.CenterVertically
) {
- content.invoke()
+ content()
}
}
@@ -202,6 +202,7 @@ fun PreviewSettingsLayout() {
SettingsLayout(
profile = Profile.sampleData,
showModal = {{}},
+ showCustomModal = {{}},
showTerminal = {}
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
index 4dd2ad0d44..f72e512bca 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
@@ -1,15 +1,24 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
-import androidx.compose.foundation.clickable
+import android.widget.ScrollView
+import androidx.compose.foundation.*
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.PhotoCamera
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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
@@ -17,29 +26,32 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.helpers.withApi
+import chat.simplex.app.views.chat.CIListState
+import chat.simplex.app.views.helpers.*
+import chat.simplex.app.views.newchat.ModalView
+import com.google.accompanist.insets.ProvideWindowInsets
+import com.google.accompanist.insets.navigationBarsWithImePadding
+import kotlinx.coroutines.launch
@Composable
-fun UserProfileView(chatModel: ChatModel) {
+fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val user = chatModel.currentUser.value
if (user != null) {
- var editProfile by remember { mutableStateOf(false) }
+ var editProfile = remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
+ close = close,
editProfile = editProfile,
profile = profile,
- editProfileOff = { editProfile = false },
- editProfileOn = { editProfile = true },
- saveProfile = { displayName: String, fullName: String ->
+ saveProfile = { displayName, fullName, image ->
withApi {
- val newProfile = chatModel.controller.apiUpdateProfile(
- profile = Profile(displayName, fullName)
- )
+ val p = Profile(displayName, fullName, image)
+ val newProfile = chatModel.controller.apiUpdateProfile(p)
if (newProfile != null) {
chatModel.updateUserProfile(newProfile)
profile = newProfile
}
- editProfile = false
+ editProfile.value = false
}
}
)
@@ -48,119 +60,192 @@ fun UserProfileView(chatModel: ChatModel) {
@Composable
fun UserProfileLayout(
- editProfile: Boolean,
+ close: () -> Unit,
+ editProfile: MutableState,
profile: Profile,
- editProfileOff: () -> Unit,
- editProfileOn: () -> Unit,
- saveProfile: (String, String) -> Unit,
+ saveProfile: (String, String, String?) -> Unit,
) {
- Column(horizontalAlignment = Alignment.Start) {
- Text(
- "Your chat profile",
- Modifier.padding(bottom = 24.dp),
- style = MaterialTheme.typography.h1,
- color = MaterialTheme.colors.onBackground
- )
- Text(
- "Your profile is stored on your device and shared only with your contacts.\n" +
- "SimpleX servers cannot see your profile.",
- Modifier.padding(bottom = 24.dp),
- color = MaterialTheme.colors.onBackground
- )
- if (editProfile) {
- var displayName by remember { mutableStateOf(profile.displayName) }
- var fullName by remember { mutableStateOf(profile.fullName) }
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.Start
- ) {
- // TODO hints
- BasicTextField(
- value = displayName,
- onValueChange = { displayName = it },
- modifier = Modifier
- .padding(bottom = 24.dp)
- .fillMaxWidth(),
- textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrect = false
- ),
- singleLine = true
- )
- BasicTextField(
- value = fullName,
- onValueChange = { fullName = it },
- modifier = Modifier
- .padding(bottom = 24.dp)
- .fillMaxWidth(),
- textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.None,
- autoCorrect = false
- ),
- singleLine = true
- )
- Row {
- Text(
- "Cancel",
- color = MaterialTheme.colors.primary,
- modifier = Modifier
- .clickable(onClick = editProfileOff),
- )
- Spacer(Modifier.padding(horizontal = 8.dp))
- Text(
- "Save (and notify contacts)",
- color = MaterialTheme.colors.primary,
- modifier = Modifier
- .clickable(onClick = { saveProfile(displayName, fullName) })
- )
- }
- }
- } else {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.Start
- ) {
- Row(
- Modifier.padding(bottom = 24.dp)
+ val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
+ val displayName = remember { mutableStateOf(profile.displayName) }
+ val fullName = remember { mutableStateOf(profile.fullName) }
+ val profileImage = remember { mutableStateOf(profile.image) }
+ val scope = rememberCoroutineScope()
+ val scrollState = rememberScrollState()
+ val keyboardState by getKeyboardState()
+ var savedKeyboardState by remember { mutableStateOf(keyboardState) }
+
+ ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
+ ModalBottomSheetLayout(
+ scrimColor = Color.Black.copy(alpha = 0.12F),
+ modifier = Modifier.navigationBarsWithImePadding(),
+ sheetContent = {
+ GetImageBottomSheet(profileImage, hideBottomSheet = {
+ scope.launch { bottomSheetModalState.hide() }
+ })
+ },
+ sheetState = bottomSheetModalState,
+ sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
+ ) {
+ ModalView(close = close) {
+ Column(
+ Modifier
+ .verticalScroll(scrollState)
+ .padding(bottom = 16.dp),
+ horizontalAlignment = Alignment.Start
) {
Text(
- "Display name:",
+ "Your chat profile",
+ Modifier.padding(bottom = 24.dp),
+ style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
- Spacer(Modifier.padding(horizontal = 4.dp))
Text(
- profile.displayName,
- fontWeight = FontWeight.Bold,
+ "Your profile is stored on your device and shared only with your contacts.\n\n" +
+ "SimpleX servers cannot see your profile.",
+ Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
+ if (editProfile.value) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(contentAlignment = Alignment.TopEnd) {
+ Box(contentAlignment = Alignment.Center) {
+ ProfileImage(192.dp, profileImage.value)
+ EditImageButton { scope.launch { bottomSheetModalState.show() } }
+ }
+ if (profileImage.value != null) {
+ DeleteImageButton { profileImage.value = null }
+ }
+ }
+ }
+ ProfileNameTextField(displayName)
+ ProfileNameTextField(fullName)
+ Row {
+ TextButton("Cancel") {
+ displayName.value = profile.displayName
+ fullName.value = profile.fullName
+ profileImage.value = profile.image
+ editProfile.value = false
+ }
+ Spacer(Modifier.padding(horizontal = 8.dp))
+ TextButton("Save (and notify contacts)") {
+ saveProfile(displayName.value, fullName.value, profileImage.value)
+ }
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp), contentAlignment = Alignment.Center
+ ) {
+ ProfileImage(192.dp, profile.image)
+ if (profile.image == null) {
+ EditImageButton {
+ editProfile.value = true
+ scope.launch { bottomSheetModalState.show() }
+ }
+ }
+ }
+ ProfileNameRow("Display name:", profile.displayName)
+ ProfileNameRow("Full name:", profile.fullName)
+ TextButton("Edit") { editProfile.value = true }
+ }
+ }
+ if (savedKeyboardState != keyboardState) {
+ LaunchedEffect(keyboardState) {
+ scope.launch {
+ savedKeyboardState = keyboardState
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+ }
+ }
}
- Row(
- Modifier.padding(bottom = 24.dp)
- ) {
- Text(
- "Full name:",
- color = MaterialTheme.colors.onBackground
- )
- Spacer(Modifier.padding(horizontal = 4.dp))
- Text(
- profile.fullName,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colors.onBackground
- )
- }
- Text(
- "Edit",
- color = MaterialTheme.colors.primary,
- modifier = Modifier
- .clickable(onClick = editProfileOn)
- )
}
}
}
}
+@Composable
+private fun ProfileNameTextField(name: MutableState) {
+ BasicTextField(
+ value = name.value,
+ onValueChange = { name.value = it },
+ modifier = Modifier
+ .padding(bottom = 24.dp)
+ .fillMaxWidth(),
+ textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrect = false
+ ),
+ singleLine = true
+ )
+}
+
+@Composable
+private fun ProfileNameRow(label: String, text: String) {
+ Row(Modifier.padding(bottom = 24.dp)) {
+ Text(
+ label,
+ color = MaterialTheme.colors.onBackground
+ )
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text(
+ text,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onBackground
+ )
+ }
+}
+
+@Composable
+private fun TextButton(text: String, click: () -> Unit) {
+ Text(
+ text,
+ color = MaterialTheme.colors.primary,
+ modifier = Modifier.clickable(onClick = click),
+ )
+}
+
+@Composable
+fun EditImageButton(click: () -> Unit) {
+ IconButton(
+ onClick = click,
+ modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape)
+ ) {
+ Icon(
+ Icons.Outlined.PhotoCamera,
+ contentDescription = "Edit image",
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.size(36.dp)
+ )
+ }
+}
+
+@Composable
+fun DeleteImageButton(click: () -> Unit) {
+ IconButton(onClick = click) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = "Delete image",
+ tint = MaterialTheme.colors.primary,
+ )
+ }
+}
+
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -171,11 +256,10 @@ fun UserProfileLayout(
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
+ close = {},
profile = Profile.sampleData,
- editProfile = false,
- editProfileOff = {},
- editProfileOn = {},
- saveProfile = { _, _ -> }
+ editProfile = remember { mutableStateOf(false) },
+ saveProfile = { _, _, _ -> }
)
}
}
@@ -190,11 +274,10 @@ fun PreviewUserProfileLayoutEditOff() {
fun PreviewUserProfileLayoutEditOn() {
SimpleXTheme {
UserProfileLayout(
+ close = {},
profile = Profile.sampleData,
- editProfile = true,
- editProfileOff = {},
- editProfileOn = {},
- saveProfile = { _, _ -> }
+ editProfile = remember { mutableStateOf(true) },
+ saveProfile = {_, _, _ ->}
)
}
}
diff --git a/apps/android/app/src/main/res/xml/file_paths.xml b/apps/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000000..5cb7c4876d
--- /dev/null
+++ b/apps/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index c553543b3a..c0713686c3 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -190,8 +190,8 @@ struct User: Decodable, NamedChat {
var activeUser: Bool
var displayName: String { get { profile.displayName } }
-
var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
static let sampleData = User(
userId: 1,
@@ -209,6 +209,7 @@ typealias GroupName = String
struct Profile: Codable, NamedChat {
var displayName: String
var fullName: String
+ var image: String?
static let sampleData = Profile(
displayName: "alice",
@@ -225,6 +226,7 @@ enum ChatType: String {
protocol NamedChat {
var displayName: String { get }
var fullName: String { get }
+ var image: String? { get }
}
extension NamedChat {
@@ -270,6 +272,16 @@ enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
+ var image: String? {
+ get {
+ switch self {
+ case let .direct(contact): return contact.image
+ case let .group(groupInfo): return groupInfo.image
+ case let .contactRequest(contactRequest): return contactRequest.image
+ }
+ }
+ }
+
var id: ChatId {
get {
switch self {
@@ -420,6 +432,7 @@ struct Contact: Identifiable, Decodable, NamedChat {
var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
static let sampleData = Contact(
contactId: 1,
@@ -452,6 +465,7 @@ struct UserContactRequest: Decodable, NamedChat {
var ready: Bool { get { true } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
+ var image: String? { get { profile.image } }
static let sampleData = UserContactRequest(
contactRequestId: 1,
@@ -472,6 +486,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
var ready: Bool { get { true } }
var displayName: String { get { groupProfile.displayName } }
var fullName: String { get { groupProfile.fullName } }
+ var image: String? { get { groupProfile.image } }
static let sampleData = GroupInfo(
groupId: 1,
@@ -484,6 +499,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
struct GroupProfile: Codable, NamedChat {
var displayName: String
var fullName: String
+ var image: String?
static let sampleData = GroupProfile(
displayName: "team",
@@ -548,10 +564,10 @@ struct ChatItem: Identifiable, Decodable {
}
}
- static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil) -> ChatItem {
+ static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem {
ChatItem(
chatDir: dir,
- meta: CIMeta.getSample(id, ts, text, status),
+ meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
content: .sndMsgContent(msgContent: .text(text)),
quotedItem: quotedItem
)
@@ -582,16 +598,22 @@ struct CIMeta: Decodable {
var itemText: String
var itemStatus: CIStatus
var createdAt: Date
+ var itemDeleted: Bool
+ var itemEdited: Bool
+ var editable: Bool
var timestampText: Text { get { SimpleX.timestampText(itemTs) } }
- static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta {
+ static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta {
CIMeta(
itemId: id,
itemTs: ts,
itemText: text,
itemStatus: status,
- createdAt: ts
+ createdAt: ts,
+ itemDeleted: itemDeleted,
+ itemEdited: itemEdited,
+ editable: editable
)
}
}
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 60678b2fef..0f0386bf18 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -15,6 +15,11 @@ private var chatController: chat_ctrl?
private let jsonDecoder = getJSONDecoder()
private let jsonEncoder = getJSONEncoder()
+enum MsgDeleteMode: String {
+ case mdBroadcast = "broadcast"
+ case mdInternal = "internal"
+}
+
enum ChatCommand {
case showActiveUser
case createActiveUser(profile: Profile)
@@ -23,12 +28,14 @@ enum ChatCommand {
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
+ case apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
+ case apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode)
case getUserSMPServers
case setUserSMPServers(smpServers: [String])
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
- case updateProfile(profile: Profile)
+ case apiUpdateProfile(profile: Profile)
case createMyAddress
case deleteMyAddress
case showMyAddress
@@ -47,12 +54,14 @@ enum ChatCommand {
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)"
case let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(mc.cmdString)"
+ case let .apiUpdateMessage(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
+ case let .apiDeleteMessage(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
case .getUserSMPServers: return "/smp_servers"
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
- case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)"
+ case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
case .createMyAddress: return "/address"
case .deleteMyAddress: return "/delete_address"
case .showMyAddress: return "/show_address"
@@ -74,12 +83,14 @@ enum ChatCommand {
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
case .apiSendMessageQuote: return "apiSendMessageQuote"
+ case .apiUpdateMessage: return "apiUpdateMessage"
+ case .apiDeleteMessage: return "apiDeleteMessage"
case .getUserSMPServers: return "getUserSMPServers"
case .setUserSMPServers: return "setUserSMPServers"
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
- case .updateProfile: return "updateProfile"
+ case .apiUpdateProfile: return "apiUpdateProfile"
case .createMyAddress: return "createMyAddress"
case .deleteMyAddress: return "deleteMyAddress"
case .showMyAddress: return "showMyAddress"
@@ -135,7 +146,9 @@ enum ChatResponse: Decodable, Error {
case groupEmpty(groupInfo: GroupInfo)
case userContactLinkSubscribed
case newChatItem(chatItem: AChatItem)
+ case chatItemStatusUpdated(chatItem: AChatItem)
case chatItemUpdated(chatItem: AChatItem)
+ case chatItemDeleted(chatItem: AChatItem)
case cmdOk
case chatCmdError(chatError: ChatError)
case chatError(chatError: ChatError)
@@ -155,7 +168,7 @@ enum ChatResponse: Decodable, Error {
case .sentInvitation: return "sentInvitation"
case .contactDeleted: return "contactDeleted"
case .userProfileNoChange: return "userProfileNoChange"
- case .userProfileUpdated: return "userProfileNoChange"
+ case .userProfileUpdated: return "userProfileUpdated"
case .userContactLink: return "userContactLink"
case .userContactLinkCreated: return "userContactLinkCreated"
case .userContactLinkDeleted: return "userContactLinkDeleted"
@@ -173,7 +186,9 @@ enum ChatResponse: Decodable, Error {
case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItem: return "newChatItem"
+ case .chatItemStatusUpdated: return "chatItemStatusUpdated"
case .chatItemUpdated: return "chatItemUpdated"
+ case .chatItemDeleted: return "chatItemDeleted"
case .cmdOk: return "cmdOk"
case .chatCmdError: return "chatCmdError"
case .chatError: return "chatError"
@@ -214,7 +229,9 @@ enum ChatResponse: Decodable, Error {
case let .groupEmpty(groupInfo): return String(describing: groupInfo)
case .userContactLinkSubscribed: return noDetails
case let .newChatItem(chatItem): return String(describing: chatItem)
+ case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
+ case let .chatItemDeleted(chatItem): return String(describing: chatItem)
case .cmdOk: return noDetails
case let .chatCmdError(chatError): return String(describing: chatError)
case let .chatError(chatError): return String(describing: chatError)
@@ -393,6 +410,18 @@ func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgCon
throw r
}
+func apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
+ let r = await chatSendCmd(.apiUpdateMessage(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay)
+ if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
+ throw r
+}
+
+func apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode) async throws -> ChatItem {
+ let r = await chatSendCmd(.apiDeleteMessage(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
+ if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
+ throw r
+}
+
func getUserSMPServers() throws -> [String] {
let r = chatSendCmdSync(.getUserSMPServers)
if case let .userSMPServers(smpServers) = r { return smpServers }
@@ -427,7 +456,7 @@ func apiDeleteChat(type: ChatType, id: Int64) async throws {
}
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
- let r = await chatSendCmd(.updateProfile(profile: profile))
+ let r = await chatSendCmd(.apiUpdateProfile(profile: profile))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(_, toProfile): return toProfile
@@ -601,7 +630,7 @@ func processReceivedMsg(_ res: ChatResponse) {
let cItem = aChatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
- case let .chatItemUpdated(aChatItem):
+ case let .chatItemStatusUpdated(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if chatModel.upsertChatItem(cInfo, cItem) {
@@ -614,6 +643,15 @@ func processReceivedMsg(_ res: ChatResponse) {
default: break
}
}
+ case let .chatItemUpdated(aChatItem):
+ let cInfo = aChatItem.chatInfo
+ let cItem = aChatItem.chatItem
+ if chatModel.upsertChatItem(cInfo, cItem) {
+ NtfManager.shared.notifyMessageReceived(cInfo, cItem)
+ }
+ case .chatItemDeleted(_):
+ // TODO let .chatItemDeleted(aChatItem)
+ return
default:
logger.debug("unsupported event: \(res.responseType)")
}
@@ -703,10 +741,13 @@ private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? {
return try? JSONSerialization.jsonObject(with: d) as? NSDictionary
}
-private func encodeCJSON(_ value: T) -> [CChar] {
+private func encodeJSON(_ value: T) -> String {
let data = try! jsonEncoder.encode(value)
- let str = String(decoding: data, as: UTF8.self)
- return str.cString(using: .utf8)!
+ return String(decoding: data, as: UTF8.self)
+}
+
+private func encodeCJSON(_ value: T) -> [CChar] {
+ encodeJSON(value).cString(using: .utf8)!
}
enum ChatError: Decodable {
@@ -742,6 +783,8 @@ enum ChatErrorType: Decodable {
case fileSend(fileId: Int64, agentError: String)
case fileRcvChunk(message: String)
case fileInternal(message: String)
+ case invalidQuote
+ case invalidMessageUpdate
case agentVersion
case commandError(message: String)
}
@@ -773,6 +816,8 @@ enum StoreError: Decodable {
case noMsgDelivery(connId: Int64, agentMsgId: String)
case badChatItem(itemId: Int64)
case chatItemNotFound(itemId: Int64)
+ case quotedChatItemNotFound
+ case chatItemSharedMsgIdNotFound(sharedMsgId: String)
}
enum AgentErrorType: Decodable {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index 08112976fc..80ed91e072 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -13,6 +13,10 @@ struct CIMetaView: View {
var body: some View {
HStack(alignment: .center, spacing: 4) {
+ if chatItem.meta.itemEdited {
+ statusImage("pencil", .secondary, 9)
+ }
+
switch chatItem.meta.itemStatus {
case .sndSent:
statusImage("checkmark", .secondary)
@@ -31,17 +35,20 @@ struct CIMetaView: View {
}
}
- private func statusImage(_ systemName: String, _ color: Color) -> some View {
+ private func statusImage(_ systemName: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View {
Image(systemName: systemName)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(color)
- .frame(maxHeight: 8)
+ .frame(maxHeight: maxHeight)
}
}
struct CIMetaView_Previews: PreviewProvider {
static var previews: some View {
- CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
+ return Group {
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, false, true))
+ }
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index 90e5d4e2a6..b50abd392c 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -54,7 +54,8 @@ struct FramedItemView: View {
content: chatItem.content,
formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName,
- metaText: chatItem.timestampText
+ metaText: chatItem.timestampText,
+ edited: chatItem.meta.itemEdited
)
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -63,14 +64,15 @@ struct FramedItemView: View {
.textSelection(.enabled)
}
}
- .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
CIMetaView(chatItem: chatItem)
- .padding(.trailing, 12)
+ .padding(.horizontal, 12)
.padding(.bottom, 6)
+ .overlay(DetermineWidth())
}
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
+ .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
switch chatItem.meta.itemStatus {
case .sndErrorAuth:
@@ -110,3 +112,19 @@ struct FramedItemView_Previews: PreviewProvider {
.previewLayout(.fixed(width: 360, height: 200))
}
}
+
+struct FramedItemViewEdited_Previews: PreviewProvider {
+ static var previews: some View {
+ Group{
+ FramedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, false, true))
+ FramedItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), false, true))
+ FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), false, true))
+ FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), false, true))
+ FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, false, true))
+ FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, false, true))
+ FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, false, true))
+ FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, false, true))
+ }
+ .previewLayout(.fixed(width: 360, height: 200))
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index 4620fb34c8..9ffea020a3 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -16,20 +16,22 @@ struct MsgContentView: View {
var formattedText: [FormattedText]? = nil
var sender: String? = nil
var metaText: Text? = nil
+ var edited: Bool = false
- var body: some View {
+ var body: some View {
let v = messageText(content, formattedText, sender)
if let mt = metaText {
- return v + reserveSpaceForMeta(mt)
+ return v + reserveSpaceForMeta(mt, edited)
} else {
return v
}
}
- private func reserveSpaceForMeta(_ meta: Text) -> Text {
- (Text(" ") + meta)
- .font(.caption)
- .foregroundColor(.clear)
+ private func reserveSpaceForMeta(_ meta: Text, _ edited: Bool) -> Text {
+ let reserve = edited ? " " : " "
+ return (Text(reserve) + meta)
+ .font(.caption)
+ .foregroundColor(.clear)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 5860cfdf6a..c52a2e2b04 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -12,7 +12,9 @@ struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
+ @State var message: String = ""
@State var quotedItem: ChatItem? = nil
+ @State var editingItem: ChatItem? = nil
@State private var inProgress: Bool = false
@FocusState private var keyboardVisible: Bool
@State private var showChatInfo = false
@@ -31,7 +33,10 @@ struct ChatView: View {
ChatItemView(chatItem: ci)
.contextMenu {
Button {
- withAnimation { quotedItem = ci }
+ withAnimation {
+ editingItem = nil
+ quotedItem = ci
+ }
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
Button {
showShareSheet(items: [ci.content.text])
@@ -39,6 +44,15 @@ struct ChatView: View {
Button {
UIPasteboard.general.string = ci.content.text
} label: { Label("Copy", systemImage: "doc.on.doc") }
+// if (ci.chatDir.sent && ci.meta.editable) {
+// Button {
+// withAnimation {
+// quotedItem = nil
+// editingItem = ci
+// message = ci.content.text
+// }
+// } label: { Label("Edit", systemImage: "square.and.pencil") }
+// }
}
.padding(.horizontal)
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
@@ -71,8 +85,11 @@ struct ChatView: View {
Spacer(minLength: 0)
ComposeView(
+ message: $message,
quotedItem: $quotedItem,
+ editingItem: $editingItem,
sendMessage: sendMessage,
+ resetMessage: { message = "" },
inProgress: inProgress,
keyboardVisible: $keyboardVisible
)
@@ -130,20 +147,35 @@ struct ChatView: View {
}
func sendMessage(_ msg: String) {
+ logger.debug("ChatView sendMessage")
Task {
+ logger.debug("ChatView sendMessage: in Task")
do {
- let chatItem = try await apiSendMessage(
- type: chat.chatInfo.chatType,
- id: chat.chatInfo.apiId,
- quotedItemId: quotedItem?.meta.itemId,
- msg: .text(msg)
- )
- DispatchQueue.main.async {
- quotedItem = nil
- chatModel.addChatItem(chat.chatInfo, chatItem)
+ if let ei = editingItem {
+ let chatItem = try await apiUpdateMessage(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ itemId: ei.id,
+ msg: .text(msg)
+ )
+ DispatchQueue.main.async {
+ editingItem = nil
+ let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem)
+ }
+ } else {
+ let chatItem = try await apiSendMessage(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ quotedItemId: quotedItem?.meta.itemId,
+ msg: .text(msg)
+ )
+ DispatchQueue.main.async {
+ quotedItem = nil
+ chatModel.addChatItem(chat.chatInfo, chatItem)
+ }
}
} catch {
- logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)")
+ logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 1e02ee3eeb..3dc3e1b71a 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -8,35 +8,69 @@
import SwiftUI
+// TODO
+//enum ComposeState {
+// case plain
+// case quoted(quotedItem: ChatItem)
+// case editing(editingItem: ChatItem)
+//}
+
struct ComposeView: View {
+ @Binding var message: String
@Binding var quotedItem: ChatItem?
+ @Binding var editingItem: ChatItem?
var sendMessage: (String) -> Void
+ var resetMessage: () -> Void
var inProgress: Bool = false
@FocusState.Binding var keyboardVisible: Bool
+ @State var editing: Bool = false
var body: some View {
VStack(spacing: 0) {
- QuotedItemView(quotedItem: $quotedItem)
- .transition(.move(edge: .bottom))
+ if (quotedItem != nil) {
+ ContextItemView(contextItem: $quotedItem, editing: $editing)
+ } else if (editingItem != nil) {
+ ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage)
+ }
SendMessageView(
sendMessage: sendMessage,
inProgress: inProgress,
- keyboardVisible: $keyboardVisible
+ message: $message,
+ keyboardVisible: $keyboardVisible,
+ editing: $editing
)
.background(.background)
}
+ .onChange(of: editingItem == nil) { _ in
+ editing = (editingItem != nil)
+ }
}
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
+ @State var message: String = ""
@FocusState var keyboardVisible: Bool
- @State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
+ @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
+ @State var nilItem: ChatItem? = nil
- return ComposeView(
- quotedItem: $quotedItem,
- sendMessage: { print ($0) },
- keyboardVisible: $keyboardVisible
- )
+ return Group {
+ ComposeView(
+ message: $message,
+ quotedItem: $item,
+ editingItem: $nilItem,
+ sendMessage: { print ($0) },
+ resetMessage: {},
+ keyboardVisible: $keyboardVisible
+ )
+ ComposeView(
+ message: $message,
+ quotedItem: $nilItem,
+ editingItem: $item,
+ sendMessage: { print ($0) },
+ resetMessage: {},
+ keyboardVisible: $keyboardVisible
+ )
+ }
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
new file mode 100644
index 0000000000..d853e17026
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -0,0 +1,55 @@
+//
+// ContextItemView.swift
+// SimpleX
+//
+// Created by JRoberts on 13/03/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ContextItemView: View {
+ @Environment(\.colorScheme) var colorScheme
+ @Binding var contextItem: ChatItem?
+ @Binding var editing: Bool
+ var resetMessage: () -> Void = {}
+
+ var body: some View {
+ if let cxtItem = contextItem {
+ HStack {
+ contextText(cxtItem).lineLimit(3)
+ Spacer()
+ Button {
+ withAnimation {
+ contextItem = nil
+ if editing { resetMessage() }
+ }
+ } label: {
+ Image(systemName: "multiply")
+ }
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .background(chatItemFrameColor(cxtItem, colorScheme))
+ .padding(.top, 8)
+ } else {
+ EmptyView()
+ }
+ }
+
+ func contextText(_ cxtItem: ChatItem) -> some View {
+ if let s = cxtItem.memberDisplayName {
+ return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.content.text)"))
+ } else {
+ return Text(cxtItem.content.text)
+ }
+ }
+}
+
+struct ContextItemView_Previews: PreviewProvider {
+ static var previews: some View {
+ @State var contextItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
+ @State var editing: Bool = false
+ return ContextItemView(contextItem: $contextItem, editing: $editing)
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift
deleted file mode 100644
index 36a49637ac..0000000000
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-//
-// QuotedItemView.swift
-// SimpleX
-//
-// Created by Evgeny on 13/03/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-
-struct QuotedItemView: View {
- @Environment(\.colorScheme) var colorScheme
- @Binding var quotedItem: ChatItem?
-
- var body: some View {
- if let qi = quotedItem {
- HStack {
- quoteText(qi).lineLimit(3)
- Spacer()
- Button {
- withAnimation { quotedItem = nil }
- } label: {
- Image(systemName: "multiply")
- }
- }
- .padding(12)
- .frame(maxWidth: .infinity)
- .background(chatItemFrameColor(qi, colorScheme))
- .padding(.top, 8)
- } else {
- EmptyView()
- }
- }
-
- func quoteText(_ qi: ChatItem) -> some View {
- if let s = qi.memberDisplayName {
- return (Text(s).fontWeight(.medium) + Text(": \(qi.content.text)"))
- } else {
- return Text(qi.content.text)
- }
- }
-}
-
-struct QuotedItemView_Previews: PreviewProvider {
- static var previews: some View {
- @State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
- return QuotedItemView(quotedItem: $quotedItem)
- }
-}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
index af639999fc..a760195fa9 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -11,9 +11,10 @@ import SwiftUI
struct SendMessageView: View {
var sendMessage: (String) -> Void
var inProgress: Bool = false
- @State private var message: String = "" //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 et dolore magna aliqua. Ut enim ad minim veniam, quis"// nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
+ @Binding var message: String //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 et dolore magna aliqua. Ut enim ad minim veniam, quis"// nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
+ @Binding var editing: Bool
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
var maxHeight: CGFloat = 360
@@ -47,7 +48,7 @@ struct SendMessageView: View {
.padding([.bottom, .trailing], 3)
} else {
Button(action: submit) {
- Image(systemName: "arrow.up.circle.fill")
+ Image(systemName: editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
}
@@ -85,15 +86,34 @@ struct SendMessageView: View {
struct SendMessageView_Previews: PreviewProvider {
static var previews: some View {
+ @State var message: String = ""
@FocusState var keyboardVisible: Bool
+ @State var editingOff: Bool = false
+ @State var editingOn: Bool = true
+ @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
+ @State var nilItem: ChatItem? = nil
- return VStack {
- Text("")
- Spacer(minLength: 0)
- SendMessageView(
- sendMessage: { print ($0) },
- keyboardVisible: $keyboardVisible
- )
+ return Group {
+ VStack {
+ Text("")
+ Spacer(minLength: 0)
+ SendMessageView(
+ sendMessage: { print ($0) },
+ message: $message,
+ keyboardVisible: $keyboardVisible,
+ editing: $editingOff
+ )
+ }
+ VStack {
+ Text("")
+ Spacer(minLength: 0)
+ SendMessageView(
+ sendMessage: { print ($0) },
+ message: $message,
+ keyboardVisible: $keyboardVisible,
+ editing: $editingOn
+ )
+ }
}
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
index 6f95a9be97..c1b9abc2f4 100644
--- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
@@ -19,10 +19,11 @@ struct ChatInfoImage: View {
case .group: iconName = "person.2.circle.fill"
default: iconName = "circle.fill"
}
-
- return Image(systemName: iconName)
- .resizable()
- .foregroundColor(color)
+ return ProfileImage(
+ imageStr: chat.chatInfo.image,
+ iconName: iconName,
+ color: color
+ )
}
}
diff --git a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift
index ba6577a9b0..d2a0aaab1d 100644
--- a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift
+++ b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift
@@ -11,12 +11,12 @@ import SwiftUI
struct DetermineWidth: View {
typealias Key = MaximumWidthPreferenceKey
var body: some View {
- GeometryReader {
- proxy in
+ GeometryReader { proxy in
Color.clear
- .anchorPreference(key: Key.self, value: .bounds) {
- anchor in proxy[anchor].size.width
- }
+ .preference(
+ key: MaximumWidthPreferenceKey.self,
+ value: proxy.size.width
+ )
}
}
}
diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
new file mode 100644
index 0000000000..8786e40da0
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
@@ -0,0 +1,48 @@
+//
+// ImagePicker.swift
+// SimpleX
+//
+// Created by Evgeny on 23/03/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ImagePicker: UIViewControllerRepresentable {
+ @Environment(\.presentationMode) var presentationMode
+ var source: UIImagePickerController.SourceType
+ @Binding var image: UIImage?
+
+ class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
+ let parent: ImagePicker
+
+ init(_ parent: ImagePicker) {
+ self.parent = parent
+ }
+
+ func imagePickerController(_ picker: UIImagePickerController,
+ didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
+ if let uiImage = info[.originalImage] as? UIImage {
+ parent.image = uiImage
+ }
+ parent.presentationMode.wrappedValue.dismiss()
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+
+ func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController {
+ let picker = UIImagePickerController()
+ picker.sourceType = source
+ picker.allowsEditing = false
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) {
+
+ }
+}
diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
new file mode 100644
index 0000000000..74abaca4b9
--- /dev/null
+++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift
@@ -0,0 +1,45 @@
+//
+// ProfileImage.swift
+// SimpleX
+//
+// Created by Evgeny on 23/03/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ProfileImage: View {
+ var imageStr: String? = nil
+ var iconName: String = "person.crop.circle.fill"
+ var color = Color(uiColor: .tertiarySystemGroupedBackground)
+
+ var body: some View {
+ if let image = imageStr,
+ let data = Data(base64Encoded: dropImagePrefix(image)),
+ let uiImage = UIImage(data: data) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .clipShape(Circle())
+ } else {
+ Image(systemName: iconName)
+ .resizable()
+ .foregroundColor(color)
+ }
+ }
+
+ func dropPrefix(_ s: String, _ prefix: String) -> String {
+ s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
+ }
+
+ func dropImagePrefix(_ s: String) -> String {
+ dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
+ }
+}
+
+struct ProfileImage_Previews: PreviewProvider {
+ static var previews: some View {
+ ProfileImage(imageStr: "data:image/png;base64,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")
+ .previewLayout(.fixed(width: 63, height: 63))
+ .background(.black)
+ }
+}
diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift
index 60e54807a9..ae9321fd43 100644
--- a/apps/ios/Shared/Views/TerminalView.swift
+++ b/apps/ios/Shared/Views/TerminalView.swift
@@ -13,7 +13,9 @@ private let terminalFont = Font.custom("Menlo", size: 16)
struct TerminalView: View {
@EnvironmentObject var chatModel: ChatModel
@State var inProgress: Bool = false
+ @State var message: String = ""
@FocusState private var keyboardVisible: Bool
+ @State var editing: Bool = false
var body: some View {
VStack {
@@ -54,7 +56,9 @@ struct TerminalView: View {
SendMessageView(
sendMessage: sendMessage,
inProgress: inProgress,
- keyboardVisible: $keyboardVisible
+ message: $message,
+ keyboardVisible: $keyboardVisible,
+ editing: $editing
)
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index f16c8cf5a5..feb7ec85fc 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -30,15 +30,18 @@ struct SettingsView: View {
.navigationTitle("Your chat profile")
} label: {
HStack {
- Image(systemName: "person.crop.circle")
- .padding(.trailing, 8)
+ ProfileImage(imageStr: user.image)
+ .frame(width: 44, height: 44)
+ .padding(.trailing, 6)
+ .padding(.vertical, 6)
VStack(alignment: .leading) {
- Text(user.profile.displayName)
+ Text(user.displayName)
.fontWeight(.bold)
.font(.title2)
- Text(user.profile.fullName)
+ Text(user.fullName)
}
}
+ .padding(.leading, -8)
}
NavigationLink {
UserAddress()
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 79b33d03bc..7e92301383 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -11,7 +11,11 @@ import SwiftUI
struct UserProfile: View {
@EnvironmentObject var chatModel: ChatModel
@State private var profile = Profile(displayName: "", fullName: "")
- @State private var editProfile: Bool = false
+ @State private var editProfile = false
+ @State private var showChooseSource = false
+ @State private var showImagePicker = false
+ @State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
+ @State private var pickedImage: UIImage? = nil
var body: some View {
let user: User = chatModel.currentUser!
@@ -19,16 +23,30 @@ struct UserProfile: View {
return VStack(alignment: .leading) {
Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.")
.padding(.bottom)
+
if editProfile {
+ ZStack(alignment: .center) {
+ ZStack(alignment: .topTrailing) {
+ profileImageView(profile.image)
+ if user.image != nil {
+ Button {
+ profile.image = nil
+ } label: {
+ Image(systemName: "multiply")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12)
+ }
+ }
+ }
+
+ editImageButton { showChooseSource = true }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+
VStack(alignment: .leading) {
- TextField("Display name", text: $profile.displayName)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
- .padding(.bottom)
- TextField("Full name (optional)", text: $profile.fullName)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
- .padding(.bottom)
+ profileNameTextEdit("Display name", $profile.displayName)
+ profileNameTextEdit("Full name (optional)", $profile.fullName)
HStack(spacing: 20) {
Button("Cancel") { editProfile = false }
Button("Save (and notify contacts)") { saveProfile() }
@@ -36,19 +54,19 @@ struct UserProfile: View {
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
} else {
+ ZStack(alignment: .center) {
+ profileImageView(user.image)
+ .onTapGesture { startEditingImage(user) }
+
+ if user.image == nil {
+ editImageButton { startEditingImage(user) }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+
VStack(alignment: .leading) {
- HStack {
- Text("Display name:")
- Text(user.profile.displayName)
- .fontWeight(.bold)
- }
- .padding(.bottom)
- HStack {
- Text("Full name:")
- Text(user.profile.fullName)
- .fontWeight(.bold)
- }
- .padding(.bottom)
+ profileNameView("Display name:", user.profile.displayName)
+ profileNameView("Full name:", user.profile.fullName)
Button("Edit") {
profile = user.profile
editProfile = true
@@ -59,6 +77,70 @@ struct UserProfile: View {
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
+ .confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) {
+ Button("Take picture") {
+ imageSource = .camera
+ showImagePicker = true
+ }
+ Button("Choose from library") {
+ imageSource = .photoLibrary
+ showImagePicker = true
+ }
+ }
+ .sheet(isPresented: $showImagePicker) {
+ ImagePicker(source: imageSource, image: $pickedImage)
+ }
+ .onChange(of: pickedImage) { image in
+ if let image = image,
+ let data = resizeToSquare(image, 104).jpegData(compressionQuality: 0.85) {
+ let imageStr = "data:image/jpg;base64,\(data.base64EncodedString())"
+ if imageStr.count <= 12500 {
+ profile.image = imageStr
+ } else {
+ logger.error("UserProfile: resized image is too big \(imageStr.count)")
+ }
+ } else {
+ profile.image = nil
+ }
+ }
+ }
+
+ func profileNameTextEdit(_ label: String, _ name: Binding) -> some View {
+ TextField(label, text: name)
+ .textInputAutocapitalization(.never)
+ .disableAutocorrection(true)
+ .padding(.bottom)
+ }
+
+ func profileNameView(_ label: String, _ name: String) -> some View {
+ HStack {
+ Text(label)
+ Text(name).fontWeight(.bold)
+ }
+ .padding(.bottom)
+ }
+
+ func profileImageView(_ imageStr: String?) -> some View {
+ ProfileImage(imageStr: imageStr)
+ .aspectRatio(1, contentMode: .fit)
+ .frame(maxWidth: 192, maxHeight: 192)
+ }
+
+ func editImageButton(action: @escaping () -> Void) -> some View {
+ Button {
+ action()
+ } label: {
+ Image(systemName: "camera")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 48)
+ }
+ }
+
+ func startEditingImage(_ user: User) {
+ profile = user.profile
+ editProfile = true
+ showChooseSource = true
}
func saveProfile() {
@@ -78,11 +160,42 @@ struct UserProfile: View {
}
}
-struct UserProfile_Previews: PreviewProvider {
- static var previews: some View {
- let chatModel = ChatModel()
- chatModel.currentUser = User.sampleData
- return UserProfile()
- .environmentObject(chatModel)
+func resize(_ image: UIImage, to newSize: CGSize) -> UIImage {
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = 1.0
+ format.opaque = true
+ return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in
+ let size = image.size
+ let hScale = newSize.height / size.height
+ let vScale = newSize.width / size.width
+ let scale = max(hScale, vScale) // scaleToFill
+ let resizeSize = CGSize(width: size.width * scale, height: size.height * scale)
+ var middle = CGPoint.zero
+ if resizeSize.width > newSize.width {
+ middle.x -= (resizeSize.width - newSize.width) / 2
+ } else if resizeSize.height > newSize.height {
+ middle.y -= (resizeSize.height - newSize.height) / 2
+ }
+ image.draw(in: CGRect(origin: middle, size: resizeSize))
+ }
+}
+
+func resizeToSquare(_ image: UIImage, _ side: CGFloat) -> UIImage {
+ resize(image, to: CGSize(width: side, height: side))
+}
+
+struct UserProfile_Previews: PreviewProvider {
+ static var previews: some View {
+ let chatModel1 = ChatModel()
+ chatModel1.currentUser = User.sampleData
+ let chatModel2 = ChatModel()
+ chatModel2.currentUser = User.sampleData
+ chatModel2.currentUser?.profile.image = "data:image/jpg;base64,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"
+ return Group {
+ UserProfile()
+ .environmentObject(chatModel1)
+ UserProfile()
+ .environmentObject(chatModel2)
+ }
}
}
diff --git a/apps/ios/Shared/dummy.m b/apps/ios/Shared/dummy.m
index 73cb36a91d..301c29909a 100644
--- a/apps/ios/Shared/dummy.m
+++ b/apps/ios/Shared/dummy.m
@@ -6,3 +6,18 @@
//
#import
+
+#if defined(__x86_64__) && TARGET_IPHONE_SIMULATOR
+
+#import
+
+int readdir_r$INODE64(DIR *restrict dirp, struct dirent *restrict entry,
+ struct dirent **restrict result) {
+ return readdir_r(dirp, entry, result);
+}
+
+DIR *opendir$INODE64(const char *name) {
+ return opendir(name);
+}
+
+#endif
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 0012039e51..7ac5a13589 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -9,20 +9,20 @@
/* Begin PBXBuildFile section */
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
- 5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; };
- 5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; };
- 5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; };
- 5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; };
- 5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; };
- 5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; };
- 5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; };
- 5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; };
- 5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; };
- 5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; };
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
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 */; };
+ 5C27D01727E863F900DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01227E863F800DD6182 /* libffi.a */; };
+ 5C27D01827E863F900DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01227E863F800DD6182 /* libffi.a */; };
+ 5C27D01927E863F900DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01327E863F800DD6182 /* libgmp.a */; };
+ 5C27D01A27E863F900DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01327E863F800DD6182 /* libgmp.a */; };
+ 5C27D01B27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */; };
+ 5C27D01C27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */; };
+ 5C27D01D27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */; };
+ 5C27D01E27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */; };
+ 5C27D01F27E863F900DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01627E863F900DD6182 /* libgmpxx.a */; };
+ 5C27D02027E863F900DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01627E863F900DD6182 /* libgmpxx.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 */; };
@@ -43,6 +43,10 @@
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 */; };
+ 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
+ 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
+ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; };
+ 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.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 */; };
@@ -106,12 +110,12 @@
5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
- 5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; };
- 5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; };
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
+ 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
+ 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -133,13 +137,13 @@
/* Begin PBXFileReference section */
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; };
- 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a"; sourceTree = ""; };
- 5C0E5EF227E24676003DE3D0 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
- 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a"; sourceTree = ""; };
- 5C0E5EF427E24676003DE3D0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
- 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; };
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; };
+ 5C27D01227E863F800DD6182 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 5C27D01327E863F800DD6182 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
+ 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a"; sourceTree = ""; };
+ 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a"; sourceTree = ""; };
+ 5C27D01627E863F900DD6182 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; };
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; };
5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; };
@@ -151,6 +155,8 @@
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; };
+ 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; };
+ 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; };
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; };
@@ -190,9 +196,9 @@
5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; };
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; };
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; };
- 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedItemView.swift; sourceTree = ""; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; };
+ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -200,14 +206,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 5C27D01D27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
- 5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */,
+ 5C27D01927E863F900DD6182 /* libgmp.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
- 5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */,
- 5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */,
- 5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */,
+ 5C27D01F27E863F900DD6182 /* libgmpxx.a in Frameworks */,
+ 5C27D01B27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
- 5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */,
+ 5C27D01727E863F900DD6182 /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -216,12 +222,12 @@
buildActionMask = 2147483647;
files = (
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */,
- 5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */,
+ 5C27D01C27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */,
+ 5C27D01827E863F900DD6182 /* libffi.a in Frameworks */,
+ 5C27D01A27E863F900DD6182 /* libgmp.a in Frameworks */,
+ 5C27D01E27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */,
5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */,
- 5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */,
- 5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */,
- 5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */,
- 5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */,
+ 5C27D02027E863F900DD6182 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -273,11 +279,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
- 5C0E5EF227E24676003DE3D0 /* libffi.a */,
- 5C0E5EF427E24676003DE3D0 /* libgmp.a */,
- 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */,
- 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */,
- 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */,
+ 5C27D01227E863F800DD6182 /* libffi.a */,
+ 5C27D01327E863F800DD6182 /* libgmp.a */,
+ 5C27D01627E863F900DD6182 /* libgmpxx.a */,
+ 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */,
+ 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */,
);
path = Libraries;
sourceTree = "";
@@ -310,6 +316,8 @@
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */,
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */,
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */,
+ 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */,
+ 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */,
);
path = Helpers;
sourceTree = "";
@@ -432,7 +440,7 @@
children = (
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */,
5CEACCE227DE9246000BD591 /* ComposeView.swift */,
- 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */,
+ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */,
);
path = ComposeMessage;
sourceTree = "";
@@ -615,7 +623,6 @@
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */,
- 5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */,
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -634,6 +641,7 @@
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
+ 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
@@ -644,8 +652,10 @@
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */,
+ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
+ 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -664,7 +674,6 @@
5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */,
5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */,
640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */,
- 5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */,
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -683,6 +692,7 @@
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
+ 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */,
@@ -693,8 +703,10 @@
5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */,
+ 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */,
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */,
+ 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -851,7 +863,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 29;
+ CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -871,7 +883,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
- MARKETING_VERSION = 1.3;
+ MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -891,7 +903,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 29;
+ CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -909,9 +921,10 @@
"$(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 = 1.3;
+ MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
diff --git a/cabal.project b/cabal.project
index 4d47cbcee4..88af2f527b 100644
--- a/cabal.project
+++ b/cabal.project
@@ -3,7 +3,7 @@ packages: .
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
- tag: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc
+ tag: 800581b2bf5dacb2134dfda751be08cbf78df978
source-repository-package
type: git
diff --git a/flake.lock b/flake.lock
index 3bfecf43ae..d2deeaa040 100644
--- a/flake.lock
+++ b/flake.lock
@@ -133,11 +133,11 @@
"hackage": {
"flake": false,
"locked": {
- "lastModified": 1646625282,
- "narHash": "sha256-U23F/EXZC1UOxO3SkfzS82TwYtT42sp5Y6BImXsHWMo=",
+ "lastModified": 1647047557,
+ "narHash": "sha256-6A7jjz77f53GkvFxqVmeuqqXyDWsU24rUtFtOg68CAg=",
"owner": "input-output-hk",
"repo": "hackage.nix",
- "rev": "bff4ab542bc6f68fc078ccd0df2e8eae61650e32",
+ "rev": "fc07d4d4f2597334caa96f455cec190bdcc931f4",
"type": "github"
},
"original": {
@@ -169,11 +169,11 @@
"stackage": "stackage"
},
"locked": {
- "lastModified": 1646643560,
- "narHash": "sha256-mCzOavKLzXof7NuTBGQx+KWX2AIarrxxGykBE4OvjzY=",
+ "lastModified": 1647308139,
+ "narHash": "sha256-GRvEGSCz9YQwE/zYUtFYkq2mNm1QxVNyfVwfN+o6mbM=",
"owner": "input-output-hk",
"repo": "haskell.nix",
- "rev": "98de1769b4d5d9a4d137a77c5ec153c900ab0fa5",
+ "rev": "d42e6bdd52b6a36ee54344a0d680ce248e64773f",
"type": "github"
},
"original": {
@@ -217,11 +217,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1645623357,
- "narHash": "sha256-vAaI91QFn/kY/uMiebW+kG2mPmxirMSJWYtkqkBKdDc=",
+ "lastModified": 1646955661,
+ "narHash": "sha256-AYLta1PubJnrkv15+7G+6ErW5m9NcI9wSdJ+n7pKAe0=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "9222ae36b208d1c6b55d88e10aa68f969b5b5244",
+ "rev": "e9545762b032559c27d8ec9141ed63ceca1aa1ac",
"type": "github"
},
"original": {
@@ -322,11 +322,11 @@
"stackage": {
"flake": false,
"locked": {
- "lastModified": 1646625386,
- "narHash": "sha256-dIsnm5vx9Dlxx/rRjFyO7uMBfKjEN6RX7oAenwfetHY=",
+ "lastModified": 1646961451,
+ "narHash": "sha256-fs3+CsqzgNVT2mJSJOc+MnhbRoIoB/L1ZEhiJn0nXHQ=",
"owner": "input-output-hk",
"repo": "stackage.nix",
- "rev": "e6a7664a79ed4ec8a19d76fb60731190b8763874",
+ "rev": "02b9e7ea7304027b5d473233c2465d04a21a17e3",
"type": "github"
},
"original": {
diff --git a/package.yaml b/package.yaml
index ca39fdb7b6..bbbd45a0c3 100644
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
name: simplex-chat
-version: 1.3.3
+version: 1.4.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -61,6 +61,8 @@ tests:
- hspec == 2.7.*
- network == 3.1.*
- stm == 2.5.*
+ ghc-options:
+ - -threaded
ghc-options:
# - -haddock
diff --git a/sha256map.nix b/sha256map.nix
index c5c5c00503..16c6ef8bb1 100644
--- a/sha256map.nix
+++ b/sha256map.nix
@@ -1,6 +1,6 @@
{
- "git://github.com/simplex-chat/simplexmq.git"."5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc" = "0qjmldlrxl5waqfbsckjhxkd3zn25bkbyqwf9l0r4gq3c7l6k358";
- "git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
- "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
- "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
+ "https://github.com/simplex-chat/simplexmq.git"."800581b2bf5dacb2134dfda751be08cbf78df978" = "1xmn6dfwmmc84zpj9pnklxc4lh4bwwf6pv55qaqcj15crvqhvnyg";
+ "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
+ "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
+ "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
}
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index dee280bcf6..34b6e02f15 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 1.3.3
+version: 1.4.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -32,6 +32,7 @@ library
Simplex.Chat.Migrations.M20220301_smp_servers
Simplex.Chat.Migrations.M20220302_profile_images
Simplex.Chat.Migrations.M20220304_msg_quotes
+ Simplex.Chat.Migrations.M20220321_chat_item_edited
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.Protocol
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index 87fa2934b6..458a34c460 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -215,6 +215,38 @@ processChatCommand = \case
msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId}
in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} mc) mc (Just quotedItem)
CTContactRequest -> pure $ chatCmdError "not supported"
+ APIUpdateMessage cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of
+ CTDirect -> do
+ (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId
+ case ci of
+ CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do
+ case (ciContent, itemSharedMsgId) of
+ (CISndMsgContent _, Just itemSharedMId) -> do
+ SndMessage {msgId} <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc)
+ updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) msgId
+ setActive $ ActiveC c
+ pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi
+ _ -> throwChatError CEInvalidMessageUpdate
+ CChatItem SMDRcv _ -> throwChatError CEInvalidMessageUpdate
+ CTGroup -> do
+ Group gInfo@GroupInfo {groupId, localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId
+ unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
+ ci <- withStore $ \st -> getGroupChatItem st user chatId itemId
+ case ci of
+ CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do
+ case (ciContent, itemSharedMsgId) of
+ (CISndMsgContent _, Just itemSharedMId) -> do
+ SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgUpdate itemSharedMId mc)
+ updCi <- withStore $ \st -> updateGroupChatItem st user groupId itemId (CISndMsgContent mc) msgId
+ setActive $ ActiveG gName
+ pure . CRChatItemUpdated $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) updCi
+ _ -> throwChatError CEInvalidMessageUpdate
+ CChatItem SMDRcv _ -> throwChatError CEInvalidMessageUpdate
+ CTContactRequest -> pure $ chatCmdError "not supported"
+ APIDeleteMessage cType _chatId _itemId _mode -> withUser $ \_user -> withChatLock $ case cType of
+ CTDirect -> pure CRCmdOk
+ CTGroup -> pure CRCmdOk
+ CTContactRequest -> pure $ chatCmdError "not supported"
APIChatRead cType chatId fromToIds -> withChatLock $ case cType of
CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk
CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk
@@ -244,6 +276,7 @@ processChatCommand = \case
`E.finally` deleteContactRequest st userId connReqId
withAgent $ \a -> rejectContact a connId invId
pure $ CRContactRequestRejected cReq
+ APIUpdateProfile profile -> withUser (`updateProfile` profile)
GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user))
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do
withStore $ \st -> overwriteSMPServers st user smpServers
@@ -381,8 +414,8 @@ processChatCommand = \case
SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do
(fileSize, chSize) <- checkSndFile f
contact <- withStore $ \st -> getContactByName st userId cName
- (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation)
- let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq}
+ (agentConnId, connReq) <- withAgent (`createConnection` SCMInvitation)
+ let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = ACR SCMInvitation connReq}
SndFileTransfer {fileId} <- withStore $ \st ->
createSndFileTransfer st userId contact f fileInv agentConnId chSize
ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing
@@ -395,8 +428,8 @@ processChatCommand = \case
unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
let fileName = takeFileName f
ms <- forM (filter memberActive members) $ \m -> do
- (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation)
- pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq})
+ (connId, connReq) <- withAgent (`createConnection` SCMInvitation)
+ pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = ACR SCMInvitation connReq})
fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize
-- TODO sendGroupChatItem - same file invitation to all
forM_ ms $ \(m, _, fileInv) ->
@@ -409,7 +442,7 @@ processChatCommand = \case
withStore $ \st -> updateFileTransferChatItemId st fileId itemId
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem
ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do
- ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId
+ ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq = ACR _ fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId
unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName
withChatLock . procCmd $ do
tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case
@@ -670,10 +703,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
withAckMessage agentConnId meta $ pure ()
ackMsgDeliveryEvent conn meta
SENT msgId ->
- -- ? updateDirectChatItem
+ -- ? updateDirectChatItemStatus
sentMsgDeliveryEvent conn msgId
-- TODO print errors
- MERR _ _ -> pure () -- ? updateDirectChatItem
+ MERR _ _ -> pure () -- ? updateDirectChatItemStatus
ERR _ -> pure ()
-- TODO add debugging output
_ -> pure ()
@@ -683,6 +716,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
withAckMessage agentConnId msgMeta $
case chatMsgEvent of
XMsgNew mc -> newContentMessage ct mc msg msgMeta
+ XMsgUpdate sharedMsgId mContent -> messageUpdate ct sharedMsgId mContent msg msgMeta
XFile fInv -> processFileInvitation ct fInv msg msgMeta
XInfo p -> xInfo ct p
XGrpInv gInv -> processGroupInvitation ct gInv
@@ -728,8 +762,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
case chatItemId_ of
Nothing -> pure ()
Just chatItemId -> do
- chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId CISSndSent
- toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
+ chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId CISSndSent
+ toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
END -> do
toView $ CRContactAnotherClient ct
showToast (c <> "> ") "connected to another client"
@@ -747,8 +781,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
case chatItemId_ of
Nothing -> pure ()
Just chatItemId -> do
- chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (agentErrToItemStatus err)
- toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
+ chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId (agentErrToItemStatus err)
+ toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
ERR _ -> pure ()
-- TODO add debugging output
_ -> pure ()
@@ -822,6 +856,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
withAckMessage agentConnId msgMeta $
case chatMsgEvent of
XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta
+ XMsgUpdate sharedMsgId mContent -> groupMessageUpdate gInfo sharedMsgId mContent msg
XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta
XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo
XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo
@@ -1000,6 +1035,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
showMsgToast (c <> "> ") content formattedText
setActive $ ActiveC c
+ messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m ()
+ messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc RcvMessage {msgId} msgMeta = do
+ updCi <- withStore $ \st -> updateDirectChatItemByMsgId st userId contactId sharedMsgId (CIRcvMsgContent mc) msgId
+ toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+ setActive $ ActiveC c
+
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do
let content = mcContent mc
@@ -1009,6 +1051,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
setActive $ ActiveG g
+ groupMessageUpdate :: GroupInfo -> SharedMsgId -> MsgContent -> RcvMessage -> m ()
+ groupMessageUpdate gInfo@GroupInfo {groupId} sharedMsgId mc RcvMessage {msgId} = do
+ updCi <- withStore $ \st -> updateGroupChatItemByMsgId st user groupId sharedMsgId (CIRcvMsgContent mc) msgId
+ toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) updCi
+ let g = groupName' gInfo
+ setActive $ ActiveG g
+
processFileInvitation :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
processFileInvitation ct@Contact {localDisplayName = c} fInv msg msgMeta = do
-- TODO chunk size has to be sent as part of invitation
@@ -1396,8 +1445,9 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brok
mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d)
mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do
tz <- getCurrentTimeZone
+ currentTs <- liftIO getCurrentTime
let itemText = ciContentToText content
- meta = mkCIMeta ciId itemText ciStatusNew sharedMsgId tz itemTs createdAt
+ meta = mkCIMeta ciId itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt
pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem}
allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m ()
@@ -1517,10 +1567,13 @@ chatCommandP =
<|> "/_get items count=" *> (APIGetChatItems <$> A.decimal)
<|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP)
<|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP)
+ <|> "/_update item " *> (APIUpdateMessage <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP)
+ <|> "/_delete item " *> (APIDeleteMessage <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgDeleteMode)
<|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))
<|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal)
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
<|> "/_reject " *> (APIRejectContact <$> A.decimal)
+ <|> "/_profile " *> (APIUpdateProfile <$> jsonP)
<|> "/smp_servers default" $> SetUserSMPServers []
<|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP)
<|> "/smp_servers" $> GetUserSMPServers
@@ -1577,7 +1630,8 @@ chatCommandP =
<|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
msgContentP =
"text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString)
- <|> "json " *> (J.eitherDecodeStrict' <$?> A.takeByteString)
+ <|> "json " *> jsonP
+ msgDeleteMode = "broadcast" $> MDBroadcast <|> "internal" $> MDInternal
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString
quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space
@@ -1590,6 +1644,8 @@ chatCommandP =
userProfile = do
(cName, fullName) <- userNames
pure Profile {displayName = cName, fullName, image = Nothing}
+ jsonP :: J.FromJSON a => Parser a
+ jsonP = J.eitherDecodeStrict' <$?> A.takeByteString
groupProfile = do
gName <- displayName
fullName <- fullNameP gName
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index 7d5fa8c8b3..d2bb4bfeb4 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -81,6 +81,9 @@ data ChatController = ChatController
data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSQuotes
deriving (Show, Generic)
+data MsgDeleteMode = MDBroadcast | MDInternal
+ deriving (Show, Generic)
+
instance ToJSON HelpSection where
toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS"
toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS"
@@ -94,10 +97,13 @@ data ChatCommand
| APIGetChatItems Int
| APISendMessage ChatType Int64 MsgContent
| APISendMessageQuote ChatType Int64 ChatItemId MsgContent
+ | APIUpdateMessage ChatType Int64 ChatItemId MsgContent
+ | APIDeleteMessage ChatType Int64 ChatItemId MsgDeleteMode
| APIChatRead ChatType Int64 (ChatItemId, ChatItemId)
| APIDeleteChat ChatType Int64
| APIAcceptContact Int64
| APIRejectContact Int64
+ | APIUpdateProfile Profile
| GetUserSMPServers
| SetUserSMPServers [SMPServer]
| ChatHelp HelpSection
@@ -146,7 +152,9 @@ data ChatResponse
| CRApiChat {chat :: AChat}
| CRUserSMPServers {smpServers :: [SMPServer]}
| CRNewChatItem {chatItem :: AChatItem}
+ | CRChatItemStatusUpdated {chatItem :: AChatItem}
| CRChatItemUpdated {chatItem :: AChatItem}
+ | CRChatItemDeleted {chatItem :: AChatItem}
| CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile
| CRCmdAccepted {corr :: CorrId}
| CRCmdOk
@@ -295,6 +303,7 @@ data ChatErrorType
| CEFileRcvChunk {message :: String}
| CEFileInternal {message :: String}
| CEInvalidQuote
+ | CEInvalidMessageUpdate
| CEAgentVersion
| CECommandError {message :: String}
deriving (Show, Exception, Generic)
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index dc8d9ece9d..011b3c338f 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -22,7 +22,7 @@ import Data.Int (Int64)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
-import Data.Time.Clock (UTCTime)
+import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay)
import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime)
import Data.Type.Equality
import Data.Typeable (Typeable)
@@ -206,15 +206,19 @@ data CIMeta (d :: MsgDirection) = CIMeta
itemText :: Text,
itemStatus :: CIStatus d,
itemSharedMsgId :: Maybe SharedMsgId,
+ itemDeleted :: Bool,
+ itemEdited :: Bool,
+ editable :: Bool,
localItemTs :: ZonedTime,
createdAt :: UTCTime
}
deriving (Show, Generic)
-mkCIMeta :: ChatItemId -> Text -> CIStatus d -> Maybe SharedMsgId -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d
-mkCIMeta itemId itemText itemStatus itemSharedMsgId tz itemTs createdAt =
+mkCIMeta :: ChatItemId -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> CIMeta d
+mkCIMeta itemId itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt =
let localItemTs = utcToZonedTime tz itemTs
- in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, localItemTs, createdAt}
+ editable = diffUTCTime currentTs itemTs < nominalDay
+ in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt}
instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions
@@ -343,6 +347,8 @@ type ChatItemTs = UTCTime
data CIContent (d :: MsgDirection) where
CISndMsgContent :: MsgContent -> CIContent 'MDSnd
CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv
+ CISndMsgDeleted :: MsgContent -> CIContent 'MDSnd
+ CIRcvMsgDeleted :: MsgContent -> CIContent 'MDRcv
CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd
CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv
@@ -352,6 +358,8 @@ ciContentToText :: CIContent d -> Text
ciContentToText = \case
CISndMsgContent mc -> msgContentText mc
CIRcvMsgContent mc -> msgContentText mc
+ CISndMsgDeleted _ -> "this message is deleted"
+ CIRcvMsgDeleted _ -> "this message is deleted"
CISndFileInvitation fId fPath -> "you sent file #" <> T.pack (show fId) <> ": " <> T.pack fPath
CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName
@@ -379,6 +387,8 @@ instance FromField ACIContent where fromField = fromTextField_ $ fmap aciContent
data JSONCIContent
= JCISndMsgContent {msgContent :: MsgContent}
| JCIRcvMsgContent {msgContent :: MsgContent}
+ | JCISndMsgDeleted {msgContent :: MsgContent}
+ | JCIRcvMsgDeleted {msgContent :: MsgContent}
| JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath}
| JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer}
deriving (Generic)
@@ -394,6 +404,8 @@ jsonCIContent :: CIContent d -> JSONCIContent
jsonCIContent = \case
CISndMsgContent mc -> JCISndMsgContent mc
CIRcvMsgContent mc -> JCIRcvMsgContent mc
+ CISndMsgDeleted mc -> JCISndMsgDeleted mc
+ CIRcvMsgDeleted mc -> JCIRcvMsgDeleted mc
CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath
CIRcvFileInvitation ft -> JCIRcvFileInvitation ft
@@ -401,6 +413,8 @@ aciContentJSON :: JSONCIContent -> ACIContent
aciContentJSON = \case
JCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc
JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc
+ JCISndMsgDeleted mc -> ACIContent SMDSnd $ CISndMsgDeleted mc
+ JCIRcvMsgDeleted mc -> ACIContent SMDRcv $ CIRcvMsgDeleted mc
JCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath
JCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft
@@ -408,6 +422,8 @@ aciContentJSON = \case
data DBJSONCIContent
= DBJCISndMsgContent {msgContent :: MsgContent}
| DBJCIRcvMsgContent {msgContent :: MsgContent}
+ | DBJCISndMsgDeleted {msgContent :: MsgContent}
+ | DBJCIRcvMsgDeleted {msgContent :: MsgContent}
| DBJCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath}
| DBJCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer}
deriving (Generic)
@@ -423,6 +439,8 @@ dbJsonCIContent :: CIContent d -> DBJSONCIContent
dbJsonCIContent = \case
CISndMsgContent mc -> DBJCISndMsgContent mc
CIRcvMsgContent mc -> DBJCIRcvMsgContent mc
+ CISndMsgDeleted mc -> DBJCISndMsgDeleted mc
+ CIRcvMsgDeleted mc -> DBJCIRcvMsgDeleted mc
CISndFileInvitation fId fPath -> DBJCISndFileInvitation fId fPath
CIRcvFileInvitation ft -> DBJCIRcvFileInvitation ft
@@ -430,6 +448,8 @@ aciContentDBJSON :: DBJSONCIContent -> ACIContent
aciContentDBJSON = \case
DBJCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc
DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc
+ DBJCISndMsgDeleted ciId -> ACIContent SMDSnd $ CISndMsgDeleted ciId
+ DBJCIRcvMsgDeleted ciId -> ACIContent SMDRcv $ CIRcvMsgDeleted ciId
DBJCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath
DBJCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft
diff --git a/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs b/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs
new file mode 100644
index 0000000000..7a77f00262
--- /dev/null
+++ b/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs
@@ -0,0 +1,12 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Migrations.M20220321_chat_item_edited where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+m20220321_chat_item_edited :: Query
+m20220321_chat_item_edited =
+ [sql|
+ALTER TABLE chat_items ADD COLUMN item_edited INTEGER; -- 1 for edited
+|]
diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs
index a238f0e028..ff011ea8a3 100644
--- a/src/Simplex/Chat/Protocol.hs
+++ b/src/Simplex/Chat/Protocol.hs
@@ -109,6 +109,8 @@ instance StrEncoding ChatMessage where
data ChatMsgEvent
= XMsgNew MsgContainer
+ | XMsgUpdate SharedMsgId MsgContent
+ | XMsgDel SharedMsgId
| XFile FileInvitation
| XFileAcpt String
| XInfo Profile
@@ -232,6 +234,8 @@ instance FromField MsgContent where
data CMEventTag
= XMsgNew_
+ | XMsgUpdate_
+ | XMsgDel_
| XFile_
| XFileAcpt_
| XInfo_
@@ -258,6 +262,8 @@ data CMEventTag
instance StrEncoding CMEventTag where
strEncode = \case
XMsgNew_ -> "x.msg.new"
+ XMsgUpdate_ -> "x.msg.update"
+ XMsgDel_ -> "x.msg.del"
XFile_ -> "x.file"
XFileAcpt_ -> "x.file.acpt"
XInfo_ -> "x.info"
@@ -281,6 +287,8 @@ instance StrEncoding CMEventTag where
XUnknown_ t -> encodeUtf8 t
strDecode = \case
"x.msg.new" -> Right XMsgNew_
+ "x.msg.update" -> Right XMsgUpdate_
+ "x.msg.del" -> Right XMsgDel_
"x.file" -> Right XFile_
"x.file.acpt" -> Right XFileAcpt_
"x.info" -> Right XInfo_
@@ -307,6 +315,8 @@ instance StrEncoding CMEventTag where
toCMEventTag :: ChatMsgEvent -> CMEventTag
toCMEventTag = \case
XMsgNew _ -> XMsgNew_
+ XMsgUpdate _ _ -> XMsgUpdate_
+ XMsgDel _ -> XMsgDel_
XFile _ -> XFile_
XFileAcpt _ -> XFileAcpt_
XInfo _ -> XInfo_
@@ -350,7 +360,9 @@ appToChatMessage AppMessage {msgId, event, params} = do
opt :: FromJSON a => J.Key -> Either String (Maybe a)
opt key = JT.parseEither (.:? key) params
msg = \case
- XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params
+ XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params
+ XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content"
+ XMsgDel_ -> XMsgDel <$> p "msgId"
XFile_ -> XFile <$> p "file"
XFileAcpt_ -> XFileAcpt <$> p "fileName"
XInfo_ -> XInfo <$> p "profile"
@@ -382,6 +394,8 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p
key .=? value = maybe id ((:) . (key .=)) value
params = case chatMsgEvent of
XMsgNew container -> msgContainerJSON container
+ XMsgUpdate msgId' content -> o ["msgId" .= msgId', "content" .= content]
+ XMsgDel msgId' -> o ["msgId" .= msgId']
XFile fileInv -> o ["file" .= fileInv]
XFileAcpt fileName -> o ["fileName" .= fileName]
XInfo profile -> o ["profile" .= profile]
diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs
index 7d0be6b857..1ccc0c9ef6 100644
--- a/src/Simplex/Chat/Store.hs
+++ b/src/Simplex/Chat/Store.hs
@@ -123,8 +123,12 @@ module Simplex.Chat.Store
getGroupChatItem,
getDirectChatItemIdByText,
getGroupChatItemIdByText,
+ updateDirectChatItemStatus,
updateDirectChatItem,
+ updateDirectChatItemByMsgId,
updateDirectChatItemsRead,
+ updateGroupChatItem,
+ updateGroupChatItemByMsgId,
updateGroupChatItemsRead,
getSMPServers,
overwriteSMPServers,
@@ -148,7 +152,7 @@ import Data.Function (on)
import Data.Functor (($>))
import Data.Int (Int64)
import Data.List (find, sortBy, sortOn)
-import Data.Maybe (isJust, listToMaybe)
+import Data.Maybe (fromMaybe, isJust, listToMaybe)
import Data.Ord (Down (..))
import Data.Text (Text)
import qualified Data.Text as T
@@ -168,10 +172,11 @@ import Simplex.Chat.Migrations.M20220224_messages_fks
import Simplex.Chat.Migrations.M20220301_smp_servers
import Simplex.Chat.Migrations.M20220302_profile_images
import Simplex.Chat.Migrations.M20220304_msg_quotes
+import Simplex.Chat.Migrations.M20220321_chat_item_edited
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe)
-import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..))
+import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..))
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
import qualified Simplex.Messaging.Crypto as C
@@ -190,7 +195,8 @@ schemaMigrations =
("20220224_messages_fks", m20220224_messages_fks),
("20220301_smp_servers", m20220301_smp_servers),
("20220302_profile_images", m20220302_profile_images),
- ("20220304_msg_quotes", m20220304_msg_quotes)
+ ("20220304_msg_quotes", m20220304_msg_quotes),
+ ("20220321_chat_item_edited", m20220321_chat_item_edited)
]
-- | The list of migrations in ascending order by date
@@ -1871,7 +1877,7 @@ getRcvFileTransfer_ db userId fileId =
(userId, fileId)
where
rcvFileTransfer ::
- [(FileStatus, ConnReqInvitation, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] ->
+ [(FileStatus, AConnectionRequestUri, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] ->
Either StoreError RcvFileTransfer
rcvFileTransfer [(fileStatus', fileConnReq, fileName, fileSize, chunkSize, contactName_, memberName_, filePath_, connId_, agentConnId_)] =
let fileInv = FileInvitation {fileName, fileSize, fileConnReq}
@@ -2182,7 +2188,7 @@ createNewSndChatItem st user chatDirection SndMessage {msgId, sharedMsgId} ciCon
quoteRow :: NewQuoteRow
quoteRow = case quotedItem of
Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing)
- Just (CIQuote {chatDir, sharedMsgId = quotedSharedMsgId, sentAt, content}) ->
+ Just CIQuote {chatDir, sharedMsgId = quotedSharedMsgId, sentAt, content} ->
uncurry (quotedSharedMsgId,Just sentAt,Just content,,) $ case chatDir of
CIQDirectSnd -> (Just True, Nothing)
CIQDirectRcv -> (Just False, Nothing)
@@ -2320,7 +2326,8 @@ chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs
getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat]
getDirectChatPreviews_ db User {userId} = do
tz <- getCurrentTimeZone
- map (toDirectChatPreview tz)
+ currentTs <- getCurrentTime
+ map (toDirectChatPreview tz currentTs)
<$> DB.query
db
[sql|
@@ -2333,7 +2340,7 @@ getDirectChatPreviews_ db User {userId} = do
-- ChatStats
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0),
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM contacts ct
@@ -2370,17 +2377,18 @@ getDirectChatPreviews_ db User {userId} = do
|]
(CISRcvNew, userId, ConnReady, ConnSndReady)
where
- toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
- toDirectChatPreview tz (contactRow :. connRow :. statsRow :. ciRow_) =
+ toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
+ toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) =
let contact = toContact $ contactRow :. connRow
- ci_ = toDirectChatItemList tz ciRow_
+ ci_ = toDirectChatItemList tz currentTs ciRow_
stats = toChatStats statsRow
in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats
getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat]
getGroupChatPreviews_ db User {userId, userContactId} = do
tz <- getCurrentTimeZone
- map (toGroupChatPreview tz)
+ currentTs <- getCurrentTime
+ map (toGroupChatPreview tz currentTs)
<$> DB.query
db
[sql|
@@ -2394,7 +2402,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
-- ChatStats
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0),
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- Maybe GroupMember - sender
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
@@ -2433,10 +2441,10 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
|]
(CISRcvNew, userId, userContactId)
where
- toGroupChatPreview :: TimeZone -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat
- toGroupChatPreview tz (groupInfoRow :. statsRow :. ciRow_) =
+ toGroupChatPreview :: TimeZone -> UTCTime -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat
+ toGroupChatPreview tz currentTs (groupInfoRow :. statsRow :. ciRow_) =
let groupInfo = toGroupInfo userContactId groupInfoRow
- ci_ = toGroupChatItemList tz userContactId ciRow_
+ ci_ = toGroupChatItemList tz currentTs userContactId ciRow_
stats = toChatStats statsRow
in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats
@@ -2480,13 +2488,14 @@ getDirectChatLast_ db User {userId} contactId count = do
getDirectChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsLast_ = do
tz <- getCurrentTimeZone
- mapM (toDirectChatItem tz)
+ currentTs <- getCurrentTime
+ mapM (toDirectChatItem tz currentTs)
<$> DB.query
db
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
@@ -2507,13 +2516,14 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do
getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsAfter_ = do
tz <- getCurrentTimeZone
- mapM (toDirectChatItem tz)
+ currentTs <- getCurrentTime
+ mapM (toDirectChatItem tz currentTs)
<$> DB.query
db
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
@@ -2534,13 +2544,14 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do
getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsBefore_ = do
tz <- getCurrentTimeZone
- mapM (toDirectChatItem tz)
+ currentTs <- getCurrentTime
+ mapM (toDirectChatItem tz currentTs)
<$> DB.query
db
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
@@ -2633,13 +2644,14 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do
getGroupChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTGroup])
getGroupChatItemsLast_ = do
tz <- getCurrentTimeZone
- mapM (toGroupChatItem tz userContactId)
+ currentTs <- getCurrentTime
+ mapM (toGroupChatItem tz currentTs userContactId)
<$> DB.query
db
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
@@ -2672,13 +2684,14 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId
getGroupChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTGroup])
getGroupChatItemsAfter_ = do
tz <- getCurrentTimeZone
- mapM (toGroupChatItem tz userContactId)
+ currentTs <- getCurrentTime
+ mapM (toGroupChatItem tz currentTs userContactId)
<$> DB.query
db
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
@@ -2711,13 +2724,14 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI
getGroupChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTGroup])
getGroupChatItemsBefore_ = do
tz <- getCurrentTimeZone
- mapM (toGroupChatItem tz userContactId)
+ currentTs <- getCurrentTime
+ mapM (toGroupChatItem tz currentTs userContactId)
<$> DB.query
db
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
@@ -2810,8 +2824,8 @@ getChatItemIdByAgentMsgId st connId msgId =
|]
(connId, msgId)
-updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d)
-updateDirectChatItem st userId contactId itemId itemStatus =
+updateDirectChatItemStatus :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d)
+updateDirectChatItemStatus st userId contactId itemId itemStatus =
liftIOEither . withTransaction st $ \db -> runExceptT $ do
ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId
currentTs <- liftIO getCurrentTime
@@ -2821,6 +2835,50 @@ updateDirectChatItem st userId contactId itemId itemStatus =
correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
+updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d)
+updateDirectChatItem st userId contactId itemId newContent msgId =
+ liftIOEither . withTransaction st $ \db -> updateDirectChatItem_ db userId contactId itemId newContent msgId
+
+updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTDirect d))
+updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ do
+ ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId
+ currentTs <- liftIO getCurrentTime
+ let newText = ciContentToText newContent
+ liftIO $
+ DB.execute
+ db
+ [sql|
+ UPDATE chat_items
+ SET item_content = ?, item_text = ?, item_edited = 1, updated_at = ?
+ WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?
+ |]
+ (newContent, newText, currentTs, userId, contactId, itemId)
+ liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs)
+ pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText}
+ where
+ correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
+ correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
+
+updateDirectChatItemByMsgId :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d)
+updateDirectChatItemByMsgId st userId contactId sharedMsgId newContent msgId =
+ liftIOEither . withTransaction st $ \db -> runExceptT $ do
+ itemId <- ExceptT $ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId
+ liftIOEither $ updateDirectChatItem_ db userId contactId itemId newContent msgId
+
+getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Either StoreError Int64)
+getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId =
+ firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $
+ DB.query
+ db
+ [sql|
+ SELECT chat_item_id
+ FROM chat_items
+ WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ?
+ ORDER BY chat_item_id DESC
+ LIMIT 1
+ |]
+ (userId, contactId, sharedMsgId)
+
getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect)
getDirectChatItem st userId contactId itemId =
liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId
@@ -2828,7 +2886,8 @@ getDirectChatItem st userId contactId itemId =
getDirectChatItem_ :: DB.Connection -> UserId -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTDirect))
getDirectChatItem_ db userId contactId itemId = do
tz <- getCurrentTimeZone
- join <$> firstRow (toDirectChatItem tz) (SEChatItemNotFound itemId) getItem
+ currentTs <- getCurrentTime
+ join <$> firstRow (toDirectChatItem tz currentTs) (SEChatItemNotFound itemId) getItem
where
getItem =
DB.query
@@ -2836,7 +2895,7 @@ getDirectChatItem_ db userId contactId itemId = do
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
@@ -2860,19 +2919,67 @@ getDirectChatItemIdByText st userId contactId msgDir quotedMsg =
|]
(userId, contactId, msgDir, quotedMsg <> "%")
-getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup)
-getGroupChatItem st User {userId, userContactId} groupId itemId =
- liftIOEither . withTransaction st $ \db -> do
- tz <- getCurrentTimeZone
- join <$> firstRow (toGroupChatItem tz userContactId) (SEChatItemNotFound itemId) (getItem db)
+updateGroupChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> User -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTGroup d)
+updateGroupChatItem st user groupId itemId newContent msgId =
+ liftIOEither . withTransaction st $ \db -> updateGroupChatItem_ db user groupId itemId newContent msgId
+
+updateGroupChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTGroup d))
+updateGroupChatItem_ db user@User {userId} groupId itemId newContent msgId = runExceptT $ do
+ ci <- ExceptT $ (correctDir =<<) <$> getGroupChatItem_ db user groupId itemId
+ currentTs <- liftIO getCurrentTime
+ let newText = ciContentToText newContent
+ liftIO $
+ DB.execute
+ db
+ [sql|
+ UPDATE chat_items
+ SET item_content = ?, item_text = ?, item_edited = 1, updated_at = ?
+ WHERE user_id = ? AND group_id = ? AND chat_item_id = ?
+ |]
+ (newContent, newText, currentTs, userId, groupId, itemId)
+ liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs)
+ pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText}
where
- getItem db =
+ correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
+ correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
+
+updateGroupChatItemByMsgId :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> User -> Int64 -> SharedMsgId -> CIContent d -> MessageId -> m (ChatItem 'CTGroup d)
+updateGroupChatItemByMsgId st user groupId sharedMsgId newContent msgId =
+ liftIOEither . withTransaction st $ \db -> runExceptT $ do
+ itemId <- ExceptT $ getGroupChatItemIdBySharedMsgId_ db user groupId sharedMsgId
+ liftIOEither $ updateGroupChatItem_ db user groupId itemId newContent msgId
+
+getGroupChatItemIdBySharedMsgId_ :: DB.Connection -> User -> Int64 -> SharedMsgId -> IO (Either StoreError Int64)
+getGroupChatItemIdBySharedMsgId_ db User {userId} groupId sharedMsgId =
+ firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $
+ DB.query
+ db
+ [sql|
+ SELECT chat_item_id
+ FROM chat_items
+ WHERE user_id = ? AND group_id = ? AND shared_msg_id = ?
+ ORDER BY chat_item_id DESC
+ LIMIT 1
+ |]
+ (userId, groupId, sharedMsgId)
+
+getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup)
+getGroupChatItem st user groupId itemId =
+ liftIOEither . withTransaction st $ \db -> getGroupChatItem_ db user groupId itemId
+
+getGroupChatItem_ :: DB.Connection -> User -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTGroup))
+getGroupChatItem_ db User {userId, userContactId} groupId itemId = do
+ tz <- getCurrentTimeZone
+ currentTs <- liftIO getCurrentTime
+ join <$> firstRow (toGroupChatItem tz currentTs userContactId) (SEChatItemNotFound itemId) getItem
+ where
+ getItem =
DB.query
db
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
@@ -2967,9 +3074,9 @@ type ChatStatsRow = (Int, ChatItemId)
toChatStats :: ChatStatsRow -> ChatStats
toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadItemId}
-type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, UTCTime)
+type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime)
-type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe UTCTime)
+type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime)
type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool)
@@ -2990,8 +3097,8 @@ toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c)
toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir =
CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent)
-toDirectChatItem :: TimeZone -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect)
-toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. quoteRow) =
+toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect)
+toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow) =
case (itemContent, itemStatus) of
(ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent
(ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus) -> Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent
@@ -3002,12 +3109,12 @@ toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedM
CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow}
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIStatus d -> CIMeta d
- ciMeta status = mkCIMeta itemId itemText status sharedMsgId tz itemTs createdAt
+ ciMeta status = mkCIMeta itemId itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
-toDirectChatItemList :: TimeZone -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect]
-toDirectChatItemList tz ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just createdAt) :. quoteRow) =
- either (const []) (: []) $ toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. quoteRow)
-toDirectChatItemList _ _ = []
+toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect]
+toDirectChatItemList tz currentTs ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. quoteRow) =
+ either (const []) (: []) $ toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow)
+toDirectChatItemList _ _ _ = []
type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow
@@ -3021,8 +3128,8 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction
direction (Just False) Nothing = Just $ CIQGroupRcv Nothing
direction _ _ = Nothing
-toGroupChatItem :: TimeZone -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup)
-toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do
+toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup)
+toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do
let member_ = toMaybeGroupMember userContactId memberRow_
let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_
case (itemContent, itemStatus, member_) of
@@ -3035,12 +3142,12 @@ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemSt
CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_}
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIStatus d -> CIMeta d
- ciMeta status = mkCIMeta itemId itemText status sharedMsgId tz itemTs createdAt
+ ciMeta status = mkCIMeta itemId itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
-toGroupChatItemList :: TimeZone -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup]
-toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) =
- either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_)
-toGroupChatItemList _ _ _ = []
+toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup]
+toGroupChatItemList tz currentTs userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) =
+ either (const []) (: []) $ toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_)
+toGroupChatItemList _ _ _ _ = []
getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer]
getSMPServers st User {userId} =
@@ -3160,6 +3267,7 @@ data StoreError
| SEBadChatItem {itemId :: ChatItemId}
| SEChatItemNotFound {itemId :: ChatItemId}
| SEQuotedChatItemNotFound
+ | SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId}
deriving (Show, Exception, Generic)
instance ToJSON StoreError where
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index 84883ab558..54ad6267fc 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -31,7 +31,7 @@ import Database.SQLite.Simple.Internal (Field (..))
import Database.SQLite.Simple.Ok (Ok (Ok))
import Database.SQLite.Simple.ToField (ToField (..))
import GHC.Generics (Generic)
-import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId)
+import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId)
import Simplex.Messaging.Agent.Store.SQLite (fromTextField_)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
@@ -522,7 +522,7 @@ type FileTransferId = Int64
data FileInvitation = FileInvitation
{ fileName :: String,
fileSize :: Integer,
- fileConnReq :: ConnReqInvitation
+ fileConnReq :: AConnectionRequestUri
}
deriving (Eq, Show, Generic, FromJSON)
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index 07d952c0af..61e514b325 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -48,7 +48,9 @@ responseToView testView = \case
CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat]
CRUserSMPServers smpServers -> viewSMPServers smpServers testView
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item
- CRChatItemUpdated _ -> []
+ CRChatItemStatusUpdated _ -> []
+ CRChatItemUpdated (AChatItem _ _ chat item) -> viewMessageUpdate chat item
+ CRChatItemDeleted _ -> [] -- TODO
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
CRCmdAccepted _ -> []
CRCmdOk -> ["ok"]
@@ -166,11 +168,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
DirectChat c -> case chatDir of
CIDirectSnd -> case content of
CISndMsgContent mc -> viewSentMessage to quote mc meta
+ CISndMsgDeleted _mc -> []
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
where
to = ttyToContact' c
CIDirectRcv -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
+ CIRcvMsgDeleted _mc -> []
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft
where
from = ttyFromContact' c
@@ -179,33 +183,62 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
GroupChat g -> case chatDir of
CIGroupSnd -> case content of
CISndMsgContent mc -> viewSentMessage to quote mc meta
+ CISndMsgDeleted _mc -> []
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
where
to = ttyToGroup g
CIGroupRcv m -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
+ CIRcvMsgDeleted _mc -> []
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft
where
from = ttyFromGroup' g m
where
quote = maybe [] (groupQuote g) quotedItem
_ -> []
- where
- directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString]
- directQuote _ CIQuote {content = qmc, chatDir = qouteDir} =
- quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection qouteDir then ">>" else ">"
- groupQuote :: GroupInfo -> CIQuote 'CTGroup -> [StyledString]
- groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQuotedMember $ sentByMember g quoteDir
- sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember
- sentByMember GroupInfo {membership} = \case
- CIQGroupSnd -> Just membership
- CIQGroupRcv m -> m
- quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc
- msgPreview = msgPlain . preview . msgContentText
+
+viewMessageUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString]
+viewMessageUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
+ DirectChat Contact {localDisplayName = c} -> case chatDir of
+ CIDirectRcv -> case content of
+ CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
+ _ -> []
where
- preview t
- | T.length t <= 60 = t
- | otherwise = t <> "..."
+ from = ttyFromContactEdited c
+ quote = maybe [] (directQuote chatDir) quotedItem
+ CIDirectSnd -> []
+ GroupChat g -> case chatDir of
+ CIGroupRcv GroupMember {localDisplayName = m} -> case content of
+ CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
+ _ -> []
+ where
+ from = ttyFromGroupEdited g m
+ quote = maybe [] (groupQuote g) quotedItem
+ CIGroupSnd -> []
+ where
+ _ -> []
+
+directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString]
+directQuote _ CIQuote {content = qmc, chatDir = quoteDir} =
+ quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection quoteDir then ">>" else ">"
+
+groupQuote :: GroupInfo -> CIQuote 'CTGroup -> [StyledString]
+groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQuotedMember $ sentByMember g quoteDir
+
+sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember
+sentByMember GroupInfo {membership} = \case
+ CIQGroupSnd -> Just membership
+ CIQGroupRcv m -> m
+
+quoteText :: MsgContent -> StyledString -> [StyledString]
+quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc
+
+msgPreview :: MsgContent -> [StyledString]
+msgPreview = msgPlain . preview . msgContentText
+ where
+ preview t
+ | T.length t <= 60 = t
+ | otherwise = t <> "..."
viewMsgIntegrityError :: MsgErrorType -> [StyledString]
viewMsgIntegrityError err = msgError $ case err of
@@ -552,6 +585,7 @@ viewChatError = \case
CEFileRcvChunk e -> ["error receiving file: " <> plain e]
CEFileInternal e -> ["file error: " <> plain e]
CEInvalidQuote -> ["cannot reply to this message"]
+ CEInvalidMessageUpdate -> ["cannot update this message"]
CEAgentVersion -> ["unsupported agent version"]
CECommandError e -> ["bad chat command: " <> plain e]
-- e -> ["chat error: " <> sShow e]
@@ -602,6 +636,9 @@ ttyToContact c = styled (colored Cyan) $ "@" <> c <> " "
ttyFromContact :: ContactName -> StyledString
ttyFromContact c = ttyFrom $ c <> "> "
+ttyFromContactEdited :: ContactName -> StyledString
+ttyFromContactEdited c = ttyFrom $ c <> "> [edited] "
+
ttyToContact' :: Contact -> StyledString
ttyToContact' Contact {localDisplayName = c} = ttyToContact c
@@ -633,6 +670,9 @@ ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullN
ttyFromGroup :: GroupInfo -> ContactName -> StyledString
ttyFromGroup GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> "
+ttyFromGroupEdited :: GroupInfo -> ContactName -> StyledString
+ttyFromGroupEdited GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> [edited] "
+
ttyFrom :: Text -> StyledString
ttyFrom = styled $ colored Yellow
diff --git a/stack.yaml b/stack.yaml
index 922f7b4902..3eb93c6fa2 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -36,6 +36,7 @@ packages:
#
extra-deps:
- cryptostore-0.2.1.0@sha256:9896e2984f36a1c8790f057fd5ce3da4cbcaf8aa73eb2d9277916886978c5b19,3881
+ - network-3.1.2.7@sha256:e3d78b13db9512aeb106e44a334ab42b7aa48d26c097299084084cb8be5c5568,4888
- simple-logger-0.1.0@sha256:be8ede4bd251a9cac776533bae7fb643369ebd826eb948a9a18df1a8dd252ff8,1079
- tls-1.5.7@sha256:1cc30253a9696b65a9cafc0317fbf09f7dcea15e3a145ed6c9c0e28c632fa23a,6991
# below hackage dependancies are to update Aeson to 2.0.3
@@ -48,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
- commit: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc
+ commit: 800581b2bf5dacb2134dfda751be08cbf78df978
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
- github: simplex-chat/aeson
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs
index ca270a60b4..19b28a7cbb 100644
--- a/tests/ChatTests.hs
+++ b/tests/ChatTests.hs
@@ -35,6 +35,7 @@ chatTests = do
describe "direct messages" $ do
it "add contact and send/receive message" testAddContact
it "direct message quoted replies" testDirectMessageQuotedReply
+ it "direct message update" testDirectMessageUpdate
describe "chat groups" $ do
it "add contacts, create group and send/receive messages" testGroup
it "create and join group with 4 members" testGroup2
@@ -44,6 +45,7 @@ chatTests = do
it "remove contact from group and add again" testGroupRemoveAdd
it "list groups containing group invitations" testGroupList
it "group message quoted replies" testGroupMessageQuotedReply
+ it "group message update" testGroupMessageUpdate
describe "user profiles" $ do
it "update user profiles and notify contacts" testUpdateProfile
it "update user profile with image" testUpdateProfileImage
@@ -150,6 +152,59 @@ testDirectMessageQuotedReply = do
bob #$> ("/_get chat @2 count=1", chat', [((1, "will tell more"), Just (1, "all good - you?"))])
alice #$> ("/_get chat @2 count=1", chat', [((0, "will tell more"), Just (0, "all good - you?"))])
+testDirectMessageUpdate :: IO ()
+testDirectMessageUpdate = do
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+
+ -- msg id 1
+ alice #> "@bob hello 🙂"
+ bob <# "alice> hello 🙂"
+
+ -- msg id 2
+ bob `send` "> @alice (hello) hi alice"
+ bob <# "@alice > hello 🙂"
+ bob <## " hi alice"
+ alice <# "bob> > hello 🙂"
+ alice <## " hi alice"
+
+ alice #$> ("/_get chat @2 count=100", chat', [((1, "hello 🙂"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))])
+ bob #$> ("/_get chat @2 count=100", chat', [((0, "hello 🙂"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂"))])
+
+ alice ##> "/_update item @2 1 text hey 👋"
+ bob <# "alice> [edited] hey 👋"
+
+ alice #$> ("/_get chat @2 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))])
+ bob #$> ("/_get chat @2 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂"))])
+
+ -- msg id 3
+ bob `send` "> @alice (hey) hey alice"
+ bob <# "@alice > hey 👋"
+ bob <## " hey alice"
+ alice <# "bob> > hey 👋"
+ alice <## " hey alice"
+
+ alice #$> ("/_get chat @2 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂")), ((0, "hey alice"), Just (1, "hey 👋"))])
+ bob #$> ("/_get chat @2 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂")), ((1, "hey alice"), Just (0, "hey 👋"))])
+
+ alice ##> "/_update item @2 1 text greetings 🤝"
+ bob <# "alice> [edited] greetings 🤝"
+
+ alice #$> ("/_get chat @2 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂")), ((0, "hey alice"), Just (1, "hey 👋"))])
+ bob #$> ("/_get chat @2 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂")), ((1, "hey alice"), Just (0, "hey 👋"))])
+
+ bob ##> "/_update item @2 2 text hey Alice"
+ alice <# "bob> [edited] > hello 🙂"
+ alice <## " hey Alice"
+
+ bob ##> "/_update item @2 3 text greetings Alice"
+ alice <# "bob> [edited] > hey 👋"
+ alice <## " greetings Alice"
+
+ alice #$> ("/_get chat @2 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hey Alice"), Just (1, "hello 🙂")), ((0, "greetings Alice"), Just (1, "hey 👋"))])
+ bob #$> ("/_get chat @2 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hey Alice"), Just (0, "hello 🙂")), ((1, "greetings Alice"), Just (0, "hey 👋"))])
+
testGroup :: IO ()
testGroup =
testChat3 aliceProfile bobProfile cathProfile $
@@ -619,7 +674,7 @@ testGroupMessageQuotedReply =
cath #$> ("/_get chat #1 count=1", chat', [((1, "hi there!"), Just (0, "hello, all good, you?"))])
alice #$> ("/_get chat #1 count=1", chat', [((0, "hi there!"), Just (0, "hello, all good, you?"))])
bob #$> ("/_get chat #1 count=1", chat', [((0, "hi there!"), Just (1, "hello, all good, you?"))])
- alice `send ` "> #team (will tell) go on"
+ alice `send` "> #team (will tell) go on"
alice <# "#team > bob will tell more"
alice <## " go on"
concurrently_
@@ -632,6 +687,66 @@ testGroupMessageQuotedReply =
cath <## " go on"
)
+testGroupMessageUpdate :: IO ()
+testGroupMessageUpdate = do
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+ alice #> "#team hello!"
+ concurrently_
+ (bob <# "#team alice> hello!")
+ (cath <# "#team alice> hello!")
+
+ alice ##> "/_update item #1 1 text hey 👋"
+ concurrently_
+ (bob <# "#team alice> [edited] hey 👋")
+ (cath <# "#team alice> [edited] hey 👋")
+
+ alice #$> ("/_get chat #1 count=100", chat', [((1, "hey 👋"), Nothing)])
+ bob #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing)])
+ cath #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing)])
+
+ threadDelay 1000000
+ bob `send` "> #team @alice (hey) hi alice"
+ bob <# "#team > alice hey 👋"
+ bob <## " hi alice"
+ concurrently_
+ ( do
+ alice <# "#team bob> > alice hey 👋"
+ alice <## " hi alice"
+ )
+ ( do
+ cath <# "#team bob> > alice hey 👋"
+ cath <## " hi alice"
+ )
+
+ alice #$> ("/_get chat #1 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hey 👋"))])
+ bob #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hey 👋"))])
+ cath #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing), ((0, "hi alice"), Just (0, "hey 👋"))])
+
+ alice ##> "/_update item #1 1 text greetings 🤝"
+ concurrently_
+ (bob <# "#team alice> [edited] greetings 🤝")
+ (cath <# "#team alice> [edited] greetings 🤝")
+
+ threadDelay 1000000
+ cath `send` "> #team @alice (greetings) greetings!"
+ cath <# "#team > alice greetings 🤝"
+ cath <## " greetings!"
+ concurrently_
+ ( do
+ alice <# "#team cath> > alice greetings 🤝"
+ alice <## " greetings!"
+ )
+ ( do
+ bob <# "#team cath> > alice greetings 🤝"
+ bob <## " greetings!"
+ )
+
+ alice #$> ("/_get chat #1 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (1, "hey 👋")), ((0, "greetings!"), Just (1, "greetings 🤝"))])
+ bob #$> ("/_get chat #1 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hey 👋")), ((0, "greetings!"), Just (0, "greetings 🤝"))])
+ cath #$> ("/_get chat #1 count=100", chat', [((0, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (0, "hey 👋")), ((1, "greetings!"), Just (0, "greetings 🤝"))])
+
testUpdateProfile :: IO ()
testUpdateProfile =
testChat3 aliceProfile bobProfile cathProfile $
@@ -679,11 +794,14 @@ testUpdateProfileImage =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
- -- Note we currently don't support removing profile image.
alice ##> "/profile_image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
alice <## "profile image updated"
alice ##> "/profile_image"
alice <## "profile image removed"
+ alice ##> "/_profile {\"displayName\": \"alice2\", \"fullName\": \"\"}"
+ alice <## "user profile is changed to alice2 (your contacts are notified)"
+ bob <## "contact alice changed to alice2"
+ bob <## "use @alice2 to send messages"
(bob )
testFileTransfer :: IO ()
diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs
index fba1ce62a5..2ac9801cb5 100644
--- a/tests/ProtocolTests.hs
+++ b/tests/ProtocolTests.hs
@@ -71,10 +71,10 @@ s ##==## msg = do
s ==## msg
(==#) :: ByteString -> ChatMsgEvent -> Expectation
-s ==# msg = s ==## (ChatMessage Nothing msg)
+s ==# msg = s ==## ChatMessage Nothing msg
(#==) :: ByteString -> ChatMsgEvent -> Expectation
-s #== msg = s ##== (ChatMessage Nothing msg)
+s #== msg = s ##== ChatMessage Nothing msg
(#==#) :: ByteString -> ChatMsgEvent -> Expectation
s #==# msg = do
@@ -93,23 +93,22 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCSimple $ MCText "hello"))
it "x.msg.new" $
"{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}"
- ##==## ( ChatMessage
- (Just $ SharedMsgId "\1\2\3\4")
- ( XMsgNew $
- MCQuote
- ( QuotedMsg
- (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing)
- $ MCText "hello there!"
- )
- (MCText "hello to you too")
- )
- )
+ ##==## ChatMessage
+ (Just $ SharedMsgId "\1\2\3\4")
+ ( XMsgNew $
+ MCQuote
+ ( QuotedMsg
+ (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing)
+ $ MCText "hello there!"
+ )
+ (MCText "hello to you too")
+ )
it "x.msg.new" $
"{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}"
- ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello"))
+ ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello")
it "x.file" $
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
- #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = testConnReq}
+ #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = ACR SCMInvitation testConnReq}
it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg"
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing}