diff --git a/cli/src/api.rs b/cli/src/api.rs index 3cfa5905..a0c49e58 100644 --- a/cli/src/api.rs +++ b/cli/src/api.rs @@ -284,7 +284,10 @@ impl ApiClient { match response { HostResponse::ContractResponse(ContractResponse::PutResponse { key }) => { - info!("Room republished successfully with contract key: {}", key.id()); + info!( + "Room republished successfully with contract key: {}", + key.id() + ); if key != contract_key { return Err(anyhow!( "Contract key mismatch: expected {}, got {}", @@ -311,7 +314,7 @@ impl ApiClient { info!("Getting room state for contract: {}", contract_key.id()); let get_request = ContractRequest::Get { - key: *contract_key.id(), // GET uses ContractInstanceId + key: *contract_key.id(), // GET uses ContractInstanceId return_contract_code: true, // Request full contract to enable caching subscribe: false, // Always false, we'll subscribe separately if needed }; @@ -487,7 +490,7 @@ impl ApiClient { // Perform a GET request to fetch the room state let get_request = ContractRequest::Get { - key: *contract_key.id(), // GET uses ContractInstanceId + key: *contract_key.id(), // GET uses ContractInstanceId return_contract_code: true, // Request full contract to enable caching subscribe: false, // We'll subscribe separately after GET succeeds }; @@ -886,7 +889,11 @@ impl ApiClient { match tokio::time::timeout(std::time::Duration::from_secs(60), web_api.recv()).await { Ok(Ok(response)) => response, Ok(Err(e)) => return Err(anyhow!("Failed to receive response: {}", e)), - Err(_) => return Err(anyhow!("Timeout waiting for update response after 60 seconds")), + Err(_) => { + return Err(anyhow!( + "Timeout waiting for update response after 60 seconds" + )) + } }; match response { @@ -1126,8 +1133,7 @@ impl ApiClient { } // Save the updated state locally - self.storage - .update_room_state(room_owner_key, room_state)?; + self.storage.update_room_state(room_owner_key, room_state)?; // Create delta with just the member info update let delta = ChatRoomStateV1Delta { @@ -1173,10 +1179,7 @@ impl ApiClient { match response { HostResponse::ContractResponse(ContractResponse::UpdateResponse { key, .. }) => { - info!( - "Nickname updated successfully for contract: {}", - key.id() - ); + info!("Nickname updated successfully for contract: {}", key.id()); Ok(()) } _ => Err(anyhow!("Unexpected response type: {:?}", response)), @@ -1272,9 +1275,9 @@ impl ApiClient { return Err(anyhow!("Circular invite chain detected")); } - let inviter = members_by_id.get(¤t_id).ok_or_else(|| { - anyhow!("Invite chain broken: inviter not found") - })?; + let inviter = members_by_id + .get(¤t_id) + .ok_or_else(|| anyhow!("Invite chain broken: inviter not found"))?; current_id = inviter.member.invited_by; } @@ -1285,10 +1288,7 @@ impl ApiClient { } } - info!( - "Banning member with ID: {}", - banned_member_id.to_string() - ); + info!("Banning member with ID: {}", banned_member_id.to_string()); // Create the ban let user_ban = UserBan { @@ -1474,11 +1474,8 @@ impl ApiClient { // Wait for next message with a short timeout to allow checking shutdown let mut web_api = self.web_api.lock().await; - let recv_result = tokio::time::timeout( - std::time::Duration::from_millis(500), - web_api.recv(), - ) - .await; + let recv_result = + tokio::time::timeout(std::time::Duration::from_millis(500), web_api.recv()).await; match recv_result { Ok(Ok(HostResponse::ContractResponse(ContractResponse::UpdateNotification { @@ -1492,14 +1489,16 @@ impl ApiClient { match update { UpdateData::Delta(delta_bytes) => { // Parse the delta - if let Ok(delta) = - ciborium::de::from_reader::(&delta_bytes[..]) - { + if let Ok(delta) = ciborium::de::from_reader::( + &delta_bytes[..], + ) { // Check for new messages in the delta if let Some(messages) = &delta.recent_messages { for msg in messages { - let msg_id = - format!("{:?}:{:?}", msg.message.author, msg.message.time); + let msg_id = format!( + "{:?}:{:?}", + msg.message.author, msg.message.time + ); if seen_messages.insert(msg_id.clone()) { // Need to get current room state for nickname lookup @@ -1517,7 +1516,8 @@ impl ApiClient { new_message_count += 1; web_api = self.web_api.lock().await; - if max_messages > 0 && new_message_count >= max_messages { + if max_messages > 0 && new_message_count >= max_messages + { return Ok(()); } } diff --git a/cli/src/commands/member.rs b/cli/src/commands/member.rs index 2625fa2c..8f383794 100644 --- a/cli/src/commands/member.rs +++ b/cli/src/commands/member.rs @@ -147,7 +147,10 @@ pub async fn execute(command: MemberCommands, api: ApiClient, format: OutputForm match api.ban_member(&owner_vk, &member_id).await { Ok(()) => match format { OutputFormat::Human => { - println!("{}", format!("Member '{}' has been banned.", member_id).green()); + println!( + "{}", + format!("Member '{}' has been banned.", member_id).green() + ); } OutputFormat::Json => { println!( diff --git a/cli/tests/message_flow.rs b/cli/tests/message_flow.rs index 8e09728b..0a8da427 100644 --- a/cli/tests/message_flow.rs +++ b/cli/tests/message_flow.rs @@ -39,18 +39,19 @@ fn owner_key_to_contract_key(owner_key_str: &str) -> Result { .context("Invalid owner key encoding")? .try_into() .map_err(|_| anyhow!("Owner key must be 32 bytes"))?; - let owner_vk = VerifyingKey::from_bytes(&owner_bytes) - .context("Invalid owner verifying key")?; + let owner_vk = VerifyingKey::from_bytes(&owner_bytes).context("Invalid owner verifying key")?; let params = ChatRoomParametersV1 { owner: owner_vk }; let params_bytes = { let mut buf = Vec::new(); - ciborium::ser::into_writer(¶ms, &mut buf) - .context("Failed to serialize parameters")?; + ciborium::ser::into_writer(¶ms, &mut buf).context("Failed to serialize parameters")?; buf }; let contract_code = ContractCode::from(ROOM_CONTRACT_WASM); - Ok(ContractKey::from_params_and_code(Parameters::from(params_bytes), &contract_code)) + Ok(ContractKey::from_params_and_code( + Parameters::from(params_bytes), + &contract_code, + )) } #[derive(Deserialize)] @@ -1417,7 +1418,10 @@ async fn run_late_joiner_test() -> Result<()> { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(30); - println!("Waiting {}s for topology stabilization...", stabilization_secs); + println!( + "Waiting {}s for topology stabilization...", + stabilization_secs + ); sleep(Duration::from_secs(stabilization_secs)).await; dump_topology(&network).await; @@ -1435,7 +1439,14 @@ async fn run_late_joiner_test() -> Result<()> { let create_stdout = run_riverctl( owner_config.path(), &owner_url, - &["room", "create", "--name", "Late Joiner Test Room", "--nickname", "owner"], + &[ + "room", + "create", + "--name", + "Late Joiner Test Room", + "--nickname", + "owner", + ], ) .await .context("Failed to create room")?; @@ -1465,7 +1476,13 @@ async fn run_late_joiner_test() -> Result<()> { run_riverctl( early_joiner_config.path(), &early_url, - &["invite", "accept", &invite1.invitation_code, "--nickname", "early_joiner"], + &[ + "invite", + "accept", + &invite1.invitation_code, + "--nickname", + "early_joiner", + ], ) .await .context("Failed to accept invite for early joiner")?; @@ -1482,8 +1499,12 @@ async fn run_late_joiner_test() -> Result<()> { Ok(handle) => break handle, Err(err) => { if subscribe_attempts >= 3 { - return Err(err) - .with_context(|| format!("Failed to subscribe peer {} after {} attempts", peer_idx, subscribe_attempts)); + return Err(err).with_context(|| { + format!( + "Failed to subscribe peer {} after {} attempts", + peer_idx, subscribe_attempts + ) + }); } println!( "Subscribe attempt {} failed for peer {}, retrying: {}", @@ -1558,7 +1579,13 @@ async fn run_late_joiner_test() -> Result<()> { run_riverctl( late_joiner_config.path(), &late_url, - &["invite", "accept", &invite2.invitation_code, "--nickname", "late_joiner"], + &[ + "invite", + "accept", + &invite2.invitation_code, + "--nickname", + "late_joiner", + ], ) .await .context("Failed to accept invite for late joiner")?; @@ -1628,7 +1655,7 @@ async fn run_late_joiner_test() -> Result<()> { "REGRESSION: Late joiner failed to receive post-join messages. \ This indicates the contract key mismatch bug (PR #2360) may have regressed. \ The late joiner fetched the contract via GET, but subsequent UPDATEs failed \ - because the contract store couldn't find the contract with the key used in the UPDATE." + because the contract store couldn't find the contract with the key used in the UPDATE.", )?; // Also verify early joiner received all messages diff --git a/common/src/room_state/message.rs b/common/src/room_state/message.rs index 97285ac9..1e572573 100644 --- a/common/src/room_state/message.rs +++ b/common/src/room_state/message.rs @@ -8,12 +8,28 @@ use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; use freenet_scaffold::util::{fast_hash, FastHash}; use freenet_scaffold::ComposableState; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt; use std::time::SystemTime; +/// Computed state for message actions (edits, deletes, reactions) +/// This is rebuilt from action messages and not serialized +#[derive(Clone, PartialEq, Debug, Default)] +pub struct MessageActionsState { + /// Messages that have been edited: message_id -> new content + pub edited_content: HashMap, + /// Messages that have been deleted + pub deleted: std::collections::HashSet, + /// Reactions on messages: message_id -> (emoji -> list of reactors) + pub reactions: HashMap>>, +} + #[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)] pub struct MessagesV1 { pub messages: Vec, + /// Computed state from action messages (not serialized - rebuilt on each delta) + #[serde(skip)] + pub actions_state: MessageActionsState, } impl ComposableState for MessagesV1 { @@ -90,7 +106,7 @@ impl ComposableState for MessagesV1 { let privacy_mode = &parent_state.configuration.configuration.privacy_mode; let current_secret_version = parent_state.secrets.current_version; - // Validate private message constraints before adding + // Validate message constraints before adding if let Some(delta) = delta { for msg in delta { match &msg.message.content { @@ -120,6 +136,31 @@ impl ComposableState for MessagesV1 { return Err("Cannot send public messages in private room".to_string()); } } + // Action messages (Edit, Delete, Reaction, RemoveReaction) are always allowed + // Authorization is checked when applying the action + RoomMessageBody::Edit { new_content, .. } => { + // For edits in private rooms, the new content must be properly encrypted + if *privacy_mode == PrivacyMode::Private { + if let RoomMessageBody::Private { secret_version, .. } = + new_content.as_ref() + { + if *secret_version != current_secret_version { + return Err(format!( + "Edit's new content secret version {} does not match current version {}", + secret_version, current_secret_version + )); + } + } else { + return Err("Edit's new content must be encrypted in private room" + .to_string()); + } + } + } + RoomMessageBody::Delete { .. } + | RoomMessageBody::Reaction { .. } + | RoomMessageBody::RemoveReaction { .. } => { + // These actions don't have content constraints + } } } @@ -156,10 +197,130 @@ impl ComposableState for MessagesV1 { .drain(0..self.messages.len() - max_recent_messages); } + // Rebuild computed state from action messages + self.rebuild_actions_state(); + Ok(()) } } +impl MessagesV1 { + /// Rebuild the computed actions state by scanning all action messages + pub fn rebuild_actions_state(&mut self) { + // Clear existing computed state + self.actions_state = MessageActionsState::default(); + + // Build a map of message_id -> author for authorization checks + let message_authors: HashMap = self + .messages + .iter() + .filter(|m| !m.message.content.is_action()) + .map(|m| (m.id(), m.message.author)) + .collect(); + + // Process action messages in timestamp order (messages are already sorted) + for msg in &self.messages { + let actor = msg.message.author; + match &msg.message.content { + RoomMessageBody::Edit { + target, + new_content, + } => { + // Only the original author can edit their message + if let Some(&original_author) = message_authors.get(target) { + if actor == original_author { + // Don't allow editing deleted messages + if !self.actions_state.deleted.contains(target) { + self.actions_state + .edited_content + .insert(target.clone(), *new_content.clone()); + } + } + } + } + RoomMessageBody::Delete { target } => { + // Only the original author can delete their message + if let Some(&original_author) = message_authors.get(target) { + if actor == original_author { + self.actions_state.deleted.insert(target.clone()); + // Also remove any edited content for deleted messages + self.actions_state.edited_content.remove(target); + } + } + } + RoomMessageBody::Reaction { target, emoji } => { + // Anyone can add reactions to non-deleted messages + if message_authors.contains_key(target) + && !self.actions_state.deleted.contains(target) + { + let reactions = self + .actions_state + .reactions + .entry(target.clone()) + .or_default(); + let reactors = reactions.entry(emoji.clone()).or_default(); + // Idempotent: only add if not already present + if !reactors.contains(&actor) { + reactors.push(actor); + } + } + } + RoomMessageBody::RemoveReaction { target, emoji } => { + // Users can only remove their own reactions + if let Some(reactions) = self.actions_state.reactions.get_mut(target) { + if let Some(reactors) = reactions.get_mut(emoji) { + reactors.retain(|r| r != &actor); + // Clean up empty entries + if reactors.is_empty() { + reactions.remove(emoji); + } + } + if reactions.is_empty() { + self.actions_state.reactions.remove(target); + } + } + } + RoomMessageBody::Public { .. } | RoomMessageBody::Private { .. } => { + // Regular messages don't affect computed state + } + } + } + } + + /// Check if a message has been edited + pub fn is_edited(&self, message_id: &MessageId) -> bool { + self.actions_state.edited_content.contains_key(message_id) + } + + /// Check if a message has been deleted + pub fn is_deleted(&self, message_id: &MessageId) -> bool { + self.actions_state.deleted.contains(message_id) + } + + /// Get the effective content for a message (edited content if edited, original otherwise) + /// Returns a clone since edited content may have a different lifetime than the original + pub fn effective_content(&self, message: &AuthorizedMessageV1) -> RoomMessageBody { + let id = message.id(); + self.actions_state + .edited_content + .get(&id) + .cloned() + .unwrap_or_else(|| message.message.content.clone()) + } + + /// Get reactions for a message + pub fn reactions(&self, message_id: &MessageId) -> Option<&HashMap>> { + self.actions_state.reactions.get(message_id) + } + + /// Get all non-deleted, non-action messages for display + pub fn display_messages(&self) -> impl Iterator { + self.messages.iter().filter(|m| { + !m.message.content.is_action() && !self.actions_state.deleted.contains(&m.id()) + }) + } +} + /// Message body that can be either public plaintext or private encrypted #[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] pub enum RoomMessageBody { @@ -171,6 +332,17 @@ pub enum RoomMessageBody { nonce: [u8; 12], secret_version: SecretVersion, }, + /// Edit action: replace target message content (author only) + Edit { + target: MessageId, + new_content: Box, + }, + /// Delete action: remove target message (author only) + Delete { target: MessageId }, + /// Reaction action: add emoji reaction to target message + Reaction { target: MessageId, emoji: String }, + /// Remove reaction action: remove own emoji reaction from target message + RemoveReaction { target: MessageId, emoji: String }, } impl RoomMessageBody { @@ -188,6 +360,29 @@ impl RoomMessageBody { } } + /// Create an edit action + pub fn edit(target: MessageId, new_content: RoomMessageBody) -> Self { + Self::Edit { + target, + new_content: Box::new(new_content), + } + } + + /// Create a delete action + pub fn delete(target: MessageId) -> Self { + Self::Delete { target } + } + + /// Create a reaction action + pub fn reaction(target: MessageId, emoji: String) -> Self { + Self::Reaction { target, emoji } + } + + /// Create a remove reaction action + pub fn remove_reaction(target: MessageId, emoji: String) -> Self { + Self::RemoveReaction { target, emoji } + } + /// Check if this is a public message pub fn is_public(&self) -> bool { matches!(self, Self::Public { .. }) @@ -198,11 +393,36 @@ impl RoomMessageBody { matches!(self, Self::Private { .. }) } + /// Check if this is an action message (edit, delete, reaction, etc.) + pub fn is_action(&self) -> bool { + matches!( + self, + Self::Edit { .. } + | Self::Delete { .. } + | Self::Reaction { .. } + | Self::RemoveReaction { .. } + ) + } + + /// Get the target message ID if this is an action + pub fn target_id(&self) -> Option<&MessageId> { + match self { + Self::Edit { target, .. } + | Self::Delete { target } + | Self::Reaction { target, .. } + | Self::RemoveReaction { target, .. } => Some(target), + Self::Public { .. } | Self::Private { .. } => None, + } + } + /// Get the content length for validation pub fn content_len(&self) -> usize { match self { Self::Public { plaintext } => plaintext.len(), Self::Private { ciphertext, .. } => ciphertext.len(), + Self::Edit { new_content, .. } => new_content.content_len(), + Self::Delete { .. } => 0, + Self::Reaction { emoji, .. } | Self::RemoveReaction { emoji, .. } => emoji.len(), } } @@ -211,6 +431,8 @@ impl RoomMessageBody { match self { Self::Public { .. } => None, Self::Private { secret_version, .. } => Some(*secret_version), + Self::Edit { new_content, .. } => new_content.secret_version(), + Self::Delete { .. } | Self::Reaction { .. } | Self::RemoveReaction { .. } => None, } } @@ -230,14 +452,20 @@ impl RoomMessageBody { secret_version ) } + Self::Edit { target, .. } => format!("[Edit of message {}]", target), + Self::Delete { target } => format!("[Delete of message {}]", target), + Self::Reaction { target, emoji } => format!("[Reaction {} to {}]", emoji, target), + Self::RemoveReaction { target, emoji } => { + format!("[Remove reaction {} from {}]", emoji, target) + } } } - /// Try to get the public plaintext, returns None if private + /// Try to get the public plaintext, returns None if private or action pub fn as_public_string(&self) -> Option<&str> { match self { Self::Public { plaintext } => Some(plaintext), - Self::Private { .. } => None, + _ => None, } } } @@ -419,6 +647,7 @@ mod tests { // Create a Messages struct with the authorized message let messages = MessagesV1 { messages: vec![authorized_message], + ..Default::default() }; // Set up a parent room_state (ChatRoomState) with the author as a member @@ -460,6 +689,7 @@ mod tests { AuthorizedMessageV1::new(invalid_message, &author_signing_key); let invalid_messages = MessagesV1 { messages: vec![invalid_authorized_message], + ..Default::default() }; assert!( invalid_messages.verify(&parent_state, ¶meters).is_err(), @@ -481,6 +711,7 @@ mod tests { let messages = MessagesV1 { messages: vec![authorized_message1.clone(), authorized_message2.clone()], + ..Default::default() }; let parent_state = ChatRoomStateV1::default(); @@ -494,7 +725,7 @@ mod tests { assert_eq!(summary[1], authorized_message2.id()); // Test empty messages - let empty_messages = MessagesV1 { messages: vec![] }; + let empty_messages = MessagesV1::default(); let empty_summary = empty_messages.summarize(&parent_state, ¶meters); assert!(empty_summary.is_empty()); } @@ -519,6 +750,7 @@ mod tests { authorized_message2.clone(), authorized_message3.clone(), ], + ..Default::default() }; let parent_state = ChatRoomStateV1::default(); @@ -599,6 +831,7 @@ mod tests { // Initial room_state with 2 messages let mut messages = MessagesV1 { messages: vec![message1.clone(), message2.clone()], + ..Default::default() }; // Apply delta with 2 new messages @@ -697,6 +930,7 @@ mod tests { // Create a messages state with both messages let messages = MessagesV1 { messages: vec![auth_msg1.clone(), auth_msg2.clone()], + ..Default::default() }; // Verify authors are preserved @@ -731,4 +965,323 @@ mod tests { "User ID strings should be different" ); } + + #[test] + fn test_edit_action() { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let owner_id = MemberId::from(&verifying_key); + let author_id = owner_id; + + // Create original message + let original_msg = MessageV1 { + room_owner: owner_id, + author: author_id, + time: SystemTime::now(), + content: RoomMessageBody::public("Original content".to_string()), + }; + let auth_original = AuthorizedMessageV1::new(original_msg, &signing_key); + let original_id = auth_original.id(); + + // Create edit action + let edit_msg = MessageV1 { + room_owner: owner_id, + author: author_id, + time: SystemTime::now() + Duration::from_secs(1), + content: RoomMessageBody::edit( + original_id.clone(), + RoomMessageBody::public("Edited content".to_string()), + ), + }; + let auth_edit = AuthorizedMessageV1::new(edit_msg, &signing_key); + + // Create messages state and rebuild + let mut messages = MessagesV1 { + messages: vec![auth_original.clone(), auth_edit], + ..Default::default() + }; + messages.rebuild_actions_state(); + + // Verify edit was applied + assert!(messages.is_edited(&original_id)); + let effective = messages.effective_content(&auth_original); + assert_eq!(effective.as_public_string(), Some("Edited content")); + + // Verify display_messages still shows the original message + let display: Vec<_> = messages.display_messages().collect(); + assert_eq!(display.len(), 1); + } + + #[test] + fn test_edit_by_non_author_ignored() { + let owner_sk = SigningKey::generate(&mut OsRng); + let owner_vk = owner_sk.verifying_key(); + let owner_id = MemberId::from(&owner_vk); + + let other_sk = SigningKey::generate(&mut OsRng); + let other_id = MemberId::from(&other_sk.verifying_key()); + + // Create message by owner + let original_msg = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now(), + content: RoomMessageBody::public("Original content".to_string()), + }; + let auth_original = AuthorizedMessageV1::new(original_msg, &owner_sk); + let original_id = auth_original.id(); + + // Create edit action by OTHER user (should be ignored) + let edit_msg = MessageV1 { + room_owner: owner_id, + author: other_id, + time: SystemTime::now() + Duration::from_secs(1), + content: RoomMessageBody::edit( + original_id.clone(), + RoomMessageBody::public("Hacked content".to_string()), + ), + }; + let auth_edit = AuthorizedMessageV1::new(edit_msg, &other_sk); + + let mut messages = MessagesV1 { + messages: vec![auth_original.clone(), auth_edit], + ..Default::default() + }; + messages.rebuild_actions_state(); + + // Edit should be ignored - original content preserved + assert!(!messages.is_edited(&original_id)); + let effective = messages.effective_content(&auth_original); + assert_eq!(effective.as_public_string(), Some("Original content")); + } + + #[test] + fn test_delete_action() { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let owner_id = MemberId::from(&verifying_key); + + // Create original message + let original_msg = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now(), + content: RoomMessageBody::public("Will be deleted".to_string()), + }; + let auth_original = AuthorizedMessageV1::new(original_msg, &signing_key); + let original_id = auth_original.id(); + + // Create delete action + let delete_msg = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now() + Duration::from_secs(1), + content: RoomMessageBody::delete(original_id.clone()), + }; + let auth_delete = AuthorizedMessageV1::new(delete_msg, &signing_key); + + let mut messages = MessagesV1 { + messages: vec![auth_original, auth_delete], + ..Default::default() + }; + messages.rebuild_actions_state(); + + // Verify message is deleted + assert!(messages.is_deleted(&original_id)); + + // Verify display_messages excludes deleted message + let display: Vec<_> = messages.display_messages().collect(); + assert_eq!(display.len(), 0); + } + + #[test] + fn test_reaction_action() { + let user1_sk = SigningKey::generate(&mut OsRng); + let user1_id = MemberId::from(&user1_sk.verifying_key()); + + let user2_sk = SigningKey::generate(&mut OsRng); + let user2_id = MemberId::from(&user2_sk.verifying_key()); + + let owner_id = user1_id; + + // Create original message + let original_msg = MessageV1 { + room_owner: owner_id, + author: user1_id, + time: SystemTime::now(), + content: RoomMessageBody::public("React to me!".to_string()), + }; + let auth_original = AuthorizedMessageV1::new(original_msg, &user1_sk); + let original_id = auth_original.id(); + + // Create reaction from user2 + let reaction_msg = MessageV1 { + room_owner: owner_id, + author: user2_id, + time: SystemTime::now() + Duration::from_secs(1), + content: RoomMessageBody::reaction(original_id.clone(), "👍".to_string()), + }; + let auth_reaction = AuthorizedMessageV1::new(reaction_msg, &user2_sk); + + // Create another reaction from user1 + let reaction_msg2 = MessageV1 { + room_owner: owner_id, + author: user1_id, + time: SystemTime::now() + Duration::from_secs(2), + content: RoomMessageBody::reaction(original_id.clone(), "👍".to_string()), + }; + let auth_reaction2 = AuthorizedMessageV1::new(reaction_msg2, &user1_sk); + + let mut messages = MessagesV1 { + messages: vec![auth_original, auth_reaction, auth_reaction2], + ..Default::default() + }; + messages.rebuild_actions_state(); + + // Verify reactions + let reactions = messages.reactions(&original_id).unwrap(); + let thumbs_up = reactions.get("👍").unwrap(); + assert_eq!(thumbs_up.len(), 2); + assert!(thumbs_up.contains(&user1_id)); + assert!(thumbs_up.contains(&user2_id)); + } + + #[test] + fn test_remove_reaction_action() { + let user_sk = SigningKey::generate(&mut OsRng); + let user_id = MemberId::from(&user_sk.verifying_key()); + let owner_id = user_id; + + // Create original message + let original_msg = MessageV1 { + room_owner: owner_id, + author: user_id, + time: SystemTime::now(), + content: RoomMessageBody::public("Test message".to_string()), + }; + let auth_original = AuthorizedMessageV1::new(original_msg, &user_sk); + let original_id = auth_original.id(); + + // Add reaction + let reaction_msg = MessageV1 { + room_owner: owner_id, + author: user_id, + time: SystemTime::now() + Duration::from_secs(1), + content: RoomMessageBody::reaction(original_id.clone(), "❤️".to_string()), + }; + let auth_reaction = AuthorizedMessageV1::new(reaction_msg, &user_sk); + + // Remove reaction + let remove_msg = MessageV1 { + room_owner: owner_id, + author: user_id, + time: SystemTime::now() + Duration::from_secs(2), + content: RoomMessageBody::remove_reaction(original_id.clone(), "❤️".to_string()), + }; + let auth_remove = AuthorizedMessageV1::new(remove_msg, &user_sk); + + let mut messages = MessagesV1 { + messages: vec![auth_original, auth_reaction, auth_remove], + ..Default::default() + }; + messages.rebuild_actions_state(); + + // Verify reaction was removed + assert!(messages.reactions(&original_id).is_none()); + } + + #[test] + fn test_action_on_deleted_message_ignored() { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let owner_id = MemberId::from(&verifying_key); + + // Create original message + let original_msg = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now(), + content: RoomMessageBody::public("Will be deleted".to_string()), + }; + let auth_original = AuthorizedMessageV1::new(original_msg, &signing_key); + let original_id = auth_original.id(); + + // Delete it + let delete_msg = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now() + Duration::from_secs(1), + content: RoomMessageBody::delete(original_id.clone()), + }; + let auth_delete = AuthorizedMessageV1::new(delete_msg, &signing_key); + + // Try to edit deleted message (should be ignored) + let edit_msg = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now() + Duration::from_secs(2), + content: RoomMessageBody::edit( + original_id.clone(), + RoomMessageBody::public("Too late!".to_string()), + ), + }; + let auth_edit = AuthorizedMessageV1::new(edit_msg, &signing_key); + + let mut messages = MessagesV1 { + messages: vec![auth_original, auth_delete, auth_edit], + ..Default::default() + }; + messages.rebuild_actions_state(); + + // Message should be deleted, edit should be ignored + assert!(messages.is_deleted(&original_id)); + assert!(!messages.is_edited(&original_id)); + } + + #[test] + fn test_display_messages_filters_actions() { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let owner_id = MemberId::from(&verifying_key); + + // Create regular message + let msg1 = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now(), + content: RoomMessageBody::public("Hello".to_string()), + }; + let auth_msg1 = AuthorizedMessageV1::new(msg1, &signing_key); + let msg1_id = auth_msg1.id(); + + // Create reaction (action message) + let reaction_msg = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now() + Duration::from_secs(1), + content: RoomMessageBody::reaction(msg1_id, "👍".to_string()), + }; + let auth_reaction = AuthorizedMessageV1::new(reaction_msg, &signing_key); + + // Create another regular message + let msg2 = MessageV1 { + room_owner: owner_id, + author: owner_id, + time: SystemTime::now() + Duration::from_secs(2), + content: RoomMessageBody::public("World".to_string()), + }; + let auth_msg2 = AuthorizedMessageV1::new(msg2, &signing_key); + + let mut messages = MessagesV1 { + messages: vec![auth_msg1, auth_reaction, auth_msg2], + ..Default::default() + }; + messages.rebuild_actions_state(); + + // display_messages should only return regular messages, not actions + let display: Vec<_> = messages.display_messages().collect(); + assert_eq!(display.len(), 2); + assert_eq!(display[0].message.content.as_public_string(), Some("Hello")); + assert_eq!(display[1].message.content.as_public_string(), Some("World")); + } } diff --git a/common/tests/private_room_test.rs b/common/tests/private_room_test.rs index 47c057e5..032abcc1 100644 --- a/common/tests/private_room_test.rs +++ b/common/tests/private_room_test.rs @@ -648,5 +648,8 @@ fn test_encrypted_messages_in_private_room() { RoomMessageBody::Public { .. } => { panic!("Expected encrypted message in private room"); } + _ => { + panic!("Expected regular message, not action message"); + } } } diff --git a/delegates/chat-delegate/src/handlers.rs b/delegates/chat-delegate/src/handlers.rs index 1d2f02f7..77c8cb1f 100644 --- a/delegates/chat-delegate/src/handlers.rs +++ b/delegates/chat-delegate/src/handlers.rs @@ -65,7 +65,14 @@ pub(crate) fn handle_application_message( message_bytes, } => { logging::info(format!("Delegate received SignMessage for room: {room_key:?}").as_str()); - handle_sign_request(&mut context, origin, room_key, request_id, message_bytes, app_msg.app) + handle_sign_request( + &mut context, + origin, + room_key, + request_id, + message_bytes, + app_msg.app, + ) } ChatDelegateRequestMsg::SignMember { room_key, @@ -73,7 +80,14 @@ pub(crate) fn handle_application_message( member_bytes, } => { logging::info(format!("Delegate received SignMember for room: {room_key:?}").as_str()); - handle_sign_request(&mut context, origin, room_key, request_id, member_bytes, app_msg.app) + handle_sign_request( + &mut context, + origin, + room_key, + request_id, + member_bytes, + app_msg.app, + ) } ChatDelegateRequestMsg::SignBan { room_key, @@ -81,7 +95,14 @@ pub(crate) fn handle_application_message( ban_bytes, } => { logging::info(format!("Delegate received SignBan for room: {room_key:?}").as_str()); - handle_sign_request(&mut context, origin, room_key, request_id, ban_bytes, app_msg.app) + handle_sign_request( + &mut context, + origin, + room_key, + request_id, + ban_bytes, + app_msg.app, + ) } ChatDelegateRequestMsg::SignConfig { room_key, @@ -89,7 +110,14 @@ pub(crate) fn handle_application_message( config_bytes, } => { logging::info(format!("Delegate received SignConfig for room: {room_key:?}").as_str()); - handle_sign_request(&mut context, origin, room_key, request_id, config_bytes, app_msg.app) + handle_sign_request( + &mut context, + origin, + room_key, + request_id, + config_bytes, + app_msg.app, + ) } ChatDelegateRequestMsg::SignMemberInfo { room_key, @@ -116,7 +144,14 @@ pub(crate) fn handle_application_message( logging::info( format!("Delegate received SignSecretVersion for room: {room_key:?}").as_str(), ); - handle_sign_request(&mut context, origin, room_key, request_id, record_bytes, app_msg.app) + handle_sign_request( + &mut context, + origin, + room_key, + request_id, + record_bytes, + app_msg.app, + ) } ChatDelegateRequestMsg::SignEncryptedSecret { room_key, @@ -126,7 +161,14 @@ pub(crate) fn handle_application_message( logging::info( format!("Delegate received SignEncryptedSecret for room: {room_key:?}").as_str(), ); - handle_sign_request(&mut context, origin, room_key, request_id, secret_bytes, app_msg.app) + handle_sign_request( + &mut context, + origin, + room_key, + request_id, + secret_bytes, + app_msg.app, + ) } ChatDelegateRequestMsg::SignUpgrade { room_key, @@ -134,7 +176,14 @@ pub(crate) fn handle_application_message( upgrade_bytes, } => { logging::info(format!("Delegate received SignUpgrade for room: {room_key:?}").as_str()); - handle_sign_request(&mut context, origin, room_key, request_id, upgrade_bytes, app_msg.app) + handle_sign_request( + &mut context, + origin, + room_key, + request_id, + upgrade_bytes, + app_msg.app, + ) } } } diff --git a/ui/src/components/app.rs b/ui/src/components/app.rs index 3e4c4b06..b058028e 100644 --- a/ui/src/components/app.rs +++ b/ui/src/components/app.rs @@ -14,8 +14,8 @@ use crate::components::room_list::edit_room_modal::EditRoomModal; use crate::components::room_list::receive_invitation_modal::ReceiveInvitationModal; use crate::invites::PendingInvites; use crate::room_data::{CurrentRoom, Rooms}; -use dioxus::logger::tracing::{debug, error, info}; use dioxus::document::Stylesheet; +use dioxus::logger::tracing::{debug, error, info}; use dioxus::prelude::*; use ed25519_dalek::VerifyingKey; use freenet_stdlib::client_api::WebApi; @@ -84,10 +84,18 @@ pub fn App() -> Element { let new_url = if new_search.is_empty() { window.location().pathname().unwrap_or_default() } else { - format!("{}?{}", window.location().pathname().unwrap_or_default(), new_search) + format!( + "{}?{}", + window.location().pathname().unwrap_or_default(), + new_search + ) }; if let Ok(history) = window.history() { - let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&new_url)); + let _ = history.replace_state_with_url( + &wasm_bindgen::JsValue::NULL, + "", + Some(&new_url), + ); } } } diff --git a/ui/src/components/app/chat_delegate.rs b/ui/src/components/app/chat_delegate.rs index 044184bd..c40e34f4 100644 --- a/ui/src/components/app/chat_delegate.rs +++ b/ui/src/components/app/chat_delegate.rs @@ -206,14 +206,46 @@ fn get_request_key(request: &ChatDelegateRequestMsg) -> Vec { } // Signing operations - use prefix + room_key + request_id for uniqueness - ChatDelegateRequestMsg::SignMessage { room_key, request_id, .. } - | ChatDelegateRequestMsg::SignMember { room_key, request_id, .. } - | ChatDelegateRequestMsg::SignBan { room_key, request_id, .. } - | ChatDelegateRequestMsg::SignConfig { room_key, request_id, .. } - | ChatDelegateRequestMsg::SignMemberInfo { room_key, request_id, .. } - | ChatDelegateRequestMsg::SignSecretVersion { room_key, request_id, .. } - | ChatDelegateRequestMsg::SignEncryptedSecret { room_key, request_id, .. } - | ChatDelegateRequestMsg::SignUpgrade { room_key, request_id, .. } => { + ChatDelegateRequestMsg::SignMessage { + room_key, + request_id, + .. + } + | ChatDelegateRequestMsg::SignMember { + room_key, + request_id, + .. + } + | ChatDelegateRequestMsg::SignBan { + room_key, + request_id, + .. + } + | ChatDelegateRequestMsg::SignConfig { + room_key, + request_id, + .. + } + | ChatDelegateRequestMsg::SignMemberInfo { + room_key, + request_id, + .. + } + | ChatDelegateRequestMsg::SignSecretVersion { + room_key, + request_id, + .. + } + | ChatDelegateRequestMsg::SignEncryptedSecret { + room_key, + request_id, + .. + } + | ChatDelegateRequestMsg::SignUpgrade { + room_key, + request_id, + .. + } => { let mut key = SIGN_PREFIX.to_vec(); key.extend_from_slice(room_key); key.extend_from_slice(&request_id.to_le_bytes()); diff --git a/ui/src/components/app/freenet_api/constants.rs b/ui/src/components/app/freenet_api/constants.rs index cac08d1d..f332c279 100644 --- a/ui/src/components/app/freenet_api/constants.rs +++ b/ui/src/components/app/freenet_api/constants.rs @@ -1,7 +1,8 @@ #![allow(dead_code)] /// Fallback WebSocket URL for non-browser environments -const FALLBACK_WEBSOCKET_URL: &str = "ws://localhost:7509/v1/contract/command?encodingProtocol=native"; +const FALLBACK_WEBSOCKET_URL: &str = + "ws://localhost:7509/v1/contract/command?encodingProtocol=native"; /// Get the WebSocket URL for connecting to the Freenet node. /// Derives the URL from the current window.location, allowing River to work @@ -14,7 +15,10 @@ pub fn get_websocket_url() -> String { let host = location.host().unwrap_or_default(); // includes port let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - format!("{}//{}/v1/contract/command?encodingProtocol=native", ws_protocol, host) + format!( + "{}//{}/v1/contract/command?encodingProtocol=native", + ws_protocol, host + ) } else { FALLBACK_WEBSOCKET_URL.to_string() } diff --git a/ui/src/components/app/freenet_api/freenet_synchronizer.rs b/ui/src/components/app/freenet_api/freenet_synchronizer.rs index 025d6d8f..658548ee 100644 --- a/ui/src/components/app/freenet_api/freenet_synchronizer.rs +++ b/ui/src/components/app/freenet_api/freenet_synchronizer.rs @@ -115,9 +115,12 @@ impl FreenetSynchronizer { let callback = Closure::::new(move || { if let Some(window) = web_sys::window() { if let Some(document) = window.document() { - if document.visibility_state() == web_sys::VisibilityState::Visible { + if document.visibility_state() == web_sys::VisibilityState::Visible + { info!("Page became visible, checking connection health"); - if let Err(e) = visibility_tx.unbounded_send(SynchronizerMessage::PageBecameVisible) { + if let Err(e) = visibility_tx + .unbounded_send(SynchronizerMessage::PageBecameVisible) + { error!("Failed to send PageBecameVisible message: {}", e); } } @@ -166,7 +169,9 @@ impl FreenetSynchronizer { let error_str = e.to_string(); if error_str.contains("WebSocket") || error_str.contains("not open") { warn!("WebSocket error during room processing, triggering reconnection"); - if let Err(e) = message_tx.unbounded_send(SynchronizerMessage::ConnectionLost) { + if let Err(e) = + message_tx.unbounded_send(SynchronizerMessage::ConnectionLost) + { error!("Failed to send ConnectionLost: {}", e); } } @@ -196,14 +201,17 @@ impl FreenetSynchronizer { info!("Page visibility changed to visible, checking connection status"); if !connection_manager.is_connected() { info!("Connection is not active after wake, triggering reconnection"); - if let Err(e) = message_tx.unbounded_send(SynchronizerMessage::Connect) { + if let Err(e) = message_tx.unbounded_send(SynchronizerMessage::Connect) + { error!("Failed to send Connect message after wake: {}", e); } } else { // Connection appears active, trigger a room sync to verify // This will fail fast if the connection is actually dead info!("Connection appears active, triggering room sync to verify"); - if let Err(e) = message_tx.unbounded_send(SynchronizerMessage::ProcessRooms) { + if let Err(e) = + message_tx.unbounded_send(SynchronizerMessage::ProcessRooms) + { error!("Failed to send ProcessRooms message after wake: {}", e); } } @@ -295,9 +303,9 @@ impl FreenetSynchronizer { )) .await; info!("Re-PUT delay elapsed, triggering ProcessRooms to PUT contract"); - if let Err(e) = - tx.unbounded_send(SynchronizerMessage::ProcessRooms) - { + if let Err(e) = tx.unbounded_send( + SynchronizerMessage::ProcessRooms, + ) { error!("Failed to schedule re-PUT: {}", e); } }); @@ -313,10 +321,13 @@ impl FreenetSynchronizer { )) .await; info!("Subscription timeout check triggered"); - if let Err(e) = - tx.unbounded_send(SynchronizerMessage::ProcessRooms) - { - error!("Failed to schedule timeout check: {}", e); + if let Err(e) = tx.unbounded_send( + SynchronizerMessage::ProcessRooms, + ) { + error!( + "Failed to schedule timeout check: {}", + e + ); } }); } diff --git a/ui/src/components/app/freenet_api/response_handler.rs b/ui/src/components/app/freenet_api/response_handler.rs index 2f19d046..d78422df 100644 --- a/ui/src/components/app/freenet_api/response_handler.rs +++ b/ui/src/components/app/freenet_api/response_handler.rs @@ -176,9 +176,15 @@ impl ResponseHandler { response.clone(), ), // Signing response - use both room_key and request_id for correlation - ChatDelegateResponseMsg::SignResponse { room_key, request_id, .. } => { - complete_pending_sign_request(room_key, *request_id, response.clone()) - } + ChatDelegateResponseMsg::SignResponse { + room_key, + request_id, + .. + } => complete_pending_sign_request( + room_key, + *request_id, + response.clone(), + ), }; if completed { diff --git a/ui/src/components/app/freenet_api/response_handler/subscribe_response.rs b/ui/src/components/app/freenet_api/response_handler/subscribe_response.rs index 6f5b538d..299b3471 100644 --- a/ui/src/components/app/freenet_api/response_handler/subscribe_response.rs +++ b/ui/src/components/app/freenet_api/response_handler/subscribe_response.rs @@ -53,7 +53,9 @@ pub fn handle_subscribe_response(key: ContractKey, subscribed: bool) -> bool { ); SYNC_INFO.write().update_sync_status( &owner_vk, - RoomSyncStatus::Error("Subscription failed - contract not found on network".to_string()), + RoomSyncStatus::Error( + "Subscription failed - contract not found on network".to_string(), + ), ); false } diff --git a/ui/src/components/app/freenet_api/room_synchronizer.rs b/ui/src/components/app/freenet_api/room_synchronizer.rs index 05c4a262..22e9c9e5 100644 --- a/ui/src/components/app/freenet_api/room_synchronizer.rs +++ b/ui/src/components/app/freenet_api/room_synchronizer.rs @@ -190,7 +190,7 @@ impl RoomSynchronizer { // Create a get request without subscription (will subscribe after response) let get_request = ContractRequest::Get { - key: *contract_key.id(), // GET uses ContractInstanceId + key: *contract_key.id(), // GET uses ContractInstanceId return_contract_code: true, // I think this should be false but apparently that was triggering a bug subscribe: false, }; @@ -295,8 +295,10 @@ impl RoomSynchronizer { ); // Update sync status to error using with_mut SYNC_INFO.with_mut(|sync_info| { - sync_info - .update_sync_status(owner_vk, RoomSyncStatus::Error(e.to_string())); + sync_info.update_sync_status( + owner_vk, + RoomSyncStatus::Error(e.to_string()), + ); }); } } diff --git a/ui/src/components/app/notifications.rs b/ui/src/components/app/notifications.rs index c557e9f3..1afe94ff 100644 --- a/ui/src/components/app/notifications.rs +++ b/ui/src/components/app/notifications.rs @@ -435,6 +435,11 @@ fn get_message_preview( "[Encrypted message]".to_string() } } + // Action messages don't generate notification previews + RoomMessageBody::Edit { .. } => "[Edited a message]".to_string(), + RoomMessageBody::Delete { .. } => "[Deleted a message]".to_string(), + RoomMessageBody::Reaction { emoji, .. } => format!("Reacted with {}", emoji), + RoomMessageBody::RemoveReaction { .. } => "[Removed a reaction]".to_string(), }; // Truncate to ~50 chars for notification diff --git a/ui/src/components/conversation.rs b/ui/src/components/conversation.rs index 537fb9c5..7851b139 100644 --- a/ui/src/components/conversation.rs +++ b/ui/src/components/conversation.rs @@ -3,6 +3,7 @@ use crate::components::app::{CURRENT_ROOM, EDIT_ROOM_MODAL, MEMBER_INFO_MODAL, N use crate::room_data::SendMessageError; use crate::util::ecies::encrypt_with_symmetric_key; use crate::util::{format_utc_as_full_datetime, format_utc_as_local_time, get_current_system_time}; +mod message_actions; mod message_input; mod not_member_notification; use self::not_member_notification::NotMemberNotification; @@ -15,8 +16,11 @@ use dioxus_free_icons::Icon; use freenet_scaffold::ComposableState; use river_core::room_state::member::MemberId; use river_core::room_state::member_info::MemberInfoV1; -use river_core::room_state::message::{AuthorizedMessageV1, MessageV1, RoomMessageBody}; +use river_core::room_state::message::{ + AuthorizedMessageV1, MessageId, MessageV1, MessagesV1, RoomMessageBody, +}; use river_core::room_state::{ChatRoomParametersV1, ChatRoomStateV1Delta}; +use std::collections::HashMap; use std::rc::Rc; use std::time::Duration; use wasm_bindgen_futures::spawn_local; @@ -37,11 +41,14 @@ struct GroupedMessage { #[allow(dead_code)] time: DateTime, id: String, + message_id: MessageId, + edited: bool, + reactions: HashMap>, } /// Group consecutive messages from the same sender within 5 minutes fn group_messages( - messages: &[AuthorizedMessageV1], + messages_state: &MessagesV1, member_info: &MemberInfoV1, self_member_id: MemberId, room_secret: Option<[u8; 32]>, @@ -50,9 +57,11 @@ fn group_messages( let mut groups: Vec = Vec::new(); let group_threshold = Duration::from_secs(5 * 60); // 5 minutes - for message in messages { + // Only iterate over displayable messages (non-deleted, non-action) + for message in messages_state.display_messages() { let author_id = message.message.author; let message_time = DateTime::::from(message.message.time); + let message_id = message.id(); let author_name = member_info .member_info @@ -61,15 +70,27 @@ fn group_messages( .map(|ami| ami.member_info.preferred_nickname.to_string_lossy()) .unwrap_or_else(|| "Unknown".to_string()); + // Get effective content (may be edited) + let effective_content = messages_state.effective_content(message); let content_text = - decrypt_message_content(&message.message.content, room_secret, room_secret_version); + decrypt_message_content(&effective_content, room_secret, room_secret_version); let content_html = message_to_html(&content_text); let is_self = author_id == self_member_id; + // Get edited status and reactions + let edited = messages_state.is_edited(&message_id); + let reactions = messages_state + .reactions(&message_id) + .cloned() + .unwrap_or_default(); + let grouped_message = GroupedMessage { content_html, time: message_time, - id: format!("{:?}", message.id().0), + id: format!("{:?}", message_id.0), + message_id, + edited, + reactions, }; // Check if we should add to the last group @@ -127,6 +148,11 @@ fn decrypt_message_content( content.to_string_lossy() } } + // Action messages should not be displayed directly + RoomMessageBody::Edit { .. } + | RoomMessageBody::Delete { .. } + | RoomMessageBody::Reaction { .. } + | RoomMessageBody::RemoveReaction { .. } => content.to_string_lossy(), } } @@ -257,6 +283,9 @@ pub fn Conversation() -> Element { }; let last_chat_element = use_signal(|| None as Option>); + // State for delete confirmation modal + let mut pending_delete: Signal> = use_signal(|| None); + let current_room_label = use_memo({ move || { let current_room = CURRENT_ROOM.read(); @@ -284,10 +313,16 @@ pub fn Conversation() -> Element { let rooms = ROOMS.read(); if let Some(room_data) = rooms.map.get(&key) { let room_state = &room_data.room_state; - if !room_state.recent_messages.messages.is_empty() { + // Check if there are any displayable messages + if room_state + .recent_messages + .display_messages() + .next() + .is_some() + { let self_member_id = MemberId::from(&room_data.self_sk.verifying_key()); return Some(group_messages( - &room_state.recent_messages.messages, + &room_state.recent_messages, &room_state.member_info, self_member_id, room_data.current_secret, @@ -309,6 +344,131 @@ pub fn Conversation() -> Element { } }); + // Handler for adding a reaction to a message + let handle_add_reaction = { + let current_room_data = current_room_data.clone(); + move |target_message_id: MessageId, emoji: String| { + if let (Some(current_room), Some(current_room_data)) = + (CURRENT_ROOM.read().owner_key, current_room_data.clone()) + { + let room_key = current_room_data.room_key(); + let self_sk = current_room_data.self_sk.clone(); + let room_state_clone = current_room_data.room_state.clone(); + + spawn_local(async move { + let content = RoomMessageBody::Reaction { + target: target_message_id, + emoji, + }; + + let message = MessageV1 { + room_owner: MemberId::from(current_room), + author: MemberId::from(&self_sk.verifying_key()), + content, + time: get_current_system_time(), + }; + + let mut message_bytes = Vec::new(); + if let Err(e) = ciborium::ser::into_writer(&message, &mut message_bytes) { + error!("Failed to serialize reaction message: {:?}", e); + return; + } + + let signature = crate::signing::sign_message_with_fallback( + room_key, + message_bytes, + &self_sk, + ) + .await; + + let auth_message = AuthorizedMessageV1::with_signature(message, signature); + let delta = ChatRoomStateV1Delta { + recent_messages: Some(vec![auth_message]), + ..Default::default() + }; + info!("Sending reaction"); + ROOMS.with_mut(|rooms| { + if let Some(room_data) = rooms.map.get_mut(¤t_room) { + if let Err(e) = room_data.room_state.apply_delta( + &room_state_clone, + &ChatRoomParametersV1 { + owner: current_room, + }, + &Some(delta), + ) { + error!("Failed to apply reaction delta: {:?}", e); + } else { + NEEDS_SYNC.write().insert(current_room); + } + } + }); + }); + } + } + }; + + // Handler for deleting a message + let handle_delete_message = { + let current_room_data = current_room_data.clone(); + move |target_message_id: MessageId| { + if let (Some(current_room), Some(current_room_data)) = + (CURRENT_ROOM.read().owner_key, current_room_data.clone()) + { + let room_key = current_room_data.room_key(); + let self_sk = current_room_data.self_sk.clone(); + let room_state_clone = current_room_data.room_state.clone(); + + spawn_local(async move { + let content = RoomMessageBody::Delete { + target: target_message_id, + }; + + let message = MessageV1 { + room_owner: MemberId::from(current_room), + author: MemberId::from(&self_sk.verifying_key()), + content, + time: get_current_system_time(), + }; + + let mut message_bytes = Vec::new(); + if let Err(e) = ciborium::ser::into_writer(&message, &mut message_bytes) { + error!("Failed to serialize delete message: {:?}", e); + return; + } + + let signature = crate::signing::sign_message_with_fallback( + room_key, + message_bytes, + &self_sk, + ) + .await; + + let auth_message = AuthorizedMessageV1::with_signature(message, signature); + let delta = ChatRoomStateV1Delta { + recent_messages: Some(vec![auth_message]), + ..Default::default() + }; + info!("Sending delete action"); + ROOMS.with_mut(|rooms| { + if let Some(room_data) = rooms.map.get_mut(¤t_room) { + if let Err(e) = room_data.room_state.apply_delta( + &room_state_clone, + &ChatRoomParametersV1 { + owner: current_room, + }, + &Some(delta), + ) { + error!("Failed to apply delete delta: {:?}", e); + } else { + NEEDS_SYNC.write().insert(current_room); + } + } + }); + }); + } + } + }; + // Message sending handler - receives message text from MessageInput component let handle_send_message = { let current_room_data = current_room_data.clone(); @@ -489,17 +649,26 @@ pub fn Conversation() -> Element { let groups_len = groups.len(); Some(rsx! { div { class: "space-y-4", - {groups.into_iter().enumerate().map(|(group_idx, group)| { + {groups.into_iter().enumerate().map({ + let handle_add_reaction = handle_add_reaction.clone(); + move |(group_idx, group)| { let is_last_group = group_idx == groups_len - 1; let key = group.messages[0].id.clone(); + let handle_add_reaction = handle_add_reaction.clone(); rsx! { MessageGroupComponent { key: "{key}", group: group, last_chat_element: if is_last_group { Some(last_chat_element) } else { None }, + on_react: move |(msg_id, emoji)| { + handle_add_reaction(msg_id, emoji); + }, + on_request_delete: move |msg_id| { + pending_delete.set(Some(msg_id)); + }, } } - })} + }})} } }) } @@ -573,20 +742,64 @@ pub fn Conversation() -> Element { }, } } + + // Delete confirmation modal + if pending_delete.read().is_some() { + div { + class: "fixed inset-0 bg-black/50 flex items-center justify-center z-50", + onclick: move |_| pending_delete.set(None), + div { + class: "bg-panel rounded-lg shadow-xl p-6 max-w-sm mx-4", + onclick: move |e| e.stop_propagation(), + h3 { class: "text-lg font-semibold text-text mb-2", + "Delete Message?" + } + p { class: "text-text-muted text-sm mb-4", + "This action cannot be undone. The message will be permanently deleted." + } + div { class: "flex gap-3 justify-end", + button { + class: "px-4 py-2 rounded-lg bg-surface hover:bg-surface/80 text-text transition-colors", + onclick: move |_| pending_delete.set(None), + "Cancel" + } + button { + class: "px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors", + onclick: move |_| { + let msg_id_opt = pending_delete.read().clone(); + if let Some(msg_id) = msg_id_opt { + handle_delete_message(msg_id); + } + pending_delete.set(None); + }, + "Delete" + } + } + } + } + } } } } +/// Curated emoji set for reactions - covers most common emotional responses +const REACTION_EMOJIS: &[&str] = &["👍", "❤️", "😂", "😮", "😢", "😡", "🎉", "🤔"]; + #[component] fn MessageGroupComponent( group: MessageGroup, last_chat_element: Option>>>, + on_react: EventHandler<(MessageId, String)>, + on_request_delete: EventHandler, ) -> Element { let timestamp_ms = group.first_time.timestamp_millis(); let time_str = format_utc_as_local_time(timestamp_ms); let full_time_str = format_utc_as_full_datetime(timestamp_ms); let is_self = group.is_self; + // Track which message's emoji picker is open (by message ID string) + let mut open_emoji_picker: Signal> = use_signal(|| None); + rsx! { div { class: format!( @@ -622,7 +835,7 @@ fn MessageGroupComponent( // Message bubbles div { class: format!( - "space-y-0.5 {}", + "space-y-1 {}", if is_self { "flex flex-col items-end" } else { "" } ), { @@ -630,52 +843,190 @@ fn MessageGroupComponent( group.messages.into_iter().enumerate().map(move |(idx, msg)| { let is_last = idx == messages_len - 1; let is_first = idx == 0; + let has_reactions = !msg.reactions.is_empty(); rsx! { div { key: "{msg.id}", - class: format!( - "px-3 py-2 text-sm {} {} {}", - if is_self { - "bg-accent text-white" - } else { - "bg-surface text-text" - }, - // Rounded corners based on position - if is_self { - if is_first && is_last { - "rounded-2xl" - } else if is_first { - "rounded-t-2xl rounded-bl-2xl rounded-br-md" - } else if is_last { - "rounded-b-2xl rounded-tl-2xl rounded-tr-md" - } else { - "rounded-l-2xl rounded-r-md" + class: "flex flex-col", + // Container for message bubble + hover actions + div { + class: "relative group", + // Message bubble + div { + class: format!( + "px-3 py-2 text-sm {} {} {}", + if is_self { + "bg-accent text-white" + } else { + "bg-surface text-text" + }, + // Rounded corners based on position + if is_self { + if is_first && is_last && !has_reactions { + "rounded-2xl" + } else if is_first { + "rounded-t-2xl rounded-bl-2xl rounded-br-md" + } else if is_last && !has_reactions { + "rounded-b-2xl rounded-tl-2xl rounded-tr-md" + } else { + "rounded-l-2xl rounded-r-md" + } + } else { + if is_first && is_last && !has_reactions { + "rounded-2xl" + } else if is_first { + "rounded-t-2xl rounded-br-2xl rounded-bl-md" + } else if is_last && !has_reactions { + "rounded-b-2xl rounded-tr-2xl rounded-tl-md" + } else { + "rounded-r-2xl rounded-l-md" + } + }, + // Max width for readability + "max-w-prose" + ), + onmounted: move |cx| { + if is_last { + if let Some(mut last_el) = last_chat_element { + last_el.set(Some(cx.data())); + } + } + }, + span { + class: "prose prose-sm dark:prose-invert max-w-none", + dangerous_inner_html: "{msg.content_html}" } - } else { - if is_first && is_last { - "rounded-2xl" - } else if is_first { - "rounded-t-2xl rounded-br-2xl rounded-bl-md" - } else if is_last { - "rounded-b-2xl rounded-tr-2xl rounded-tl-md" - } else { - "rounded-r-2xl rounded-l-md" + // Edited indicator + if msg.edited { + span { + class: format!( + "text-xs ml-2 {}", + if is_self { "text-white/70" } else { "text-text-muted" } + ), + "(edited)" + } } - }, - // Max width for readability - "max-w-prose" - ), - onmounted: move |cx| { - if is_last { - if let Some(mut last_el) = last_chat_element { - last_el.set(Some(cx.data())); + } + // Hover action bar with emoji picker + { + let msg_id_str = msg.id.clone(); + let msg_id_for_delete = msg.message_id.clone(); + let is_picker_open = open_emoji_picker.read().as_ref() == Some(&msg_id_str); + rsx! { + // Invisible backdrop to catch outside clicks when picker is open + if is_picker_open { + div { + class: "fixed inset-0 z-40", + onclick: move |_| open_emoji_picker.set(None), + } + } + div { + class: format!( + "absolute top-0 -translate-y-1/2 transition-opacity z-50 flex items-center gap-0.5 bg-panel rounded-lg shadow-md border border-border p-1 {} {}", + if is_self { "left-0 -translate-x-full -ml-2" } else { "right-0 translate-x-full ml-2" }, + // Keep visible when picker is open, otherwise use hover + if is_picker_open { "opacity-100" } else { "opacity-0 group-hover:opacity-100" } + ), + // Reaction trigger with expandable picker + div { class: "relative", + button { + class: "p-1.5 rounded-full hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors text-sm", + title: "Add reaction", + onclick: { + let msg_id_str = msg_id_str.clone(); + move |e: MouseEvent| { + e.stop_propagation(); + let current = open_emoji_picker.read().clone(); + if current.as_ref() == Some(&msg_id_str) { + open_emoji_picker.set(None); + } else { + open_emoji_picker.set(Some(msg_id_str.clone())); + } + } + }, + "😊" + } + // Emoji picker dropdown - vertical layout + if is_picker_open { + div { + class: format!( + "absolute top-full mt-1 p-1 bg-panel rounded-xl shadow-xl border border-border grid grid-cols-2 gap-0.5 z-50 {}", + if is_self { "right-0" } else { "left-0" } + ), + onclick: move |e: MouseEvent| e.stop_propagation(), + {REACTION_EMOJIS.iter().map(|emoji| { + let emoji_str = emoji.to_string(); + let msg_id = msg.message_id.clone(); + rsx! { + button { + key: "{emoji}", + class: "p-2 rounded-lg hover:bg-surface hover:scale-110 transition-all text-xl", + title: "React with {emoji}", + onclick: move |_| { + on_react.call((msg_id.clone(), emoji_str.clone())); + open_emoji_picker.set(None); + }, + "{emoji}" + } + } + })} + } + } + } + // Divider + if is_self { + div { class: "w-px h-5 bg-border mx-0.5" } + } + // Edit button (only for own messages) + if is_self { + button { + class: "p-1.5 rounded hover:bg-surface transition-colors text-sm opacity-50 hover:opacity-100 cursor-not-allowed", + title: "Edit message (coming soon)", + "✏️" + } + } + // Delete button (only for own messages) + if is_self { + button { + class: "p-1.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-500 transition-colors text-sm opacity-50 hover:opacity-100", + title: "Delete message", + onclick: move |_| { + on_request_delete.call(msg_id_for_delete.clone()); + }, + "🗑️" + } + } + } + } + } + } + // Reactions display + if has_reactions { + div { + class: format!( + "flex flex-wrap gap-1 mt-0.5 {}", + if is_self { "justify-end" } else { "justify-start" } + ), + { + let mut sorted_reactions: Vec<_> = msg.reactions.iter().collect(); + sorted_reactions.sort_by_key(|(emoji, _)| emoji.as_str()); + sorted_reactions.into_iter().map(|(emoji, reactors)| { + let count = reactors.len(); + rsx! { + span { + key: "{emoji}", + class: "inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-surface text-xs border border-border hover:border-accent transition-colors cursor-default", + title: "{count} reaction(s)", + "{emoji}" + if count > 1 { + span { class: "text-text-muted", "{count}" } + } + } + } + }) } } - }, - span { - class: "prose prose-sm dark:prose-invert max-w-none", - dangerous_inner_html: "{msg.content_html}" } } } diff --git a/ui/src/components/conversation/message_actions.rs b/ui/src/components/conversation/message_actions.rs new file mode 100644 index 00000000..84151456 --- /dev/null +++ b/ui/src/components/conversation/message_actions.rs @@ -0,0 +1,138 @@ +use dioxus::prelude::*; +use river_core::room_state::member::MemberId; +use river_core::room_state::message::MessageId; + +/// Common emoji reactions to show in the picker +pub const REACTION_EMOJIS: &[&str] = &["👍", "❤️", "😂", "😮", "😢", "😡", "🎉", "🤔"]; + +/// Props for the message actions component +#[derive(Props, Clone, PartialEq)] +pub struct MessageActionsProps { + /// The message ID for this message + pub message_id: MessageId, + /// Whether the current user is the author of this message + pub is_own_message: bool, + /// Current user's ID for checking existing reactions + pub self_member_id: MemberId, + /// Callback when edit is clicked + pub on_edit: EventHandler, + /// Callback when delete is clicked + pub on_delete: EventHandler, + /// Callback when a reaction is toggled (message_id, emoji) + pub on_toggle_reaction: EventHandler<(MessageId, String)>, +} + +/// Message actions component - shows on hover/click +#[component] +pub fn MessageActions(props: MessageActionsProps) -> Element { + let mut show_emoji_picker = use_signal(|| false); + let message_id = props.message_id.clone(); + let message_id_for_edit = message_id.clone(); + let message_id_for_delete = message_id.clone(); + + rsx! { + div { + class: "flex items-center gap-0.5 bg-panel rounded-lg shadow-lg border border-border p-0.5", + // Emoji reaction button + div { class: "relative", + button { + class: "p-1.5 rounded hover:bg-surface transition-colors text-text-muted hover:text-text", + title: "Add reaction", + onclick: move |_| { + show_emoji_picker.set(!show_emoji_picker()); + }, + "😀" + } + // Emoji picker dropdown + if show_emoji_picker() { + div { + class: "absolute bottom-full left-0 mb-1 bg-panel rounded-lg shadow-lg border border-border p-2 z-50", + div { class: "flex flex-wrap gap-1 max-w-[200px]", + {REACTION_EMOJIS.iter().map(|emoji| { + let emoji_str = emoji.to_string(); + let msg_id = message_id.clone(); + rsx! { + button { + key: "{emoji}", + class: "p-1.5 rounded hover:bg-surface transition-colors text-lg", + title: "React with {emoji}", + onclick: move |_| { + props.on_toggle_reaction.call((msg_id.clone(), emoji_str.clone())); + show_emoji_picker.set(false); + }, + "{emoji}" + } + } + })} + } + } + } + } + // Edit button (only for own messages) + if props.is_own_message { + button { + class: "p-1.5 rounded hover:bg-surface transition-colors text-text-muted hover:text-text", + title: "Edit message", + onclick: move |_| { + props.on_edit.call(message_id_for_edit.clone()); + }, + "✏️" + } + } + // Delete button (only for own messages) + if props.is_own_message { + button { + class: "p-1.5 rounded hover:bg-error-bg hover:text-red-600 transition-colors text-text-muted", + title: "Delete message", + onclick: move |_| { + props.on_delete.call(message_id_for_delete.clone()); + }, + "🗑️" + } + } + } + } +} + +/// Props for an inline reaction button (shown when hovering a message) +#[derive(Props, Clone, PartialEq)] +pub struct QuickReactionProps { + pub message_id: MessageId, + pub on_toggle_reaction: EventHandler<(MessageId, String)>, +} + +/// Quick reaction button - a simpler inline option +#[component] +pub fn QuickReactionButton(props: QuickReactionProps) -> Element { + let mut show_picker = use_signal(|| false); + + rsx! { + div { class: "relative inline-block", + button { + class: "opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-surface text-text-muted hover:text-text text-sm", + onclick: move |_| show_picker.set(!show_picker()), + "+" + } + if show_picker() { + div { + class: "absolute bottom-full right-0 mb-1 bg-panel rounded-lg shadow-lg border border-border p-1 flex gap-0.5 z-50", + {REACTION_EMOJIS.iter().take(6).map(|emoji| { + let emoji_str = emoji.to_string(); + let message_id = props.message_id.clone(); + rsx! { + button { + key: "{emoji}", + class: "p-1 rounded hover:bg-surface transition-colors", + onclick: move |_| { + props.on_toggle_reaction.call((message_id.clone(), emoji_str.clone())); + show_picker.set(false); + }, + "{emoji}" + } + } + })} + } + } + } + } +} diff --git a/ui/src/components/members.rs b/ui/src/components/members.rs index 5c947a5d..399b55e5 100644 --- a/ui/src/components/members.rs +++ b/ui/src/components/members.rs @@ -256,17 +256,17 @@ pub fn MemberList() -> Element { } rsx! { - aside { class: "w-56 flex-shrink-0 bg-panel border-l border-border flex flex-col overflow-y-auto", + aside { class: "w-56 flex-shrink-0 bg-panel border-l border-border flex flex-col", // Header - div { class: "px-4 py-3 border-b border-border", + div { class: "px-4 py-3 border-b border-border flex-shrink-0", h2 { class: "text-sm font-semibold text-text-muted uppercase tracking-wide flex items-center gap-2", Icon { icon: FaUsers, width: 16, height: 16 } span { "Members" } } } - // Member list - ul { class: "flex-1 px-2 py-2 space-y-0.5", + // Member list - scrollable independently + ul { class: "flex-1 px-2 py-2 space-y-0.5 overflow-y-auto min-h-0", for (display_name, member_id) in members { li { key: "{member_id}", button { @@ -281,8 +281,8 @@ pub fn MemberList() -> Element { } } - // Invite button - div { class: "p-3 border-t border-border", + // Invite button - fixed at bottom + div { class: "p-3 border-t border-border flex-shrink-0", button { class: "w-full flex items-center justify-center gap-2 px-3 py-2 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors", onclick: move |_| invite_modal_active.set(true), @@ -291,8 +291,8 @@ pub fn MemberList() -> Element { } } - // Connection status indicator - div { class: "px-3 pb-3", + // Connection status indicator - fixed at bottom + div { class: "px-3 pb-3 flex-shrink-0", div { class: format!( "w-full px-3 py-1.5 rounded-full flex items-center justify-center text-xs font-medium {}", diff --git a/ui/src/example_data.rs b/ui/src/example_data.rs index ce513230..89294221 100644 --- a/ui/src/example_data.rs +++ b/ui/src/example_data.rs @@ -11,7 +11,13 @@ use lipsum::lipsum; use rand::rngs::OsRng; use river_core::room_state::ChatRoomParametersV1; use river_core::{ - room_state::{configuration::*, member::*, member_info::*, message::*, privacy::{RoomDisplayMetadata, SealedBytes}}, + room_state::{ + configuration::*, + member::*, + member_info::*, + message::*, + privacy::{RoomDisplayMetadata, SealedBytes}, + }, ChatRoomStateV1, }; use std::collections::HashMap; @@ -32,7 +38,10 @@ pub fn create_example_rooms() -> Rooms { let room3 = create_room(&"Your Private Room".to_string(), SelfIs::Owner); map.insert(room3.owner_vk, room3.room_data); - Rooms { map, current_room_key: None } + Rooms { + map, + current_room_key: None, + } } struct CreatedRoom { @@ -88,7 +97,9 @@ fn create_room(room_name: &String, self_is: SelfIs) -> CreatedRoom { MemberInfo { member_id: owner_id, version: 0, - preferred_nickname: SealedBytes::public((random_full_name() + " (Owner)").into_bytes()), + preferred_nickname: SealedBytes::public( + (random_full_name() + " (Owner)").into_bytes(), + ), }, owner_sk, )); @@ -110,7 +121,9 @@ fn create_room(room_name: &String, self_is: SelfIs) -> CreatedRoom { MemberInfo { member_id: self_id, version: 0, - preferred_nickname: SealedBytes::public((random_full_name() + " (You)").into_bytes()), + preferred_nickname: SealedBytes::public( + (random_full_name() + " (You)").into_bytes(), + ), }, &self_sk, )); @@ -149,7 +162,9 @@ fn create_room(room_name: &String, self_is: SelfIs) -> CreatedRoom { MemberInfo { member_id: other_member_id, version: 0, - preferred_nickname: SealedBytes::public((random_full_name() + " (Member)").into_bytes()), + preferred_nickname: SealedBytes::public( + (random_full_name() + " (Member)").into_bytes(), + ), }, &other_member_sk, )); @@ -199,6 +214,7 @@ fn create_room(room_name: &String, self_is: SelfIs) -> CreatedRoom { current_secret: None, current_secret_version: None, last_secret_rotation: None, + key_migrated_to_delegate: true, // Example data doesn't need migration }, } } diff --git a/ui/src/pending_invites.rs b/ui/src/pending_invites.rs index b26f0dea..ef786323 100644 --- a/ui/src/pending_invites.rs +++ b/ui/src/pending_invites.rs @@ -19,13 +19,13 @@ pub enum PendingRoomStatus { #[derive(Clone)] pub struct PendingInvites { - pub map: HashMap + pub map: HashMap, } impl Default for PendingInvites { fn default() -> Self { Self { - map: HashMap::new() + map: HashMap::new(), } } } diff --git a/ui/src/util.rs b/ui/src/util.rs index 645cd291..bc59c8cb 100644 --- a/ui/src/util.rs +++ b/ui/src/util.rs @@ -61,7 +61,7 @@ pub fn format_utc_as_local_time(timestamp_ms: i64) -> String { #[cfg(not(target_arch = "wasm32"))] { - use chrono::{TimeZone, Utc, Local}; + use chrono::{Local, TimeZone, Utc}; let utc_time = Utc.timestamp_millis_opt(timestamp_ms).unwrap(); utc_time.with_timezone(&Local).format("%H:%M").to_string() } @@ -76,9 +76,12 @@ pub fn format_utc_as_full_datetime(timestamp_ms: i64) -> String { #[cfg(not(target_arch = "wasm32"))] { - use chrono::{TimeZone, Utc, Local}; + use chrono::{Local, TimeZone, Utc}; let utc_time = Utc.timestamp_millis_opt(timestamp_ms).unwrap(); - utc_time.with_timezone(&Local).format("%a, %b %d, %Y %H:%M:%S").to_string() + utc_time + .with_timezone(&Local) + .format("%a, %b %d, %Y %H:%M:%S") + .to_string() } }