diff --git a/.claude/skills/file-issue/SKILL.md b/.claude/skills/file-issue/SKILL.md
index ae70d188..5af60e80 100644
--- a/.claude/skills/file-issue/SKILL.md
+++ b/.claude/skills/file-issue/SKILL.md
@@ -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.
\ No newline at end of file
diff --git a/resources/i18n/en.json b/resources/i18n/en.json
index d8bae712..a3b48afc 100644
--- a/resources/i18n/en.json
+++ b/resources/i18n/en.json
@@ -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",
@@ -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.",
diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json
index 9942aa7c..cdbe0723 100644
--- a/resources/i18n/zh-CN.json
+++ b/resources/i18n/zh-CN.json
@@ -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": "删除",
@@ -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": "确认要删除这条消息吗?此操作无法撤销。",
diff --git a/resources/icons/lock_filled.svg b/resources/icons/lock_filled.svg
new file mode 100644
index 00000000..7bd60f6b
--- /dev/null
+++ b/resources/icons/lock_filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/resources/icons/lock_open.svg b/resources/icons/lock_open.svg
new file mode 100644
index 00000000..4f41c146
--- /dev/null
+++ b/resources/icons/lock_open.svg
@@ -0,0 +1,3 @@
+
diff --git a/specs/month-1/encryption-indicator.spec.md b/specs/month-1/encryption-indicator.spec.md
new file mode 100644
index 00000000..6b6c735c
--- /dev/null
+++ b/specs/month-1/encryption-indicator.spec.md
@@ -0,0 +1,344 @@
+spec: task
+name: "Encryption Indicator"
+inherits: project
+tags: [month-1, security, ui]
+---
+
+
+
+## Intent
+
+
+
+
+
+Surface this signal in two coordinated places:
+
+1. **In-room timeline notice.** Render a clear, non-interactive notice at the very top of every room's timeline indicating whether the conversation is end-to-end encrypted. The notice is always the first PortalList item (`item_id == 0`), scrolls with timeline content, and helps users understand the room's encryption posture at a glance — including a prompt to verify the other party (for encrypted rooms). Because injecting a synthetic PortalList item shifts indices by exactly one, the change includes a small, scoped refactor to centralize timeline index translation so existing message-action and scroll-to-message behavior remains correct.
+
+2. **Room-list lock indicator.** Render a small, non-interactive lock icon in each joined-room entry in the rooms list, positioned alongside the existing tombstone icon in all three adaptive variants of `RoomsListEntry`. The icon is shown only when the room is end-to-end encrypted and not tombstoned; absence of the icon means "not encrypted" (or unknown). The two surfaces read from the same `JoinedRoomInfo.is_encrypted` field and respond to the same `RoomsListUpdate::UpdateIsEncrypted` live-update channel.
+
+## Decisions
+
+
+- Single banner widget (`EncryptionNotice`) rendered inside `room_screen.rs`'s PortalList — no avatar badges, no room-header lock icon, no popup/modal.
+
+- Placement: the notice is **always** at PortalList index `0`. It is rendered as the very first item, before any `tl_items` entry (including date dividers). All `tl_items` are shifted by exactly one position in the PortalList: `tl_items[i]` renders at PortalList index `i + 1`.
+
+- Encrypted state copy: title is the literal string `Encryption enabled`; body is the literal string `Messages here are end-to-end encrypted. Verify {first_other_member} in their profile - tap on their profile picture.` The dash between `profile` and `tap` is ASCII hyphen-minus (U+002D) surrounded by single spaces.
+- Not-encrypted state copy: title is the literal string `Encryption not enabled`; body is the literal string `Messages here are not end-to-end encrypted.` (title-only swap; body has no verify sentence.)
+
+
+- `{first_other_member}` is the first non-self member returned by `Room::members()` iteration. The verify sentence is rendered **only** when this name has resolved to a non-empty display name (or, as fallback, a non-empty user-id string).
+- While the member list has not yet been fetched, OR the room contains no non-self members, OR no display name is available, the verify sentence is suppressed and the body renders as the literal string `Messages here are end-to-end encrypted.`. No "…" placeholder is ever rendered into user-visible copy.
+- When the member-list load completes (or membership changes) and a non-self display name becomes available, the body re-renders to include the verify sentence with the resolved name.
+- Long display names are truncated to exactly 30 characters and suffixed with `…` (U+2026) before substitution into the body — the only place "…" is allowed to appear in rendered copy.
+
+- Visual: light-gray rounded rectangle, background color `#F0F2F5`, corner radius `6`, padding `12`, horizontal margin `16`, vertical margin `8`. Lock icon (filled for encrypted, open for not-encrypted) on the left, size `16`, color `#888888`. Bold title; regular-weight body. (Units are Makepad logical pixels.)
+
+- Both state icons (`lock_filled_icon`, `lock_open_icon`) are declared as named `View` siblings inside the `EncryptionNotice` `script_mod!` block, each wrapping a single `Icon` with the appropriate SVG. Both Views are always present in the widget tree. Runtime visibility is controlled by calling `self.view.view(cx, ids!(lock_filled_icon)).set_visible(cx, is_encrypted)` and the inverse for `lock_open_icon`. Do **not** declare the Icons directly (without a wrapping View) and do **not** call `set_visible` on the Icon directly — Makepad 2.0's `Icon` widget does not gate its draw on the `visible` field, so a bare `Icon { visible: false }` still renders. The working reference for this pattern in the codebase is `TombstoneIcon` in `src/home/rooms_list_entry.rs:20` and the `IconYes`/`IconNo`/`IconUnk` Views in `src/shared/verification_badge.rs`.
+- Icon assets: `resources/icons/lock_filled.svg` and `resources/icons/lock_open.svg` (added if not already present).
+- The notice is informational and non-interactive: no tap handler, no action emitted, no popup.
+- Encryption state is sourced from a new `is_encrypted: Option` field on `JoinedRoomInfo`. The field is initialized during sliding-sync room registration and updated live via a new `RoomsListUpdate::UpdateIsEncrypted { room_id, is_encrypted }` variant (parallels the existing `UpdateIsDirect`).
+- Live updates: when a room transitions off→on encrypted mid-session, the notice swaps from "not encrypted" to "encrypted" without requiring a room re-open. (Matrix encryption is monotonic; off→on is the only transition handled.)
+
+- The cached `is_encrypted` value is monotonic in the upgrade direction only. Allowed transitions: `None → Some(false)`, `None → Some(true)`, `Some(false) → Some(true)`. The system never demotes a known state back to `None`, and never demotes `Some(true)` back to `Some(false)`.
+- When `is_encrypted` is `None` (initial state before sliding-sync registration completes, or fetch failed), the notice does not render. No false-positive encryption claim is ever shown.
+
+
+- The notice renders as soon as `is_encrypted` is `Some(_)`, regardless of whether the member list has loaded. While no name has resolved, the body omits the verify sentence; encryption-state resolution is the sole gate on visibility (not member-list load).
+
+
+- PortalList index math is centralized in a helper (`tl_idx_from_item_id`) that maps PortalList `item_id` → `tl_items` index using the constant rule: `item_id == 0` is the encryption notice (no `tl_items` lookup); `item_id >= 1` maps to `tl_items[item_id - 1]`. Every site that receives a PortalList `item_id` and then either (a) indexes into `tl_items`, (b) compares against a `tl_idx`-keyed structure (e.g., `SmallStateEventGroup::start`), or (c) calls a function expecting `tl_idx` (e.g., `toggle_small_state_event_group`) must route through this helper. Sites that must convert include: the render loop in `draw_walk`; `MessageAction::HighlightMessage` and `JumpToRelated` handlers; jump-to-bottom and scroll-to-message; `handle_image_click`; the body of `portal_list.items_with_actions(actions)` in `handle_event` (specifically the `state_group_toggle_button` click branch and the `invite_user_button` click branch). The helper is applied **exactly once** per `item_id` value — never apply the offset by hand or pass an `item_id` to a function that internally calls the helper again.
+
+
+### Room-list indicator (surface 2)
+
+- Add a new `EncryptionIcon` widget in `rooms_list_entry.rs`'s `script_mod!` block, sibling to the existing `TombstoneIcon` declared at the same location. The widget structure mirrors `TombstoneIcon` exactly: outer `View { width: Fit, height: Fit, visible: false }` wrapping an `Icon { width: 19, height: 19, icon_walk: Walk{ width: 15, height: 15 } }`.
+- Icon SVG: reuses `resources/icons/lock_filled.svg` already declared by the in-room notice. The open-lock variant is **not** rendered in the room list (indicator is encrypted-only).
+- Icon color: `#888888` (matches the in-room banner icon color for visual consistency).
+- The `encryption_icon` instance is added to all three adaptive variants of `RoomsListEntry` (`OnlyIcon`, `IconAndName`, `FullPreview`), placed in the DSL tree immediately before each existing `tombstone_icon` so the two siblings stay co-located across layouts.
+- Visibility rule (applied in the joined-room arm of `set_entry`): `encryption_icon` is visible iff `matches!(room_info.is_encrypted, Some(true)) && !room_info.is_tombstoned`. Tombstone takes visual priority; encrypted-but-tombstoned rooms show only the tombstone icon.
+- Show only for joined rooms. `InvitedRoomInfo` entries do not render an encryption icon (invited rooms lack reliable encryption metadata pre-join).
+- The indicator is informational and non-interactive: no tap handler, no action emitted. Tapping the row continues to open the room as today.
+- Live updates: the same `RoomsListUpdate::UpdateIsEncrypted` channel that drives the in-room notice also drives the room-list icon. When an update arrives, the affected row redraws and visibility recomputes from the new `is_encrypted` value.
+- Avatar overlay (badge on the avatar itself) is **not** the chosen surface — the icon is a sibling element in the row, exactly as `tombstone_icon` is.
+
+## Boundaries
+
+### Allowed to Modify
+
+
+- `src/home/room_screen.rs` — inject `EncryptionNotice` into the PortalList render loop; apply index offset; refresh on encryption-state updates.
+- `src/home/rooms_list.rs` — add `is_encrypted: Option` to `JoinedRoomInfo`; handle the new `UpdateIsEncrypted` variant.
+- `src/home/rooms_list_entry.rs` — declare `EncryptionIcon` widget; place `encryption_icon` instance in all three adaptive variants beside each existing `tombstone_icon`; toggle visibility in the joined-room arm of `set_entry`.
+- `src/sliding_sync.rs` — populate initial `is_encrypted` during room registration; subscribe to room state and emit `UpdateIsEncrypted` on encryption-enabled transitions.
+
+### Must Create
+
+- `src/home/encryption_notice.rs` — the banner widget (Makepad 2.0 `script_mod!` widget, View with lock icon + title + body labels).
+- `resources/icons/lock_filled.svg` and `resources/icons/lock_open.svg` if not already present.
+
+### Forbidden
+
+
+- Do NOT introduce any new call to `crate::sliding_sync::get_client()` in any file created or modified by this task. This applies to `src/home/encryption_notice.rs` (new), `src/home/room_screen.rs`, `src/home/rooms_list.rs`, `src/sliding_sync.rs`, and any helper modules added in support. The rule also forbids introducing intermediate helpers anywhere in `src/home/` or `src/shared/` that proxy to `get_client()`. Pre-existing callsites in the repository at the time of this spec — `src/home/main_desktop_ui.rs`, `src/home/room_screen.rs` (lines outside the new injection block), and `src/shared/verification_badge.rs` — are out of scope for this task and must not be removed or relocated as part of it.
+- All Matrix queries needed by this task (`Room::is_encrypted()`, room-state subscription, member fetches) must run inside `sliding_sync.rs` async tasks. UI code reads only from cached `JoinedRoomInfo` fields and from actions posted via `Cx::post_action()`.
+- Do NOT spawn raw tokio tasks for encryption-state subscription — register the watcher inside the existing room-registration flow in `sliding_sync.rs`.
+- Do NOT add an encryption badge on room-list avatars (`rooms_list.rs` / `rooms_list_entry.rs` UI is unchanged for this purpose).
+- Do NOT add a room-header lock icon or "Encrypted" text label outside the PortalList notice.
+- Do NOT add a popup, modal, or sliding pane for encryption details (`src/shared/encryption_info.rs` from the prior spec draft is explicitly dropped).
+- Do NOT change existing E2EE decryption logic.
+- Do NOT modify the device verification flow.
+- Do NOT alter room encryption settings or expose a toggle.
+- Do NOT make the notice tappable or emit actions from it.
+
+## Completion Criteria
+
+
+
+
+Scenario: Encrypted room shows notice at PortalList index 0
+ Test:
+ package: robrix
+ filter: test_notice_encrypted
+ Given user opens a room with end-to-end encryption enabled
+ When the room screen renders
+ Then the EncryptionNotice appears at PortalList index 0
+ And its title is "Encryption enabled"
+ And its body is "Messages here are end-to-end encrypted. Verify {first_other_member} in their profile - tap on their profile picture."
+
+
+Scenario: Unencrypted room shows notice at index 0 with title swap
+ Test:
+ package: robrix
+ filter: test_notice_unencrypted
+ Given user opens a room with no encryption
+ When the room screen renders
+ Then the EncryptionNotice appears at PortalList index 0
+ And its title is "Encryption not enabled"
+ And its body is "Messages here are not end-to-end encrypted."
+
+
+Scenario: Empty room still places notice at index 0
+ Test:
+ package: robrix
+ filter: test_notice_empty_room
+ Given user opens a room with no timeline items (tl_items.len() == 0)
+ When the room screen renders
+ Then the EncryptionNotice appears at PortalList index 0
+ And no other PortalList item is rendered before it
+
+
+
+Scenario: Notice suppresses verify sentence when members are not loaded
+ Test:
+ package: robrix
+ filter: test_notice_member_placeholder
+ Level: integration
+ Given JoinedRoomInfo.is_encrypted is Some(true) for the open room
+ And the room's member list has not yet been fetched
+ When the room screen renders
+ Then the notice body is "Messages here are end-to-end encrypted."
+ And no verify sentence is included
+ And the body does not contain the character "…"
+
+
+Scenario: Notice adds verify sentence when member name resolves
+ Test:
+ package: robrix
+ filter: test_notice_member_backfill
+ Level: integration
+ Given the notice is rendered with the generic body "Messages here are end-to-end encrypted."
+ And the room's member-loading completion action subsequently posts loaded members to the timeline state with at least one non-self member that has a display name
+ When the next render runs
+ Then the notice body becomes "Messages here are end-to-end encrypted. Verify {first_other_member} in their profile - tap on their profile picture."
+ And no other body text changes
+
+Scenario: Room with no other members drops verify sentence
+ Test:
+ package: robrix
+ filter: test_notice_lonely_room
+ Given user opens an encrypted room where they are the only member
+ When the room screen renders
+ Then the notice body is "Messages here are end-to-end encrypted."
+ And the verify sentence is absent
+
+Scenario: Encryption enabled mid-session updates notice live
+ Test:
+ package: robrix
+ filter: test_notice_live_update
+ Level: integration
+ Test Double: mock matrix-sdk room state subscription
+ Given user is viewing an unencrypted room with the notice visible
+ When the room transitions to encrypted (RoomsListUpdate::UpdateIsEncrypted arrives)
+ Then the notice title swaps to "Encryption enabled" without re-opening the room
+ And the body updates to include the verify sentence
+
+
+Scenario: Encryption-state fetch failure during room registration is logged and surfaces as None
+ Test:
+ package: robrix
+ filter: test_notice_fetch_failure_logs
+ Level: integration
+ Test Double: mock matrix-sdk Room::is_encrypted() to return an error
+ Given a room registration is in progress
+ And the underlying Room::is_encrypted() call returns an error
+ When sliding_sync completes registration for the room
+ Then JoinedRoomInfo.is_encrypted for that room is None
+ And an error is logged via the project's standard logging facility describing the failed encryption-state fetch
+ And no UpdateIsEncrypted action is posted for that room until a subsequent successful fetch
+
+Scenario: Unknown encryption state hides notice
+ Test:
+ package: robrix
+ filter: test_notice_unknown_hidden
+ Given JoinedRoomInfo.is_encrypted is None for the open room
+ When the room screen renders
+ Then no EncryptionNotice is rendered
+ And no false positive encryption claim appears
+
+
+Scenario: Switching rooms updates notice to the new room's state
+ Test:
+ package: robrix
+ filter: test_notice_room_switch
+ Level: integration
+ Given user is viewing encrypted room A with the notice showing "Encryption enabled"
+ When user opens unencrypted room B
+ Then the notice in the room screen shows title "Encryption not enabled"
+ And the body is "Messages here are not end-to-end encrypted."
+ And no stale state from room A persists in the rendered notice
+
+
+Scenario: Constant +1 PortalList offset preserves message actions
+ Test:
+ package: robrix
+ filter: test_notice_offset_actions
+ Given the EncryptionNotice renders at PortalList index 0 (the constant invariant)
+ And tl_items[i] renders at PortalList index i + 1 for all i in 0..tl_items.len()
+ When the user triggers a message highlight on the timeline item at tl_items index k
+ Then MessageAction::HighlightMessage targets PortalList item_id k + 1
+ And jump-to-bottom and scroll-to-message land on the intended tl_items entry after applying the same offset
+
+
+Scenario: Small-state-group fold/unfold works in encrypted rooms
+ Test:
+ package: robrix
+ filter: test_notice_offset_preserves_state_group_toggle
+ Level: integration
+ Given user opens an encrypted room where the encryption notice renders at item_id 0
+ And the timeline contains a small-state-event group starting at tl_items index k (i.e. PortalList item_id k + 1)
+ And the group is currently expanded
+ When the user clicks the state_group_toggle_button on the first event of the group
+ Then the portal_list.items_with_actions handler converts item_id (k + 1) to tl_idx k via tl_idx_from_item_id
+ And toggle_small_state_event_group is invoked with tl_idx k
+ And the group transitions from expanded to collapsed
+ And the same flow works in reverse (collapsed → expanded) on a subsequent click
+
+
+Scenario: Invite-user button on a small-state event reads the correct tl_item in encrypted rooms
+ Test:
+ package: robrix
+ filter: test_notice_offset_preserves_invite_button
+ Level: integration
+ Given user opens an encrypted room where the encryption notice renders at item_id 0
+ And a small-state event at tl_items index k displays an invite_user_button
+ When the user clicks the invite_user_button
+ Then the handler converts item_id (k + 1) to tl_idx k via tl_idx_from_item_id
+ And tl.items.get(k) returns the intended event_tl_item
+ And the invite confirmation modal is populated with the correct user_id and username from that event
+
+
+Scenario: Encrypted joined room shows lock icon in all three adaptive variants
+ Test:
+ package: robrix
+ filter: test_room_list_icon_visible_when_encrypted
+ Given JoinedRoomInfo.is_encrypted is Some(true) for a room
+ And the room is not tombstoned (is_tombstoned == false)
+ When the room's RoomsListEntry renders in each of the three adaptive variants (OnlyIcon, IconAndName, FullPreview)
+ Then the encryption_icon View has visible == true in all three variants
+ And the icon uses resources/icons/lock_filled.svg with color #888888
+
+Scenario: Unencrypted joined room hides lock icon
+ Test:
+ package: robrix
+ filter: test_room_list_icon_hidden_when_unencrypted
+ Given JoinedRoomInfo.is_encrypted is Some(false) for a room
+ When the room's RoomsListEntry renders
+ Then the encryption_icon View has visible == false in all three adaptive variants
+
+Scenario: Unknown encryption state hides lock icon
+ Test:
+ package: robrix
+ filter: test_room_list_icon_hidden_when_unknown
+ Given JoinedRoomInfo.is_encrypted is None for a room
+ When the room's RoomsListEntry renders
+ Then the encryption_icon View has visible == false in all three adaptive variants
+ And no false-positive encryption claim is rendered
+
+Scenario: Tombstoned encrypted room hides encryption icon and shows tombstone
+ Test:
+ package: robrix
+ filter: test_room_list_icon_yields_to_tombstone
+ Given JoinedRoomInfo.is_encrypted is Some(true) and is_tombstoned is true for a room
+ When the room's RoomsListEntry renders
+ Then the encryption_icon View has visible == false
+ And the tombstone_icon View has visible == true
+
+Scenario: UpdateIsEncrypted live-updates the row icon
+ Test:
+ package: robrix
+ filter: test_room_list_icon_live_update
+ Level: integration
+ Given a visible RoomsListEntry with is_encrypted == Some(false) and the encryption_icon hidden
+ When a RoomsListUpdate::UpdateIsEncrypted { is_encrypted: true } for that room is processed
+ Then the row redraws and encryption_icon becomes visible
+ And no full room-list rebuild is required
+
+
+Scenario: UpdateIsEncrypted resolves None to Some(true) and reveals the row icon
+ Test:
+ package: robrix
+ filter: test_room_list_icon_resolve_from_unknown
+ Level: integration
+ Given a visible RoomsListEntry with is_encrypted == None and the encryption_icon hidden
+ When a RoomsListUpdate::UpdateIsEncrypted { is_encrypted: true } for that room is processed (the room transitions from unknown to known-encrypted)
+ Then the row redraws and encryption_icon becomes visible
+ And the underlying JoinedRoomInfo.is_encrypted is now Some(true)
+
+Scenario: Invited rooms render no encryption icon
+ Test:
+ package: robrix
+ filter: test_room_list_icon_invited_room_unaffected
+ Given a RoomsListEntry rendering an InvitedRoomInfo (not a JoinedRoomInfo)
+ When the entry renders
+ Then no encryption_icon is rendered for that entry regardless of any encryption metadata on the underlying room
+
+
+Scenario: This task does not add new direct matrix-client queries from the UI tree
+ Test:
+ package: robrix
+ filter: ci_check_no_new_get_client_in_ui
+ Level: code review / CI
+ Given the encryption_notice module, room_screen integration, rooms_list integration, and any helpers added by this task
+ And the enforcement mechanism is a PR-diff grep — pattern `get_client\s*\(`, paths `src/home/**` and `src/shared/**`, comparing the PR head against main, expecting zero net-new matching lines
+ When CI runs the named grep over PR-diff additions against `src/home/` and `src/shared/`
+ Then no net-new lines containing `get_client(` are added by this task
+ And all is_encrypted reads in net-new code come from JoinedRoomInfo
+ And all is_encrypted writes added by this task originate inside sliding_sync.rs async tasks
+
+## Out of Scope
+
+
+
+- Per-room "all devices verified" rollup (green checkmark / orange warning indicators) — deferred to a future spec.
+- Encryption indicator drawn as an overlay on the room avatar (badge over avatar image) — explicitly out of scope. The room-list indicator added by this spec is a sibling icon in the row, mirroring `tombstone_icon`'s position, not an avatar overlay.
+- Encryption details popup or sliding pane — dropped.
+- Room header lock icon or "Encrypted" text label inside `room_screen.rs` — dropped (the in-room signal lives in the PortalList notice, not the header).
+- Device verification flow — existing feature, untouched.
+- Key backup indicators — Month 8.
+- Per-message encryption status — out of scope.
+- Encryption settings toggle — out of scope.
+- Reaction to encryption being disabled mid-session — impossible per Matrix monotonicity; no handler needed.
+- Encryption indicator on `InvitedRoomInfo` entries — out of scope (joined-room only).
diff --git a/specs/month-1/forward-messages.spec.md b/specs/month-1/forward-messages.spec.md
new file mode 100644
index 00000000..8883e01d
--- /dev/null
+++ b/specs/month-1/forward-messages.spec.md
@@ -0,0 +1,181 @@
+
+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.
diff --git a/src/app.rs b/src/app.rs
index 7da3840e..7d3a8c80 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -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,
}
@@ -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 { }
@@ -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;
diff --git a/src/home/encryption_notice.rs b/src/home/encryption_notice.rs
new file mode 100644
index 00000000..b13f7c1f
--- /dev/null
+++ b/src/home/encryption_notice.rs
@@ -0,0 +1,240 @@
+use makepad_widgets::*;
+use matrix_sdk::room::RoomMember;
+
+use crate::sliding_sync::current_user_id;
+
+script_mod! {
+ use mod.prelude.widgets.*
+ use mod.widgets.*
+
+ mod.widgets.EncryptionNotice = set_type_default() do #(EncryptionNotice::register_widget(vm)) {
+ width: Fill,
+ height: Fit,
+ margin: Inset{left: 16, right: 16, top: 8, bottom: 8}
+ padding: 12
+ flow: Right
+ spacing: 10
+ align: Align{x: 0.0, y: 0.5}
+ show_bg: true
+ draw_bg +: {
+ color: #xF0F2F5
+ border_radius: 6.0
+ }
+
+ lock_filled_icon := View {
+ visible: false
+ width: Fit, height: Fit
+ Icon {
+ width: 16,
+ height: 16,
+ align: Align{x: 0.5, y: 0.5}
+ draw_icon +: {
+ svg: (ICON_LOCK_FILLED)
+ color: #888888
+ }
+ icon_walk: Walk{width: 16, height: 16}
+ }
+ }
+
+ lock_open_icon := View {
+ visible: false
+ width: Fit, height: Fit
+ Icon {
+ width: 16,
+ height: 16,
+ align: Align{x: 0.5, y: 0.5}
+ draw_icon +: {
+ svg: (ICON_LOCK_OPEN)
+ color: #888888
+ }
+ icon_walk: Walk{width: 16, height: 16}
+ }
+ }
+
+ text := View {
+ width: Fill,
+ height: Fit,
+ flow: Down
+ spacing: 3
+
+ title := Label {
+ width: Fill,
+ height: Fit
+ draw_text +: {
+ color: #202124
+ text_style: BOLD_TEXT { font_size: 10.5 }
+ }
+ text: ""
+ }
+
+ body := Label {
+ width: Fill,
+ height: Fit
+ flow: Flow.Right{wrap: true}
+ draw_text +: {
+ color: #444444
+ text_style: REGULAR_TEXT { font_size: 9.5 }
+ }
+ text: ""
+ }
+ }
+ }
+}
+
+const ENCRYPTED_TITLE: &str = "Encryption enabled";
+const UNENCRYPTED_TITLE: &str = "Encryption not enabled";
+const ENCRYPTED_BODY: &str = "Messages here are end-to-end encrypted.";
+const UNENCRYPTED_BODY: &str = "Messages here are not end-to-end encrypted.";
+const VERIFY_PREFIX: &str = "Messages here are end-to-end encrypted. Verify ";
+const VERIFY_SUFFIX: &str = " in their profile - tap on their profile picture.";
+const LOADING_MEMBER_PLACEHOLDER: &str = "…";
+const DISPLAY_NAME_LIMIT: usize = 30;
+
+pub fn first_other_member_display_name(members: Option<&[RoomMember]>) -> Option