@@ -845,6 +845,18 @@ impl App for Skrepka {
846 if model.secret_key.is_some() && model.conn != ConnStatus::Online {
846 if model.secret_key.is_some() && model.conn != ConnStatus::Online {
847 Command::event(Event::Authenticate)
847 Command::event(Event::Authenticate)
848+ } else if model.secret_key.is_some()
849+ && model.conn == ConnStatus::Online
852+ // The poll loop can die without `conn` going offline: after
853+ // `PollResult` clears `polling`, the re-poll is chained off
854+ // `SavedCursor`. If the shell drops that effect (backgrounded
855+ // app, bug), `polling` stays `false` with no pending re-poll
856+ // and the watchdog sees `!polling` and does nothing. The
857+ // status shows "online" but no polling ever happens. Restart
858+ // the loop here — `Connect` is fired on every foreground return.
859+ Command::event(Event::Poll)
@@ -1528,7 +1540,6 @@ impl Skrepka {
1528 let id = uuid::Uuid::new_v4().to_string();
1540 let id = uuid::Uuid::new_v4().to_string();
1530 let convo = model.messages.entry(peer.clone()).or_default();
1542 let convo = model.messages.entry(peer.clone()).or_default();
1531− let first_to_peer = !convo.iter().any(|m| m.outgoing);
1532 // Insert in order rather than appending: an incoming message may hold a
1543 // Insert in order rather than appending: an incoming message may hold a
1533 // `ts` up to `MAX_FUTURE_SKEW_MS` ahead of ours, so appending would leave
1544 // `ts` up to `MAX_FUTURE_SKEW_MS` ahead of ours, so appending would leave
1534 // the conversation unsorted — and `trim_history` ages out the *front*.
1545 // the conversation unsorted — and `trim_history` ages out the *front*.
@@ -1544,21 +1555,26 @@ impl Skrepka {
1545 trim_history(convo);
1556 trim_history(convo);
1547− // Auto-create a bare contact if messaging a brand-new key.
1558+ // Auto-create a bare contact if messaging a brand-new key, respecting
1548− model
1559+ // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
1549− .contacts
1560+ // user-driven (not an attack vector), but the cap exists to bound the
1550− .entry(peer.clone())
1561+ // `contacts` kv blob, which is rewritten in full on every change.
1562+ let was_new = !model.contacts.contains_key(&peer);
1563+ if was_new && model.contacts.len() >= MAX_CONTACTS {
1564+ model.error = Some("contact list is full".into());
1551− .or_insert_with(|| Contact::new(peer.clone(), String::new(), ts));
1570+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1553− let text = Payload::Text { id, body };
1554− model.outbox.push_back(OutboxItem::new(
1556− Arc::new(protocol::serialize_payload(&text, ts)),
1558− // On first contact, also share our profile. Marked as a profile like any
1573+ // On first contact, share our profile *before* the text so the
1559− // other, so a `SaveProfile` before this ever goes out supersedes it rather
1574+ // recipient's first impression is a name and avatar, not a truncated
1560− // than queueing a second one behind it.
1575+ // @p that gets replaced a moment later. Both are queued behind any
1561− if first_to_peer {
1576+ // in-flight items, so the ordering is preserved through the outbox.
1562 let profile = Payload::Profile {
1578 let profile = Payload::Profile {
1563 display_name: model.profile.display_name.clone(),
1579 display_name: model.profile.display_name.clone(),
1564 bio: model.profile.bio.clone(),
1580 bio: model.profile.bio.clone(),
@@ -1570,6 +1586,12 @@ impl Skrepka {
1589+ let text = Payload::Text { id, body };
1590+ model.outbox.push_back(OutboxItem::new(
1592+ Arc::new(protocol::serialize_payload(&text, ts)),
1573 Command::all([
1595 Command::all([
1574 self.persist_messages(model, &peer),
1596 self.persist_messages(model, &peer),
1575 self.persist_contacts(model),
1597 self.persist_contacts(model),
@@ -1852,8 +1874,14 @@ impl Skrepka {
1852 // `ts` is chosen by the sender and drives conversation ordering,
1874 // `ts` is chosen by the sender and drives conversation ordering,
1853 // in-chat ordering, and the displayed time. Bound it to the near
1875 // in-chat ordering, and the displayed time. Bound it to the near
1854 // future so a peer cannot pin itself to the top of the list forever
1876 // future so a peer cannot pin itself to the top of the list forever
1855− // with `ts = i64::MAX`.
1877+ // with `ts = i64::MAX`, and bound it to the non-negative past so a
1878+ // malicious peer cannot make their own message invisible by dating
1879+ // it at `i64::MIN` — `insert_sorted` would put it at position 0 and
1880+ // `trim_history` would age it out first.
1856− let ts = parsed.ts.min(now.saturating_add(MAX_FUTURE_SKEW_MS));
1884+ .min(now.saturating_add(MAX_FUTURE_SKEW_MS));
1857 let known = model.contacts.contains_key(&sender);
1885 let known = model.contacts.contains_key(&sender);
1858 match parsed.payload {
1886 match parsed.payload {
1859 Payload::Text { id: msg_id, body } => {
1887 Payload::Text { id: msg_id, body } => {