~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
237 lines · 7.1 KB Raw History
//
//  Models.swift
//  AT-IRC
//

import Foundation

/// A message as returned by chat.atirc.post / chat.atirc.list / chat.atirc.subscribe.
struct AtircMessage: Identifiable, Codable, Hashable {
    var seq: Int64?
    var channel: String?
    var did: String?
    var handle: String?
    var nick: String?
    var text: String
    var ts: String?
    var source: String?
    var identity: String?

    var id: String {
        if let seq { return "seq-\(seq)" }
        return "\(did ?? "")-\(ts ?? "")-\(text.hashValue)"
    }

    var displayNick: String {
        nick ?? identity ?? handle ?? "unknown"
    }

    /// Best-effort parse of the server timestamp.
    var date: Date? {
        guard let ts else { return nil }
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let date = formatter.date(from: ts) { return date }
        formatter.formatOptions = [.withInternetDateTime]
        return formatter.date(from: ts)
    }

    var isMine: Bool { source == "xrpc" }
}

struct AtircListResponse: Codable {
    var messages: [AtircMessage]
    var cursor: Int64
}

/// A member currently connected to a channel, from `#presence` frames.
///
/// Presence identities are `nick@host` strings. The whole string is unique
/// (and is the row id); only the nick half gets a nickname color.
struct AtircMember: Identifiable, Hashable {
    let id: String
    var nick: String
    /// Secondary identity shown beside the nick: the host half of `nick@host`.
    var identity: String?

    /// Split a presence identity, accepting a bare nick defensively.
    static func from(presenceIdentity raw: String) -> AtircMember? {
        let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !value.isEmpty else { return nil }
        guard let at = value.firstIndex(of: "@") else {
            return AtircMember(id: value, nick: value, identity: nil)
        }
        let nick = String(value[..<at])
        let host = String(value[value.index(after: at)...])
        return AtircMember(
            id: value,
            nick: nick.isEmpty ? value : nick,
            identity: host.isEmpty ? nil : host
        )
    }
}

/// A join/leave presence event rendered inline in the transcript.
struct ChatNotice: Identifiable, Hashable {
    enum Kind: Hashable {
        case join
        case leave
    }

    let id = UUID()
    let channel: String
    let nick: String
    let identity: String?
    let kind: Kind
    let date: Date

    init(channel: String, nick: String, identity: String?, kind: Kind, date: Date = Date()) {
        self.channel = channel
        self.nick = nick
        self.identity = identity
        self.kind = kind
        self.date = date
    }
}

/// One row of the chat transcript: a message or an inline presence notice.
enum ChatRow: Identifiable {
    case message(AtircMessage)
    case notice(ChatNotice)

    var id: String {
        switch self {
        case .message(let message):
            return message.id
        case .notice(let notice):
            return "notice-\(notice.id.uuidString)"
        }
    }

    var date: Date? {
        switch self {
        case .message(let message):
            return message.date
        case .notice(let notice):
            return notice.date
        }
    }

    var seq: Int64? {
        guard case .message(let message) = self else { return nil }
        return message.seq
    }
}

/// One owner question from chat.atirc.ask / chat.atirc.questions / chat.atirc.answer.
///
/// `status` is `pending`, `answered` (owner answered), or `decided` (an agent
/// decided autonomously while the owner was away; `decision`/`adr` then carry
/// the record the owner can review).
struct AskQuestion: Identifiable, Codable, Hashable {
    var id: String
    var account: String? = nil
    /// Display-only handle; `account` is the account DID.
    var accountHandle: String? = nil
    var askedBy: String? = nil
    var askedHandle: String? = nil
    var question: String
    var options: [String]? = nil
    var context: String? = nil
    var channel: String? = nil
    /// Unix seconds.
    var createdAt: Int64? = nil
    var status: String
    var answer: String? = nil
    /// 0-based index into `options` when the owner picked one.
    var choice: Int? = nil
    var answeredBy: String? = nil
    var answeredAt: Int64? = nil
    var decision: String? = nil
    var decidedBy: String? = nil
    var adr: String? = nil

    var isPending: Bool { status == "pending" }
    var asker: String { askedHandle ?? askedBy ?? "agent" }

    var date: Date? {
        guard let createdAt else { return nil }
        return Date(timeIntervalSince1970: TimeInterval(createdAt))
    }
}

struct AskQuestionsResponse: Codable {
    var mode: String
    var questions: [AskQuestion]
}

struct OwnerModeResponse: Codable {
    var mode: String
}

/// Response from chat.atirc.mint plus the atirc endpoint it was minted for.
struct MintResult: Codable, Hashable {
    var token: String
    var endpoint: String?
    var did: String
    var handle: String
    var subhandle: String
    var nick: String
    var scope: [String]

    enum CodingKeys: String, CodingKey {
        case token, endpoint, did, handle, subhandle, nick, scope
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        token = try container.decode(String.self, forKey: .token)
        endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint)
        did = try container.decode(String.self, forKey: .did)
        handle = try container.decode(String.self, forKey: .handle)
        subhandle = try container.decode(String.self, forKey: .subhandle)
        nick = try container.decode(String.self, forKey: .nick)
        scope = try container.decodeIfPresent([String].self, forKey: .scope) ?? []
    }
}

/// OAuth session for the user's PDS (DPoP-bound public client).
struct OAuthSession: Codable {
    var accessToken: String
    var refreshToken: String?
    var expiresAt: Date
    var tokenType: String
    var did: String
    var handle: String?
    var pdsURL: String
    var authServer: String
    var dpopKey: String          // base64 of the P-256 private key raw representation
    var scope: String?

    var isExpired: Bool {
        expiresAt.timeIntervalSinceNow < 30
    }

    var dpopKeyValue: DPoPKey? {
        guard let data = Data(base64Encoded: dpopKey) else { return nil }
        return try? DPoPKey(rawRepresentation: data)
    }
}

/// Persisted app account: OAuth session + minted atirc token.
struct AtircAccount: Codable {
    var oauth: OAuthSession
    var mint: MintResult
    var atircBaseURL: String = AtircService.defaultBaseURL
    var channel: String
}

enum AtircService {
    static let baseURL = URL(string: "https://irc.fedfork.com")!
    static let defaultBaseURL = "https://irc.fedfork.com"
    static let did = "did:web:irc.fedfork.com"
    /// DID reference with service fragment, used as the service-auth audience
    /// and in the granular OAuth permission scope.
    static let serviceRef = "did:web:irc.fedfork.com#atirc"
    static let xrpcPrefix = "/xrpc/"
}