@@ -845,6 +845,18 @@ impl App for Skrepka {
845845 }
846846 if model.secret_key.is_some() && model.conn != ConnStatus::Online {
847847 Command::event(Event::Authenticate)
848+ } else if model.secret_key.is_some()
849+ && model.conn == ConnStatus::Online
850+ && !model.polling
851+ {
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)
848860 } else {
849861 render()
850862 }
@@ -1528,7 +1540,6 @@ impl Skrepka {
15281540 let id = uuid::Uuid::new_v4().to_string();
15291541
15301542 let convo = model.messages.entry(peer.clone()).or_default();
1531− let first_to_peer = !convo.iter().any(|m| m.outgoing);
15321543 // Insert in order rather than appending: an incoming message may hold a
15331544 // `ts` up to `MAX_FUTURE_SKEW_MS` ahead of ours, so appending would leave
15341545 // the conversation unsorted — and `trim_history` ages out the *front*.
@@ -1544,21 +1555,26 @@ impl Skrepka {
15441555 );
15451556 trim_history(convo);
15461557
1547− // Auto-create a bare contact if messaging a brand-new key.
1548− model
1549− .contacts
1550− .entry(peer.clone())
1551− .or_insert_with(|| Contact::new(peer.clone(), String::new(), ts));
1558+ // Auto-create a bare contact if messaging a brand-new key, respecting
1559+ // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
1560+ // user-driven (not an attack vector), but the cap exists to bound the
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());
1565+ return render();
1566+ }
1567+ if was_new {
1568+ model
1569+ .contacts
1570+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1571+ }
15521572
1553− let text = Payload::Text { id, body };
1554− model.outbox.push_back(OutboxItem::new(
1555− peer.clone(),
1556− Arc::new(protocol::serialize_payload(&text, ts)),
1557− ));
1558− // On first contact, also share our profile. Marked as a profile like any
1559− // other, so a `SaveProfile` before this ever goes out supersedes it rather
1560− // than queueing a second one behind it.
1561− if first_to_peer {
1573+ // On first contact, share our profile *before* the text so the
1574+ // recipient's first impression is a name and avatar, not a truncated
1575+ // @p that gets replaced a moment later. Both are queued behind any
1576+ // in-flight items, so the ordering is preserved through the outbox.
1577+ if was_new {
15621578 let profile = Payload::Profile {
15631579 display_name: model.profile.display_name.clone(),
15641580 bio: model.profile.bio.clone(),
@@ -1570,6 +1586,12 @@ impl Skrepka {
15701586 ));
15711587 }
15721588
1589+ let text = Payload::Text { id, body };
1590+ model.outbox.push_back(OutboxItem::new(
1591+ peer.clone(),
1592+ Arc::new(protocol::serialize_payload(&text, ts)),
1593+ ));
1594+
15731595 Command::all([
15741596 self.persist_messages(model, &peer),
15751597 self.persist_contacts(model),
@@ -1852,8 +1874,14 @@ impl Skrepka {
18521874 // `ts` is chosen by the sender and drives conversation ordering,
18531875 // in-chat ordering, and the displayed time. Bound it to the near
18541876 // future so a peer cannot pin itself to the top of the list forever
1855− // with `ts = i64::MAX`.
1856− let ts = parsed.ts.min(now.saturating_add(MAX_FUTURE_SKEW_MS));
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.
1881+ let ts = parsed
1882+ .ts
1883+ .max(0)
1884+ .min(now.saturating_add(MAX_FUTURE_SKEW_MS));
18571885 let known = model.contacts.contains_key(&sender);
18581886 match parsed.payload {
18591887 Payload::Text { id: msg_id, body } => {