iOS: show message sent/unread status (#293)
* light github image for dark mode * show message received status, remove chevrons in chat list * show unread message status * add message send error mark * refactor alerts to use AlertManager * show alert message on tapping undelivered message, simplify text-only alerts
This commit is contained in:
committed by
GitHub
parent
af5abae558
commit
9d9bb68d50
@@ -17,11 +17,11 @@ final class ChatModel: ObservableObject {
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var chatItems: [ChatItem] = []
|
||||
@Published var chatToTop: String?
|
||||
// items in the terminal view
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: String?
|
||||
@Published var appOpenUrl: URL?
|
||||
@Published var connectViaUrl = false
|
||||
static let shared = ChatModel()
|
||||
|
||||
func hasChat(_ id: String) -> Bool {
|
||||
@@ -43,8 +43,8 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||
if let ix = getChatIndex(cInfo.id) {
|
||||
chats[ix].chatInfo = cInfo
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatInfo = cInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func replaceChat(_ id: String, _ chat: Chat) {
|
||||
if let ix = chats.firstIndex(where: { $0.id == id }) {
|
||||
chats[ix] = chat
|
||||
if let i = getChatIndex(id) {
|
||||
chats[i] = chat
|
||||
} else {
|
||||
// invalid state, correcting
|
||||
chats.insert(chat, at: 0)
|
||||
@@ -73,23 +73,101 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) {
|
||||
chats[ix].chatItems = [cItem]
|
||||
if ix > 0 {
|
||||
// update previews
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatItems = [cItem]
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
||||
}
|
||||
if i > 0 {
|
||||
if chatId == nil {
|
||||
withAnimation { popChat(ix) }
|
||||
withAnimation { popChat_(i) }
|
||||
} else if chatId == cInfo.id {
|
||||
chatToTop = cInfo.id
|
||||
} else {
|
||||
DispatchQueue.main.async { self.popChat(ix) }
|
||||
popChat_(i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
}
|
||||
// add to current chat
|
||||
if chatId == cInfo.id {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if self.chatId == cInfo.id {
|
||||
SimpleX.markChatItemRead(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func popChat(_ ix: Int) {
|
||||
let chat = chats.remove(at: ix)
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
// update preview
|
||||
if let chat = getChat(cInfo.id) {
|
||||
chat.chatStats = ChatStats()
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
var i = 0
|
||||
while i < chatItems.count {
|
||||
if case .rcvNew = chatItems[i].meta.itemStatus {
|
||||
chatItems[i].meta.itemStatus = .rcvRead
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update preview
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
chatItems[j].meta.itemStatus = .rcvRead
|
||||
}
|
||||
}
|
||||
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
popChat_(i)
|
||||
}
|
||||
}
|
||||
|
||||
private func popChat_(_ i: Int) {
|
||||
let chat = chats.remove(at: i)
|
||||
chats.insert(chat, at: 0)
|
||||
}
|
||||
|
||||
@@ -107,14 +185,6 @@ struct User: Decodable, NamedChat {
|
||||
var profile: Profile
|
||||
var activeUser: Bool
|
||||
|
||||
// internal init(userId: Int64, userContactId: Int64, localDisplayName: ContactName, profile: Profile, activeUser: Bool) {
|
||||
// self.userId = userId
|
||||
// self.userContactId = userContactId
|
||||
// self.localDisplayName = localDisplayName
|
||||
// self.profile = profile
|
||||
// self.activeUser = activeUser
|
||||
// }
|
||||
|
||||
var displayName: String { get { profile.displayName } }
|
||||
|
||||
var fullName: String { get { profile.fullName } }
|
||||
@@ -226,6 +296,16 @@ enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
}
|
||||
}
|
||||
|
||||
var ready: Bool {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.ready
|
||||
case let .group(groupInfo): return groupInfo.ready
|
||||
case let .contactRequest(contactRequest): return contactRequest.ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var createdAt: Date {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.createdAt
|
||||
@@ -250,6 +330,7 @@ enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
final class Chat: ObservableObject, Identifiable {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
@Published var chatStats: ChatStats
|
||||
@Published var serverInfo = ServerInfo(networkStatus: .unknown)
|
||||
|
||||
struct ServerInfo: Decodable {
|
||||
@@ -297,11 +378,13 @@ final class Chat: ObservableObject, Identifiable {
|
||||
init(_ cData: ChatData) {
|
||||
self.chatInfo = cData.chatInfo
|
||||
self.chatItems = cData.chatItems
|
||||
self.chatStats = cData.chatStats
|
||||
}
|
||||
|
||||
init(chatInfo: ChatInfo, chatItems: [ChatItem] = []) {
|
||||
init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) {
|
||||
self.chatInfo = chatInfo
|
||||
self.chatItems = chatItems
|
||||
self.chatStats = chatStats
|
||||
}
|
||||
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
@@ -310,10 +393,16 @@ final class Chat: ObservableObject, Identifiable {
|
||||
struct ChatData: Decodable, Identifiable {
|
||||
var chatInfo: ChatInfo
|
||||
var chatItems: [ChatItem]
|
||||
var chatStats: ChatStats
|
||||
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
}
|
||||
|
||||
struct ChatStats: Decodable {
|
||||
var unreadCount: Int = 0
|
||||
var minUnreadItemId: Int64 = 0
|
||||
}
|
||||
|
||||
struct Contact: Identifiable, Decodable, NamedChat {
|
||||
var contactId: Int64
|
||||
var localDisplayName: ContactName
|
||||
@@ -351,6 +440,7 @@ struct UserContactRequest: Decodable, NamedChat {
|
||||
|
||||
var id: ChatId { get { "<@\(contactRequestId)" } }
|
||||
var apiId: Int64 { get { contactRequestId } }
|
||||
var ready: Bool { get { true } }
|
||||
var displayName: String { get { profile.displayName } }
|
||||
var fullName: String { get { profile.fullName } }
|
||||
|
||||
@@ -370,6 +460,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
|
||||
var id: ChatId { get { "#\(groupId)" } }
|
||||
var apiId: Int64 { get { groupId } }
|
||||
var ready: Bool { get { true } }
|
||||
var displayName: String { get { groupProfile.displayName } }
|
||||
var fullName: String { get { groupProfile.fullName } }
|
||||
|
||||
@@ -424,10 +515,17 @@ struct ChatItem: Identifiable, Decodable {
|
||||
|
||||
var id: Int64 { get { meta.itemId } }
|
||||
|
||||
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String) -> ChatItem {
|
||||
var timestampText: String { get { meta.timestampText } }
|
||||
|
||||
func isRcvNew() -> Bool {
|
||||
if case .rcvNew = meta.itemStatus { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: dir,
|
||||
meta: CIMeta.getSample(id, ts, text),
|
||||
meta: CIMeta.getSample(id, ts, text, status),
|
||||
content: .sndMsgContent(msgContent: .text(text))
|
||||
)
|
||||
}
|
||||
@@ -455,23 +553,32 @@ struct CIMeta: Decodable {
|
||||
var itemId: Int64
|
||||
var itemTs: Date
|
||||
var itemText: String
|
||||
var itemStatus: CIStatus
|
||||
var createdAt: Date
|
||||
|
||||
static func getSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta {
|
||||
var timestampText: String { get { SimpleX.timestampText(itemTs) } }
|
||||
|
||||
static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta {
|
||||
CIMeta(
|
||||
itemId: id,
|
||||
itemTs: ts,
|
||||
itemText: text,
|
||||
itemStatus: status,
|
||||
createdAt: ts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func timestampText(_ date: Date) -> String {
|
||||
date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
|
||||
enum CIStatus: Decodable {
|
||||
case sndNew
|
||||
case sndSent
|
||||
case sndErrorAuth
|
||||
case sndError(agentErrorType: AgentErrorType)
|
||||
case sndError(agentError: AgentErrorType)
|
||||
case rcvNew
|
||||
case rcvRead
|
||||
}
|
||||
@@ -479,18 +586,25 @@ enum CIStatus: Decodable {
|
||||
enum CIContent: Decodable {
|
||||
case sndMsgContent(msgContent: MsgContent)
|
||||
case rcvMsgContent(msgContent: MsgContent)
|
||||
// files etc.
|
||||
case sndFileInvitation(fileId: Int64, filePath: String)
|
||||
case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer)
|
||||
|
||||
var text: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .sndMsgContent(mc): return mc.text
|
||||
case let .rcvMsgContent(mc): return mc.text
|
||||
case .sndFileInvitation: return "sending files is not supported yet"
|
||||
case .rcvFileInvitation: return "receiving files is not supported yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RcvFileTransfer: Decodable {
|
||||
|
||||
}
|
||||
|
||||
enum MsgContent {
|
||||
case text(String)
|
||||
case unknown(type: String, text: String)
|
||||
|
||||
@@ -31,6 +31,7 @@ enum ChatCommand {
|
||||
case showMyAddress
|
||||
case apiAcceptContact(contactReqId: Int64)
|
||||
case apiRejectContact(contactReqId: Int64)
|
||||
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
|
||||
case string(String)
|
||||
|
||||
var cmdString: String {
|
||||
@@ -40,21 +41,50 @@ enum ChatCommand {
|
||||
case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)"
|
||||
case .startChat: return "/_start"
|
||||
case .apiGetChats: return "/_get chats"
|
||||
case let .apiGetChat(type, id): return "/_get chat \(type.rawValue)\(id) count=500"
|
||||
case let .apiSendMessage(type, id, mc): return "/_send \(type.rawValue)\(id) \(mc.cmdString)"
|
||||
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 .addContact: return "/connect"
|
||||
case let .connect(connReq): return "/connect \(connReq)"
|
||||
case let .apiDeleteChat(type, id): return "/_delete \(type.rawValue)\(id)"
|
||||
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
|
||||
case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)"
|
||||
case .createMyAddress: return "/address"
|
||||
case .deleteMyAddress: return "/delete_address"
|
||||
case .showMyAddress: return "/show_address"
|
||||
case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
|
||||
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
|
||||
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
|
||||
case let .string(str): return str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmdType: String {
|
||||
get {
|
||||
switch self {
|
||||
case .showActiveUser: return "showActiveUser"
|
||||
case .createActiveUser: return "createActiveUser"
|
||||
case .startChat: return "startChat"
|
||||
case .apiGetChats: return "apiGetChats"
|
||||
case .apiGetChat: return "apiGetChat"
|
||||
case .apiSendMessage: return "apiSendMessage"
|
||||
case .addContact: return "addContact"
|
||||
case .connect: return "connect"
|
||||
case .apiDeleteChat: return "apiDeleteChat"
|
||||
case .updateProfile: return "updateProfile"
|
||||
case .createMyAddress: return "createMyAddress"
|
||||
case .deleteMyAddress: return "deleteMyAddress"
|
||||
case .showMyAddress: return "showMyAddress"
|
||||
case .apiAcceptContact: return "apiAcceptContact"
|
||||
case .apiRejectContact: return "apiRejectContact"
|
||||
case .apiChatRead: return "apiChatRead"
|
||||
case .string: return "console command"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ref(_ type: ChatType, _ id: Int64) -> String {
|
||||
"\(type.rawValue)\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
struct APIResponse: Decodable {
|
||||
@@ -88,6 +118,8 @@ enum ChatResponse: Decodable, Error {
|
||||
case groupEmpty(groupInfo: GroupInfo)
|
||||
case userContactLinkSubscribed
|
||||
case newChatItem(chatItem: AChatItem)
|
||||
case chatItemUpdated(chatItem: AChatItem)
|
||||
case cmdOk
|
||||
case chatCmdError(chatError: ChatError)
|
||||
case chatError(chatError: ChatError)
|
||||
|
||||
@@ -120,6 +152,8 @@ enum ChatResponse: Decodable, Error {
|
||||
case .groupEmpty: return "groupEmpty"
|
||||
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
|
||||
case .newChatItem: return "newChatItem"
|
||||
case .chatItemUpdated: return "chatItemUpdated"
|
||||
case .cmdOk: return "cmdOk"
|
||||
case .chatCmdError: return "chatCmdError"
|
||||
case .chatError: return "chatError"
|
||||
}
|
||||
@@ -155,6 +189,8 @@ 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 .chatItemUpdated(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)
|
||||
}
|
||||
@@ -198,7 +234,9 @@ enum TerminalItem: Identifiable {
|
||||
|
||||
func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
|
||||
var c = cmd.cmdString.cString(using: .utf8)!
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
|
||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
|
||||
ChatModel.shared.terminalItems.append(.resp(.now, resp))
|
||||
@@ -315,6 +353,12 @@ func apiRejectContactRequest(contactReqId: Int64) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) throws {
|
||||
let r = try chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func acceptContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
|
||||
@@ -334,6 +378,27 @@ func rejectContactRequest(_ contactRequest: UserContactRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
func markChatRead(_ chat: Chat) {
|
||||
do {
|
||||
let minItemId = chat.chatStats.minUnreadItemId
|
||||
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
|
||||
let cInfo = chat.chatInfo
|
||||
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
ChatModel.shared.markChatItemsRead(cInfo)
|
||||
} catch {
|
||||
logger.error("markChatRead apiChatRead error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
do {
|
||||
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
} catch {
|
||||
logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func initializeChat() {
|
||||
do {
|
||||
ChatModel.shared.currentUser = try apiGetActiveUser()
|
||||
@@ -419,6 +484,12 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
let cItem = aChatItem.chatItem
|
||||
chatModel.addChatItem(cInfo, cItem)
|
||||
NtfManager.shared.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)
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user