mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-23 16:03:19 +00:00
403 lines
14 KiB
Swift
403 lines
14 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
/// Cache-invalidation domains. Views watch `realtime.pulse(for:)` and reload
|
|
/// when the counter bumps — the iOS analog of the web's AUDIT_CREATED spine.
|
|
enum RealtimeDomain: String, CaseIterable, Sendable {
|
|
case unibox, campaigns, contacts, emailAccounts, analytics, team
|
|
case templates, crm, meetings, automations, integrations, apiKeys
|
|
case settings, notifications, audit, billing, me, ai
|
|
}
|
|
|
|
// MARK: - Presence
|
|
|
|
struct PresenceMeta: Identifiable, Sendable {
|
|
var id: String { phxRef ?? UUID().uuidString }
|
|
var phxRef: String?
|
|
var name: String?
|
|
var avatar: String?
|
|
var page: String?
|
|
var resource: String?
|
|
var action: String?
|
|
var onlineAt: Double?
|
|
var updatedAt: Double?
|
|
|
|
static func parse(_ dict: [String: Any]) -> PresenceMeta {
|
|
PresenceMeta(
|
|
phxRef: dict["phx_ref"] as? String,
|
|
name: dict["name"] as? String,
|
|
avatar: dict["avatar"] as? String,
|
|
page: dict["page"] as? String,
|
|
resource: dict["resource"] as? String,
|
|
action: dict["action"] as? String,
|
|
onlineAt: dict["online_at"] as? Double,
|
|
updatedAt: dict["updated_at"] as? Double
|
|
)
|
|
}
|
|
}
|
|
|
|
struct PresenceMember: Identifiable, Sendable {
|
|
var id: String
|
|
var metas: [PresenceMeta]
|
|
|
|
/// The most informative meta (an active editing/replying one wins).
|
|
var primary: PresenceMeta? {
|
|
metas.first { $0.action == "editing" || $0.action == "replying" } ?? metas.first
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class PresenceStore {
|
|
private(set) var members: [String: [PresenceMeta]] = [:]
|
|
|
|
var online: [PresenceMember] {
|
|
members
|
|
.filter { !$0.value.isEmpty }
|
|
.map { PresenceMember(id: $0.key, metas: $0.value) }
|
|
.sorted { ($0.primary?.name ?? $0.id) < ($1.primary?.name ?? $1.id) }
|
|
}
|
|
|
|
var onlineCount: Int { members.values.filter { !$0.isEmpty }.count }
|
|
|
|
/// Members currently on a given resource (excluding a user id, usually self).
|
|
func viewers(of resource: String, excluding userID: String?) -> [PresenceMember] {
|
|
online.compactMap { member in
|
|
guard member.id != userID else { return nil }
|
|
let matching = member.metas.filter { $0.resource == resource }
|
|
guard !matching.isEmpty else { return nil }
|
|
return PresenceMember(id: member.id, metas: matching)
|
|
}
|
|
}
|
|
|
|
func applyState(_ payload: [String: Any]) {
|
|
var next: [String: [PresenceMeta]] = [:]
|
|
for (userID, entry) in payload {
|
|
guard let entry = entry as? [String: Any],
|
|
let metas = entry["metas"] as? [[String: Any]]
|
|
else { continue }
|
|
next[userID] = metas.map(PresenceMeta.parse)
|
|
}
|
|
members = next
|
|
}
|
|
|
|
func applyDiff(_ payload: [String: Any]) {
|
|
var next = members
|
|
if let leaves = payload["leaves"] as? [String: Any] {
|
|
for (userID, entry) in leaves {
|
|
guard let entry = entry as? [String: Any],
|
|
let metas = entry["metas"] as? [[String: Any]]
|
|
else { continue }
|
|
let leftRefs = Set(metas.compactMap { $0["phx_ref"] as? String })
|
|
next[userID] = (next[userID] ?? []).filter { meta in
|
|
guard let ref = meta.phxRef else { return false }
|
|
return !leftRefs.contains(ref)
|
|
}
|
|
if next[userID]?.isEmpty == true { next.removeValue(forKey: userID) }
|
|
}
|
|
}
|
|
if let joins = payload["joins"] as? [String: Any] {
|
|
for (userID, entry) in joins {
|
|
guard let entry = entry as? [String: Any],
|
|
let metas = entry["metas"] as? [[String: Any]]
|
|
else { continue }
|
|
let incoming = metas.map(PresenceMeta.parse)
|
|
let incomingRefs = Set(incoming.compactMap(\.phxRef))
|
|
var kept = (next[userID] ?? []).filter { meta in
|
|
guard let ref = meta.phxRef else { return false }
|
|
return !incomingRefs.contains(ref)
|
|
}
|
|
kept.append(contentsOf: incoming)
|
|
next[userID] = kept
|
|
}
|
|
}
|
|
members = next
|
|
}
|
|
|
|
func reset() { members = [:] }
|
|
}
|
|
|
|
/// Thread-safe holder for the resume sequence (read from socket threads).
|
|
private final class SeqBox: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var seq: Int?
|
|
|
|
var value: Int? {
|
|
get { lock.lock(); defer { lock.unlock() }; return seq }
|
|
set { lock.lock(); seq = newValue; lock.unlock() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Realtime service
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class RealtimeService {
|
|
enum ConnectionState { case disconnected, connecting, connected }
|
|
|
|
let presence = PresenceStore()
|
|
private(set) var connectionState: ConnectionState = .disconnected
|
|
private(set) var pulses: [RealtimeDomain: Int] = [:]
|
|
|
|
private let api: APIClient
|
|
private let socket = PhoenixSocket()
|
|
private let log = Logger(subsystem: "com.warmbly.app", category: "realtime")
|
|
|
|
private var userID: String?
|
|
private var orgID: String?
|
|
private let seqBox = SeqBox()
|
|
private var lastSeq: Int? {
|
|
get { seqBox.value }
|
|
set { seqBox.value = newValue }
|
|
}
|
|
|
|
private var hadSession = false
|
|
private var currentPresence: (page: String?, resource: String?, action: String?) = (nil, nil, nil)
|
|
private var lastPresencePush = Date.distantPast
|
|
|
|
init(api: APIClient) {
|
|
self.api = api
|
|
|
|
socket.urlProvider = { [api] in
|
|
guard let bootstrap: GetSocket = try? await api.post("getaway"),
|
|
var components = URLComponents(string: bootstrap.url)
|
|
else { return nil }
|
|
var items = components.queryItems ?? []
|
|
if !items.contains(where: { $0.name == "vsn" }) {
|
|
items.append(URLQueryItem(name: "vsn", value: "1.0.0"))
|
|
}
|
|
components.queryItems = items
|
|
// Advanced override: swap only the origin, keeping the signed
|
|
// path/token the backend minted.
|
|
let override = AppConfig.realtimeOriginOverride.trimmingCharacters(in: .whitespaces)
|
|
if !override.isEmpty, let originURL = URL(string: override), let host = originURL.host {
|
|
components.host = host
|
|
components.port = originURL.port
|
|
if let scheme = originURL.scheme {
|
|
components.scheme = scheme == "https" ? "wss" : scheme == "http" ? "ws" : scheme
|
|
}
|
|
}
|
|
return components.url
|
|
}
|
|
socket.joinPayloadProvider = { [seqBox] topic in
|
|
// Runs on socket threads; only touch the lock-protected seq box.
|
|
if topic.hasPrefix("org:"), let seq = seqBox.value {
|
|
return ["resume": ["last_seq": seq]]
|
|
}
|
|
return [:]
|
|
}
|
|
socket.onState = { [weak self] state in
|
|
Task { @MainActor in self?.handleSocketState(state) }
|
|
}
|
|
socket.onEvent = { [weak self] message in
|
|
let event = message.event
|
|
let payload = message.payload
|
|
Task { @MainActor in self?.handleEvent(event, payload) }
|
|
}
|
|
socket.onJoin = { [weak self] topic, ok, response in
|
|
Task { @MainActor in self?.handleJoin(topic: topic, ok: ok, response: response) }
|
|
}
|
|
}
|
|
|
|
// MARK: Lifecycle
|
|
|
|
func connect(userID: String, orgID: String) {
|
|
self.userID = userID
|
|
if let previous = self.orgID, previous != orgID {
|
|
socket.leave("org:\(previous)")
|
|
presence.reset()
|
|
lastSeq = nil
|
|
}
|
|
self.orgID = orgID
|
|
socket.join("user:\(userID)")
|
|
socket.join("org:\(orgID)")
|
|
socket.connect()
|
|
}
|
|
|
|
/// Foreground kick: recheck the socket immediately instead of letting the
|
|
/// heartbeat watchdog discover a suspension-killed connection ~30s late.
|
|
func nudge() {
|
|
guard userID != nil else { return }
|
|
socket.nudge()
|
|
}
|
|
|
|
func disconnect() {
|
|
socket.disconnect()
|
|
presence.reset()
|
|
userID = nil
|
|
orgID = nil
|
|
lastSeq = nil
|
|
hadSession = false
|
|
connectionState = .disconnected
|
|
}
|
|
|
|
private func handleSocketState(_ state: PhoenixSocket.State) {
|
|
switch state {
|
|
case .disconnected: connectionState = .disconnected
|
|
case .connecting: connectionState = .connecting
|
|
case .connected:
|
|
connectionState = .connected
|
|
if hadSession {
|
|
// Events missed while down may be gone; refresh everything.
|
|
bumpAll()
|
|
}
|
|
hadSession = true
|
|
}
|
|
}
|
|
|
|
private func handleJoin(topic: String, ok: Bool, response: [String: Any]) {
|
|
guard topic.hasPrefix("org:") else { return }
|
|
if ok {
|
|
if lastSeq == nil, let seq = response["seq"] as? Int { lastSeq = seq }
|
|
pushCurrentPresence(force: true)
|
|
} else {
|
|
log.warning("org join failed: \(String(describing: response))")
|
|
}
|
|
}
|
|
|
|
// MARK: Invalidation
|
|
|
|
func pulse(for domain: RealtimeDomain) -> Int {
|
|
pulses[domain] ?? 0
|
|
}
|
|
|
|
private func bump(_ domains: [RealtimeDomain]) {
|
|
for domain in domains {
|
|
pulses[domain] = (pulses[domain] ?? 0) + 1
|
|
}
|
|
}
|
|
|
|
private func bumpAll() {
|
|
bump(RealtimeDomain.allCases)
|
|
}
|
|
|
|
private func handleEvent(_ event: String, _ payload: [String: Any]) {
|
|
if let seq = payload["seq"] as? Int {
|
|
if let last = lastSeq, seq <= last { return }
|
|
lastSeq = seq
|
|
}
|
|
|
|
switch event {
|
|
case "presence_state":
|
|
presence.applyState(payload)
|
|
pushCurrentPresence(force: true)
|
|
return
|
|
case "presence_diff":
|
|
presence.applyDiff(payload)
|
|
return
|
|
case "rate_limited":
|
|
return
|
|
case "resumed":
|
|
return
|
|
case "resume_failed":
|
|
if let seq = payload["current_seq"] as? Int { lastSeq = seq }
|
|
bumpAll()
|
|
return
|
|
default:
|
|
break
|
|
}
|
|
|
|
let name = normalized(event)
|
|
if name.hasPrefix("PRESENCE") || name.hasPrefix("LIVE_") || name == "RATE_LIMITED" { return }
|
|
|
|
if name == "AUDIT_CREATED" {
|
|
bump([.audit])
|
|
if let entityType = payload["entity_type"] as? String {
|
|
bump(Self.spine[entityType] ?? [])
|
|
}
|
|
return
|
|
}
|
|
bump(Self.domains(forEventName: name))
|
|
}
|
|
|
|
private func normalized(_ raw: String) -> String {
|
|
raw.uppercased()
|
|
.map { ".:- \t".contains($0) ? "_" : $0 }
|
|
.reduce(into: "") { partial, ch in
|
|
if ch == "_", partial.hasSuffix("_") { return }
|
|
partial.append(ch)
|
|
}
|
|
}
|
|
|
|
/// entity_type -> domains, mirroring the web's spine map.
|
|
private static let spine: [String: [RealtimeDomain]] = [
|
|
"contact": [.contacts],
|
|
"campaign": [.campaigns, .analytics],
|
|
"step": [.campaigns],
|
|
"email_account": [.emailAccounts, .analytics],
|
|
"api_key": [.apiKeys],
|
|
"webhook": [.settings, .integrations],
|
|
"template": [.templates],
|
|
"organization": [.team, .settings],
|
|
"organization_member": [.team],
|
|
"invitation": [.team],
|
|
"team": [.team],
|
|
"role": [.team],
|
|
"automation": [.automations],
|
|
"integration": [.integrations],
|
|
"lead_sync_source": [.integrations, .contacts],
|
|
"meeting": [.meetings, .crm],
|
|
"subscription": [.billing],
|
|
"referral": [.billing],
|
|
"referral_credit": [.billing],
|
|
"settings": [.settings],
|
|
"unibox": [.unibox],
|
|
"crm_note": [.crm, .contacts],
|
|
"crm_pipeline": [.crm],
|
|
"crm_stage": [.crm],
|
|
"crm_deal": [.crm],
|
|
"crm_task": [.crm],
|
|
"warmup_routing_rule": [.emailAccounts, .analytics],
|
|
"folder": [.me, .campaigns],
|
|
"tag": [.me, .emailAccounts, .unibox],
|
|
"category": [.me, .contacts],
|
|
"ai_session": [.ai],
|
|
]
|
|
|
|
private static func domains(forEventName name: String) -> [RealtimeDomain] {
|
|
if name.contains("NOTIFICATION") { return [.notifications] }
|
|
if name.contains("INBOX") || name == "EMAIL_RECEIVED" {
|
|
return [.unibox, .analytics, .emailAccounts]
|
|
}
|
|
if name == "EMAIL_UPDATED" || name == "EMAIL_DELETED" { return [.unibox, .analytics] }
|
|
if name == "EMAIL_REPLIED" { return [.campaigns, .analytics, .unibox] }
|
|
if name == "EMAIL_SENT" || name == "EMAIL_OPENED" || name == "EMAIL_CLICKED"
|
|
|| name == "EMAIL_FAILED" || name == "EMAIL_BOUNCED" || name.contains("TASK_PROGRESS") {
|
|
return [.campaigns, .analytics]
|
|
}
|
|
if name == "EMAIL_STATUS" || name == "EMAIL_ERROR" { return [.emailAccounts, .analytics] }
|
|
if name.contains("CAMPAIGN") { return [.campaigns, .analytics] }
|
|
if name.contains("CONTACT") { return [.contacts, .campaigns, .analytics] }
|
|
if name.contains("ACCOUNT") || name.contains("WARMUP") { return [.emailAccounts, .analytics] }
|
|
if name.contains("BULK") { return [.contacts] }
|
|
if name.contains("MEETING") || name.contains("BOOKING") { return [.meetings, .crm] }
|
|
if name.contains("DEAL") { return [.crm, .contacts] }
|
|
if name.contains("SUBSCRIPTION") || name.contains("PLAN") || name.contains("BILLING") || name.contains("LIMIT") {
|
|
return [.billing, .me]
|
|
}
|
|
if name.contains("AUTOMATION") { return [.automations] }
|
|
if name.contains("TASK") { return [.campaigns] }
|
|
return []
|
|
}
|
|
|
|
// MARK: Presence pushes
|
|
|
|
/// Report what the user is looking at; throttled to respect the 60/min
|
|
/// ws_event budget.
|
|
func setPresence(page: String?, resource: String?, action: String?) {
|
|
currentPresence = (page, resource, action)
|
|
pushCurrentPresence(force: false)
|
|
}
|
|
|
|
private func pushCurrentPresence(force: Bool) {
|
|
guard connectionState == .connected, let orgID else { return }
|
|
if !force, Date().timeIntervalSince(lastPresencePush) < 1.5 { return }
|
|
lastPresencePush = Date()
|
|
var payload: [String: Any] = [:]
|
|
payload["page"] = currentPresence.page ?? NSNull()
|
|
payload["resource"] = currentPresence.resource ?? NSNull()
|
|
payload["action"] = currentPresence.action ?? NSNull()
|
|
socket.push(topic: "org:\(orgID)", event: "presence:update", payload: payload)
|
|
}
|
|
}
|