mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-29 01:10:08 +00:00
mobile: message delete (#480)
* mobile: message delete * ios * android api * meta * android * new ios libs * bug fixes * adjust alert * fix deleted item upsert * change border color for ios * format * android - red button * ios: deleted item design * android: deleted item design * android alert msg Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
co-authored by
Evgeny Poberezkin
parent
8574674c2d
commit
f388512592
@@ -134,6 +134,26 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
if (i >= 0) {
|
||||
chat = chats[i]
|
||||
val pItem = chat.chatItems.last()
|
||||
if (pItem.id == cItem.id) {
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
|
||||
}
|
||||
}
|
||||
// remove from current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems.removeAt(itemIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markChatItemsRead(cInfo: ChatInfo) {
|
||||
val chatIdx = getChatIndex(cInfo.id)
|
||||
// update current chat
|
||||
@@ -490,6 +510,20 @@ data class ChatItem (
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
|
||||
else null
|
||||
|
||||
val isMsgContent: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.SndMsgContent -> true
|
||||
is CIContent.RcvMsgContent -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
val isDeletedContent: Boolean get() =
|
||||
when (content) {
|
||||
is CIContent.SndDeleted -> true
|
||||
is CIContent.RcvDeleted -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSampleData(
|
||||
id: Long = 1,
|
||||
@@ -508,6 +542,20 @@ data class ChatItem (
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
|
||||
quotedItem = quotedItem
|
||||
)
|
||||
|
||||
fun getDeletedContentSampleData(
|
||||
id: Long = 1,
|
||||
dir: CIDirection = CIDirection.DirectRcv(),
|
||||
ts: Instant = Clock.System.now(),
|
||||
text: String = "this item is deleted",
|
||||
status: CIStatus = CIStatus.RcvRead()
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, false, false, false),
|
||||
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||
quotedItem = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +646,12 @@ sealed class CIStatus {
|
||||
class RcvRead: CIStatus()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CIDeleteMode(val deleteMode: String) {
|
||||
@SerialName("internal") cidmInternal("internal"),
|
||||
@SerialName("broadcast") cidmBroadcast("broadcast");
|
||||
}
|
||||
|
||||
interface ItemContent {
|
||||
val text: String
|
||||
}
|
||||
@@ -616,6 +670,16 @@ sealed class CIContent: ItemContent {
|
||||
override val text get() = msgContent.text
|
||||
}
|
||||
|
||||
@Serializable @SerialName("sndDeleted")
|
||||
class SndDeleted(val deleteMode: CIDeleteMode): CIContent() {
|
||||
override val text get() = "deleted"
|
||||
}
|
||||
|
||||
@Serializable @SerialName("rcvDeleted")
|
||||
class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() {
|
||||
override val text get() = "deleted"
|
||||
}
|
||||
|
||||
@Serializable @SerialName("sndFileInvitation")
|
||||
class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() {
|
||||
override val text get() = "sending files is not supported yet"
|
||||
|
||||
@@ -147,17 +147,17 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiUpdateMessage(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
|
||||
val r = sendCmd(CC.ApiUpdateMessage(type, id, itemId, mc))
|
||||
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
|
||||
val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc))
|
||||
if (r is CR.ChatItemUpdated) return r.chatItem
|
||||
Log.e(TAG, "apiUpdateMessage bad response: ${r.responseType} ${r.details}")
|
||||
Log.e(TAG, "apiUpdateChatItem 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}")
|
||||
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): AChatItem? {
|
||||
val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
|
||||
if (r is CR.ChatItemDeleted) return r.toChatItem
|
||||
Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
"Error saving SMP servers",
|
||||
"Make sure SMP server addresses are in correct format, line separated and are not duplicated"
|
||||
"Make sure SMP server addresses are in correct format, line separated and are not duplicated."
|
||||
)
|
||||
false
|
||||
}
|
||||
@@ -196,7 +196,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
|
||||
r is CR.ContactAlreadyExists -> {
|
||||
AlertManager.shared.showAlertMsg("Contact already exists",
|
||||
"You are already connected to ${r.contact.displayName} via this link"
|
||||
"You are already connected to ${r.contact.displayName} via this link."
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -223,7 +223,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
"Can't delete contact!",
|
||||
"Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}"
|
||||
"Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,11 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
is CR.ChatItemStatusUpdated -> {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
if (chatModel.upsertChatItem(cInfo, cItem)) {
|
||||
var res = false
|
||||
if (!cItem.isDeletedContent) {
|
||||
res = chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
if (res) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
@@ -348,7 +352,14 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
}
|
||||
}
|
||||
is CR.ChatItemDeleted -> {
|
||||
// TODO
|
||||
val cInfo = r.toChatItem.chatInfo
|
||||
val cItem = r.toChatItem.chatItem
|
||||
if (cItem.meta.itemDeleted) {
|
||||
chatModel.removeChatItem(cInfo, cItem)
|
||||
} else {
|
||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||
chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||
@@ -450,11 +461,6 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
}
|
||||
}
|
||||
|
||||
enum class MsgDeleteMode(val mode: String) {
|
||||
Broadcast("broadcast"),
|
||||
Internal("internal");
|
||||
}
|
||||
|
||||
// ChatCommand
|
||||
sealed class CC {
|
||||
class Console(val cmd: String): CC()
|
||||
@@ -465,8 +471,8 @@ 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 ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
|
||||
class GetUserSMPServers(): CC()
|
||||
class SetUserSMPServers(val smpServers: List<String>): CC()
|
||||
class AddContact: CC()
|
||||
@@ -489,8 +495,8 @@ 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 ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
is GetUserSMPServers -> "/smp_servers"
|
||||
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
|
||||
is AddContact -> "/connect"
|
||||
@@ -514,8 +520,8 @@ sealed class CC {
|
||||
is ApiGetChat -> "apiGetChat"
|
||||
is ApiSendMessage -> "apiSendMessage"
|
||||
is ApiSendMessageQuote -> "apiSendMessageQuote"
|
||||
is ApiUpdateMessage -> "apiUpdateMessage"
|
||||
is ApiDeleteMessage -> "apiDeleteMessage"
|
||||
is ApiUpdateChatItem -> "apiUpdateChatItem"
|
||||
is ApiDeleteChatItem -> "apiDeleteChatItem"
|
||||
is GetUserSMPServers -> "getUserSMPServers"
|
||||
is SetUserSMPServers -> "setUserSMPServers"
|
||||
is AddContact -> "addContact"
|
||||
@@ -601,7 +607,7 @@ sealed class 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("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: 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()
|
||||
@@ -682,7 +688,7 @@ sealed class CR {
|
||||
is NewChatItem -> json.encodeToString(chatItem)
|
||||
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
|
||||
is ChatItemUpdated -> json.encodeToString(chatItem)
|
||||
is ChatItemDeleted -> json.encodeToString(chatItem)
|
||||
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
|
||||
is CmdOk -> noDetails()
|
||||
is ChatCmdError -> chatError.string
|
||||
is ChatRespError -> chatError.string
|
||||
|
||||
@@ -77,7 +77,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
val cInfo = chat.chatInfo
|
||||
val ei = editingItem.value
|
||||
if (ei != null) {
|
||||
val updatedItem = chatModel.controller.apiUpdateMessage(
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
@@ -98,7 +98,19 @@ fun ChatView(chatModel: ChatModel) {
|
||||
quotedItem.value = null
|
||||
}
|
||||
},
|
||||
resetMessage = { msg.value = "" }
|
||||
resetMessage = { msg.value = "" },
|
||||
deleteMessage = { itemId, mode ->
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
val toItem = chatModel.controller.apiDeleteChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = itemId,
|
||||
mode = mode
|
||||
)
|
||||
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,7 +127,8 @@ fun ChatLayout(
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
sendMessage: (String) -> Unit,
|
||||
resetMessage: () -> Unit
|
||||
resetMessage: () -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
Modifier
|
||||
@@ -129,7 +142,7 @@ fun ChatLayout(
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat)
|
||||
ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +214,8 @@ fun ChatItemsList(
|
||||
msg: MutableState<String>,
|
||||
quotedItem: MutableState<ChatItem?>,
|
||||
editingItem: MutableState<ChatItem?>,
|
||||
openDirectChat: (Long) -> Unit
|
||||
openDirectChat: (Long) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val keyboardState by getKeyboardState()
|
||||
@@ -224,7 +238,11 @@ fun ChatItemsList(
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
} else {
|
||||
Box(Modifier.clip(CircleShape).clickable { openDirectChat(contactId) }) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { openDirectChat(contactId) }
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
}
|
||||
@@ -232,20 +250,22 @@ fun ChatItemsList(
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember)
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler)
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
)) {
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler)
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
)
|
||||
) {
|
||||
ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,9 +281,7 @@ fun ChatItemsList(
|
||||
|
||||
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
|
||||
return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd ||
|
||||
( prevItem.chatDir is CIDirection.GroupRcv &&
|
||||
prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId
|
||||
)
|
||||
(prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -287,14 +305,15 @@ fun PreviewChatLayout() {
|
||||
ChatItem.getSampleData(
|
||||
2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
3, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getDeletedContentSampleData(3),
|
||||
ChatItem.getSampleData(
|
||||
4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
5, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
5, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
ChatLayout(
|
||||
@@ -312,7 +331,8 @@ fun PreviewChatLayout() {
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
sendMessage = {},
|
||||
resetMessage = {}
|
||||
resetMessage = {},
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -328,14 +348,15 @@ fun PreviewGroupChatLayout() {
|
||||
ChatItem.getSampleData(
|
||||
2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getDeletedContentSampleData(3),
|
||||
ChatItem.getSampleData(
|
||||
3, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
4, CIDirection.GroupSnd(), Clock.System.now(), "hello"
|
||||
5, CIDirection.GroupSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
ChatItem.getSampleData(
|
||||
5, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
ChatLayout(
|
||||
@@ -353,7 +374,8 @@ fun PreviewGroupChatLayout() {
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
sendMessage = {},
|
||||
resetMessage = {}
|
||||
resetMessage = {},
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,16 @@ fun CIMetaView(chatItem: ChatItem) {
|
||||
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,
|
||||
)
|
||||
if (!chatItem.isDeletedContent) {
|
||||
if (chatItem.meta.itemEdited) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
modifier = Modifier.height(12.dp),
|
||||
contentDescription = "Edited",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
}
|
||||
// TODO status
|
||||
}
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
@@ -58,3 +61,11 @@ fun PreviewCIMetaViewEdited() {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewCIMetaViewDeletedContent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
@@ -17,8 +18,7 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.copyText
|
||||
import chat.simplex.app.views.helpers.shareText
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -31,6 +31,7 @@ fun ChatItemView(
|
||||
cxt: Context,
|
||||
uriHandler: UriHandler? = null,
|
||||
showMember: Boolean = false,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
val sent = cItem.chatDir.sent
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
@@ -42,52 +43,96 @@ fun ChatItemView(
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) {
|
||||
if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
} else {
|
||||
FramedItemView(user, cItem, uriHandler, showMember = showMember)
|
||||
if (cItem.isMsgContent) {
|
||||
if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
} else {
|
||||
FramedItemView(user, cItem, uriHandler, showMember = showMember)
|
||||
}
|
||||
} else if (cItem.isDeletedContent) {
|
||||
DeletedItemView(cItem, showMember = showMember)
|
||||
}
|
||||
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||
ItemAction("Reply", Icons.Outlined.Reply, onClick = {
|
||||
editingItem.value = null
|
||||
quotedItem.value = cItem
|
||||
showMenu = false
|
||||
})
|
||||
ItemAction("Share", Icons.Outlined.Share, onClick = {
|
||||
shareText(cxt, cItem.content.text)
|
||||
showMenu = false
|
||||
})
|
||||
ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = {
|
||||
copyText(cxt, cItem.content.text)
|
||||
showMenu = false
|
||||
})
|
||||
// if (cItem.chatDir.sent && cItem.meta.editable) {
|
||||
// ItemAction("Edit", Icons.Filled.Edit, onClick = {
|
||||
// quotedItem.value = null
|
||||
// editingItem.value = cItem
|
||||
// msg.value = cItem.content.text
|
||||
// showMenu = false
|
||||
// })
|
||||
// }
|
||||
if (cItem.isMsgContent) {
|
||||
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||
ItemAction("Reply", Icons.Outlined.Reply, onClick = {
|
||||
editingItem.value = null
|
||||
quotedItem.value = cItem
|
||||
showMenu = false
|
||||
})
|
||||
ItemAction("Share", Icons.Outlined.Share, onClick = {
|
||||
shareText(cxt, cItem.content.text)
|
||||
showMenu = false
|
||||
})
|
||||
ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = {
|
||||
copyText(cxt, cItem.content.text)
|
||||
showMenu = false
|
||||
})
|
||||
if (cItem.chatDir.sent && cItem.meta.editable) {
|
||||
ItemAction("Edit", Icons.Filled.Edit, onClick = {
|
||||
quotedItem.value = null
|
||||
editingItem.value = cItem
|
||||
msg.value = cItem.content.text
|
||||
showMenu = false
|
||||
})
|
||||
}
|
||||
ItemAction(
|
||||
"Delete",
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
showMenu = false
|
||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit) {
|
||||
private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = HighOrLowlight) {
|
||||
DropdownMenuItem(onClick) {
|
||||
Row {
|
||||
Text(
|
||||
text, modifier = Modifier
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.weight(1F),
|
||||
color = color
|
||||
)
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Icon(icon, text, tint = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = "Delete message?",
|
||||
text = "Message will be deleted - this cannot be undone!",
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text("For me only") }
|
||||
if (chatItem.meta.editable) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Button(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text("For everyone") }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView() {
|
||||
@@ -100,7 +145,24 @@ fun PreviewChatItemView() {
|
||||
msg = remember { mutableStateOf("") },
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
cxt = LocalContext.current
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemViewDeletedContent() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
User.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
msg = remember { mutableStateOf("") },
|
||||
quotedItem = remember { mutableStateOf(null) },
|
||||
editingItem = remember { mutableStateOf(null) },
|
||||
cxt = LocalContext.current,
|
||||
deleteMessage = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
|
||||
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = "Accept connection request?",
|
||||
text = "If you choose to reject sender will NOT be notified",
|
||||
text = "If you choose to reject sender will NOT be notified.",
|
||||
confirmText = "Accept",
|
||||
onConfirm = {
|
||||
withApi {
|
||||
|
||||
@@ -21,6 +21,22 @@ class AlertManager {
|
||||
alertView.value = null
|
||||
}
|
||||
|
||||
fun showAlertDialogButtons(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = buttons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialog(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
|
||||
@@ -61,7 +61,7 @@ fun SMPServersView(chatModel: ChatModel) {
|
||||
if (userSMPServers.isNotEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Use SimpleX Chat servers?",
|
||||
text = "Saved SMP servers will be removed",
|
||||
text = "Saved SMP servers will be removed.",
|
||||
confirmText = "Confirm",
|
||||
onConfirm = {
|
||||
saveSMPServers(listOf())
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ fun UserAddressView(chatModel: ChatModel) {
|
||||
deleteAddress = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = "Delete address?",
|
||||
text = "All your contacts will remain connected",
|
||||
text = "All your contacts will remain connected.",
|
||||
confirmText = "Delete",
|
||||
onConfirm = {
|
||||
withApi {
|
||||
|
||||
@@ -135,6 +135,23 @@ final class ChatModel: ObservableObject {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update previews
|
||||
if let chat = getChat(cInfo.id) {
|
||||
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
|
||||
chat.chatItems = [cItem]
|
||||
}
|
||||
}
|
||||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
_ = withAnimation {
|
||||
self.chatItems.remove(at: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
// update preview
|
||||
@@ -571,6 +588,22 @@ struct ChatItem: Identifiable, Decodable {
|
||||
if case .rcvNew = meta.itemStatus { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
func isMsgContent() -> Bool {
|
||||
switch content {
|
||||
case .sndMsgContent: return true
|
||||
case .rcvMsgContent: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDeletedContent() -> Bool {
|
||||
switch content {
|
||||
case .sndDeleted: return true
|
||||
case .rcvDeleted: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var memberDisplayName: String? {
|
||||
get {
|
||||
@@ -584,10 +617,19 @@ struct ChatItem: Identifiable, Decodable {
|
||||
|
||||
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, itemDeleted, itemEdited, editable),
|
||||
content: .sndMsgContent(msgContent: .text(text)),
|
||||
quotedItem: quotedItem
|
||||
chatDir: dir,
|
||||
meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
|
||||
content: .sndMsgContent(msgContent: .text(text)),
|
||||
quotedItem: quotedItem
|
||||
)
|
||||
}
|
||||
|
||||
static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: dir,
|
||||
meta: CIMeta.getSample(id, ts, text, status, false, false, false),
|
||||
content: .rcvDeleted(deleteMode: .cidmBroadcast),
|
||||
quotedItem: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -655,6 +697,11 @@ enum CIStatus: Decodable {
|
||||
case rcvRead
|
||||
}
|
||||
|
||||
enum CIDeleteMode: String, Decodable {
|
||||
case cidmBroadcast = "broadcast"
|
||||
case cidmInternal = "internal"
|
||||
}
|
||||
|
||||
protocol ItemContent {
|
||||
var text: String { get }
|
||||
}
|
||||
@@ -662,6 +709,8 @@ protocol ItemContent {
|
||||
enum CIContent: Decodable, ItemContent {
|
||||
case sndMsgContent(msgContent: MsgContent)
|
||||
case rcvMsgContent(msgContent: MsgContent)
|
||||
case sndDeleted(deleteMode: CIDeleteMode)
|
||||
case rcvDeleted(deleteMode: CIDeleteMode)
|
||||
case sndFileInvitation(fileId: Int64, filePath: String)
|
||||
case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer)
|
||||
|
||||
@@ -670,6 +719,8 @@ enum CIContent: Decodable, ItemContent {
|
||||
switch self {
|
||||
case let .sndMsgContent(mc): return mc.text
|
||||
case let .rcvMsgContent(mc): return mc.text
|
||||
case .sndDeleted: return "deleted"
|
||||
case .rcvDeleted: return "deleted"
|
||||
case .sndFileInvitation: return "sending files is not supported yet"
|
||||
case .rcvFileInvitation: return "receiving files is not supported yet"
|
||||
}
|
||||
|
||||
@@ -15,11 +15,6 @@ 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)
|
||||
@@ -28,8 +23,8 @@ 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 apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
|
||||
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
|
||||
case getUserSMPServers
|
||||
case setUserSMPServers(smpServers: [String])
|
||||
case addContact
|
||||
@@ -54,8 +49,8 @@ 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 let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
|
||||
case let .apiDeleteChatItem(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"
|
||||
@@ -83,8 +78,8 @@ enum ChatCommand {
|
||||
case .apiGetChat: return "apiGetChat"
|
||||
case .apiSendMessage: return "apiSendMessage"
|
||||
case .apiSendMessageQuote: return "apiSendMessageQuote"
|
||||
case .apiUpdateMessage: return "apiUpdateMessage"
|
||||
case .apiDeleteMessage: return "apiDeleteMessage"
|
||||
case .apiUpdateChatItem: return "apiUpdateChatItem"
|
||||
case .apiDeleteChatItem: return "apiDeleteChatItem"
|
||||
case .getUserSMPServers: return "getUserSMPServers"
|
||||
case .setUserSMPServers: return "setUserSMPServers"
|
||||
case .addContact: return "addContact"
|
||||
@@ -148,7 +143,7 @@ enum ChatResponse: Decodable, Error {
|
||||
case newChatItem(chatItem: AChatItem)
|
||||
case chatItemStatusUpdated(chatItem: AChatItem)
|
||||
case chatItemUpdated(chatItem: AChatItem)
|
||||
case chatItemDeleted(chatItem: AChatItem)
|
||||
case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
|
||||
case cmdOk
|
||||
case chatCmdError(chatError: ChatError)
|
||||
case chatError(chatError: ChatError)
|
||||
@@ -231,7 +226,7 @@ enum ChatResponse: Decodable, Error {
|
||||
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 let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
|
||||
case .cmdOk: return noDetails
|
||||
case let .chatCmdError(chatError): return String(describing: chatError)
|
||||
case let .chatError(chatError): return String(describing: chatError)
|
||||
@@ -410,15 +405,15 @@ 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)
|
||||
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiUpdateChatItem(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 }
|
||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -633,7 +628,11 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
case let .chatItemStatusUpdated(aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if chatModel.upsertChatItem(cInfo, cItem) {
|
||||
var res = false
|
||||
if !cItem.isDeletedContent() {
|
||||
res = chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
if res {
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
} else if let endTask = chatModel.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
@@ -649,9 +648,15 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
if chatModel.upsertChatItem(cInfo, cItem) {
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
case .chatItemDeleted(_):
|
||||
// TODO let .chatItemDeleted(aChatItem)
|
||||
return
|
||||
case let .chatItemDeleted(_, toChatItem):
|
||||
let cInfo = toChatItem.chatInfo
|
||||
let cItem = toChatItem.chatItem
|
||||
if cItem.meta.itemDeleted {
|
||||
chatModel.removeChatItem(cInfo, cItem)
|
||||
} else {
|
||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||
_ = chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
@@ -784,7 +789,8 @@ enum ChatErrorType: Decodable {
|
||||
case fileRcvChunk(message: String)
|
||||
case fileInternal(message: String)
|
||||
case invalidQuote
|
||||
case invalidMessageUpdate
|
||||
case invalidChatItemUpdate
|
||||
case invalidChatItemDelete
|
||||
case agentVersion
|
||||
case commandError(message: String)
|
||||
}
|
||||
|
||||
@@ -13,20 +13,22 @@ struct CIMetaView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
if chatItem.meta.itemEdited {
|
||||
statusImage("pencil", .secondary, 9)
|
||||
}
|
||||
if !chatItem.isDeletedContent() {
|
||||
if chatItem.meta.itemEdited {
|
||||
statusImage("pencil", .secondary, 9)
|
||||
}
|
||||
|
||||
switch chatItem.meta.itemStatus {
|
||||
case .sndSent:
|
||||
statusImage("checkmark", .secondary)
|
||||
case .sndErrorAuth:
|
||||
statusImage("multiply", .red)
|
||||
case .sndError:
|
||||
statusImage("exclamationmark.triangle.fill", .yellow)
|
||||
case .rcvNew:
|
||||
statusImage("circlebadge.fill", Color.accentColor)
|
||||
default: EmptyView()
|
||||
switch chatItem.meta.itemStatus {
|
||||
case .sndSent:
|
||||
statusImage("checkmark", .secondary)
|
||||
case .sndErrorAuth:
|
||||
statusImage("multiply", .red)
|
||||
case .sndError:
|
||||
statusImage("exclamationmark.triangle.fill", .yellow)
|
||||
case .rcvNew:
|
||||
statusImage("circlebadge.fill", Color.accentColor)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
chatItem.timestampText
|
||||
@@ -49,6 +51,8 @@ struct CIMetaView_Previews: PreviewProvider {
|
||||
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))
|
||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 100))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// FramedItemView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by JRoberts on 04/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DeletedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ")
|
||||
}
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
// .background(Color(uiColor: .systemBackground))
|
||||
// .overlay(
|
||||
// RoundedRectangle(cornerRadius: 18)
|
||||
// .stroke(.quaternary, lineWidth: 1)
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
struct DeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
|
||||
DeletedItemView(
|
||||
chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)),
|
||||
showMember: true
|
||||
)
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,14 @@ struct ChatItemView: View {
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) {
|
||||
EmojiItemView(chatItem: chatItem)
|
||||
} else {
|
||||
FramedItemView(chatItem: chatItem, showMember: showMember)
|
||||
if chatItem.isMsgContent() {
|
||||
if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) {
|
||||
EmojiItemView(chatItem: chatItem)
|
||||
} else {
|
||||
FramedItemView(chatItem: chatItem, showMember: showMember)
|
||||
}
|
||||
} else if chatItem.isDeletedContent() {
|
||||
DeletedItemView(chatItem: chatItem, showMember: showMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +33,7 @@ struct ChatItemView_Previews: PreviewProvider {
|
||||
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
|
||||
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
|
||||
ChatItemView(chatItem: ChatItem.getDeletedContentSample())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ struct ChatView: View {
|
||||
@State var message: String = ""
|
||||
@State var quotedItem: ChatItem? = nil
|
||||
@State var editingItem: ChatItem? = nil
|
||||
@State var deletingItem: ChatItem? = nil
|
||||
@State private var inProgress: Bool = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showChatInfo = false
|
||||
@State private var showDeleteMessage = false
|
||||
|
||||
var body: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
@@ -118,27 +120,46 @@ struct ChatView: View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
return ChatItemView(chatItem: ci, showMember: showMember)
|
||||
.contextMenu {
|
||||
Button {
|
||||
withAnimation {
|
||||
editingItem = nil
|
||||
quotedItem = ci
|
||||
if ci.isMsgContent() {
|
||||
Button {
|
||||
withAnimation {
|
||||
editingItem = nil
|
||||
quotedItem = ci
|
||||
}
|
||||
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
|
||||
Button {
|
||||
showShareSheet(items: [ci.content.text])
|
||||
} label: { Label("Share", systemImage: "square.and.arrow.up") }
|
||||
Button {
|
||||
UIPasteboard.general.string = ci.content.text
|
||||
} label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
if ci.meta.editable {
|
||||
Button {
|
||||
withAnimation {
|
||||
quotedItem = nil
|
||||
editingItem = ci
|
||||
message = ci.content.text
|
||||
}
|
||||
} label: { Label("Edit", systemImage: "square.and.pencil") }
|
||||
}
|
||||
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
|
||||
Button {
|
||||
showShareSheet(items: [ci.content.text])
|
||||
} label: { Label("Share", systemImage: "square.and.arrow.up") }
|
||||
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") }
|
||||
// }
|
||||
Button(role: .destructive) {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal)
|
||||
}
|
||||
if let di = deletingItem {
|
||||
if di.meta.editable {
|
||||
Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
@@ -185,7 +206,7 @@ struct ChatView: View {
|
||||
logger.debug("ChatView sendMessage: in Task")
|
||||
do {
|
||||
if let ei = editingItem {
|
||||
let chatItem = try await apiUpdateMessage(
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
@@ -212,6 +233,29 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMessage(_ mode: CIDeleteMode) {
|
||||
logger.debug("ChatView deleteMessage")
|
||||
Task {
|
||||
logger.debug("ChatView deleteMessage: in Task")
|
||||
do {
|
||||
if let di = deletingItem {
|
||||
let toItem = try await apiDeleteChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: di.id,
|
||||
mode: mode
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
deletingItem = nil
|
||||
let _ = chatModel.removeChatItem(chat.chatInfo, toItem)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatView_Previews: PreviewProvider {
|
||||
@@ -222,11 +266,12 @@ struct ChatView_Previews: PreviewProvider {
|
||||
ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||
ChatItem.getSample(2, .directRcv, .now, "hi"),
|
||||
ChatItem.getSample(3, .directRcv, .now, "hi there"),
|
||||
ChatItem.getSample(4, .directRcv, .now, "hello again"),
|
||||
ChatItem.getSample(5, .directSnd, .now, "hi there!!!"),
|
||||
ChatItem.getSample(6, .directSnd, .now, "how are you?"),
|
||||
ChatItem.getSample(7, .directSnd, .now, "👍👍👍👍"),
|
||||
ChatItem.getSample(8, .directSnd, .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.")
|
||||
ChatItem.getDeletedContentSample(4),
|
||||
ChatItem.getSample(5, .directRcv, .now, "hello again"),
|
||||
ChatItem.getSample(6, .directSnd, .now, "hi there!!!"),
|
||||
ChatItem.getSample(7, .directSnd, .now, "how are you?"),
|
||||
ChatItem.getSample(8, .directSnd, .now, "👍👍👍👍"),
|
||||
ChatItem.getSample(9, .directSnd, .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.")
|
||||
]
|
||||
return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||
.environmentObject(chatModel)
|
||||
|
||||
@@ -116,6 +116,13 @@
|
||||
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 */; };
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
|
||||
64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
|
||||
64C2576927F45A42007F8541 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576427F45A42007F8541 /* libgmpxx.a */; };
|
||||
64C2576A27F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576527F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */; };
|
||||
64C2576B27F45A42007F8541 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576627F45A42007F8541 /* libgmp.a */; };
|
||||
64C2576C27F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576727F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */; };
|
||||
64C2576D27F45A42007F8541 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576827F45A42007F8541 /* libffi.a */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -199,6 +206,12 @@
|
||||
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
||||
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
|
||||
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
|
||||
64C2576427F45A42007F8541 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
64C2576527F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
64C2576627F45A42007F8541 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
64C2576727F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a"; sourceTree = "<group>"; };
|
||||
64C2576827F45A42007F8541 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -431,6 +444,7 @@
|
||||
5CE4407827ADB701007B033A /* EmojiItemView.swift */,
|
||||
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
|
||||
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
|
||||
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
|
||||
);
|
||||
path = ChatItem;
|
||||
sourceTree = "<group>";
|
||||
@@ -653,6 +667,7 @@
|
||||
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
|
||||
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */,
|
||||
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
|
||||
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
|
||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
|
||||
@@ -704,6 +719,7 @@
|
||||
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */,
|
||||
5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */,
|
||||
5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */,
|
||||
64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */,
|
||||
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */,
|
||||
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||
64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user