Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/file-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,4 @@ Tell the user:
- Local issue doc path
- GitHub issue URL (in `owner/repo#number` format for clickable link)

**Success criteria**: Both paths reported in a concise summary.
**Success criteria**: Both paths reported in a concise summary.
9 changes: 9 additions & 0 deletions resources/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@
"new_message_context_menu.button.copy_text": "Copy Text",
"new_message_context_menu.button.copy_text_html": "Copy Text as HTML",
"new_message_context_menu.button.copy_link": "Copy Link to Message",
"new_message_context_menu.button.forward_message": "Forward Message",
"new_message_context_menu.button.view_source": "View Source",
"new_message_context_menu.button.jump_related": "Jump to Related Event",
"new_message_context_menu.button.delete": "Delete",
Expand Down Expand Up @@ -525,8 +526,16 @@
"room_screen.popup.message.copy_text_not_found": "Could not find message in timeline to copy text from. Please try again.",
"room_screen.popup.message.copy_html_not_found": "Could not find message in timeline to copy HTML from. Please try again.",
"room_screen.popup.message.copy_link_failed": "Couldn't create permalink to message. Please try again.",
"room_screen.popup.message.forward_not_found": "Could not find a forwardable message in the timeline. Please try again.",
"room_screen.popup.message.view_source_not_found": "Could not find message in timeline to view source.",
"room_screen.popup.message.related_not_found": "Could not find related message or event in timeline.",

"forward_modal.title": "Forward Message",
"forward_modal.body": "Enter the destination Matrix room ID.",
"forward_modal.input.destination_room_id": "!room:example.org",
"forward_modal.button.cancel": "Cancel",
"forward_modal.button.forward": "Forward",
"forward_modal.popup.submitting": "Forwarding message...",
"room_screen.popup.action_response.failed": "Failed to send action response.\n\nError: {error}",
"room_screen.modal.delete_message.title": "Delete Message",
"room_screen.modal.delete_message.body": "Are you sure you want to delete this message? This cannot be undone.",
Expand Down
9 changes: 9 additions & 0 deletions resources/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@
"new_message_context_menu.button.copy_text": "复制文本",
"new_message_context_menu.button.copy_text_html": "复制文本为 HTML",
"new_message_context_menu.button.copy_link": "复制消息链接",
"new_message_context_menu.button.forward_message": "转发消息",
"new_message_context_menu.button.view_source": "查看源码",
"new_message_context_menu.button.jump_related": "跳转到关联事件",
"new_message_context_menu.button.delete": "删除",
Expand Down Expand Up @@ -523,8 +524,16 @@
"room_screen.popup.message.copy_text_not_found": "在时间线中找不到可复制文本的消息,请重试。",
"room_screen.popup.message.copy_html_not_found": "在时间线中找不到可复制 HTML 的消息,请重试。",
"room_screen.popup.message.copy_link_failed": "无法创建消息永久链接,请重试。",
"room_screen.popup.message.forward_not_found": "在时间线中找不到可转发的消息,请重试。",
"room_screen.popup.message.view_source_not_found": "在时间线中找不到要查看源码的消息。",
"room_screen.popup.message.related_not_found": "在时间线中找不到关联消息或事件。",

"forward_modal.title": "转发消息",
"forward_modal.body": "请输入目标 Matrix 房间 ID。",
"forward_modal.input.destination_room_id": "!room:example.org",
"forward_modal.button.cancel": "取消",
"forward_modal.button.forward": "转发",
"forward_modal.popup.submitting": "正在转发消息...",
"room_screen.popup.action_response.failed": "发送动作响应失败。\n\n错误:{error}",
"room_screen.modal.delete_message.title": "删除消息",
"room_screen.modal.delete_message.body": "确认要删除这条消息吗?此操作无法撤销。",
Expand Down
3 changes: 3 additions & 0 deletions resources/icons/lock_filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions resources/icons/lock_open.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
344 changes: 344 additions & 0 deletions specs/month-1/encryption-indicator.spec.md

Large diffs are not rendered by default.

181 changes: 181 additions & 0 deletions specs/month-1/forward-messages.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<!-- Generated at Eastside Church of Christ from user prompts:
"In message right click context, Add an option to Forward Message."
"Fix Action feedback loop in Forward Message modal buttons, cancel and Submit." -->
spec: task
name: "Forward Messages"
inherits: project
tags: [month-1, messaging, high-priority, context-menu]
---

## Intent

Add a `Forward Message` option to the message right-click / long-press context menu so users can send an existing message to another Matrix room. The first implementation should be reliable and reviewable: it must open a forward modal, submit through the Matrix async request path, and close cleanly without modal/action feedback loops.

## Decisions

- UI trigger: message context menu opened by right-click or long-press.
- Menu label: `Forward Message`.
- The option is visible only for real message events with forwardable message content.
- Forwardable in v1 means text, notice, and emote message content. Media, location, verification, polls, stickers, encrypted attachment re-upload, and custom message types are out of scope unless they can be resent without re-upload.
- Forward flow: context menu emits `MessageAction::Forward`, `RoomScreen` extracts the latest effective message content, and the app opens a forward modal.
- Initial destination UI: modal accepts a Matrix destination room ID.
- Submit path: use `submit_async_request(MatrixRequest::ForwardMessage { ... })`; do not spawn raw Matrix tasks from UI code.
- Close semantics: cancel, Escape, and successful submit emit one modal close action; passive `ModalAction::Dismissed` only clears modal state and must not emit another close action.
- Forwarded content uses the latest effective message content so edited messages forward their edited content.
- Success and failure feedback are surfaced through toast notifications from the async forwarding path.

## Boundaries

### Allowed to Modify

- `src/home/new_message_context_menu.rs` - Add context-menu item, ability flag, and action emission.
- `src/home/room_screen.rs` - Handle `MessageAction::Forward` and prepare forward payload.
- `src/app.rs` - Host and open/close the forward modal.
- `src/shared/mod.rs` - Register the forward modal widget.
- `src/sliding_sync.rs` - Add or use `MatrixRequest::ForwardMessage`.
- `resources/i18n/en.json` - Add menu label.
- `resources/i18n/zh-CN.json` - Add menu label.

### Must Create

- `src/shared/forward_modal.rs` - Forward modal and forwarding result helpers.

### Optional

- `src/shared/room_selector.rs` - Reusable room selector helper for a later multi-room/search UI.

### Forbidden

- Do not forward reactions, receipts, read markers, or other message metadata.
- Do not show the forward option for non-message timeline items.
- Do not use raw tokio tasks from UI code for Matrix forwarding.
- Do not create an action feedback loop when closing or dismissing the forward modal.
- Do not run `cargo fmt` or `rustfmt`.

## Acceptance Criteria

Scenario: Forward option appears in message context menu
Test:
package: robrix
filter: test_forward_menu
Given user right-clicks or long-presses a forwardable message
Then the message context menu appears
And the "Forward Message" option is visible

Scenario: Forward option hidden for non-forwardable items
Test:
package: robrix
filter: test_forward_menu_hidden_non_message
Given user opens the context menu for a non-message timeline item
Then the "Forward Message" option is not visible

Scenario: Forward modal opens from context menu
Test:
package: robrix
filter: test_forward_modal_opens
Given user opens the message context menu for a forwardable message
When user selects "Forward Message"
Then the context menu closes
And the forward modal opens
And the destination room ID input has keyboard focus

Scenario: Forward submit sends async Matrix request
Test:
package: robrix
filter: test_forward_submit_request
Given the forward modal is open for a message
And user enters a valid destination room ID
When user clicks Forward
Then `MatrixRequest::ForwardMessage` is submitted via `submit_async_request`
And the modal closes

Scenario: Forward submit preserves edited content
Test:
package: robrix
filter: test_forward_uses_latest_effective_content
Given the selected message has been edited
When user forwards the message
Then the forwarded content uses the latest effective message content
And stale original content is not sent

Scenario: Invalid destination room ID stays in modal
Test:
package: robrix
filter: test_forward_invalid_room_id
Given the forward modal is open
When user enters an invalid room ID
And clicks Forward
Then no Matrix request is submitted
And the modal remains open
And an inline validation error is shown

Scenario: Cancel closes modal once
Test:
package: robrix
filter: test_forward_cancel_no_feedback_loop
Given the forward modal is open
When user clicks Cancel
Then one `ForwardMessageModalAction::Close` action is emitted
And the app closes the modal
And no repeated close/dismiss action loop occurs

Scenario: Cancel abort the forward Message request
Test:
package: robrix
filter: test_forward_cancel_no_feedback_loop
Given the forward modal is open
When user submit a forward cancel request and clicks Cancel
Then one `ForwardMessageModalAction::Close` action is emitted,
The Forward message request can be aborted,
When the user clicks the cancel button,
And the app closes the modal
And no repeated close/dismiss action loop occurs

Scenario: Escape closes modal once
Test:
package: robrix
filter: test_forward_escape_no_feedback_loop
Given the forward modal is open
When user presses Escape
Then one `ForwardMessageModalAction::Close` action is emitted
And the app closes the modal
And no repeated close/dismiss action loop occurs

Scenario: Passive dismiss does not emit close action
Test:
package: robrix
filter: test_forward_dismiss_no_feedback_loop
Given the forward modal is open
When the modal emits `ModalAction::Dismissed`
Then the forward modal clears its pending message state
And it does not emit `ForwardMessageModalAction::Close`
And no feedback loop occurs

Scenario: Successful forward shows feedback
Test:
package: robrix
filter: test_forward_success_feedback
Given user forwards a message to one destination room
When the async send succeeds
Then a success toast is shown

Scenario: Forward failure shows feedback
Test:
package: robrix
filter: test_forward_failure_feedback
Level: integration
Test Double: mock matrix-sdk send endpoint
Targets: forward_modal, sliding_sync
Given user forwards a message
When the Matrix send fails
Then a warning or error toast is shown
And the source room is not affected

## Out of Scope

- Forwarding to users not in shared rooms.
- Batch forwarding multiple selected messages.
- Forwarding thread context as a thread.
- Forwarding reactions, receipts, redactions, or read markers.
- Full searchable room selector and multi-room checkbox UI beyond helper scaffolding.
- Media re-upload for encrypted media attachments.
26 changes: 25 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::{
event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt, mark_invite_modal_closed}, invite_screen::{InviteScreenWidgetRefExt, LeaveRoomResultAction}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, TimelineUpdate, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, space_lobby::SpaceLobbyScreenWidgetRefExt, spaces_bar::SpacesBarRef
}, i18n::{AppLanguage, tr_fmt, tr_key}, join_leave_room_modal::{
JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt
}, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, register::RegisterAction, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, file_upload_modal::{FilePreviewerAction, FileUploadModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::FilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, AccountSwitchAction, current_user_id, get_client, submit_async_request, get_timeline_update_sender}, updater::{UpdateCheckOutcome, check_for_updates, load_skipped_update_version, save_skipped_update_version, update_release_page_url}, utils::RoomNameId, verification::VerificationAction, verification_modal::{
}, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, register::RegisterAction, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, file_upload_modal::{FilePreviewerAction, FileUploadModalWidgetRefExt}, forward_modal::{ForwardMessageModalAction, ForwardMessageModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::FilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, AccountSwitchAction, current_user_id, get_client, submit_async_request, get_timeline_update_sender}, updater::{UpdateCheckOutcome, check_for_updates, load_skipped_update_version, save_skipped_update_version, update_release_page_url}, utils::RoomNameId, verification::VerificationAction, verification_modal::{
VerificationModalAction,
VerificationModalWidgetRefExt,
}
Expand Down Expand Up @@ -146,6 +146,15 @@ script_mod! {
}
}

forward_message_modal := Modal {
content +: {
height: Fill,
width: Fill,
align: Align{x: 0.5, y: 0.5},
forward_message_modal_inner := ForwardMessageModal {}
}
}

// Context menus should be shown in front of other UI elements,
// but behind verification modals.
new_message_context_menu := NewMessageContextMenu { }
Expand Down Expand Up @@ -1458,6 +1467,21 @@ impl MatchEvent for App {
}
_ => {}
}
// Handle forward-message modal actions.
match action.downcast_ref() {
Some(ForwardMessageModalAction::Open(content)) => {
self.ui
.forward_message_modal(cx, ids!(forward_message_modal_inner))
.show(cx, content.clone(), self.app_state.app_language);
self.ui.modal(cx, ids!(forward_message_modal)).open(cx);
continue;
}
Some(ForwardMessageModalAction::Close) => {
self.ui.modal(cx, ids!(forward_message_modal)).close(cx);
continue;
}
_ => {}
}
// Handle actions to open/close the TSP verification modal.
#[cfg(feature = "tsp")] {
use std::ops::Deref;
Expand Down
Loading
Loading