Skip to content

feat: Add message editing, deletion, and reactions #67

@sanity

Description

@sanity

Overview

Add support for editing messages, deleting messages, and emoji reactions in River. These actions are implemented as special message types in the normal message timeline.

Design Principles

  1. Actions are messages: Edit/Delete/Reaction are special RoomMessageBody variants
  2. Actions reference targets by MessageId: Each action includes the target message's ID
  3. Actions are signed: The actor signs the action message (author for edit/delete, reactor for reactions)
  4. Actions stay in timeline: Action messages remain visible until they naturally expire via max_recent_messages
  5. Effects persist on target: Edited content and reactions are stored on the target message itself, persisting even after the action message expires
  6. Deleted messages removed: Delete action causes target to be removed from state (action message remains)

Backwards Compatibility

Adding new RoomMessageBody variants will break deserialization for older clients. Options:

  • Accept breaking change (acceptable during pre-release development)
  • Add #[serde(other)] fallback variant so old clients skip unknown message types

Recommend: Accept breaking change for now, add fallback before production release.

Data Model Changes

File: common/src/room_state/message.rs

Extend RoomMessageBody enum:

pub enum RoomMessageBody {
    // Existing
    Public { plaintext: String },
    Private { ciphertext: Vec<u8>, nonce: [u8; 12], secret_version: SecretVersion },

    // New action types
    Edit {
        target: MessageId,
        new_content: Box<RoomMessageBody>,  // Public or Private only (not another action)
    },
    Delete {
        target: MessageId,
    },
    Reaction {
        target: MessageId,
        emoji: String,  // Single emoji (e.g., "👍")
    },
    RemoveReaction {
        target: MessageId,
        emoji: String,  // Which reaction to remove
    },
}

Add reactions and edited flag to MessageV1:

pub struct MessageV1 {
    pub room_owner: MemberId,
    pub author: MemberId,
    pub time: SystemTime,
    pub content: RoomMessageBody,
    // New fields (with serde defaults for backwards compat)
    #[serde(default)]
    pub edited: bool,
    #[serde(default)]
    pub reactions: HashMap<String, Vec<MemberId>>,  // emoji -> list of reactors
}

Authorization Rules

Action Who can perform
Edit Original message author only
Delete Original message author only
Reaction Any room member
RemoveReaction The member who added that specific reaction

Processing Logic

When applying deltas in MessagesV1::apply_delta():

  1. Add incoming messages to the list (existing behavior)
  2. Process action messages (new step, before sorting):
    • Edit: Find target by ID, verify actor == target author, replace content, set edited = true
    • Delete: Find target by ID, verify actor == target author, remove target from state
    • Reaction: Find target by ID, add reactor's MemberId to reactions[emoji]
    • RemoveReaction: Find target by ID, remove reactor from reactions[emoji]
  3. Sort messages by time (existing)
  4. Prune oldest beyond max_recent_messages (existing)

Action messages remain in the timeline as regular messages (they just have action content instead of text).

Implementation Steps

Phase 1: Data Model (common crate)

File: common/src/room_state/message.rs

  1. Add new RoomMessageBody variants: Edit, Delete, Reaction, RemoveReaction
  2. Add edited: bool and reactions: HashMap<String, Vec<MemberId>> to MessageV1
  3. Add #[serde(default)] to new fields for partial backwards compat
  4. Add helper methods: RoomMessageBody::is_action(), RoomMessageBody::target_id()
  5. Update content_len() to handle action variants (they have no "content" size in traditional sense)

Phase 2: Delta Processing (common crate)

File: common/src/room_state/message.rs - MessagesV1::apply_delta()

  1. After adding delta messages, iterate through all messages looking for action types
  2. For each action message:
    • Find target message by MessageId in current state
    • Validate authorization (action author must match required permissions)
    • Apply the effect to the target message
    • If target not found (expired/deleted), action is no-op but stays in timeline
  3. Helper functions:
    • apply_edit(messages, action_msg) - replace target content, set edited=true
    • apply_delete(messages, action_msg) - remove target from vec
    • apply_reaction(messages, action_msg) - add reactor to target's reactions map
    • apply_remove_reaction(messages, action_msg) - remove reactor from reactions map

Phase 3: Verification (common crate)

File: common/src/room_state/message.rs - MessagesV1::verify()

  1. Validate signatures for action messages (already happens)
  2. For Edit/Delete actions: verify action author == target message author
  3. For RemoveReaction: verify action author is in the reaction list being removed
  4. Reactions from any member are valid (no additional check needed)

Phase 4: UI - Display (ui crate)

File: ui/src/components/conversation.rs

  1. Update message rendering to show "(edited)" indicator when message.edited == true
  2. Display reactions below message content: show emoji with count, highlight if current user reacted
  3. Filter out action messages from display OR show them as system messages (e.g., "Alice deleted a message")
  4. Handle click on own reaction to remove it

Phase 5: UI - Actions (ui crate)

Files: ui/src/components/conversation.rs, possibly new message_actions.rs

  1. Add context menu (right-click or "..." button) on messages
  2. Show Edit/Delete options only on own messages
  3. Add emoji picker for reactions (can start with small fixed set: 👍 ❤️ 😂 😮 😢 😡)
  4. Implement callbacks:
    • handle_edit_message(message_id, new_text) - creates Edit action message
    • handle_delete_message(message_id) - creates Delete action message
    • handle_toggle_reaction(message_id, emoji) - creates Reaction or RemoveReaction

Phase 6: Tests (common crate)

File: common/src/room_state/message.rs tests module

  1. Test edit: send message, send edit action, verify content updated and edited=true
  2. Test delete: send message, send delete action, verify message removed
  3. Test reaction: send message, send reaction, verify reactions map updated
  4. Test remove reaction: add reaction, remove it, verify reactions map updated
  5. Test authorization: verify edit/delete by non-author fails
  6. Test expired target: action on non-existent message is no-op

Key Files

File Changes
common/src/room_state/message.rs Data model, delta processing, verification
ui/src/components/conversation.rs Display edited indicator, reactions, action handlers
ui/src/components/conversation/message_actions.rs New file for context menu/emoji picker (optional)

Edge Cases

  1. Target expired/deleted: Action is valid signature-wise, but has no effect - stays in timeline
  2. Multiple edits: Each edit replaces content; edits processed in timestamp order
  3. Duplicate reactions: Idempotent - adding same reaction twice is no-op
  4. Private rooms: Edit's new_content must be encrypted with current secret version
  5. Action on action: Should be rejected - cannot edit/delete/react to action messages
  6. Self-reaction removal: User can only remove their own reactions

Verification Plan

  1. Unit tests: Test each action type in common/ with authorization checks
  2. Manual UI testing: Create room, send messages, test edit/delete/reactions
  3. Multi-peer: Verify actions sync correctly between two peers (use cargo make dev with two browsers)

[AI-assisted - Claude]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions