@@ -1337,18 +1337,13 @@ impl App for Skrepka {
13371337
13381338 let active_peer = model.active_peer.clone().unwrap_or_default();
13391339
1340− // `view()` runs on every render, and `ComposeChanged` renders on every
1341− // keystroke. A contact's `photo` is a base64 avatar — tens of KiB each —
1342− // so cloning all of them per keypress meant megabytes of allocation and
1343− // bincode-encoding to type one message.
1344− //
1345− // Only the chat page has a composer (`ComposeChanged` is sent from
1346− // `ChatView` and nowhere else), so only the chat page is on that hot path
1347− // — and while it is up, the only avatar on screen is the peer we are
1348− // talking to. Every *other* page renders from a cold event, and the
1349− // conversations list draws an avatar per row, so it gets the real photos.
1350− // Withholding them there would not save a single keystroke's work; it
1351− // would just replace every contact's picture with their initials.
1340+ // `view()` is called after every event the core processes. The
1341+ // per-keystroke optimization below assumes `ComposeChanged` fires on
1342+ // every keystroke, but the Swift shell only sends it before `SendText`
1343+ // — so in practice this path runs once per message, not per keypress.
1344+ // The optimization is still correct (it withholds avatars on the chat
1345+ // page regardless of how often `view` runs), just the original comment
1346+ // overstated the frequency.
13521347 let chat_open = model.page == Page::Chat;
13531348
13541349 let mut contacts: Vec<ContactVM> = model
@@ -1535,6 +1530,20 @@ impl Skrepka {
15351530 if body.is_empty() {
15361531 return render();
15371532 }
1533+
1534+ // Check the contact cap *before* mutating anything. The old order
1535+ // cleared `compose` and inserted the message into `model.messages`
1536+ // first, then hit the cap and returned early — so the user's text was
1537+ // gone (compose cleared), the message sat in an invisible orphan
1538+ // conversation (no contact entry to click on), and nothing was
1539+ // persisted (no `persist_messages` before the early return). On next
1540+ // launch the message was silently lost.
1541+ let was_new = !model.contacts.contains_key(&peer);
1542+ if was_new && model.contacts.len() >= MAX_CONTACTS {
1543+ model.error = Some("contact list is full".into());
1544+ return render();
1545+ }
1546+
15381547 model.compose.clear();
15391548 let ts = now_ms();
15401549 let id = uuid::Uuid::new_v4().to_string();
@@ -1559,11 +1568,6 @@ impl Skrepka {
15591568 // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
15601569 // user-driven (not an attack vector), but the cap exists to bound the
15611570 // `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− }
15671571 if was_new {
15681572 model
15691573 .contacts
@@ -3309,6 +3313,41 @@ mod tests {
33093313 assert!(m.outbox.is_empty());
33103314 }
33113315
3316+ /// The contact cap must be checked *before* mutating anything. The old
3317+ /// order cleared `compose` and inserted the message into `model.messages`
3318+ /// first, then hit the cap and returned early — losing the user's text and
3319+ /// leaving an invisible orphan conversation that was never persisted.
3320+ #[test]
3321+ fn send_text_to_a_new_peer_at_the_contact_cap_preserves_compose() {
3322+ let app = Skrepka;
3323+ let mut m = with_identity();
3324+ // Fill the contact list to the cap.
3325+ for i in 0..MAX_CONTACTS {
3326+ let k = format!("{i:064x}");
3327+ m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
3328+ }
3329+ let new_peer = peer_hex(9);
3330+ m.active_peer = Some(new_peer.clone());
3331+ m.compose = "hello new friend".into();
3332+
3333+ let _ = app.update(Event::SendText, &mut m);
3334+
3335+ assert_eq!(
3336+ m.compose, "hello new friend",
3337+ "compose must not be cleared on rejection"
3338+ );
3339+ assert!(
3340+ m.messages.get(&new_peer).is_none_or(Vec::is_empty),
3341+ "no orphan conversation must be created"
3342+ );
3343+ assert!(m.outbox.is_empty(), "nothing must be queued");
3344+ assert!(!m.contacts.contains_key(&new_peer), "no contact created");
3345+ assert!(
3346+ m.error.as_ref().is_some_and(|e| e.contains("full")),
3347+ "the user is told why"
3348+ );
3349+ }
3350+
33123351 #[test]
33133352 fn set_blocked_toggles_and_view_reflects_it() {
33143353 let app = Skrepka;