v0.3.5: Encrypted receipt & comment slots, message delivery indicators

Encrypted slots in BlobHeader:
- Private posts get noise-prefilled receipt slots (64B, 1 per participant)
  and comment slots (256B, ceil(participants/3), expandable)
- Slot key derived from post CEK via BLAKE3 — only participants can read
- CDN relays propagate opaque encrypted bytes without decryption
- 3 new BlobHeaderDiffOps: WriteReceiptSlot, WriteCommentSlot, AddCommentSlots

Receipt system:
- States: empty(0), delivered(1), seen(2), reacted(3)
- Slot index = position in sorted participant NodeId list
- Author can pre-feed emoji reaction at creation time
- 6 new crypto tests for slot encrypt/decrypt/derivation

Node methods:
- write_receipt_slot, write_comment_slot with upstream+downstream propagation
- read_receipt_slots, read_comment_slots with CEK-based decryption
- get_post_cek_and_participants helper for both Encrypted and GroupEncrypted

IPC: write_message_receipt, write_message_comment, get_message_receipts,
     get_message_comments

Frontend:
- DM chat bubbles show delivery indicators (check → double → blue → emoji)
- Opening conversation auto-marks incoming messages as seen
- React button on messages with emoji prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-20 14:15:33 -04:00
parent a41b11c0b8
commit b7f2d369fa
9 changed files with 882 additions and 4 deletions

View file

@ -5516,6 +5516,62 @@ impl ConnectionManager {
parent_post_id: payload.post_id,
});
}
BlobHeaderDiffOp::WriteReceiptSlot { post_id, slot_index, data } => {
// Store encrypted bytes directly — no decryption needed on relay nodes
if let Ok(Some((json, _ts))) = storage.get_blob_header(post_id) {
if let Ok(mut header) = serde_json::from_str::<crate::types::BlobHeader>(&json) {
let idx = *slot_index as usize;
while header.receipt_slots.len() <= idx {
header.receipt_slots.push(vec![0u8; 64]); // padding
}
header.receipt_slots[idx] = data.clone();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
header.updated_at = now_ms;
if let Ok(new_json) = serde_json::to_string(&header) {
let _ = storage.store_blob_header(post_id, &header.author, &new_json, now_ms);
}
}
}
}
BlobHeaderDiffOp::WriteCommentSlot { post_id, slot_index, data } => {
if let Ok(Some((json, _ts))) = storage.get_blob_header(post_id) {
if let Ok(mut header) = serde_json::from_str::<crate::types::BlobHeader>(&json) {
let idx = *slot_index as usize;
while header.comment_slots.len() <= idx {
header.comment_slots.push(vec![0u8; 256]);
}
header.comment_slots[idx] = data.clone();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
header.updated_at = now_ms;
if let Ok(new_json) = serde_json::to_string(&header) {
let _ = storage.store_blob_header(post_id, &header.author, &new_json, now_ms);
}
}
}
}
BlobHeaderDiffOp::AddCommentSlots { post_id, count: _, slots } => {
if let Ok(Some((json, _ts))) = storage.get_blob_header(post_id) {
if let Ok(mut header) = serde_json::from_str::<crate::types::BlobHeader>(&json) {
for slot in slots {
header.comment_slots.push(slot.clone());
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
header.updated_at = now_ms;
if let Ok(new_json) = serde_json::to_string(&header) {
let _ = storage.store_blob_header(post_id, &header.author, &new_json, now_ms);
}
}
}
}
BlobHeaderDiffOp::Unknown => {} // future ops — silently skip
}
}

View file

@ -517,6 +517,34 @@ pub fn re_encrypt_post(
encrypt_post(&plaintext, our_seed, our_node_id, new_recipient_ids)
}
// --- Slot encryption (receipt + comment slots for encrypted posts) ---
const SLOT_KEY_CONTEXT: &str = "itsgoin/slot/v1";
/// Derive the slot encryption key from a post's CEK.
/// Only participants who can unwrap the CEK can derive this key.
pub fn derive_slot_key(cek: &[u8; 32]) -> [u8; 32] {
blake3::derive_key(SLOT_KEY_CONTEXT, cek)
}
/// Encrypt a slot's plaintext bytes using the slot key (derived from CEK).
/// Returns encrypted payload via encrypt_bytes_with_cek.
pub fn encrypt_slot(plaintext: &[u8], slot_key: &[u8; 32]) -> Result<Vec<u8>> {
encrypt_bytes_with_cek(plaintext, slot_key)
}
/// Decrypt a slot's encrypted bytes using the slot key (derived from CEK).
pub fn decrypt_slot(encrypted: &[u8], slot_key: &[u8; 32]) -> Result<Vec<u8>> {
decrypt_bytes_with_cek(encrypted, slot_key)
}
/// Generate a random noise-filled slot (indistinguishable from encrypted data).
pub fn random_slot_noise(size: usize) -> Vec<u8> {
let mut buf = vec![0u8; size];
rand::rng().fill_bytes(&mut buf);
buf
}
// --- Engagement crypto ---
const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1";
@ -970,4 +998,84 @@ mod tests {
let result = decrypt_group_post(&encrypted_v2, &group_seed_v1, &group_pubkey_v1, &wrapped_cek_v2);
assert!(result.is_err());
}
#[test]
fn test_slot_key_derivation_deterministic() {
let cek = [42u8; 32];
let key1 = derive_slot_key(&cek);
let key2 = derive_slot_key(&cek);
assert_eq!(key1, key2);
// Different CEK gives different slot key
let cek2 = [99u8; 32];
let key3 = derive_slot_key(&cek2);
assert_ne!(key1, key3);
}
#[test]
fn test_slot_encrypt_decrypt_roundtrip() {
let cek = [42u8; 32];
let slot_key = derive_slot_key(&cek);
let plaintext = b"hello slot encryption";
let encrypted = encrypt_slot(plaintext, &slot_key).unwrap();
let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_slot_wrong_key_fails() {
let cek = [42u8; 32];
let slot_key = derive_slot_key(&cek);
let wrong_key = derive_slot_key(&[99u8; 32]);
let plaintext = b"secret data";
let encrypted = encrypt_slot(plaintext, &slot_key).unwrap();
assert!(decrypt_slot(&encrypted, &wrong_key).is_err());
}
#[test]
fn test_receipt_slot_roundtrip() {
let cek = [42u8; 32];
let slot_key = derive_slot_key(&cek);
// Build a receipt: state=seen, timestamp, no emoji
let mut plaintext = [0u8; 32];
plaintext[0] = 2; // seen
let ts: u64 = 1700000000000;
plaintext[1..9].copy_from_slice(&ts.to_le_bytes());
let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap();
let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap();
assert_eq!(decrypted[0], 2);
assert_eq!(u64::from_le_bytes(decrypted[1..9].try_into().unwrap()), ts);
}
#[test]
fn test_comment_slot_roundtrip() {
let cek = [42u8; 32];
let slot_key = derive_slot_key(&cek);
let mut plaintext = [0u8; 256];
let author = [1u8; 32];
plaintext[..32].copy_from_slice(&author);
let ts: u64 = 1700000000000;
plaintext[32..40].copy_from_slice(&ts.to_le_bytes());
let content = b"Hello from a slot comment!";
plaintext[40..40 + content.len()].copy_from_slice(content);
let encrypted = encrypt_slot(&plaintext, &slot_key).unwrap();
let decrypted = decrypt_slot(&encrypted, &slot_key).unwrap();
assert_eq!(&decrypted[..32], &author);
assert_eq!(u64::from_le_bytes(decrypted[32..40].try_into().unwrap()), ts);
let end = decrypted[40..].iter().position(|&b| b == 0).unwrap_or(216);
assert_eq!(&decrypted[40..40 + end], content);
}
#[test]
fn test_random_slot_noise_correct_size() {
let noise64 = random_slot_noise(64);
assert_eq!(noise64.len(), 64);
let noise256 = random_slot_noise(256);
assert_eq!(noise256.len(), 256);
// Different calls produce different noise (with very high probability)
assert_ne!(random_slot_noise(64), random_slot_noise(64));
}
}

View file

@ -638,6 +638,48 @@ impl Node {
// Auto-pin own blobs so they're never evicted before foreign content
let _ = storage.pin_blob(&att.cid);
}
// Initialize encrypted receipt + comment slots for non-public posts
if !matches!(visibility, PostVisibility::Public) {
let participant_count = match &visibility {
PostVisibility::Encrypted { recipients } => recipients.len(),
PostVisibility::GroupEncrypted { .. } => {
// For group posts, we don't know exact member count at creation time;
// use a reasonable default (the circle members count, resolved earlier)
match &intent {
VisibilityIntent::Circle(circle_name) => {
storage.get_circle_members(circle_name)
.map(|m| m.len() + 1) // +1 for author
.unwrap_or(2)
}
_ => 2,
}
}
PostVisibility::Public => unreachable!(),
};
let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
.map(|_| crypto::random_slot_noise(64))
.collect();
let comment_slot_count = (participant_count + 2) / 3; // ceil(participants / 3)
let comment_slots: Vec<Vec<u8>> = (0..comment_slot_count)
.map(|_| crypto::random_slot_noise(256))
.collect();
let blob_header = crate::types::BlobHeader {
post_id,
author: self.node_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
updated_at: now,
thread_splits: vec![],
receipt_slots,
comment_slots,
};
let header_json = serde_json::to_string(&blob_header)?;
storage.store_blob_header(&post_id, &self.node_id, &header_json, now)?;
}
}
// Build and store CDN manifests for blobs
@ -3546,6 +3588,400 @@ impl Node {
Ok(comments)
}
// --- Encrypted receipt/comment slot methods ---
/// Unwrap the CEK for a post we are a participant of, returning (cek, sorted_participants).
/// Returns None if this is a public post or we cannot decrypt.
async fn get_post_cek_and_participants(
&self,
post_id: &PostId,
) -> anyhow::Result<Option<([u8; 32], Vec<NodeId>)>> {
let storage = self.storage.lock().await;
let (post, visibility) = match storage.get_post_with_visibility(post_id)? {
Some(pv) => pv,
None => return Ok(None),
};
drop(storage);
match &visibility {
PostVisibility::Public => Ok(None),
PostVisibility::Encrypted { recipients } => {
let cek = crypto::unwrap_cek_for_recipient(
&self.secret_seed,
&self.node_id,
&post.author,
recipients,
)?;
match cek {
Some(cek) => {
let mut participants: Vec<NodeId> = recipients.iter().map(|wk| wk.recipient).collect();
participants.sort();
participants.dedup();
Ok(Some((cek, participants)))
}
None => Ok(None),
}
}
PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
let storage = self.storage.lock().await;
let group_seeds = storage.get_all_group_seeds_map().unwrap_or_default();
let group_key_record = storage.get_group_key(group_id)?;
let members = if let Some(ref gk) = group_key_record {
storage.get_circle_members(&gk.circle_name).unwrap_or_default()
} else {
vec![]
};
drop(storage);
if let Some((seed, pubkey)) = group_seeds.get(&(*group_id, *epoch)) {
let cek = crypto::unwrap_group_cek(seed, pubkey, wrapped_cek)?;
let mut participants: Vec<NodeId> = members;
// Ensure the author is included
if !participants.contains(&post.author) {
participants.push(post.author);
}
participants.sort();
participants.dedup();
Ok(Some((cek, participants)))
} else {
Ok(None)
}
}
}
}
/// Write our receipt slot for an encrypted post.
/// `state` is the receipt state, `emoji` is optional (only used when state == Reacted).
pub async fn write_receipt_slot(
&self,
post_id: PostId,
state: crate::types::ReceiptState,
emoji: Option<String>,
) -> anyhow::Result<()> {
let (cek, participants) = self.get_post_cek_and_participants(&post_id).await?
.ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?;
let slot_key = crypto::derive_slot_key(&cek);
// Find our slot index (sorted participant position)
let our_slot = participants.iter().position(|nid| nid == &self.node_id)
.ok_or_else(|| anyhow::anyhow!("our node_id not found in participants"))?;
// Build plaintext: [1 byte state][8 bytes timestamp_ms][23 bytes emoji+padding]
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let mut plaintext = [0u8; 32];
plaintext[0] = state as u8;
plaintext[1..9].copy_from_slice(&now.to_le_bytes());
if let Some(ref emoji_str) = emoji {
let emoji_bytes = emoji_str.as_bytes();
let copy_len = emoji_bytes.len().min(23);
plaintext[9..9 + copy_len].copy_from_slice(&emoji_bytes[..copy_len]);
}
let encrypted = crypto::encrypt_slot(&plaintext, &slot_key)?;
// Update the BlobHeader
let storage = self.storage.lock().await;
let header = storage.get_blob_header(&post_id)?;
let mut blob_header = if let Some((json, _ts)) = header {
serde_json::from_str::<crate::types::BlobHeader>(&json)
.unwrap_or_else(|_| crate::types::BlobHeader {
post_id,
author: self.node_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
updated_at: now,
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
})
} else {
crate::types::BlobHeader {
post_id,
author: self.node_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
updated_at: now,
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
}
};
// Ensure enough slots exist
while blob_header.receipt_slots.len() <= our_slot {
blob_header.receipt_slots.push(crypto::random_slot_noise(64));
}
blob_header.receipt_slots[our_slot] = encrypted.clone();
blob_header.updated_at = now;
let header_json = serde_json::to_string(&blob_header)?;
storage.store_blob_header(&post_id, &blob_header.author, &header_json, now)?;
drop(storage);
// Propagate via BlobHeaderDiff
let diff = crate::protocol::BlobHeaderDiffPayload {
post_id,
author: self.node_id,
ops: vec![crate::types::BlobHeaderDiffOp::WriteReceiptSlot {
post_id,
slot_index: our_slot as u32,
data: encrypted,
}],
timestamp_ms: now,
};
self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await;
let upstream = {
let storage = self.storage.lock().await;
storage.get_post_upstream(&post_id).ok().flatten()
};
if let Some(up) = upstream {
let _ = self.network.send_to_peer_uni(&up, crate::protocol::MessageType::BlobHeaderDiff, &diff).await;
}
Ok(())
}
/// Write a private comment to an encrypted post's comment slot.
pub async fn write_comment_slot(
&self,
post_id: PostId,
content: String,
) -> anyhow::Result<()> {
let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await?
.ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?;
let slot_key = crypto::derive_slot_key(&cek);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
// Build plaintext: [32 bytes author_node_id][8 bytes timestamp_ms][216 bytes content+padding]
let mut plaintext = [0u8; 256];
plaintext[..32].copy_from_slice(&self.node_id);
plaintext[32..40].copy_from_slice(&now.to_le_bytes());
let content_bytes = content.as_bytes();
let copy_len = content_bytes.len().min(216);
plaintext[40..40 + copy_len].copy_from_slice(&content_bytes[..copy_len]);
let encrypted = crypto::encrypt_slot(&plaintext, &slot_key)?;
// Find first available comment slot or add new ones
let storage = self.storage.lock().await;
let header = storage.get_blob_header(&post_id)?;
let mut blob_header = if let Some((json, _ts)) = header {
serde_json::from_str::<crate::types::BlobHeader>(&json)
.unwrap_or_else(|_| crate::types::BlobHeader {
post_id,
author: self.node_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
updated_at: now,
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
})
} else {
crate::types::BlobHeader {
post_id,
author: self.node_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
updated_at: now,
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
}
};
// Try to find an empty slot by attempting decryption
let mut target_index = None;
for (i, slot) in blob_header.comment_slots.iter().enumerate() {
if let Ok(decrypted) = crypto::decrypt_slot(slot, &slot_key) {
// Check if all 256 plaintext bytes are zero (empty)
if decrypted.len() == 256 && decrypted.iter().all(|&b| b == 0) {
target_index = Some(i);
break;
}
} else {
// Cannot decrypt — could be random noise (empty), use it
target_index = Some(i);
break;
}
}
let (slot_index, add_new) = if let Some(idx) = target_index {
(idx, false)
} else {
// No available slots — add one
let idx = blob_header.comment_slots.len();
blob_header.comment_slots.push(crypto::random_slot_noise(256));
(idx, true)
};
blob_header.comment_slots[slot_index] = encrypted.clone();
blob_header.updated_at = now;
let header_json = serde_json::to_string(&blob_header)?;
storage.store_blob_header(&post_id, &blob_header.author, &header_json, now)?;
drop(storage);
// Propagate
let op = if add_new {
crate::types::BlobHeaderDiffOp::AddCommentSlots {
post_id,
count: 1,
slots: vec![encrypted],
}
} else {
crate::types::BlobHeaderDiffOp::WriteCommentSlot {
post_id,
slot_index: slot_index as u32,
data: encrypted,
}
};
let diff = crate::protocol::BlobHeaderDiffPayload {
post_id,
author: self.node_id,
ops: vec![op],
timestamp_ms: now,
};
self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await;
let upstream = {
let storage = self.storage.lock().await;
storage.get_post_upstream(&post_id).ok().flatten()
};
if let Some(up) = upstream {
let _ = self.network.send_to_peer_uni(&up, crate::protocol::MessageType::BlobHeaderDiff, &diff).await;
}
Ok(())
}
/// Read and decrypt all receipt slots for an encrypted post.
pub async fn read_receipt_slots(
&self,
post_id: PostId,
) -> anyhow::Result<Vec<crate::types::ReceiptSlotData>> {
let (cek, participants) = self.get_post_cek_and_participants(&post_id).await?
.ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?;
let slot_key = crypto::derive_slot_key(&cek);
let storage = self.storage.lock().await;
let header = storage.get_blob_header(&post_id)?;
drop(storage);
let blob_header = match header {
Some((json, _ts)) => serde_json::from_str::<crate::types::BlobHeader>(&json)?,
None => return Ok(vec![]),
};
let mut results = Vec::new();
for (i, slot) in blob_header.receipt_slots.iter().enumerate() {
let participant_id = participants.get(i).copied();
match crypto::decrypt_slot(slot, &slot_key) {
Ok(plaintext) if plaintext.len() >= 9 => {
let state = crate::types::ReceiptState::from_u8(plaintext[0]);
let timestamp_ms = u64::from_le_bytes(
plaintext[1..9].try_into().unwrap_or([0u8; 8]),
);
let emoji = if state == crate::types::ReceiptState::Reacted && plaintext.len() >= 32 {
let emoji_bytes = &plaintext[9..32];
let end = emoji_bytes.iter().position(|&b| b == 0).unwrap_or(23);
if end > 0 {
String::from_utf8(emoji_bytes[..end].to_vec()).ok()
} else {
None
}
} else {
None
};
results.push(crate::types::ReceiptSlotData {
slot_index: i as u32,
node_id: participant_id,
state,
timestamp_ms,
emoji,
});
}
_ => {
// Could not decrypt — noise/uninitialized slot
results.push(crate::types::ReceiptSlotData {
slot_index: i as u32,
node_id: participant_id,
state: crate::types::ReceiptState::Empty,
timestamp_ms: 0,
emoji: None,
});
}
}
}
Ok(results)
}
/// Read and decrypt all comment slots for an encrypted post.
pub async fn read_comment_slots(
&self,
post_id: PostId,
) -> anyhow::Result<Vec<crate::types::CommentSlotData>> {
let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await?
.ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?;
let slot_key = crypto::derive_slot_key(&cek);
let storage = self.storage.lock().await;
let header = storage.get_blob_header(&post_id)?;
drop(storage);
let blob_header = match header {
Some((json, _ts)) => serde_json::from_str::<crate::types::BlobHeader>(&json)?,
None => return Ok(vec![]),
};
let mut results = Vec::new();
for (i, slot) in blob_header.comment_slots.iter().enumerate() {
match crypto::decrypt_slot(slot, &slot_key) {
Ok(plaintext) if plaintext.len() >= 40 => {
// Check if it's an empty slot (all zeros)
if plaintext.iter().all(|&b| b == 0) {
continue;
}
let mut author = [0u8; 32];
author.copy_from_slice(&plaintext[..32]);
// Skip if author is all zeros (empty)
if author == [0u8; 32] {
continue;
}
let timestamp_ms = u64::from_le_bytes(
plaintext[32..40].try_into().unwrap_or([0u8; 8]),
);
let content_bytes = &plaintext[40..];
let end = content_bytes.iter().position(|&b| b == 0).unwrap_or(content_bytes.len());
let content = String::from_utf8_lossy(&content_bytes[..end]).to_string();
results.push(crate::types::CommentSlotData {
slot_index: i as u32,
author,
timestamp_ms,
content,
});
}
_ => {
// Cannot decrypt or too short — skip
}
}
}
results.sort_by_key(|c| c.timestamp_ms);
Ok(results)
}
}
pub struct NodeStats {

View file

@ -821,6 +821,12 @@ pub enum BlobHeaderDiffOp {
DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 },
SetPolicy(CommentPolicy),
ThreadSplit { new_post_id: PostId },
/// Write an encrypted receipt slot (64 bytes encrypted data)
WriteReceiptSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
/// Write an encrypted comment slot (256 bytes encrypted data)
WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
/// Add new encrypted comment slots (each 256 bytes)
AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
/// Unknown ops from newer protocol versions — silently ignored
#[serde(other)]
Unknown,
@ -838,6 +844,71 @@ pub struct BlobHeader {
/// PostIds of split-off comment overflow posts
#[serde(default)]
pub thread_splits: Vec<PostId>,
/// Encrypted receipt slots (each 64 bytes) — only for encrypted posts
#[serde(default)]
pub receipt_slots: Vec<Vec<u8>>,
/// Encrypted comment slots (each 256 bytes) — only for encrypted posts
#[serde(default)]
pub comment_slots: Vec<Vec<u8>>,
}
/// Receipt slot state byte values
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ReceiptState {
Empty = 0,
Delivered = 1,
Seen = 2,
Reacted = 3,
}
impl ReceiptState {
pub fn from_u8(v: u8) -> Self {
match v {
1 => ReceiptState::Delivered,
2 => ReceiptState::Seen,
3 => ReceiptState::Reacted,
_ => ReceiptState::Empty,
}
}
pub fn from_str_label(s: &str) -> Self {
match s {
"delivered" => ReceiptState::Delivered,
"seen" => ReceiptState::Seen,
"reacted" => ReceiptState::Reacted,
_ => ReceiptState::Empty,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ReceiptState::Empty => "empty",
ReceiptState::Delivered => "delivered",
ReceiptState::Seen => "seen",
ReceiptState::Reacted => "reacted",
}
}
}
/// Decrypted receipt slot data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptSlotData {
pub slot_index: u32,
pub node_id: Option<NodeId>,
pub state: ReceiptState,
pub timestamp_ms: u64,
/// Emoji string (only set when state == Reacted)
pub emoji: Option<String>,
}
/// Decrypted comment slot data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentSlotData {
pub slot_index: u32,
pub author: NodeId,
pub timestamp_ms: u64,
pub content: String,
}
/// Links a split-off comment post back to its original parent