feat(01-02): chat mutations mutate demo state instead of acking (FED-04)
Demo images / Build & push demo images (push) Successful in 4m21s
Demo images / Build & push demo images (push) Successful in 4m21s
Reactions, replies, read-receipts, edits, deletes, forwards and channel sends
shared one bare `{ ok: true, sent: true }` case, so none of them rendered on
the demo — the UI derives reaction chips and reply quotes from the message
store, and there was nothing in it to derive from.
Each now mirrors its daemon counterpart. Reactions/replies/receipts push typed
messages carrying the { sender_pubkey, sender_seq } target key Mesh.vue's
reactionIndex and replyTargetPreview read. Edits rewrite the text and set
edited_at; deletes tombstone IN PLACE (plaintext, typed_payload.deleted,
message_type 'delete') because that is what mesh/mod.rs apply_local_delete
does — it does not remove the row.
Edits and deletes go through a per-session overrides overlay keyed by
sender_seq, because mesh.messages rebuilds its seed array on every read, so
in-place mutation would only ever work for messages sent this session.
mesh.refresh and mesh.reboot-radio stay acknowledgements on purpose — the
daemon's handlers have no message-store effect either — with a comment saying
so, so a later reader does not "fix" them into divergence.
Also completes the phase bookkeeping for 01-02/03/11/12/13/14/15 and lands the
orphaned 01-12/01-14 SUMMARYs.
Verified: parity harness 17/17 live assertions; full frontend suite 102 files
/ 822 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9c2ec82bdb
commit
90884e6259
+170
-11
@@ -136,6 +136,63 @@ const DEMO_MESH_PEERS = [
|
||||
// reported it (the seed values are offsets in ms).
|
||||
const DEMO_PEER_LAST_HEARD_MS = { 1: 30000, 2: 120000, 3: 600000, 4: 45000 }
|
||||
|
||||
const DEMO_PEER_NAMES = Object.fromEntries(
|
||||
DEMO_MESH_PEERS.map((p) => [p.contact_id, p.advert_name]),
|
||||
)
|
||||
// This node's own key on the demo. Reactions/replies the visitor sends are
|
||||
// direction:'sent', which is what the UI keys '__self__' off — but the field
|
||||
// still has to be present and stable for the message key to work.
|
||||
const DEMO_SELF_PUBKEY = 'demo02abababababababababababababababababababababababababababab'
|
||||
|
||||
// Append a message to this session's mesh store, matching the field set of
|
||||
// mesh/types.rs MeshMessage (id, direction, peer_contact_id, peer_name,
|
||||
// plaintext, timestamp, delivered, encrypted, transport, message_type,
|
||||
// typed_payload, sender_pubkey, sender_seq).
|
||||
function pushMeshMessage({
|
||||
contact_id = null,
|
||||
plaintext = '',
|
||||
message_type = 'text',
|
||||
typed_payload = null,
|
||||
transport = 'meshcore',
|
||||
channel = null,
|
||||
}) {
|
||||
const meshStore = currentStore().mesh
|
||||
const id = 100 + meshStore.dynamic.length
|
||||
const msg = {
|
||||
id,
|
||||
direction: 'sent',
|
||||
peer_contact_id: contact_id,
|
||||
peer_name: contact_id === null ? (channel === null ? null : `channel-${channel}`) : DEMO_PEER_NAMES[contact_id] || `peer-${contact_id}`,
|
||||
plaintext,
|
||||
timestamp: new Date().toISOString(),
|
||||
delivered: true,
|
||||
// mountain-node (3) has no pubkey, so traffic to it is unencrypted — the
|
||||
// same asymmetry the rest of the demo already models.
|
||||
encrypted: contact_id !== 3,
|
||||
transport,
|
||||
sender_pubkey: DEMO_SELF_PUBKEY,
|
||||
sender_seq: id,
|
||||
message_type,
|
||||
typed_payload,
|
||||
}
|
||||
if (channel !== null) msg.channel = channel
|
||||
meshStore.dynamic.push(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
// Apply this session's edit/delete overrides over a message list. Keyed by
|
||||
// sender_seq, mirroring mesh/mod.rs apply_local_edit / apply_local_delete,
|
||||
// which match an own-Sent message by sender_seq. Kept as an overlay rather
|
||||
// than mutating the seed array, because the seed is rebuilt on every read.
|
||||
function applyMeshOverrides(messages) {
|
||||
const overrides = currentStore().mesh.overrides
|
||||
if (!overrides || Object.keys(overrides).length === 0) return messages
|
||||
return messages.map((m) => {
|
||||
const o = m.direction === 'sent' && m.sender_seq != null ? overrides[m.sender_seq] : null
|
||||
return o ? { ...m, ...o } : m
|
||||
})
|
||||
}
|
||||
|
||||
// Boot mode: simulate server startup delay
|
||||
let BOOT_START_TIME = Date.now()
|
||||
const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis)
|
||||
@@ -3265,7 +3322,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
]
|
||||
// Messages sent this session (texts + attachments) ride after the
|
||||
// static seed so refresh-after-send shows them, same as a real node.
|
||||
const withDynamic = [...allMessages, ...currentStore().mesh.dynamic]
|
||||
const withDynamic = applyMeshOverrides([...allMessages, ...currentStore().mesh.dynamic])
|
||||
return res.json({
|
||||
result: {
|
||||
messages: withDynamic.slice(0, limit),
|
||||
@@ -4844,19 +4901,121 @@ app.post('/rpc/v1', (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Chat actions the demo only needs to acknowledge.
|
||||
//
|
||||
// ── Chat mutations ──────────────────────────────────────────────────
|
||||
// Reactions, replies, receipts and forwards are new typed messages
|
||||
// carrying a target key (typed_messages.rs send_typed_wire); edits and
|
||||
// deletes mutate the target in place (mesh/mod.rs apply_local_edit /
|
||||
// apply_local_delete). The UI derives reaction chips and reply quotes
|
||||
// from exactly these shapes (Mesh.vue reactionIndex /
|
||||
// replyTargetPreview), so a bare ack renders as nothing at all.
|
||||
case 'mesh.send-reaction': {
|
||||
const emoji = params?.emoji ?? ''
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: emoji,
|
||||
message_type: 'reaction',
|
||||
typed_payload: {
|
||||
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
|
||||
// An empty emoji clears this reactor's reaction, per the UI's
|
||||
// reactionIndex (freshest per reactor wins; '' deletes).
|
||||
emoji,
|
||||
},
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.send-reply': {
|
||||
const text = params?.text ?? ''
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: text,
|
||||
message_type: 'reply',
|
||||
typed_payload: {
|
||||
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
|
||||
text,
|
||||
},
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.send-read-receipt': {
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: '',
|
||||
message_type: 'read_receipt',
|
||||
typed_payload: {
|
||||
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
|
||||
},
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
// apply_local_edit: replaces plaintext and merges { edited_at, text }
|
||||
// into the existing typed_payload of the own-Sent message with that seq.
|
||||
case 'mesh.edit-message': {
|
||||
const seq = params?.target_seq
|
||||
const newText = params?.new_text ?? ''
|
||||
if (seq == null) {
|
||||
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
|
||||
}
|
||||
const overrides = currentStore().mesh.overrides
|
||||
const prior = overrides[seq] || {}
|
||||
overrides[seq] = {
|
||||
...prior,
|
||||
plaintext: newText,
|
||||
typed_payload: {
|
||||
...(prior.typed_payload || {}),
|
||||
edited_at: Math.floor(Date.now() / 1000),
|
||||
text: newText,
|
||||
},
|
||||
}
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
// apply_local_delete: tombstone in place — the daemon does NOT remove
|
||||
// the row, it rewrites plaintext, sets typed_payload { deleted: true }
|
||||
// and message_type 'delete'. Mirrored exactly; do not "simplify" this
|
||||
// into a splice, the UI renders the tombstone bubble.
|
||||
case 'mesh.delete-message': {
|
||||
const seq = params?.target_seq
|
||||
if (seq == null) {
|
||||
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
|
||||
}
|
||||
currentStore().mesh.overrides[seq] = {
|
||||
plaintext: '🗑 message deleted',
|
||||
typed_payload: { deleted: true },
|
||||
message_type: 'delete',
|
||||
}
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.forward-message': {
|
||||
const sourceId = params?.source_message_id
|
||||
const all = applyMeshOverrides(currentStore().mesh.dynamic)
|
||||
const source = all.find((m) => m.id === sourceId)
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: source ? source.plaintext : '(forwarded)',
|
||||
message_type: source?.message_type === 'content_ref' ? 'content_ref' : 'text',
|
||||
typed_payload: source?.typed_payload ?? null,
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.send-channel': {
|
||||
pushMeshMessage({
|
||||
contact_id: null,
|
||||
channel: params?.channel ?? 0,
|
||||
plaintext: params?.message ?? '',
|
||||
message_type: 'text',
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
// mesh.refresh and mesh.reboot-radio stay bare acknowledgements ON
|
||||
// PURPOSE: the daemon's handlers have no message-store effect either
|
||||
// (refresh re-polls the radio, reboot-radio power-cycles it), so giving
|
||||
// them demo-side state would be divergence, not parity. Do not "fix".
|
||||
case 'mesh.send-reaction':
|
||||
case 'mesh.send-reply':
|
||||
case 'mesh.send-read-receipt':
|
||||
case 'mesh.edit-message':
|
||||
case 'mesh.delete-message':
|
||||
case 'mesh.forward-message':
|
||||
case 'mesh.send-channel':
|
||||
case 'mesh.refresh':
|
||||
case 'mesh.reboot-radio': {
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
@@ -5877,7 +6036,7 @@ function makeSessionStore() {
|
||||
// contacts: pubkey_hex → { alias, notes, pinned, blocked }, mirroring the
|
||||
// daemon's state.contacts map. scheduled: queued messages awaiting their
|
||||
// fire_at, mirroring svc.scheduler's list.
|
||||
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1 },
|
||||
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1, overrides: {} },
|
||||
sockets: new Set(),
|
||||
lastSeen: Date.now(),
|
||||
}
|
||||
|
||||
@@ -214,7 +214,69 @@ try {
|
||||
else fail('cancelled request still listed as pending')
|
||||
}
|
||||
|
||||
// 6. federation.notify-did-change reports per-peer results.
|
||||
// 6. Chat mutations are observable on the next mesh.messages read — the
|
||||
// whole point of FED-04's "not a bare ok acknowledgement" requirement.
|
||||
const sent = await rpc('mesh.send-content-inline', {
|
||||
contact_id: 1,
|
||||
mime: 'text/plain',
|
||||
filename: 'mutate.txt',
|
||||
bytes_b64: Buffer.from('mutate me').toString('base64'),
|
||||
})
|
||||
const targetSeq = sent.message_id
|
||||
|
||||
await rpc('mesh.send-reaction', {
|
||||
contact_id: 1,
|
||||
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
|
||||
target_seq: targetSeq,
|
||||
emoji: '🔥',
|
||||
})
|
||||
let msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const reaction = msgs.find(
|
||||
(m) => m.message_type === 'reaction' && m.typed_payload?.target?.sender_seq === targetSeq,
|
||||
)
|
||||
if (reaction?.typed_payload?.emoji === '🔥') pass('mesh.send-reaction is visible as a reaction message with a target key')
|
||||
else fail('reaction did not appear in mesh.messages with the UI-expected shape')
|
||||
|
||||
await rpc('mesh.send-reply', {
|
||||
contact_id: 1,
|
||||
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
|
||||
target_seq: targetSeq,
|
||||
text: 'replying to that',
|
||||
})
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const reply = msgs.find((m) => m.message_type === 'reply' && m.typed_payload?.target?.sender_seq === targetSeq)
|
||||
if (reply?.plaintext === 'replying to that') pass('mesh.send-reply is visible as a reply carrying its target')
|
||||
else fail('reply did not appear with a target key')
|
||||
|
||||
await rpc('mesh.edit-message', { contact_id: 1, target_seq: targetSeq, new_text: 'edited text' })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const edited = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
|
||||
if (edited?.plaintext === 'edited text' && edited?.typed_payload?.edited_at)
|
||||
pass('mesh.edit-message rewrote the text and set the edited marker')
|
||||
else fail(`edit not applied: ${JSON.stringify(edited?.plaintext)}`)
|
||||
|
||||
await rpc('mesh.delete-message', { contact_id: 1, target_seq: targetSeq })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const deleted = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
|
||||
// The daemon tombstones in place rather than removing the row.
|
||||
if (deleted && deleted.message_type === 'delete' && deleted.typed_payload?.deleted === true)
|
||||
pass('mesh.delete-message tombstoned in place, as apply_local_delete does')
|
||||
else fail(`delete representation wrong: ${JSON.stringify(deleted)}`)
|
||||
|
||||
const beforeForward = (await rpc('mesh.messages', { limit: 500 })).count
|
||||
await rpc('mesh.forward-message', { contact_id: 2, source_message_id: targetSeq })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const forwarded = msgs.filter((m) => m.peer_contact_id === 2 && m.direction === 'sent')
|
||||
if (msgs.length > beforeForward && forwarded.length > 0) pass('mesh.forward-message pushed a copy for the destination peer')
|
||||
else fail('forward did not create a message for the destination peer')
|
||||
|
||||
await rpc('mesh.send-channel', { channel: 3, message: 'channel broadcast' })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
if (msgs.some((m) => m.channel === 3 && m.plaintext === 'channel broadcast'))
|
||||
pass('mesh.send-channel pushed a channel-addressed message')
|
||||
else fail('channel message not visible in mesh.messages')
|
||||
|
||||
// 7. federation.notify-did-change reports per-peer results.
|
||||
const notified = await rpc('federation.notify-did-change', {
|
||||
old_did: 'did:key:zOld',
|
||||
new_did: 'did:key:zNew',
|
||||
|
||||
Reference in New Issue
Block a user