Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion ui/branding/testids.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"fmArchiveBody": "fm-archive-body",
"fmArchiveDelete": "fm-archive-delete",
"fmArchiveReply": "fm-archive-reply",
"fmArchiveUnarchive": "fm-archive-unarchive",
"fmComposeSheet": "fm-compose-sheet",
"fmSend": "fm-send",
"fmToast": "fm-toast",
Expand Down
56 changes: 33 additions & 23 deletions ui/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,29 +1081,39 @@ pub(crate) async fn node_comms(
Some(identity) => {
match update {
UpdateData::Delta(delta) => {
let delta: StoredInbox =
serde_json::from_slice(delta.as_ref()).unwrap();
let updated_model = InboxModel::from_state(
Arc::clone(&identity.ml_dsa_signing_key),
identity.ml_kem_dk.clone(),
delta,
key,
)
.unwrap();
let mut models = inboxes.0.write();
if let Some(pos) = models.iter().position(|e| e.borrow().key == key)
{
let mut inbox = models[pos].borrow_mut();
inbox.merge(updated_model);
crate::log::debug!(
"updated inbox {key} with {} messages",
inbox.messages.len()
);
} else {
// Notification raced the initial GetResponse;
// insert as a fresh entry. Replaces the
// assert!(found) panic from earlier (#45).
models.push(Rc::new(RefCell::new(updated_model)));
match serde_json::from_slice::<freenet_email_inbox::UpdateInbox>(
delta.as_ref(),
) {
Ok(parsed) => {
let models = inboxes.0.write();
if let Some(pos) =
models.iter().position(|e| e.borrow().key == key)
{
let mut inbox = models[pos].borrow_mut();
inbox.apply_delta(&identity.ml_kem_dk, parsed);
crate::log::debug!(
"applied delta to inbox {key} -> {} messages",
inbox.messages.len()
);
} else {
// Notification raced GetResponse: a
// Delta on its own can't materialise
// the inbox (no settings, no key on
// the wire). The follow-up
// GetResponse will populate it.
crate::log::debug!(
"UpdateNotification(Delta) before GetResponse for {key}; dropping"
);
}
}
Err(e) => {
crate::log::error(
format!(
"UpdateNotification: failed to decode UpdateInbox delta for {key}: {e}"
),
None,
);
}
}
}
UpdateData::State(state) => {
Expand Down
13 changes: 3 additions & 10 deletions ui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1675,16 +1675,9 @@ fn OpenArchivedMessage(msg_id: u64, msg: mail_local_state::ArchivedMessage) -> E
},
"Reply"
}
// Unarchive lives behind issue #60 (inbox contract OwnerInsert
// variant). Disabled here so the affordance is visible without
// mis-promising round-trippable archives.
button {
class: "btn btn-secondary",
"data-testid": testid::FM_ARCHIVE_UNARCHIVE,
disabled: true,
title: "Unarchive requires inbox contract OwnerInsert (#60)",
"Unarchive"
}
// Unarchive intentionally hidden: blocked on inbox contract
// OwnerInsert (#60). Showing a no-op button is more confusing
// than no affordance at all.
button {
class: "btn btn-secondary",
"data-testid": testid::FM_ARCHIVE_DELETE,
Expand Down
56 changes: 39 additions & 17 deletions ui/src/inbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -697,23 +697,6 @@ impl InboxModel {
}
}

pub fn merge(&mut self, other: InboxModel) {
for m in other.messages {
if !self
.messages
.iter()
.any(|c| c.content.time == m.content.time)
{
self.add_received_message(
m.content,
m.token_assignment,
m.sender_vk,
m.signature_valid,
);
}
}
}

// TODO: only used when an inbox is created first time when putting the contract
#[allow(dead_code)]
fn to_state(&self) -> Result<State<'static>, DynError> {
Expand Down Expand Up @@ -773,6 +756,45 @@ impl InboxModel {
})
}

/// Apply an `UpdateInbox` delta received via UpdateNotification (or
/// the cross-node UPDATE_PROPAGATION path) to this in-memory model.
/// Decrypts AddMessages and de-dups by `token_assignment.assignment_hash`;
/// drops RemoveMessages by hash; ignores ModifySettings (sender-only).
pub fn apply_delta(&mut self, ml_kem_dk: &DecapsulationKey<MlKem768>, delta: UpdateInbox) {
match delta {
UpdateInbox::AddMessages { messages } => {
for m in messages {
let hash = m.token_assignment.assignment_hash;
if self
.messages
.iter()
.any(|c| c.token_assignment.assignment_hash == hash)
{
continue;
}
let content = DecryptedMessage::from_stored(ml_kem_dk, m.content.clone());
let signature_valid =
verify_message_signature(&m.content, &m.sender_vk, &m.signature);
self.add_received_message(
content,
m.token_assignment,
m.sender_vk,
signature_valid,
);
}
}
UpdateInbox::RemoveMessages { ids, .. } => {
let drop: HashSet<TokenAssignmentHash> = HashSet::from_iter(ids);
self.messages
.retain(|m| !drop.contains(&m.token_assignment.assignment_hash));
}
UpdateInbox::ModifySettings { .. } => {
// Settings deltas come from this identity only; ignore on
// the receive path.
}
}
}

/// This only affects in-memory messages, changes are not persisted.
fn add_received_message(
&mut self,
Expand Down
2 changes: 0 additions & 2 deletions ui/src/testid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ pub(crate) const FM_SENT_RESEND: &str = "fm-sent-resend";
pub(crate) const FM_ARCHIVE_BODY: &str = "fm-archive-body";
pub(crate) const FM_ARCHIVE_DELETE: &str = "fm-archive-delete";
pub(crate) const FM_ARCHIVE_REPLY: &str = "fm-archive-reply";
pub(crate) const FM_ARCHIVE_UNARCHIVE: &str = "fm-archive-unarchive";

// Compose
pub(crate) const FM_COMPOSE_SHEET: &str = "fm-compose-sheet";
Expand Down Expand Up @@ -148,7 +147,6 @@ mod tests {
("fmArchiveBody", super::FM_ARCHIVE_BODY),
("fmArchiveDelete", super::FM_ARCHIVE_DELETE),
("fmArchiveReply", super::FM_ARCHIVE_REPLY),
("fmArchiveUnarchive", super::FM_ARCHIVE_UNARCHIVE),
("fmComposeSheet", super::FM_COMPOSE_SHEET),
("fmSend", super::FM_SEND),
("fmToast", super::FM_TOAST),
Expand Down
6 changes: 4 additions & 2 deletions ui/tests/email-app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,9 +1094,11 @@ test.describe("Archive folder (#47c)", () => {
await expect(page.locator(".detail-subj")).toContainText(
"Welcome to the offline preview",
);
// Unarchive is wired but disabled until #60 lands.
// Unarchive button is intentionally hidden (not just disabled)
// until #60 lands — the dead affordance was confusing per user
// feedback. Assert it's absent rather than disabled.
const unarchive = page.locator('[data-testid="fm-archive-unarchive"]');
await expect(unarchive).toBeDisabled();
await expect(unarchive).toHaveCount(0);
});

test("Delete from Inbox does not produce an Archive entry", async ({
Expand Down
Loading
Loading