1use std::cell::RefCell;
10use std::collections::{BTreeMap, BTreeSet};
11use std::rc::Rc;
12
13use rand::Rng;
14use umsh_core::{ChannelKey, ChannelTag, NodeHint, PublicKey};
15use umsh_text::engine::sequence::MessageHandle;
16use umsh_text::engine::{
17 ArchiveKey, CompletionStatus, ComposeIntent, ComposeRef, DeliveryState, Direction, Engine,
18 EngineConfig, Event, MessageMutation, MutationKind, Output, Presence, RegardingRef,
19 ResolvedRef, StreamCheckpoint, Transmission,
20};
21use umsh_text::model::{ConversationKey, SenderScope, WireRef};
22use umsh_text::validate::DirectChannelProfile;
23
24pub(crate) type ChatEngine = Engine<DirectChannelProfile>;
25
26const CHANNEL_ADDRESS_PREFIX: &str = "ch:";
28const CHANNEL_DIRECT_ADDRESS_PREFIX: &str = "chd:";
30
31#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
32pub struct MobileChatCheckpointRecord {
33 pub conversation_address: String,
34 pub next_id: u8,
35 pub epoch: u16,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
39pub struct MobileChatArchiveRecord {
40 pub conversation_address: String,
41 pub message_id: u8,
42 pub fragment_index: Option<u8>,
43 pub payload: Vec<u8>,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
52pub struct MobileChatArchiveDeleteRecord {
53 pub conversation_address: String,
54 pub message_id: u8,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
58pub enum MobileChatMutationKind {
59 Insert,
60 UpdateBody,
61 Edit,
62 Delete,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
66pub enum MobileChatDirection {
67 Inbound,
68 Outbound,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
75pub enum MobileChatPresence {
76 Present,
77 GapPending,
78 Unavailable,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
87pub struct MobileChatRxMetadataRecord {
88 pub rssi_dbm: Option<i16>,
89 pub snr_centibels: Option<i16>,
90 pub lqi: Option<u8>,
91 pub hop_count: Option<u8>,
94 pub route_hints: Vec<Vec<u8>>,
99 pub source_authenticated: bool,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
110pub struct MobileChatSenderResolutionRecord {
111 pub conversation_address: String,
112 pub sender_hint: Vec<u8>,
113 pub sender_address: String,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
117pub struct MobileChatMutationRecord {
118 pub session_id: u64,
121 pub handle: u32,
122 pub revision: u32,
123 pub kind: MobileChatMutationKind,
124 pub conversation_address: Option<String>,
125 pub sender_address: Option<String>,
126 pub sender_hint: Option<Vec<u8>>,
130 pub direction: Option<MobileChatDirection>,
131 pub message_type: Option<u8>,
132 pub wire_id: Option<u8>,
133 pub epoch: Option<u16>,
134 pub client_token: Option<u32>,
135 pub sender_handle: Option<String>,
136 pub regarding_handle: Option<u32>,
137 pub regarding_wire_id: Option<u8>,
143 pub regarding_direction: Option<MobileChatDirection>,
144 pub regarding_sender_hint: Option<Vec<u8>>,
145 pub background_color: Option<Vec<u8>>,
146 pub text_color: Option<Vec<u8>>,
147 pub original_handle: Option<u32>,
148 pub original_wire_id: Option<u8>,
154 pub original_direction: Option<MobileChatDirection>,
155 pub original_sender_hint: Option<Vec<u8>>,
159 pub body: Option<String>,
160 pub complete: Option<bool>,
161 pub present_fragments: Option<u16>,
162 pub fragment_count: Option<u8>,
163 pub finalized: Option<bool>,
164 pub presence: MobileChatPresence,
168 pub received_late: bool,
171 pub notify: bool,
178 pub rx: Option<MobileChatRxMetadataRecord>,
180}
181
182#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
187pub struct MobileChatOriginalRef {
188 pub session_id: u64,
189 pub handle: u32,
190 pub wire_id: Option<u8>,
191 pub epoch: Option<u16>,
192}
193
194#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
202pub struct MobileChatRegardingRef {
203 pub session_id: u64,
204 pub handle: u32,
205 pub wire_id: Option<u8>,
206 pub direction: Option<MobileChatDirection>,
207 pub sender_hint: Option<Vec<u8>>,
208 pub epoch: Option<u16>,
209}
210
211#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
212pub enum MobileChatDeliveryState {
213 Sent,
214 Acknowledged,
215 Failed,
216}
217
218#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
219pub struct MobileChatDeliveryRecord {
220 pub session_id: u64,
221 pub handle: u32,
222 pub fragment_index: Option<u8>,
223 pub state: MobileChatDeliveryState,
224}
225
226#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
227pub struct MobileChatArchiveLookupRecord {
228 pub request_id: u32,
229 pub conversation_address: String,
230 pub message_id: u8,
231 pub fragment_index: Option<u8>,
232}
233
234#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
235pub enum MobileChatArchiveResultKind {
236 Found,
237 Deleted,
238 Evicted,
239 Unknown,
240}
241
242#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
243pub struct MobileChatComposeBatchRecord {
244 pub batch_id: u64,
245 pub checkpoint: MobileChatCheckpointRecord,
246 pub archive_deletes: Vec<MobileChatArchiveDeleteRecord>,
249 pub archives: Vec<MobileChatArchiveRecord>,
252 pub mutations: Vec<MobileChatMutationRecord>,
253}
254
255pub(crate) struct PendingChatBatch {
256 pub transmissions: Vec<Transmission>,
257}
258
259pub(crate) struct ComposedChatBatch {
260 pub record: MobileChatComposeBatchRecord,
261 pub deliveries: Vec<MobileChatDeliveryRecord>,
262 pub diagnostics: Vec<String>,
263}
264
265pub(crate) struct ChatDrain {
266 pub checkpoint: Option<MobileChatCheckpointRecord>,
267 pub transmissions: Vec<Transmission>,
268 pub archive_deletes: Vec<MobileChatArchiveDeleteRecord>,
269 pub archives: Vec<MobileChatArchiveRecord>,
270 pub mutations: Vec<MobileChatMutationRecord>,
271 pub deliveries: Vec<MobileChatDeliveryRecord>,
272 pub lookups: Vec<MobileChatArchiveLookupRecord>,
273 pub resolutions: Vec<MobileChatSenderResolutionRecord>,
274 pub diagnostics: Vec<String>,
275}
276
277impl ChatDrain {
278 fn new() -> Self {
279 Self {
280 checkpoint: None,
281 transmissions: Vec::new(),
282 archive_deletes: Vec::new(),
283 archives: Vec::new(),
284 mutations: Vec::new(),
285 deliveries: Vec::new(),
286 lookups: Vec::new(),
287 resolutions: Vec::new(),
288 diagnostics: Vec::new(),
289 }
290 }
291}
292
293#[derive(Default)]
301pub(crate) struct ChannelRegistry {
302 entries: BTreeMap<ChannelTag, ChannelKey>,
303}
304
305impl ChannelRegistry {
306 pub fn register(&mut self, tag: ChannelTag, key: ChannelKey) {
307 self.entries.insert(tag, key);
308 }
309
310 pub fn remove(&mut self, tag: &ChannelTag) {
311 self.entries.remove(tag);
312 }
313
314 pub fn key(&self, tag: &ChannelTag) -> Option<ChannelKey> {
315 self.entries.get(tag).copied()
316 }
317
318 pub fn contains(&self, tag: &ChannelTag) -> bool {
319 self.entries.contains_key(tag)
320 }
321}
322
323pub(crate) struct MobileChatState {
324 pub engine: Box<ChatEngine>,
328 pub session_id: u64,
329 next_batch_id: u64,
330 pub pending_batches: BTreeMap<u64, PendingChatBatch>,
331 pub channels: Rc<RefCell<ChannelRegistry>>,
334 resolved_members: BTreeMap<(ChannelTag, [u8; 3]), PublicKey>,
338 announced_members: BTreeSet<(ChannelTag, [u8; 3])>,
341}
342
343impl MobileChatState {
344 pub fn new(local_key: PublicKey, channels: Rc<RefCell<ChannelRegistry>>) -> Self {
345 Self {
346 engine: Box::new(ChatEngine::new(
347 DirectChannelProfile,
348 local_key,
349 EngineConfig::default(),
350 rand::rng().next_u64(),
351 )),
352 session_id: rand::rng().next_u64().max(1),
353 next_batch_id: 1,
354 pending_batches: BTreeMap::new(),
355 channels,
356 resolved_members: BTreeMap::new(),
357 announced_members: BTreeSet::new(),
358 }
359 }
360
361 pub fn restore(
362 &mut self,
363 checkpoints: &[MobileChatCheckpointRecord],
364 now_ms: u64,
365 ) -> Vec<String> {
366 let mut diagnostics = Vec::new();
367 let mut restored = Vec::new();
368 for record in checkpoints {
369 match self.checkpoint_from_record(record) {
370 Some(checkpoint) => restored.push(checkpoint),
371 None => diagnostics.push(format!(
372 "chat checkpoint for unknown conversation {}",
373 record.conversation_address
374 )),
375 }
376 }
377 self.engine.restore(&restored, now_ms);
378 let _ = self.drain();
379 diagnostics
380 }
381
382 pub fn resolve_member(
386 &mut self,
387 channel: ChannelTag,
388 hint: NodeHint,
389 key: PublicKey,
390 ) -> Option<MobileChatSenderResolutionRecord> {
391 self.resolved_members
392 .entry((channel, hint.0))
393 .or_insert(key);
394 if !self.announced_members.insert((channel, hint.0)) {
395 return None;
396 }
397 Some(MobileChatSenderResolutionRecord {
398 conversation_address: channel_address(channel),
399 sender_hint: hint.0.to_vec(),
400 sender_address: address(key),
401 })
402 }
403
404 pub fn compose_text(
405 &mut self,
406 conversation: ConversationKey,
407 client_token: u32,
408 body: &str,
409 now_ms: u64,
410 ) -> Result<ComposedChatBatch, ()> {
411 self.compose_batch(
412 conversation,
413 client_token,
414 ComposeIntent::Text {
415 body,
416 status: false,
417 },
418 now_ms,
419 )
420 }
421
422 pub fn compose_edit(
423 &mut self,
424 conversation: ConversationKey,
425 client_token: u32,
426 original: &MobileChatOriginalRef,
427 body: &str,
428 now_ms: u64,
429 ) -> Result<ComposedChatBatch, ()> {
430 let original = self.compose_ref(original).ok_or(())?;
431 self.compose_batch(
432 conversation,
433 client_token,
434 ComposeIntent::Edit { original, body },
435 now_ms,
436 )
437 }
438
439 pub fn compose_delete(
440 &mut self,
441 conversation: ConversationKey,
442 client_token: u32,
443 original: &MobileChatOriginalRef,
444 now_ms: u64,
445 ) -> Result<ComposedChatBatch, ()> {
446 let original = self.compose_ref(original).ok_or(())?;
447 self.compose_batch(
448 conversation,
449 client_token,
450 ComposeIntent::Delete { original },
451 now_ms,
452 )
453 }
454
455 pub fn compose_reaction(
460 &mut self,
461 conversation: ConversationKey,
462 client_token: u32,
463 target: &MobileChatRegardingRef,
464 body: &str,
465 now_ms: u64,
466 ) -> Result<ComposedChatBatch, ()> {
467 let regarding = self.regarding_ref(target).ok_or(())?;
468 self.compose_batch(
469 conversation,
470 client_token,
471 ComposeIntent::Reply {
472 body,
473 regarding,
474 status: true,
475 },
476 now_ms,
477 )
478 }
479
480 fn regarding_ref(&self, target: &MobileChatRegardingRef) -> Option<RegardingRef> {
483 if target.session_id == self.session_id {
484 return Some(RegardingRef::Handle(MessageHandle(target.handle)));
485 }
486 let sender_hint = match &target.sender_hint {
487 None => None,
488 Some(bytes) => Some(NodeHint(<[u8; 3]>::try_from(bytes.as_slice()).ok()?)),
489 };
490 Some(RegardingRef::Wire {
491 message_id: target.wire_id?,
492 direction: match target.direction? {
493 MobileChatDirection::Inbound => Direction::Inbound,
494 MobileChatDirection::Outbound => Direction::Outbound,
495 },
496 sender_hint,
497 epoch: target.epoch,
498 })
499 }
500
501 fn compose_ref(&self, original: &MobileChatOriginalRef) -> Option<ComposeRef> {
506 if original.session_id == self.session_id {
507 return Some(ComposeRef::Handle(MessageHandle(original.handle)));
508 }
509 match (original.wire_id, original.epoch) {
510 (Some(message_id), Some(epoch)) => Some(ComposeRef::Wire { message_id, epoch }),
511 _ => None,
512 }
513 }
514
515 fn compose_batch(
516 &mut self,
517 conversation: ConversationKey,
518 client_token: u32,
519 intent: ComposeIntent<'_>,
520 now_ms: u64,
521 ) -> Result<ComposedChatBatch, ()> {
522 self.engine
523 .compose(conversation, client_token, intent, now_ms)
524 .map_err(|_| ())?;
525 let mut drain = self.drain();
526 let checkpoint = drain.checkpoint.ok_or(())?;
527 let fragment_count = u8::try_from(drain.archives.len()).map_err(|_| ())?;
528 for mutation in &mut drain.mutations {
529 if mutation.kind == MobileChatMutationKind::Insert
530 && mutation.direction == Some(MobileChatDirection::Outbound)
531 {
532 mutation.fragment_count = Some(fragment_count.max(1));
533 }
534 }
535 let batch_id = self.next_batch_id;
536 self.next_batch_id = self.next_batch_id.wrapping_add(1).max(1);
537 self.pending_batches.insert(
538 batch_id,
539 PendingChatBatch {
540 transmissions: drain.transmissions,
541 },
542 );
543 Ok(ComposedChatBatch {
544 record: MobileChatComposeBatchRecord {
545 batch_id,
546 checkpoint,
547 archive_deletes: drain.archive_deletes,
548 archives: drain.archives,
549 mutations: drain.mutations,
550 },
551 deliveries: drain.deliveries,
552 diagnostics: drain.diagnostics,
553 })
554 }
555
556 pub fn drain(&mut self) -> ChatDrain {
557 let mut drained = ChatDrain::new();
558 while let Some(output) = self.engine.poll_output() {
559 match output {
560 Output::Transmit(transmission) => {
561 if let Some(archive) = transmission.archive {
562 if let Some(record) =
563 self.archive_record(archive, transmission.payload.as_slice())
564 {
565 drained.archives.push(record);
566 }
567 }
568 drained.transmissions.push(transmission);
569 }
570 Output::StoreCheckpoint {
571 conversation,
572 next_id,
573 epoch,
574 } => {
575 drained.checkpoint =
576 self.conversation_address(conversation)
577 .map(|conversation_address| MobileChatCheckpointRecord {
578 conversation_address,
579 next_id,
580 epoch,
581 });
582 }
583 Output::LookupOutbound {
584 request_id,
585 conversation,
586 sequence,
587 } => {
588 if let Some(conversation_address) = self.conversation_address(conversation) {
589 drained.lookups.push(MobileChatArchiveLookupRecord {
590 request_id,
591 conversation_address,
592 message_id: sequence.message_id,
593 fragment_index: sequence.fragment.map(|fragment| fragment.index),
594 });
595 }
596 }
597 Output::StoreArchive { key, payload } => {
598 if let Some(record) = self.archive_record(key, payload.as_slice()) {
599 drained.archives.push(record);
600 }
601 }
602 Output::DeleteArchive {
603 conversation,
604 message_id,
605 } => {
606 if let Some(conversation_address) = self.conversation_address(conversation) {
607 drained.archive_deletes.push(MobileChatArchiveDeleteRecord {
608 conversation_address,
609 message_id,
610 });
611 }
612 }
613 Output::StoreMessage(mutation) => {
614 if let Some(record) = self.mutation_record(mutation) {
615 drained.mutations.push(record);
616 }
617 }
618 Output::Event(Event::DeliveryStateChanged {
619 handle,
620 fragment,
621 state,
622 }) => drained.deliveries.push(MobileChatDeliveryRecord {
623 session_id: self.session_id,
624 handle: handle.0,
625 fragment_index: fragment,
626 state: match state {
627 DeliveryState::Sent => MobileChatDeliveryState::Sent,
628 DeliveryState::Acked => MobileChatDeliveryState::Acknowledged,
629 DeliveryState::Failed => MobileChatDeliveryState::Failed,
630 },
631 }),
632 Output::Event(event) => drained.diagnostics.push(format!("{event:?}")),
633 Output::Diagnostic(diagnostic) => {
634 drained.diagnostics.push(format!("{diagnostic:?}"));
635 }
636 }
637 }
638 drained
639 }
640
641 fn mutation_record(&self, mutation: MessageMutation) -> Option<MobileChatMutationRecord> {
642 let mut record = MobileChatMutationRecord {
643 session_id: self.session_id,
644 handle: mutation.handle.0,
645 revision: mutation.revision,
646 kind: MobileChatMutationKind::Insert,
647 conversation_address: None,
648 sender_address: None,
649 sender_hint: None,
650 direction: None,
651 message_type: None,
652 wire_id: None,
653 epoch: None,
654 client_token: None,
655 sender_handle: None,
656 regarding_handle: None,
657 regarding_wire_id: None,
658 regarding_direction: None,
659 regarding_sender_hint: None,
660 background_color: None,
661 text_color: None,
662 original_handle: None,
663 original_wire_id: None,
664 original_direction: None,
665 original_sender_hint: None,
666 body: None,
667 complete: None,
668 present_fragments: None,
669 fragment_count: None,
670 finalized: None,
671 presence: MobileChatPresence::Present,
672 received_late: false,
673 notify: false,
674 rx: None,
675 };
676 match mutation.kind {
677 MutationKind::Insert {
678 conversation,
679 sender,
680 direction,
681 message_type,
682 wire_id,
683 epoch,
684 client_token,
685 sender_handle,
686 regarding,
687 bg_color,
688 text_color,
689 body,
690 status,
691 presence,
692 late,
693 notify,
694 } => {
695 record.conversation_address = self.conversation_address(conversation);
696 record.sender_address = self.sender_address(conversation, sender);
697 record.sender_hint = claimed_hint(sender);
698 record.direction = Some(match direction {
699 Direction::Inbound => MobileChatDirection::Inbound,
700 Direction::Outbound => MobileChatDirection::Outbound,
701 });
702 record.message_type = Some(message_type.to_byte());
703 record.wire_id = wire_id;
704 record.epoch = Some(epoch);
705 record.client_token = client_token;
706 record.sender_handle =
707 sender_handle.map(|value| self.engine.body(&value).to_owned());
708 if let Some(regarding) = regarding {
709 apply_regarding(&mut record, regarding);
710 }
711 record.background_color = bg_color.map(|color| color.to_vec());
712 record.text_color = text_color.map(|color| color.to_vec());
713 record.body = Some(self.engine.body(&body).to_owned());
714 record.presence = mobile_presence(presence);
715 record.received_late = late;
716 record.notify = notify;
717 apply_completion(&mut record, status);
718 }
719 MutationKind::UpdateBody {
720 body,
721 status,
722 late,
723 notify,
724 } => {
725 record.kind = MobileChatMutationKind::UpdateBody;
726 record.body = Some(self.engine.body(&body).to_owned());
727 record.received_late = late;
728 record.notify = notify;
729 apply_completion(&mut record, status);
730 }
731 MutationKind::Edit {
732 conversation,
733 original,
734 body,
735 } => {
736 record.kind = MobileChatMutationKind::Edit;
737 record.conversation_address = self.conversation_address(conversation);
738 apply_original(&mut record, original);
739 record.body = Some(self.engine.body(&body).to_owned());
740 }
741 MutationKind::Delete {
742 conversation,
743 original,
744 } => {
745 record.kind = MobileChatMutationKind::Delete;
746 record.conversation_address = self.conversation_address(conversation);
747 apply_original(&mut record, original);
748 }
749 }
750 Some(record)
751 }
752
753 fn conversation_address(&self, conversation: ConversationKey) -> Option<String> {
757 match conversation {
758 ConversationKey::Direct { peer } => Some(address(peer)),
759 ConversationKey::ChannelGroup { channel } => Some(channel_address(channel)),
760 ConversationKey::ChannelDirect { channel, peer } => Some(format!(
761 "{CHANNEL_DIRECT_ADDRESS_PREFIX}{}:{}",
762 hex(&channel.0),
763 address(peer)
764 )),
765 ConversationKey::Room { .. } => None,
766 }
767 }
768
769 fn sender_address(&self, conversation: ConversationKey, sender: SenderScope) -> Option<String> {
772 match sender {
773 SenderScope::Peer(peer) => Some(address(peer)),
774 SenderScope::Local => None,
775 SenderScope::ClaimedMember(hint) => {
776 let ConversationKey::ChannelGroup { channel } = conversation else {
777 return None;
778 };
779 self.resolved_members
780 .get(&(channel, hint.0))
781 .map(|peer| address(*peer))
782 }
783 }
784 }
785
786 fn archive_record(&self, key: ArchiveKey, payload: &[u8]) -> Option<MobileChatArchiveRecord> {
787 Some(MobileChatArchiveRecord {
788 conversation_address: self.conversation_address(key.conversation)?,
789 message_id: key.message_id,
790 fragment_index: key.fragment,
791 payload: payload.to_vec(),
792 })
793 }
794
795 fn checkpoint_from_record(
796 &self,
797 record: &MobileChatCheckpointRecord,
798 ) -> Option<StreamCheckpoint> {
799 let conversation = self.parse_conversation_address(&record.conversation_address)?;
800 Some(StreamCheckpoint {
801 conversation,
802 next_id: record.next_id,
803 epoch: record.epoch,
804 })
805 }
806
807 pub fn parse_conversation_address(&self, value: &str) -> Option<ConversationKey> {
812 if let Some(rest) = value.strip_prefix(CHANNEL_DIRECT_ADDRESS_PREFIX) {
813 let (tag, peer) = rest.split_once(':')?;
814 let channel = parse_channel_tag(tag)?;
815 if !self.channels.borrow().contains(&channel) {
816 return None;
817 }
818 return Some(ConversationKey::ChannelDirect {
819 channel,
820 peer: decode_address(peer)?,
821 });
822 }
823 if let Some(tag) = value.strip_prefix(CHANNEL_ADDRESS_PREFIX) {
824 let channel = parse_channel_tag(tag)?;
825 if !self.channels.borrow().contains(&channel) {
826 return None;
827 }
828 return Some(ConversationKey::ChannelGroup { channel });
829 }
830 Some(ConversationKey::Direct {
831 peer: decode_address(value)?,
832 })
833 }
834}
835
836fn mobile_presence(presence: Presence) -> MobileChatPresence {
837 match presence {
838 Presence::Present => MobileChatPresence::Present,
839 Presence::GapPending => MobileChatPresence::GapPending,
840 Presence::Unavailable => MobileChatPresence::Unavailable,
841 }
842}
843
844fn apply_completion(record: &mut MobileChatMutationRecord, status: CompletionStatus) {
845 match status {
846 CompletionStatus::Complete => record.complete = Some(true),
847 CompletionStatus::Partial {
848 present,
849 count,
850 finalized,
851 } => {
852 record.complete = Some(false);
853 record.present_fragments = Some(present);
854 record.fragment_count = Some(count);
855 record.finalized = Some(finalized);
856 }
857 }
858}
859
860fn apply_original(record: &mut MobileChatMutationRecord, reference: ResolvedRef) {
864 match reference {
865 ResolvedRef::Handle(MessageHandle(handle)) => {
866 record.original_handle = Some(handle);
867 }
868 ResolvedRef::Unresolved(WireRef::SenderScoped { sender, message_id }) => {
869 record.original_wire_id = Some(message_id);
870 record.original_direction = Some(wire_ref_direction(sender));
871 record.original_sender_hint = claimed_hint(sender);
872 }
873 ResolvedRef::Unresolved(WireRef::RoomCanonical { .. }) => {}
874 }
875}
876
877fn apply_regarding(record: &mut MobileChatMutationRecord, reference: ResolvedRef) {
882 match reference {
883 ResolvedRef::Handle(MessageHandle(handle)) => {
884 record.regarding_handle = Some(handle);
885 }
886 ResolvedRef::Unresolved(WireRef::SenderScoped { sender, message_id }) => {
887 record.regarding_wire_id = Some(message_id);
888 record.regarding_direction = Some(wire_ref_direction(sender));
889 record.regarding_sender_hint = claimed_hint(sender);
890 }
891 ResolvedRef::Unresolved(WireRef::RoomCanonical { .. }) => {}
892 }
893}
894
895fn wire_ref_direction(sender: SenderScope) -> MobileChatDirection {
896 match sender {
897 SenderScope::Local => MobileChatDirection::Outbound,
898 SenderScope::Peer(_) | SenderScope::ClaimedMember(_) => MobileChatDirection::Inbound,
901 }
902}
903
904fn claimed_hint(sender: SenderScope) -> Option<Vec<u8>> {
905 match sender {
906 SenderScope::ClaimedMember(hint) => Some(hint.0.to_vec()),
907 SenderScope::Local | SenderScope::Peer(_) => None,
908 }
909}
910
911pub(crate) fn channel_address(channel: ChannelTag) -> String {
913 format!("{CHANNEL_ADDRESS_PREFIX}{}", hex(&channel.0))
914}
915
916fn parse_channel_tag(value: &str) -> Option<ChannelTag> {
917 if value.len() != 32 {
918 return None;
919 }
920 let mut bytes = [0u8; 16];
921 for (index, byte) in bytes.iter_mut().enumerate() {
922 *byte = u8::from_str_radix(value.get(index * 2..index * 2 + 2)?, 16).ok()?;
923 }
924 Some(ChannelTag(bytes))
925}
926
927fn hex(bytes: &[u8]) -> String {
928 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
929}
930
931fn address(key: PublicKey) -> String {
932 umsh_core::base58::encode(&key.0)
933 .into_iter()
934 .map(char::from)
935 .collect()
936}
937
938fn decode_address(value: &str) -> Option<PublicKey> {
939 umsh_core::base58::decode(value.as_bytes())
940 .ok()
941 .map(PublicKey)
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 const LOCAL: PublicKey = PublicKey([0xAA; 32]);
949 const PEER: PublicKey = PublicKey([0x11; 32]);
950 const CHANNEL_KEY: ChannelKey = ChannelKey([0x42; 32]);
951
952 fn direct() -> ConversationKey {
953 ConversationKey::Direct { peer: PEER }
954 }
955
956 fn state_with_channel() -> (MobileChatState, ChannelTag) {
958 let tag = crate::channel_tag(&CHANNEL_KEY);
959 let registry = Rc::new(RefCell::new(ChannelRegistry::default()));
960 registry.borrow_mut().register(tag, CHANNEL_KEY);
961 (MobileChatState::new(LOCAL, registry), tag)
962 }
963
964 fn empty_state() -> MobileChatState {
965 MobileChatState::new(LOCAL, Rc::new(RefCell::new(ChannelRegistry::default())))
966 }
967
968 #[test]
972 fn a_channel_checkpoint_round_trips_through_restore() {
973 let (mut first, tag) = state_with_channel();
974 let conversation = ConversationKey::ChannelGroup { channel: tag };
975 let composed = first
976 .compose_text(conversation, 5, "on my way", 0)
977 .expect("composing into a held channel succeeds");
978 let checkpoint = composed.record.checkpoint;
979 assert_eq!(checkpoint.conversation_address, channel_address(tag));
980
981 let (mut restarted, _) = state_with_channel();
982 assert!(
983 restarted
984 .restore(std::slice::from_ref(&checkpoint), 0)
985 .is_empty(),
986 "a checkpoint for a held channel restores without complaint"
987 );
988 let next = restarted
991 .compose_text(conversation, 6, "still moving", 1)
992 .expect("composing after restore succeeds");
993 assert_ne!(
994 next.record.mutations[0].wire_id, composed.record.mutations[0].wire_id,
995 "the restored stream must not reissue a spent wire ID"
996 );
997 }
998
999 #[test]
1003 fn a_checkpoint_for_an_unheld_channel_is_diagnosed() {
1004 let (mut held, tag) = state_with_channel();
1005 let checkpoint = held
1006 .compose_text(ConversationKey::ChannelGroup { channel: tag }, 1, "hi", 0)
1007 .expect("composing into a held channel succeeds")
1008 .record
1009 .checkpoint;
1010
1011 let diagnostics = empty_state().restore(std::slice::from_ref(&checkpoint), 0);
1012 assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
1013 assert!(
1014 diagnostics[0].contains(&checkpoint.conversation_address),
1015 "the diagnostic must name the conversation: {}",
1016 diagnostics[0]
1017 );
1018 }
1019
1020 #[test]
1025 fn edit_by_persisted_reference_after_facade_restart() {
1026 let mut first = empty_state();
1027 let composed = first
1028 .compose_text(direct(), 7, "v1", 0)
1029 .expect("compose succeeds");
1030 let insert = composed
1031 .record
1032 .mutations
1033 .iter()
1034 .find(|mutation| mutation.kind == MobileChatMutationKind::Insert)
1035 .expect("insert mutation");
1036 let original = MobileChatOriginalRef {
1037 session_id: insert.session_id,
1038 handle: insert.handle,
1039 wire_id: insert.wire_id,
1040 epoch: insert.epoch,
1041 };
1042 let checkpoint = composed.record.checkpoint;
1043
1044 let mut restarted = empty_state();
1045 assert_ne!(
1046 restarted.session_id, first.session_id,
1047 "sessions must not collide"
1048 );
1049 let _ = restarted.restore(std::slice::from_ref(&checkpoint), 0);
1050 let edited = restarted
1051 .compose_edit(direct(), 8, &original, "v2", 1)
1052 .expect("wire-referenced edit composes after restart");
1053 let edit = edited
1054 .record
1055 .mutations
1056 .iter()
1057 .find(|mutation| mutation.kind == MobileChatMutationKind::Edit)
1058 .expect("edit mutation");
1059 assert_eq!(edit.original_handle, None);
1060 assert_eq!(edit.original_wire_id, insert.wire_id);
1061 assert_eq!(edit.original_direction, Some(MobileChatDirection::Outbound));
1062 assert_eq!(edit.conversation_address, insert.conversation_address);
1063 assert_eq!(edit.body.as_deref(), Some("v2"));
1064
1065 assert!(
1068 edited
1069 .record
1070 .archive_deletes
1071 .iter()
1072 .any(|delete| Some(delete.message_id) == insert.wire_id)
1073 );
1074 assert!(
1075 edited
1076 .record
1077 .archives
1078 .iter()
1079 .any(|archive| Some(archive.message_id) == insert.wire_id)
1080 );
1081
1082 let deleted = restarted
1084 .compose_delete(direct(), 9, &original, 2)
1085 .expect("delete composes");
1086 assert!(
1087 deleted
1088 .record
1089 .archive_deletes
1090 .iter()
1091 .any(|delete| Some(delete.message_id) == insert.wire_id)
1092 );
1093 assert!(
1094 !deleted
1095 .record
1096 .archives
1097 .iter()
1098 .any(|archive| Some(archive.message_id) == insert.wire_id)
1099 );
1100
1101 let mut cold = empty_state();
1104 assert!(cold.compose_delete(direct(), 9, &original, 0).is_err());
1105 }
1106
1107 #[test]
1111 fn compose_reaction_exports_wire_regarding() {
1112 let (mut state, tag) = state_with_channel();
1113 let conversation = ConversationKey::ChannelGroup { channel: tag };
1114 let hint = vec![0x33, 0x44, 0x55];
1115 let target = MobileChatRegardingRef {
1116 session_id: state.session_id.wrapping_add(1),
1118 handle: 0,
1119 wire_id: Some(9),
1120 direction: Some(MobileChatDirection::Inbound),
1121 sender_hint: Some(hint.clone()),
1122 epoch: Some(0),
1123 };
1124 let composed = state
1125 .compose_reaction(conversation, 1, &target, "+1", 0)
1126 .expect("reaction composes against a persisted target");
1127 let insert = composed
1128 .record
1129 .mutations
1130 .iter()
1131 .find(|mutation| mutation.kind == MobileChatMutationKind::Insert)
1132 .expect("insert mutation");
1133 assert_eq!(insert.message_type, Some(1), "an emote is status text");
1134 assert_eq!(insert.body.as_deref(), Some("+1"));
1135 assert_eq!(insert.regarding_handle, None);
1136 assert_eq!(insert.regarding_wire_id, Some(9));
1137 assert_eq!(
1138 insert.regarding_direction,
1139 Some(MobileChatDirection::Inbound)
1140 );
1141 assert_eq!(insert.regarding_sender_hint, Some(hint));
1142
1143 let withdrawn = state
1146 .compose_reaction(conversation, 2, &target, "", 1)
1147 .expect("withdrawal composes");
1148 let insert = withdrawn
1149 .record
1150 .mutations
1151 .iter()
1152 .find(|mutation| mutation.kind == MobileChatMutationKind::Insert)
1153 .expect("withdrawal is an insert");
1154 assert_eq!(insert.body.as_deref(), Some(""));
1155 assert_eq!(insert.regarding_wire_id, Some(9));
1156 }
1157
1158 #[test]
1161 fn reaction_target_from_prior_session_uses_wire_ref() {
1162 let mut first = empty_state();
1163 let composed = first
1164 .compose_text(direct(), 7, "mine", 0)
1165 .expect("compose succeeds");
1166 let insert = composed
1167 .record
1168 .mutations
1169 .iter()
1170 .find(|mutation| mutation.kind == MobileChatMutationKind::Insert)
1171 .expect("insert mutation");
1172 let target = MobileChatRegardingRef {
1173 session_id: insert.session_id,
1174 handle: insert.handle,
1175 wire_id: insert.wire_id,
1176 direction: Some(MobileChatDirection::Outbound),
1177 sender_hint: None,
1178 epoch: insert.epoch,
1179 };
1180 let checkpoint = composed.record.checkpoint;
1181
1182 let mut restarted = empty_state();
1183 let _ = restarted.restore(std::slice::from_ref(&checkpoint), 0);
1184 let reacted = restarted
1185 .compose_reaction(direct(), 8, &target, "<3", 1)
1186 .expect("wire-referenced reaction composes after restart");
1187 let emote = reacted
1188 .record
1189 .mutations
1190 .iter()
1191 .find(|mutation| mutation.kind == MobileChatMutationKind::Insert)
1192 .expect("insert mutation");
1193 assert_eq!(emote.regarding_handle, None);
1194 assert_eq!(emote.regarding_wire_id, insert.wire_id);
1195 assert_eq!(
1196 emote.regarding_direction,
1197 Some(MobileChatDirection::Outbound)
1198 );
1199
1200 let mut cold = empty_state();
1203 assert!(
1204 cold.compose_reaction(direct(), 9, &target, "<3", 0)
1205 .is_err()
1206 );
1207 }
1208}