on master help sign in
skrepka/core/src/model.rs
255 lines 8.4 kB history raw
1//! Domain types: persisted state (stored in the kv capability as JSON) and the
2//! `ViewModel` types the Swift shell renders.
4use std::sync::Arc;
6use facet::Facet;
7use serde::{Deserialize, Serialize};
9use crate::phonemic;
11// ---------------------------------------------------------------------------
12// Persisted state (serialized to the kv store as JSON)
13// ---------------------------------------------------------------------------
15pub const DEFAULT_SERVER_URL: &str = "http://localhost:8080";
17/// Cap on locally retained messages per conversation (PROTOCOL.md §9: clients
18/// SHOULD age out local message history). Both the in-memory `Vec` and the
19/// `messages:<peer>` kv blob are trimmed to this many most-recent messages.
20pub const MAX_MESSAGES_PER_PEER: usize = 1000;
22/// Every persisted struct is `#[serde(default)]`.
23///
24/// A kv blob that fails to deserialize is indistinguishable from an absent key
25/// (see `app::parse_kv`), and the next mutation overwrites it — so a field added
26/// in a later release would silently wipe the user's contacts, outbox, or
27/// history on first launch. Defaulting the missing fields keeps old blobs
28/// loadable across schema evolution instead.
29#[derive(Serialize, Deserialize, Clone, Debug)]
30#[serde(default)]
31pub struct Settings {
32 pub server_url: String,
35impl Default for Settings {
36 fn default() -> Self {
37 Settings {
38 server_url: DEFAULT_SERVER_URL.to_string(),
39 }
40 }
43#[derive(Serialize, Deserialize, Clone, Debug, Default)]
44#[serde(default)]
45pub struct OwnProfile {
46 pub display_name: String,
47 pub bio: String,
48 /// base64-encoded image, or `None`.
49 pub photo: Option<String>,
52#[derive(Serialize, Deserialize, Clone, Debug, Default)]
53#[serde(default)]
54pub struct Contact {
55 pub pubkey: String,
56 pub nickname: String,
57 pub display_name: String,
58 pub bio: String,
59 pub photo: Option<String>,
60 pub blocked: bool,
61 /// `ts` of the last applied profile message (drop older replays).
62 pub last_profile_ts: i64,
63 /// `ts` of the last applied delivery.ack (drop older replays).
64 pub last_ack_ts: i64,
65 pub added_at: i64,
68impl Contact {
69 pub fn new(pubkey: String, nickname: String, added_at: i64) -> Self {
70 Contact {
71 pubkey,
72 nickname,
73 display_name: String::new(),
74 bio: String::new(),
75 photo: None,
76 blocked: false,
77 last_profile_ts: 0,
78 last_ack_ts: 0,
79 added_at,
80 }
81 }
83 /// Best human label: nickname, else shared display name, else truncated @p.
84 pub fn label(&self) -> String {
85 if !self.nickname.is_empty() {
86 self.nickname.clone()
87 } else if !self.display_name.is_empty() {
88 self.display_name.clone()
89 } else {
90 trunc_ob(&self.pubkey)
91 }
92 }
95#[derive(Serialize, Deserialize, Clone, Debug, Default)]
96#[serde(default)]
97pub struct StoredMessage {
98 pub id: String,
99 pub body: String,
100 pub ts: i64,
101 pub outgoing: bool,
102 /// Only meaningful for outgoing messages.
103 pub delivered: bool,
106/// Transient send failures one outbox item may absorb before it is given up on.
107///
108/// Without a ceiling the head of the outbox is retried forever, and because the
109/// outbox is a strict FIFO an item that can never be delivered blocks every
110/// message queued behind it — permanently.
111pub const MAX_OUTBOX_RETRIES: u32 = 10;
113/// ...and a wall-clock bound on the same thing, for the case where the retries
114/// come in slowly enough that the counter alone would take days to run out. 24h.
115pub const OUTBOX_TTL_MS: i64 = 86_400_000;
117/// A queued outbound payload awaiting encryption + send.
118#[derive(Serialize, Deserialize, Clone, Debug, Default)]
119#[serde(default)]
120pub struct OutboxItem {
121 pub recipient: String,
122 /// The serialized plaintext payload JSON (encrypted only at send time).
123 pub envelope_json: Arc<String>,
124 /// `Some("profile")` for a profile broadcast, `None` for everything else.
125 ///
126 /// A profile is *state*, not an event: only the latest one matters, so
127 /// `SaveProfile` overwrites the pending item for a recipient rather than
128 /// queueing another. Without the marker there is nothing to overwrite by, and
129 /// five quick profile edits mean five payloads (photo and all) per contact.
130 pub kind: Option<String>,
131 /// Transient send failures this item has absorbed so far.
132 pub retries: u32,
133 /// `now_ms()` when this item was first put on the wire, or `0` if it never
134 /// has been. The TTL runs from here, so an item queued during a week offline
135 /// still gets its full retry budget once the network comes back.
136 pub first_attempt: i64,
139impl OutboxItem {
140 pub fn new(recipient: String, envelope_json: Arc<String>) -> Self {
141 OutboxItem {
142 recipient,
143 envelope_json,
144 kind: None,
145 retries: 0,
146 first_attempt: 0,
147 }
148 }
150 /// A profile broadcast — the one payload kind that supersedes rather than
151 /// accumulates (see `kind`).
152 pub fn profile(recipient: String, envelope_json: Arc<String>) -> Self {
153 OutboxItem {
154 kind: Some("profile".to_string()),
155 ..OutboxItem::new(recipient, envelope_json)
156 }
157 }
159 pub fn is_profile(&self) -> bool {
160 self.kind.as_deref() == Some("profile")
161 }
163 /// Has this item exhausted its retry budget or outlived the TTL?
164 ///
165 /// `first_attempt == 0` means it has never been sent (or was queued by a build
166 /// that predates the field), so the TTL has nothing to measure from and only
167 /// the counter applies.
168 pub fn is_expired(&self, now: i64) -> bool {
169 self.retries >= MAX_OUTBOX_RETRIES
170 || (self.first_attempt > 0 && now.saturating_sub(self.first_attempt) > OUTBOX_TTL_MS)
171 }
174// ---------------------------------------------------------------------------
175// @p helpers
176// ---------------------------------------------------------------------------
178/// Full @p rendering of a hex pubkey. Falls back to the input for anything that
179/// isn't an even-length hex string — the @p encoding is only defined on byte
180/// *pairs*, so an odd-length input has no syllable spelling at all.
181pub fn hex_to_ob(hex_key: &str) -> String {
182 hex::decode(hex_key)
183 .ok()
184 .and_then(|bytes| phonemic::to_ob(&bytes))
185 .unwrap_or_else(|| hex_key.to_string())
188/// Truncated @p: first two and last two syllables (`a-b-…-y-z`).
189pub fn trunc_ob(hex_key: &str) -> String {
190 let ob = hex_to_ob(hex_key);
191 let parts: Vec<&str> = ob.split('-').collect();
192 if parts.len() > 4 {
193 format!(
194 "{}-{}-…-{}-{}",
195 parts[0],
196 parts[1],
197 parts[parts.len() - 2],
198 parts[parts.len() - 1]
199 )
200 } else {
201 ob
202 }
205// ---------------------------------------------------------------------------
206// ViewModel (rendered by the shell)
207// ---------------------------------------------------------------------------
209#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
210pub struct ProfileVM {
211 pub display_name: String,
212 pub bio: String,
213 /// base64 image or empty.
214 pub photo: String,
217#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
218pub struct ContactVM {
219 pub pubkey: String,
220 pub name: String,
221 pub ob: String,
222 pub photo: String,
223 pub blocked: bool,
224 pub last_message: String,
225 pub last_ts: i64,
228#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
229pub struct MessageVM {
230 pub id: String,
231 pub body: String,
232 pub ts: i64,
233 pub outgoing: bool,
234 pub delivered: bool,
237#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
238pub struct ViewModel {
239 /// "conversations" | "chat" | "add_contact" | "settings" | "edit_profile"
240 pub page: String,
241 pub has_identity: bool,
242 pub my_pubkey_hex: String,
243 pub my_pubkey_ob: String,
244 /// "offline" | "connecting" | "online"
245 pub conn_status: String,
246 pub server_url: String,
247 pub profile: ProfileVM,
248 pub contacts: Vec<ContactVM>,
249 pub active_peer: String,
250 pub active_peer_name: String,
251 pub active_peer_ob: String,
252 pub active_peer_blocked: bool,
253 pub messages: Vec<MessageVM>,
254 pub error: String,