Files
andros cc2c723509 Add message actions menu and reorder welcome screen
Welcome: navigation section now comes first, followed by connection
info and statistics, so the main actions are visible without scrolling.

Chat bubbles: long-press opens a context menu with three actions:
  - React: picks one of 6 common emojis and sends it as a message
    with replyId set to the target's requestId.
  - Reply: shows a reply bar above the input and attaches replyId to
    the next outgoing message. Replies render a small prefix with the
    quoted sender name in the bubble.
  - Message sender: pushes a DM chat with the author (only for channel
    chats, and never for your own messages).

Also adds a toast overlay used to surface reaction confirmations and
lightweight warnings when a message lacks a requestId.
2026-04-15 08:14:43 +02:00

111 lines
3.1 KiB
Swift

import Foundation
enum DeliveryState {
case pending, confirmed, failed, none
var icon: String {
switch self {
case .pending: return "circle.dotted"
case .confirmed: return "checkmark"
case .failed: return "xmark"
case .none: return ""
}
}
}
struct Message: Identifiable, Hashable, Decodable {
let id: String
let text: String?
let channel: Int?
let fromNodeId: String?
let fromNodeNum: Int?
let toNodeId: String?
let timestamp: Date
let requestId: Int?
let replyId: Int?
let deliveryState: String?
let ackFailed: Int?
let portnum: Int?
let emoji: Int?
enum CodingKeys: String, CodingKey {
case id, text, channel, fromNodeId, fromNodeNum, toNodeId
case timestamp, requestId, replyId, deliveryState, ackFailed
case portnum, emoji
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
// id may be a string or an integer; coerce to string.
if let s = try? c.decode(String.self, forKey: .id) {
id = s
} else if let n = try? c.decode(Int.self, forKey: .id) {
id = String(n)
} else {
id = UUID().uuidString
}
text = try c.decodeIfPresent(String.self, forKey: .text)
channel = try c.decodeIfPresent(Int.self, forKey: .channel)
fromNodeId = try c.decodeIfPresent(String.self, forKey: .fromNodeId)
fromNodeNum = try c.decodeIfPresent(Int.self, forKey: .fromNodeNum)
toNodeId = try c.decodeIfPresent(String.self, forKey: .toNodeId)
requestId = try c.decodeIfPresent(Int.self, forKey: .requestId)
replyId = try c.decodeIfPresent(Int.self, forKey: .replyId)
deliveryState = try c.decodeIfPresent(String.self, forKey: .deliveryState)
ackFailed = try c.decodeIfPresent(Int.self, forKey: .ackFailed)
portnum = try c.decodeIfPresent(Int.self, forKey: .portnum)
emoji = try c.decodeIfPresent(Int.self, forKey: .emoji)
if let n = try? c.decode(Double.self, forKey: .timestamp) {
let secs = n > 9_999_999_999 ? n / 1000.0 : n
timestamp = Date(timeIntervalSince1970: secs)
} else if let s = try? c.decode(String.self, forKey: .timestamp),
let parsed = Self.iso.date(from: s) {
timestamp = parsed
} else {
timestamp = Date()
}
}
private static let iso: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
var isText: Bool { (portnum ?? 1) == 1 }
var isReaction: Bool { emoji == 1 }
/// Integer request ID, used for replies and reactions.
/// Falls back to parsing the trailing number out of ids like "123_456".
var requestIdValue: Int? {
if let r = requestId { return r }
if let last = id.split(separator: "_").last, let n = Int(last) { return n }
return nil
}
var delivery: DeliveryState {
if ackFailed == 1 { return .failed }
switch deliveryState {
case "confirmed", "delivered": return .confirmed
case "failed", "error": return .failed
case "pending": return .pending
default: return .none
}
}
}
struct MessageListResponse: Decodable {
let data: [Message]
}
struct SendMessageData: Decodable {
let requestId: Int?
let messageId: String?
let messageCount: Int?
}
struct SendMessageResponse: Decodable {
let data: SendMessageData?
}