iOS new: Stores.swift
This commit is contained in:
parent
e025f23bcf
commit
e211cc0cc7
223
Montana-iOS/Montana Messenger/Montana Messenger/Stores.swift
Normal file
223
Montana-iOS/Montana Messenger/Montana Messenger/Stores.swift
Normal file
@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct Contact: Identifiable, Codable, Hashable {
|
||||
let accountID: String
|
||||
var name: String
|
||||
var xPubB64: String
|
||||
var addedAt: Date
|
||||
var id: String { accountID }
|
||||
}
|
||||
|
||||
struct LocalMessage: Identifiable, Codable, Hashable {
|
||||
let id: Int
|
||||
let peer: String
|
||||
let outgoing: Bool
|
||||
let text: String
|
||||
let sentAt: Int
|
||||
var read: Bool
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ContactsStore: ObservableObject {
|
||||
static let shared = ContactsStore()
|
||||
|
||||
@Published private(set) var contacts: [Contact] = []
|
||||
|
||||
private let key = "montana.contacts.v1"
|
||||
|
||||
init() { load() }
|
||||
|
||||
func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let arr = try? JSONDecoder().decode([Contact].self, from: data) else {
|
||||
contacts = []
|
||||
return
|
||||
}
|
||||
contacts = arr.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(contacts) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func contact(byID id: String) -> Contact? {
|
||||
contacts.first { $0.accountID == id }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addOrUpdate(_ c: Contact) -> Contact {
|
||||
if let idx = contacts.firstIndex(where: { $0.accountID == c.accountID }) {
|
||||
contacts[idx] = c
|
||||
} else {
|
||||
contacts.append(c)
|
||||
}
|
||||
contacts.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
persist()
|
||||
return c
|
||||
}
|
||||
|
||||
func delete(_ id: String) {
|
||||
contacts.removeAll { $0.accountID == id }
|
||||
persist()
|
||||
}
|
||||
|
||||
func addByID(_ accountID: String, fallbackName: String? = nil) async throws -> Contact {
|
||||
let trimmed = accountID.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 4, trimmed.count <= 64,
|
||||
trimmed.allSatisfy({ "0123456789abcdef".contains($0) })
|
||||
else {
|
||||
throw NSError(domain: "Contacts", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Невалидный Account ID"])
|
||||
}
|
||||
let acc = try await ApiClient.shared.getAccount(trimmed)
|
||||
let c = Contact(accountID: acc.account_id, name: acc.name,
|
||||
xPubB64: acc.x_pub, addedAt: Date())
|
||||
return addOrUpdate(c)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ChatsStore: ObservableObject {
|
||||
static let shared = ChatsStore()
|
||||
|
||||
@Published private(set) var messagesByPeer: [String: [LocalMessage]] = [:]
|
||||
@Published private(set) var lastMessageID: Int = 0
|
||||
@Published private(set) var unreadByPeer: [String: Int] = [:]
|
||||
@Published private(set) var isOnline = false
|
||||
|
||||
private let messagesKey = "montana.messages.v1"
|
||||
private let cursorKey = "montana.cursor.v1"
|
||||
private let unreadKey = "montana.unread.v1"
|
||||
|
||||
private var pollTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
if let d = UserDefaults.standard.data(forKey: messagesKey),
|
||||
let m = try? JSONDecoder().decode([String: [LocalMessage]].self, from: d) {
|
||||
messagesByPeer = m
|
||||
}
|
||||
lastMessageID = UserDefaults.standard.integer(forKey: cursorKey)
|
||||
if let d = UserDefaults.standard.data(forKey: unreadKey),
|
||||
let u = try? JSONDecoder().decode([String: Int].self, from: d) {
|
||||
unreadByPeer = u
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let d = try? JSONEncoder().encode(messagesByPeer) {
|
||||
UserDefaults.standard.set(d, forKey: messagesKey)
|
||||
}
|
||||
UserDefaults.standard.set(lastMessageID, forKey: cursorKey)
|
||||
if let d = try? JSONEncoder().encode(unreadByPeer) {
|
||||
UserDefaults.standard.set(d, forKey: unreadKey)
|
||||
}
|
||||
}
|
||||
|
||||
func messages(for peer: String) -> [LocalMessage] {
|
||||
messagesByPeer[peer] ?? []
|
||||
}
|
||||
|
||||
func chats() -> [(peer: Contact, last: LocalMessage?)] {
|
||||
let all = ContactsStore.shared.contacts
|
||||
let withMsgs = all.compactMap { c -> (Contact, LocalMessage?)? in
|
||||
let msgs = messagesByPeer[c.accountID] ?? []
|
||||
guard let last = msgs.last else { return nil }
|
||||
return (c, last)
|
||||
}
|
||||
let withoutMsgs = all.filter { (messagesByPeer[$0.accountID] ?? []).isEmpty }
|
||||
.map { ($0, LocalMessage?.none) }
|
||||
return (withMsgs.sorted { ($0.1?.sentAt ?? 0) > ($1.1?.sentAt ?? 0) } + withoutMsgs)
|
||||
}
|
||||
|
||||
func markRead(_ peer: String) {
|
||||
unreadByPeer[peer] = 0
|
||||
if let arr = messagesByPeer[peer] {
|
||||
messagesByPeer[peer] = arr.map { var m = $0; m.read = true; return m }
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
func absorb(envelope env: ApiEnvelope, identity: Identity) async {
|
||||
let me = identity.accountID
|
||||
let peer = (env.from == me) ? env.to : env.from
|
||||
let outgoing = env.from == me
|
||||
|
||||
var contact = ContactsStore.shared.contact(byID: peer)
|
||||
if contact == nil {
|
||||
if let acc = try? await ApiClient.shared.getAccount(peer) {
|
||||
contact = ContactsStore.shared.addOrUpdate(
|
||||
Contact(accountID: acc.account_id, name: acc.name,
|
||||
xPubB64: acc.x_pub, addedAt: Date())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
guard let xPub = contact?.xPubB64,
|
||||
let plaintext = MessageCrypto.decrypt(
|
||||
nonceB64: env.nonce, ciphertextB64: env.ciphertext,
|
||||
peerXPubB64: xPub, myAgreementKey: identity.agreementKey)
|
||||
else { return }
|
||||
|
||||
var arr = messagesByPeer[peer] ?? []
|
||||
if arr.contains(where: { $0.id == env.id }) { return }
|
||||
arr.append(LocalMessage(id: env.id, peer: peer, outgoing: outgoing,
|
||||
text: plaintext, sentAt: env.sent_at, read: outgoing))
|
||||
messagesByPeer[peer] = arr.sorted { $0.id < $1.id }
|
||||
|
||||
if !outgoing {
|
||||
unreadByPeer[peer] = (unreadByPeer[peer] ?? 0) + 1
|
||||
}
|
||||
if env.id > lastMessageID { lastMessageID = env.id }
|
||||
persist()
|
||||
}
|
||||
|
||||
func send(text: String, to peer: Contact, identity: Identity) async throws {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let (nonce, ct) = try MessageCrypto.encrypt(
|
||||
trimmed, recipientXPubB64: peer.xPubB64,
|
||||
mySigningKey: identity.signingKey, myAgreementKey: identity.agreementKey)
|
||||
let env = try await ApiClient.shared.send(
|
||||
to: peer.accountID, nonce: nonce, ciphertext: ct, identity: identity)
|
||||
await absorb(envelope: env, identity: identity)
|
||||
}
|
||||
|
||||
func startPolling(identity: Identity) {
|
||||
pollTask?.cancel()
|
||||
let id = identity
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
do {
|
||||
let envs = try await ApiClient.shared.inbox(since: self.lastMessageID, identity: id)
|
||||
for env in envs {
|
||||
await self.absorb(envelope: env, identity: id)
|
||||
}
|
||||
await MainActor.run { self.isOnline = true }
|
||||
} catch {
|
||||
await MainActor.run { self.isOnline = false }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
isOnline = false
|
||||
}
|
||||
|
||||
func attachWebSocket(identity: Identity) {
|
||||
let id = identity
|
||||
WebSocketLink.shared.onEnvelope = { [weak self] env in
|
||||
guard let self else { return }
|
||||
Task { await self.absorb(envelope: env, identity: id) }
|
||||
}
|
||||
WebSocketLink.shared.connect(identity: id)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user