From e0dd94eeef31ce1635bb173edfaa3fc4c7356b8f Mon Sep 17 00:00:00 2001 From: alanpoon Date: Tue, 12 May 2026 18:40:19 +0800 Subject: [PATCH 1/5] Month_1_forward --- .claude/hooks/user-prompt-submit.sh | 4 + .claude/settings.json | 14 + .claude/settings.local.json | 16 ++ resources/i18n/en.json | 9 + resources/i18n/zh-CN.json | 9 + src/app.rs | 26 +- src/home/new_message_context_menu.rs | 33 ++- src/home/room_screen.rs | 107 +++++++- src/shared/forward_modal.rs | 388 +++++++++++++++++++++++++++ src/shared/mod.rs | 2 + src/sliding_sync.rs | 94 +++++++ 11 files changed, 698 insertions(+), 4 deletions(-) create mode 100755 .claude/hooks/user-prompt-submit.sh create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 src/shared/forward_modal.rs diff --git a/.claude/hooks/user-prompt-submit.sh b/.claude/hooks/user-prompt-submit.sh new file mode 100755 index 000000000..8c39ff090 --- /dev/null +++ b/.claude/hooks/user-prompt-submit.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# mempal cowork inbox drain — prepends partner handoff messages to user prompt +# Graceful degrade: any failure exits 0 with empty stdout +mempal cowork-drain --target claude --cwd "${CLAUDE_PROJECT_CWD:-$PWD}" 2>/dev/null || true diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..e4bfa3a26 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "command": "bash .claude/hooks/user-prompt-submit.sh", + "type": "command" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..effd1b5ba --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "Bash(gh api:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:element.io)", + "WebFetch(domain:github.com)", + "WebFetch(domain:matrix.org)", + "WebFetch(domain:spec.matrix.org)", + "Bash(agent-spec parse:*)", + "Bash(agent-spec help:*)", + "Bash(agent-spec:*)" + ] + } +} diff --git a/resources/i18n/en.json b/resources/i18n/en.json index d8bae7127..a3b48afc4 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 9942aa7c3..cdbe07235 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/src/app.rs b/src/app.rs index 7da3840ee..7d3a8c80e 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/new_message_context_menu.rs b/src/home/new_message_context_menu.rs index 6fab28a70..cab40b0ea 100644 --- a/src/home/new_message_context_menu.rs +++ b/src/home/new_message_context_menu.rs @@ -3,7 +3,7 @@ use bitflags::bitflags; use makepad_widgets::*; -use matrix_sdk::ruma::OwnedEventId; +use matrix_sdk::ruma::{OwnedEventId, events::room::message::MessageType}; use matrix_sdk_ui::timeline::{EventTimelineItem, MsgLikeContent, TimelineEventItemId}; use crate::{i18n::{AppLanguage, tr_key}, sliding_sync::UserPowerLevels}; @@ -155,6 +155,11 @@ script_mod! { text: "Copy Link to Message" } + forward_message_button := mod.widgets.NewMessageContextMenuButton { + draw_icon +: { svg: (ICON_SEND) } + text: "Forward Message" + } + view_source_button := mod.widgets.NewMessageContextMenuButton { draw_icon +: { svg: (ICON_VIEW_SOURCE) } text: "View Source" @@ -231,13 +236,15 @@ bitflags! { const CanDelete = 1 << 5; /// Whether this message contains HTML content that the user can copy. const HasHtml = 1 << 6; + /// Whether this message can be forwarded to another room. + const CanForward = 1 << 7; } } impl MessageAbilities { pub fn from_user_power_and_event( user_power_levels: &UserPowerLevels, event_tl_item: &EventTimelineItem, - _message: &MsgLikeContent, + message: &MsgLikeContent, pinned_events: &[OwnedEventId], has_html: bool, ) -> Self { @@ -257,11 +264,19 @@ impl MessageAbilities { } abilities.set(Self::CanReact, user_power_levels.can_send_reaction()); abilities.set(Self::HasHtml, has_html); + abilities.set(Self::CanForward, is_forwardable_message_content(message)); abilities } } +pub fn is_forwardable_message_content(message: &MsgLikeContent) -> bool { + message.as_message().is_some_and(|message| matches!( + message.msgtype(), + MessageType::Text(..) | MessageType::Notice(..) | MessageType::Emote(..) + )) +} + /// Details about the message that define its context menu content. #[derive(Clone, Debug)] pub struct MessageDetails { @@ -448,6 +463,13 @@ impl WidgetMatchEvent for NewMessageContextMenu { ); close_menu = true; } + else if self.button(cx, ids!(forward_message_button)).clicked(actions) { + cx.widget_action( + details.room_screen_widget_uid, + MessageAction::Forward(details.clone()), + ); + close_menu = true; + } else if self.button(cx, ids!(view_source_button)).clicked(actions) { cx.widget_action( details.room_screen_widget_uid, @@ -509,6 +531,8 @@ impl NewMessageContextMenu { .set_text(cx, tr_key(self.app_language, "new_message_context_menu.button.copy_text_html")); self.view.button(cx, ids!(copy_link_to_message_button)) .set_text(cx, tr_key(self.app_language, "new_message_context_menu.button.copy_link")); + self.view.button(cx, ids!(forward_message_button)) + .set_text(cx, tr_key(self.app_language, "new_message_context_menu.button.forward_message")); self.view.button(cx, ids!(view_source_button)) .set_text(cx, tr_key(self.app_language, "new_message_context_menu.button.view_source")); self.view.button(cx, ids!(jump_to_related_button)) @@ -553,6 +577,7 @@ impl NewMessageContextMenu { let copy_text_button = self.view.button(cx, ids!(copy_text_button)); let copy_html_button = self.view.button(cx, ids!(copy_html_button)); let copy_link_button = self.view.button(cx, ids!(copy_link_to_message_button)); + let forward_message_button = self.view.button(cx, ids!(forward_message_button)); let view_source_button = self.view.button(cx, ids!(view_source_button)); let jump_to_related_button = self.view.button(cx, ids!(jump_to_related_button)); // let report_button = self.view.button(cx, ids!(report_button)); @@ -570,6 +595,7 @@ impl NewMessageContextMenu { let show_copy_text = true; let show_copy_html = details.abilities.contains(MessageAbilities::HasHtml); let show_copy_link = true; + let show_forward = details.abilities.contains(MessageAbilities::CanForward); let show_view_source = true; let show_jump_to_related = details.related_event_id.is_some(); // let show_report = true; @@ -599,6 +625,7 @@ impl NewMessageContextMenu { } pin_button.set_visible(cx, show_pin); copy_html_button.set_visible(cx, show_copy_html); + forward_message_button.set_visible(cx, show_forward); jump_to_related_button.set_visible(cx, show_jump_to_related); self.view.view(cx, ids!(divider_before_report_delete)).set_visible(cx, show_divider_before_report_delete); // report_button.set_visible(cx, show_report); @@ -613,6 +640,7 @@ impl NewMessageContextMenu { copy_text_button.reset_hover(cx); copy_html_button.reset_hover(cx); copy_link_button.reset_hover(cx); + forward_message_button.reset_hover(cx); view_source_button.reset_hover(cx); jump_to_related_button.reset_hover(cx); // report_button.reset_hover(cx); @@ -633,6 +661,7 @@ impl NewMessageContextMenu { + show_copy_text as u8 + show_copy_html as u8 + show_copy_link as u8 + + show_forward as u8 + show_view_source as u8 + show_jump_to_related as u8 // + show_report as u8 diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index d0d6e3c09..df133d744 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -33,7 +33,7 @@ use crate::{ }, room::{BasicRoomDetails, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, translation, typing_notice::TypingNoticeWidgetExt}, shared::{ - avatar::{AvatarState, AvatarWidgetExt, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalAction, ConfirmationModalContent, ConfirmationModalWidgetExt}, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt + avatar::{AvatarState, AvatarWidgetExt, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalAction, ConfirmationModalContent, ConfirmationModalWidgetExt}, forward_modal::{ForwardMessageContent, ForwardMessageModalAction}, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, sliding_sync::{BackwardsPaginateUntilEventRequest, FetchedRoomThread, MatrixRequest, PaginationDirection, RoomThreadsAction, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, current_user_id, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} }; @@ -256,6 +256,19 @@ fn original_event_content_json( .flatten() } +fn forwardable_room_message_content_from_json( + content: serde_json::Value, +) -> Option { + let mut message = serde_json::from_value::(content).ok()?; + let is_forwardable = matches!( + &message.msgtype, + MessageType::Text(..) | MessageType::Notice(..) | MessageType::Emote(..) + ); + message.relates_to = None; + message.tsp_signature = None; + is_forwardable.then_some(message) +} + fn parse_octos_approval_risk_level(value: Option<&str>) -> Option { match value { Some("normal") => Some(OctosApprovalRiskLevel::Normal), @@ -7127,6 +7140,19 @@ impl RoomScreen { .find(|ev| ev.event_id().is_some_and(|id| id == target_event_id)) } + fn forward_message_content( + timeline_kind: &TimelineKind, + event_tl_item: &EventTimelineItem, + ) -> Option { + let message = latest_effective_event_content_json(event_tl_item) + .and_then(forwardable_room_message_content_from_json)?; + Some(ForwardMessageContent { + source_room_id: timeline_kind.room_id().clone(), + source_event_id: event_tl_item.event_id()?.to_owned(), + message, + }) + } + /// Handles any [`MessageAction`]s received by this RoomScreen. fn handle_message_actions( &mut self, @@ -7379,6 +7405,25 @@ impl RoomScreen { ); } } + MessageAction::Forward(details) => { + let Some(tl) = self.tl_state.as_ref() else { return }; + if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) + && let Some(content) = Self::forward_message_content(&tl.kind, event_tl_item) + { + cx.action(ForwardMessageModalAction::Open(content)); + } else { + enqueue_popup_notification( + tr_key(self.app_language, "room_screen.popup.message.forward_not_found"), + PopupKind::Error, + Some(5.0), + ); + error!("MessageAction::Forward: couldn't find forwardable event [{}] {:?} in room {}", + details.item_id, + details.timeline_event_id, + tl.kind.room_id(), + ); + } + } MessageAction::ViewSource(details) => { let Some(tl) = self.tl_state.as_ref() else { continue }; let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) else { @@ -11110,6 +11155,8 @@ pub enum MessageAction { CopyHtml(MessageDetails), /// The user clicked the "copy link" button on a message. CopyLink(MessageDetails), + /// The user clicked the "forward message" button on a message. + Forward(MessageDetails), /// The user clicked the "view source" button on a message. ViewSource(MessageDetails), /// The user clicked the "jump to related" button on a message, @@ -11566,6 +11613,64 @@ mod tests { StreamingAnimState::new(text, true) } + #[test] + fn test_forward_menu() { + let content = serde_json::json!({ + "msgtype": "m.text", + "body": "hello" + }); + + let message = forwardable_room_message_content_from_json(content).unwrap(); + + assert!(matches!(message.msgtype, MessageType::Text(..))); + } + + #[test] + fn test_forward_menu_hidden_non_message() { + let content = serde_json::json!({ + "msgtype": "m.image", + "body": "photo.jpg", + "url": "mxc://example.org/media" + }); + + assert!(forwardable_room_message_content_from_json(content).is_none()); + } + + #[test] + fn test_forward_uses_latest_effective_content() { + let content = serde_json::json!({ + "msgtype": "m.text", + "body": "original", + "m.new_content": { + "msgtype": "m.text", + "body": "edited" + } + }); + let effective_content = effective_octos_message_content(&content).clone(); + let message = forwardable_room_message_content_from_json(effective_content).unwrap(); + + assert!(matches!( + message.msgtype, + MessageType::Text(TextMessageEventContent { body, .. }) if body == "edited" + )); + } + + #[test] + fn test_forward_does_not_send_reply_metadata() { + let content = serde_json::json!({ + "msgtype": "m.text", + "body": "reply text", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$source:example.org" + } + } + }); + let message = forwardable_room_message_content_from_json(content).unwrap(); + + assert!(message.relates_to.is_none()); + } + #[test] fn test_streaming_scan_range() { // Incremental: clamp sentinel to new_len diff --git a/src/shared/forward_modal.rs b/src/shared/forward_modal.rs new file mode 100644 index 000000000..19a03ebc8 --- /dev/null +++ b/src/shared/forward_modal.rs @@ -0,0 +1,388 @@ +//! Modal used to forward a message to another room. + +use makepad_widgets::*; +use matrix_sdk::ruma::{ + OwnedEventId, OwnedRoomId, RoomId, + events::room::message::RoomMessageEventContent, +}; + +use crate::{ + i18n::{AppLanguage, tr_key}, + shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::{MatrixRequest, submit_async_request}, +}; + +type ForwardMessageCloseHandler = Box; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.ForwardMessageModal = #(ForwardMessageModal::register_widget(vm)) { + width: Fit + height: Fit + + wrapper := RoundedView { + width: 420 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 26, right: 32, bottom: 20, left: 32} + spacing: 12 + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 4.0 + } + + title := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + text: "Forward Message" + draw_text +: { + text_style: TITLE_TEXT {font_size: 13} + color: #000 + } + } + + body := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + text: "Enter the destination Matrix room ID." + draw_text +: { + text_style: REGULAR_TEXT {font_size: 11.5} + color: #000 + } + } + + destination_room_id_input := RobrixTextInput { + width: Fill + height: Fit + empty_text: "!room:example.org" + padding: 8 + flow: Flow.Right{wrap: false} + draw_bg.border_size: 0.0 + } + + error_label := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + text: "" + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10} + color: (COLOR_FG_DANGER_RED) + } + } + + buttons_view := View { + width: Fill + height: Fit + flow: Right + padding: Inset{top: 8, bottom: 4} + align: Align{x: 1.0, y: 0.5} + spacing: 14 + + cancel_button := RobrixNeutralIconButton { + width: 120 + align: Align{x: 0.5, y: 0.5} + padding: 15 + draw_icon +: { svg: (ICON_FORBIDDEN) } + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Cancel" + } + + forward_button := RobrixPositiveIconButton { + width: 120 + align: Align{x: 0.5, y: 0.5} + padding: 15 + draw_icon +: { svg: (ICON_SEND) } + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Forward" + } + } + } + } +} + +#[derive(Clone, Debug)] +pub struct ForwardMessageContent { + pub source_room_id: OwnedRoomId, + pub source_event_id: OwnedEventId, + pub message: RoomMessageEventContent, +} + +#[derive(Clone, Debug, Default)] +pub enum ForwardMessageModalAction { + Open(ForwardMessageContent), + Close, + #[default] + None, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ForwardModalCloseEffect { + None, + ClearOnly, + EmitClose, +} + +pub fn forward_modal_close_effect( + cancel_clicked: bool, + escape_pressed: bool, + passive_dismissed: bool, +) -> ForwardModalCloseEffect { + if passive_dismissed { + ForwardModalCloseEffect::ClearOnly + } else if cancel_clicked || escape_pressed { + ForwardModalCloseEffect::EmitClose + } else { + ForwardModalCloseEffect::None + } +} + +pub fn build_forward_message_request( + content: ForwardMessageContent, + destination_room_id: OwnedRoomId, +) -> MatrixRequest { + MatrixRequest::ForwardMessage { + source_room_id: content.source_room_id, + source_event_id: content.source_event_id, + destination_room_id, + message: content.message, + } +} + +impl ActionDefaultRef for ForwardMessageModalAction { + fn default_ref() -> &'static Self { + static DEFAULT: ForwardMessageModalAction = ForwardMessageModalAction::None; + &DEFAULT + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct ForwardMessageModal { + #[deref] view: View, + #[rust] content: Option, + #[rust] app_language: AppLanguage, + #[rust] close_actions_emitted: usize, +} + +impl Widget for ForwardMessageModal { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.widget_match_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for ForwardMessageModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let destination_input = self.view.text_input(cx, ids!(destination_room_id_input)); + let cancel_button = self.view.button(cx, ids!(cancel_button)); + let forward_button = self.view.button(cx, ids!(forward_button)); + + let passive_dismissed = actions.iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))); + match forward_modal_close_effect( + cancel_button.clicked(actions), + destination_input.escaped(actions), + passive_dismissed, + ) { + ForwardModalCloseEffect::ClearOnly => { + self.clear_pending_message(cx); + return; + } + ForwardModalCloseEffect::EmitClose => { + self.emit_close(cx, None); + return; + } + ForwardModalCloseEffect::None => {} + } + + if destination_input.changed(actions).is_some() { + self.clear_error(cx); + } + + if forward_button.clicked(actions) || destination_input.returned(actions).is_some() { + let Some(content) = self.content.clone() else { + self.emit_close(cx, None); + return; + }; + let destination_room_id_text = destination_input.text().trim().to_string(); + let destination_room_id = match parse_destination_room_id(&destination_room_id_text) { + Ok(room_id) => room_id, + Err(error) => { + self.show_error(cx, &error); + return; + } + }; + let submit_handler: ForwardMessageCloseHandler = Box::new(move || { + submit_async_request(build_forward_message_request(content, destination_room_id)); + }); + enqueue_popup_notification( + tr_key(self.app_language, "forward_modal.popup.submitting"), + PopupKind::Info, + Some(3.0), + ); + self.emit_close(cx, Some(submit_handler)); + } + } +} + +impl ForwardMessageModal { + pub fn show(&mut self, cx: &mut Cx, content: ForwardMessageContent, app_language: AppLanguage) { + self.content = Some(content); + self.app_language = app_language; + self.close_actions_emitted = 0; + self.apply_static_text(cx); + self.clear_error(cx); + let input = self.view.text_input(cx, ids!(destination_room_id_input)); + input.set_text(cx, ""); + input.set_key_focus(cx); + self.view.button(cx, ids!(cancel_button)).reset_hover(cx); + self.view.button(cx, ids!(forward_button)).reset_hover(cx); + self.redraw(cx); + } + + fn apply_static_text(&mut self, cx: &mut Cx) { + self.view.label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "forward_modal.title")); + self.view.label(cx, ids!(body)) + .set_text(cx, tr_key(self.app_language, "forward_modal.body")); + self.view.text_input(cx, ids!(destination_room_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "forward_modal.input.destination_room_id").to_string()); + self.view.button(cx, ids!(cancel_button)) + .set_text(cx, tr_key(self.app_language, "forward_modal.button.cancel")); + self.view.button(cx, ids!(forward_button)) + .set_text(cx, tr_key(self.app_language, "forward_modal.button.forward")); + } + + fn show_error(&mut self, cx: &mut Cx, error: &str) { + self.view.label(cx, ids!(error_label)).set_text(cx, error); + self.redraw(cx); + } + + fn clear_error(&mut self, cx: &mut Cx) { + self.view.label(cx, ids!(error_label)).set_text(cx, ""); + self.redraw(cx); + } + + fn clear_pending_message(&mut self, cx: &mut Cx) { + self.content = None; + self.clear_error(cx); + } + + fn emit_close(&mut self, cx: &mut Cx, close_handler: Option) { + if let Some(close_handler) = close_handler { + close_handler(); + } + self.clear_pending_message(cx); + self.close_actions_emitted += 1; + cx.action(ForwardMessageModalAction::Close); + } +} + +impl ForwardMessageModalRef { + pub fn show(&self, cx: &mut Cx, content: ForwardMessageContent, app_language: AppLanguage) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.show(cx, content, app_language); + } +} + +pub fn parse_destination_room_id(value: &str) -> Result { + if value.trim().is_empty() { + return Err("Please enter a destination room ID.".to_string()); + } + RoomId::parse(value.trim()) + .map_err(|_| "Please enter a valid Matrix room ID, such as !room:example.org.".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_forward_invalid_room_id() { + assert!(parse_destination_room_id("").is_err()); + assert!(parse_destination_room_id("not-a-room").is_err()); + assert!(parse_destination_room_id("@alice:example.org").is_err()); + } + + #[test] + fn test_forward_submit_request_accepts_room_id() { + let room_id = parse_destination_room_id("!dest:example.org").unwrap(); + assert_eq!(room_id.as_str(), "!dest:example.org"); + } + + #[test] + fn test_forward_submit_request() { + let source_room_id = OwnedRoomId::try_from("!source:example.org").unwrap(); + let source_event_id = OwnedEventId::try_from("$event:example.org").unwrap(); + let destination_room_id = OwnedRoomId::try_from("!dest:example.org").unwrap(); + let request = build_forward_message_request( + ForwardMessageContent { + source_room_id: source_room_id.clone(), + source_event_id: source_event_id.clone(), + message: RoomMessageEventContent::text_plain("hello"), + }, + destination_room_id.clone(), + ); + + match request { + MatrixRequest::ForwardMessage { + source_room_id: actual_source_room_id, + source_event_id: actual_source_event_id, + destination_room_id: actual_destination_room_id, + message, + } => { + assert_eq!(actual_source_room_id, source_room_id); + assert_eq!(actual_source_event_id, source_event_id); + assert_eq!(actual_destination_room_id, destination_room_id); + assert!(matches!(message.msgtype, matrix_sdk::ruma::events::room::message::MessageType::Text(..))); + } + _ => panic!("expected MatrixRequest::ForwardMessage"), + } + } + + #[test] + fn test_forward_modal_opens() { + let action = ForwardMessageModalAction::Open(ForwardMessageContent { + source_room_id: OwnedRoomId::try_from("!source:example.org").unwrap(), + source_event_id: OwnedEventId::try_from("$event:example.org").unwrap(), + message: RoomMessageEventContent::text_plain("hello"), + }); + + assert!(matches!(action, ForwardMessageModalAction::Open(_))); + } + + #[test] + fn test_forward_cancel_no_feedback_loop() { + assert_eq!( + forward_modal_close_effect(true, false, false), + ForwardModalCloseEffect::EmitClose, + ); + } + + #[test] + fn test_forward_escape_no_feedback_loop() { + assert_eq!( + forward_modal_close_effect(false, true, false), + ForwardModalCloseEffect::EmitClose, + ); + } + + #[test] + fn test_forward_dismiss_no_feedback_loop() { + assert_eq!( + forward_modal_close_effect(false, false, true), + ForwardModalCloseEffect::ClearOnly, + ); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index e9a04b020..ccb1f025c 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -5,6 +5,7 @@ pub mod collapsible_header; pub mod expand_arrow; pub mod confirmation_modal; pub mod file_upload_modal; +pub mod forward_modal; pub mod helpers; pub mod html_or_plaintext; pub mod icon_button; @@ -48,4 +49,5 @@ pub fn script_mod(vm: &mut ScriptVm) { image_viewer::script_mod(vm); progress_bar::script_mod(vm); file_upload_modal::script_mod(vm); + forward_modal::script_mod(vm); } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 1aa40fde2..90bed5b42 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1039,6 +1039,13 @@ pub enum MatrixRequest { #[cfg(feature = "tsp")] sign_with_tsp: bool, }, + /// Request to forward an existing message's effective content to another room. + ForwardMessage { + source_room_id: OwnedRoomId, + source_event_id: OwnedEventId, + destination_room_id: OwnedRoomId, + message: RoomMessageEventContent, + }, /// Request to send a bot action response below a timeline message. SendActionResponse { timeline_kind: TimelineKind, @@ -1348,6 +1355,24 @@ async fn find_reusable_direct_message_room(client: &Client, target_user_id: &Use mod matrix_request_tests { use super::*; + #[test] + fn test_forward_success_feedback() { + let room_id = RoomId::parse("!dest:example.org").unwrap(); + + assert_eq!( + forward_success_feedback_text(room_id.as_ref()), + "Forwarded message to !dest:example.org.", + ); + } + + #[test] + fn test_forward_failure_feedback() { + assert_eq!( + forward_failure_feedback_text("network error"), + "Failed to forward message: network error", + ); + } + #[test] fn is_active_dm_room_state_only_joined_is_reusable() { assert!(is_active_dm_room_state(RoomState::Joined)); @@ -1639,6 +1664,14 @@ pub fn submit_async_request(req: MatrixRequest) { } } +fn forward_success_feedback_text(destination_room_id: &RoomId) -> String { + format!("Forwarded message to {destination_room_id}.") +} + +fn forward_failure_feedback_text(error: impl std::fmt::Display) -> String { + format!("Failed to forward message: {error}") +} + /// Details of a login request that get submitted within [`MatrixRequest::Login`]. pub enum LoginRequest{ LoginByPassword(LoginByPassword), @@ -3605,6 +3638,67 @@ async fn matrix_worker_task( }); } + MatrixRequest::ForwardMessage { + source_room_id, + source_event_id, + destination_room_id, + message, + } => { + let Some(client) = get_client() else { + enqueue_popup_notification( + "Cannot forward message: Matrix client is not ready.", + PopupKind::Error, + None, + ); + continue; + }; + + let _forward_message_task = Handle::current().spawn(async move { + let Some(destination_room) = client.get_room(&destination_room_id) else { + enqueue_popup_notification( + format!("Cannot forward message: room {destination_room_id} is not known locally."), + PopupKind::Error, + None, + ); + SignalToUI::set_ui_signal(); + return; + }; + if destination_room.state() != RoomState::Joined { + enqueue_popup_notification( + format!("Cannot forward message: not joined to {destination_room_id}."), + PopupKind::Error, + None, + ); + SignalToUI::set_ui_signal(); + return; + } + + match destination_room.send(message).await { + Ok(_response) => { + log!( + "Forwarded message {source_event_id} from {source_room_id} to {destination_room_id}." + ); + enqueue_popup_notification( + forward_success_feedback_text(destination_room_id.as_ref()), + PopupKind::Info, + Some(4.0), + ); + } + Err(error) => { + error!( + "Failed to forward message {source_event_id} from {source_room_id} to {destination_room_id}: {error:?}" + ); + enqueue_popup_notification( + forward_failure_feedback_text(&error), + PopupKind::Error, + None, + ); + } + } + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::SendActionResponse { timeline_kind, content, From b0deda043c35568922a017e6c802dd86833b1ca1 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Wed, 13 May 2026 21:14:39 +0800 Subject: [PATCH 2/5] encryption notice --- .gitignore | 1 + resources/icons/lock_filled.svg | 3 + resources/icons/lock_open.svg | 3 + src/home/encryption_notice.rs | 240 ++++++++++++++++++++++++++++++++ src/home/mod.rs | 2 + src/home/room_screen.rs | 183 +++++++++++++++++++----- src/home/rooms_list.rs | 56 ++++++++ src/home/rooms_list_entry.rs | 54 ++++++- src/shared/styles.rs | 2 + src/sliding_sync.rs | 57 ++++++++ 10 files changed, 565 insertions(+), 36 deletions(-) create mode 100644 resources/icons/lock_filled.svg create mode 100644 resources/icons/lock_open.svg create mode 100644 src/home/encryption_notice.rs diff --git a/.gitignore b/.gitignore index 9d61dcd77..55d7b0f37 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .vscode .DS_Store proxychains.conf +specs \ No newline at end of file diff --git a/resources/icons/lock_filled.svg b/resources/icons/lock_filled.svg new file mode 100644 index 000000000..7bd60f6bd --- /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 000000000..4f41c1462 --- /dev/null +++ b/resources/icons/lock_open.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/home/encryption_notice.rs b/src/home/encryption_notice.rs new file mode 100644 index 000000000..b13f7c1fd --- /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> { + let members = members?; + let own_user_id = current_user_id(); + let first_other = members + .iter() + .find(|member| own_user_id.as_ref().is_none_or(|own| member.user_id() != own))?; + + Some(Some( + first_other + .display_name() + .map(ToOwned::to_owned) + .unwrap_or_else(|| first_other.user_id().to_string()), + )) +} + +pub fn truncate_display_name(display_name: &str) -> String { + let mut chars = display_name.chars(); + let truncated: String = chars.by_ref().take(DISPLAY_NAME_LIMIT).collect(); + if chars.next().is_some() { + format!("{truncated}{LOADING_MEMBER_PLACEHOLDER}") + } else { + truncated + } +} + +pub fn encryption_notice_copy( + is_encrypted: bool, + first_other_member: Option>, +) -> (&'static str, String) { + if !is_encrypted { + return (UNENCRYPTED_TITLE, UNENCRYPTED_BODY.to_string()); + } + + // Only show the "Verify " sentence when we actually have a name to put in it. + // For every other state (members not loaded yet, lonely room, no resolvable display name) + // fall back to the generic body — never render a "…" placeholder in user-visible copy. + match first_other_member { + Some(Some(display_name)) => ( + ENCRYPTED_TITLE, + format!( + "{VERIFY_PREFIX}{}{}", + truncate_display_name(&display_name), + VERIFY_SUFFIX, + ), + ), + _ => (ENCRYPTED_TITLE, ENCRYPTED_BODY.to_string()), + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct EncryptionNotice { + #[source] source: ScriptObjectRef, + #[deref] view: View, +} + +impl Widget for EncryptionNotice { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl EncryptionNotice { + pub fn set_content( + &mut self, + cx: &mut Cx, + is_encrypted: bool, + first_other_member: Option>, + ) { + let (title, body) = encryption_notice_copy(is_encrypted, first_other_member); + self.label(cx, ids!(text.title)).set_text(cx, title); + self.label(cx, ids!(text.body)).set_text(cx, &body); + + self.view.view(cx, ids!(lock_filled_icon)).set_visible(cx, is_encrypted); + self.view.view(cx, ids!(lock_open_icon)).set_visible(cx, !is_encrypted); + } +} + +impl EncryptionNoticeRef { + pub fn set_content( + &self, + cx: &mut Cx, + is_encrypted: bool, + first_other_member: Option>, + ) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_content(cx, is_encrypted, first_other_member); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_notice_unencrypted() { + let (title, body) = encryption_notice_copy(false, None); + + assert_eq!(title, "Encryption not enabled"); + assert_eq!(body, "Messages here are not end-to-end encrypted."); + } + + #[test] + fn test_notice_member_placeholder() { + // When members are not yet loaded (first_other_member = None), we no longer + // render a "…" placeholder; the body falls back to the generic "encrypted" sentence + // and the verify sentence appears only after a real name resolves. + let (title, body) = encryption_notice_copy(true, None); + + assert_eq!(title, "Encryption enabled"); + assert_eq!(body, "Messages here are end-to-end encrypted."); + } + + #[test] + fn test_notice_lonely_room() { + let (title, body) = encryption_notice_copy(true, Some(None)); + + assert_eq!(title, "Encryption enabled"); + assert_eq!(body, "Messages here are end-to-end encrypted."); + } + + #[test] + fn test_notice_encrypted() { + let (title, body) = encryption_notice_copy(true, Some(Some("Alice".to_string()))); + + assert_eq!(title, "Encryption enabled"); + assert_eq!( + body, + "Messages here are end-to-end encrypted. Verify Alice in their profile - tap on their profile picture." + ); + } + + #[test] + fn test_notice_truncates_long_display_name_to_30_chars() { + let body = encryption_notice_copy( + true, + Some(Some("123456789012345678901234567890123".to_string())), + ).1; + + assert_eq!( + body, + "Messages here are end-to-end encrypted. Verify 123456789012345678901234567890… in their profile - tap on their profile picture." + ); + } +} diff --git a/src/home/mod.rs b/src/home/mod.rs index 73f684c08..cef543e16 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -6,6 +6,7 @@ pub mod create_bot_modal; pub mod delete_bot_modal; pub mod edited_indicator; pub mod editing_pane; +pub mod encryption_notice; pub mod event_source_modal; pub mod home_screen; pub mod invite_modal; @@ -98,6 +99,7 @@ pub fn script_mod(vm: &mut ScriptVm) { rooms_list::script_mod(vm); edited_indicator::script_mod(vm); editing_pane::script_mod(vm); + encryption_notice::script_mod(vm); new_message_context_menu::script_mod(vm); event_source_modal::script_mod(vm); room_context_menu::script_mod(vm); diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index df133d744..24c151c41 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -27,7 +27,7 @@ use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, e use matrix_sdk_ui::sync_service::State; use crate::{ - app::{AppState, AppStateAction, ConfirmDeleteAction, SelectedRoom}, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{bot_binding_modal::BotBindingModalAction, create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, edited_indicator::EditedIndicatorWidgetRefExt, invite_modal::InviteModalAction, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, room_image_viewer::{get_image_name_and_filesize, populate_matrix_image_modal}, rooms_list::{RoomsListAction, RoomsListRef}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails}, i18n::{AppLanguage, tr_fmt, tr_key}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ + app::{AppState, AppStateAction, ConfirmDeleteAction, SelectedRoom}, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{bot_binding_modal::BotBindingModalAction, create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, edited_indicator::EditedIndicatorWidgetRefExt, encryption_notice::{EncryptionNoticeWidgetRefExt, first_other_member_display_name}, invite_modal::InviteModalAction, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, room_image_viewer::{get_image_name_and_filesize, populate_matrix_image_modal}, rooms_list::{RoomsListAction, RoomsListRef}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails}, i18n::{AppLanguage, tr_fmt, tr_key}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, @@ -68,6 +68,18 @@ const TRANSLATION_LANG_POPUP_SCROLL_HEIGHT: f64 = 288.0; const TRANSLATION_LANG_POPUP_HEIGHT: f64 = TRANSLATION_LANG_POPUP_SCROLL_HEIGHT + 8.0; const TRANSLATION_LANG_POPUP_GAP: f64 = 6.0; const TRANSLATION_LANG_POPUP_MARGIN: f64 = 8.0; + +fn tl_idx_from_item_id(item_id: usize, has_encryption_notice: bool) -> Option { + if has_encryption_notice { + item_id.checked_sub(1) + } else { + Some(item_id) + } +} + +fn item_id_from_tl_idx(tl_idx: usize, has_encryption_notice: bool) -> usize { + tl_idx + usize::from(has_encryption_notice) +} const MESSAGE_PROFILE_TOP_MARGIN: f64 = 4.5; const MESSAGE_PROFILE_AVATAR_SIZE: f64 = 48.0; const MESSAGE_USERNAME_ROW_HEIGHT: f64 = 18.0; @@ -3474,6 +3486,7 @@ script_mod! { SmallStateEvent := mod.widgets.SmallStateEvent {} SmallStateEventsSummary := mod.widgets.SmallStateEventsSummary {} Empty := mod.widgets.Empty {} + EncryptionNotice := mod.widgets.EncryptionNotice {} DateDivider := mod.widgets.DateDivider {} ReadMarker := mod.widgets.ReadMarker {} AppServicePanel := mod.widgets.AppServicePanel {} @@ -4606,6 +4619,7 @@ impl Widget for RoomScreen { // we want to handle those before processing any updates that might change // the set of timeline indices (which would invalidate the index values in any actions). if let Event::Actions(actions) = event { + let has_encryption_notice = self.current_has_encryption_notice(cx); for (index, wr) in portal_list.items_with_actions(actions) { // Handle a hover-in action on the reaction list: show a reaction summary. let reaction_list = wr.reaction_list(cx, ids!(reaction_list)); @@ -4689,17 +4703,26 @@ impl Widget for RoomScreen { continue; } - if wr.button(cx, ids!(state_group_toggle_button)).clicked(actions) - || wr.button(cx, ids!(group_header.state_group_toggle_button)).clicked(actions) - { - self.toggle_small_state_event_group(cx, index); + let summary_clicked = wr.button(cx, ids!(state_group_toggle_button)).clicked(actions); + let header_clicked = wr.button(cx, ids!(group_header.state_group_toggle_button)).clicked(actions); + if summary_clicked || header_clicked { + log!( + "[encryption-notice/toggle] click reached: index={index}, has_encryption_notice={has_encryption_notice}, summary_clicked={summary_clicked}, header_clicked={header_clicked}" + ); + let Some(tl_idx) = tl_idx_from_item_id(index, has_encryption_notice) else { + log!("[encryption-notice/toggle] tl_idx_from_item_id returned None for index={index}, skipping"); + continue; + }; + log!("[encryption-notice/toggle] calling toggle_small_state_event_group(tl_idx={tl_idx})"); + self.toggle_small_state_event_group(cx, tl_idx); continue; } // Handle the invite_user_button (in a SmallStateEvent) being clicked. if wr.button(cx, ids!(event_row.invite_user_button)).clicked(actions) { + let Some(tl_idx) = tl_idx_from_item_id(index, has_encryption_notice) else { continue }; let Some(tl) = self.tl_state.as_ref() else { continue }; - if let Some(event_tl_item) = tl.items.get(index).and_then(|item| item.as_event()) { + if let Some(event_tl_item) = tl.items.get(tl_idx).and_then(|item| item.as_event()) { let user_id = event_tl_item.sender().to_owned(); let username = if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { profile.display_name.as_deref().unwrap_or(user_id.as_str()) @@ -5476,7 +5499,10 @@ impl Widget for RoomScreen { // Set the portal list's range based on the number of timeline items. let tl_items = &tl_state.items; - let last_item_id = tl_items.len() + usize::from(self.show_app_service_actions); + let has_encryption_notice = room_props.is_encrypted.is_some(); + let last_item_id = tl_items.len() + + usize::from(self.show_app_service_actions) + + usize::from(has_encryption_notice); let list = list_ref.deref_mut(); list.set_item_range(cx, 0, last_item_id); @@ -5509,7 +5535,24 @@ impl Widget for RoomScreen { while let Some(item_id) = list.next_visible_item(cx) { let item = { - let tl_idx = item_id; + if let Some(is_encrypted) = room_props.is_encrypted + && item_id == 0 + { + let item = list.item(cx, item_id, id!(EncryptionNotice)); + item.as_encryption_notice().set_content( + cx, + is_encrypted, + first_other_member_display_name( + tl_state.room_members.as_ref().map(|members| members.as_slice()), + ), + ); + item.draw_all(cx, &mut room_scope); + continue; + } + let Some(tl_idx) = tl_idx_from_item_id(item_id, has_encryption_notice) else { + list.item(cx, item_id, id!(Empty)); + continue; + }; if self.show_app_service_actions && tl_idx == tl_items.len() { list.item(cx, item_id, id!(AppServicePanel)) } else { @@ -5774,16 +5817,27 @@ impl RoomScreen { } fn toggle_small_state_event_group(&mut self, cx: &mut Cx, group_start_index: usize) { - let Some(tl_state) = self.tl_state.as_mut() else { return }; + let Some(tl_state) = self.tl_state.as_mut() else { + log!("[encryption-notice/toggle] tl_state is None, aborting"); + return; + }; let groups = compute_small_state_event_groups( &tl_state.items, &tl_state.kind, &tl_state.expanded_small_state_group_event_ids, ); + let group_starts: Vec = groups.iter().map(|g| g.start).collect(); let Some(group) = groups.into_iter().find(|group| group.start == group_start_index) else { + log!( + "[encryption-notice/toggle] FIND FAILED: looking for group.start={group_start_index}, available group.starts={group_starts:?}" + ); return; }; + log!( + "[encryption-notice/toggle] FIND OK: group.start={}, group.end={}, group.collapsed={}", + group.start, group.end, group.collapsed + ); if group.collapsed { tl_state.expanded_small_state_group_event_ids.insert(group.first_event_id); } else { @@ -5792,6 +5846,7 @@ impl RoomScreen { tl_state.content_drawn_since_last_update.remove(group.start .. group.end); tl_state.profile_drawn_since_last_update.remove(group.start .. group.end); self.redraw_timeline_list(cx); + log!("[encryption-notice/toggle] state mutated, redraw_timeline_list called"); } fn sync_translation_lang_popup(&mut self, cx: &mut Cx) { @@ -5860,6 +5915,9 @@ impl RoomScreen { let is_direct_room = cx.get_global::() .is_direct_room(&room_id) .unwrap_or(false); + let is_encrypted = cx.get_global::() + .joined_room_is_encrypted(&room_id) + .flatten(); let ( app_service_enabled, app_service_room_bound, @@ -5943,6 +6001,7 @@ impl RoomScreen { room_name_id: self.room_name_id.clone().unwrap_or_else(|| RoomNameId::empty(room_id.clone())), timeline_kind: tl.kind.clone(), room_members, + is_encrypted, is_direct_room, room_bot_user_ids, room_members_sync_pending: tl.room_members_sync_pending, @@ -5962,6 +6021,7 @@ impl RoomScreen { timeline_kind: self.timeline_kind.clone() .expect("BUG: room_name_id was set but timeline_kind was missing"), room_members: None, + is_encrypted: None, is_direct_room: false, room_bot_user_ids: Vec::new(), room_members_sort: None, @@ -5981,6 +6041,16 @@ impl RoomScreen { self.room_name_id.as_ref().map(|r| r.room_id()) } + fn current_has_encryption_notice(&self, cx: &mut Cx) -> bool { + self.room_id() + .and_then(|room_id| + cx.get_global::() + .joined_room_is_encrypted(room_id) + .flatten() + ) + .is_some() + } + /// Extract the text body from a timeline item, if it's a text message. fn extract_message_text(item: &Arc) -> Option { let TimelineItemKind::Event(event) = item.kind() else { return None }; @@ -6297,7 +6367,9 @@ impl RoomScreen { ) { let top_space = self.view(cx, ids!(top_space)); let jump_to_bottom_button = self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)); + let has_encryption_notice = self.current_has_encryption_notice(cx); let curr_first_id = portal_list.first_id(); + let curr_first_tl_idx = tl_idx_from_item_id(curr_first_id, has_encryption_notice).unwrap_or(0); let ui = self.widget_uid(); let Some(tl) = self.tl_state.as_mut() else { return }; let ( @@ -6334,7 +6406,10 @@ impl RoomScreen { tl.profile_drawn_since_last_update.clear(); tl.fully_paginated = false; // Set the portal list to the very bottom of the timeline. - portal_list.set_first_id_and_scroll(initial_items.len().saturating_sub(1), 0.0); + portal_list.set_first_id_and_scroll( + item_id_from_tl_idx(initial_items.len().saturating_sub(1), has_encryption_notice), + 0.0, + ); portal_list.set_tail_range(true); jump_to_bottom_button.update_visibility(cx, true); @@ -6406,14 +6481,17 @@ impl RoomScreen { // and then replaces the existing timeline in ALL_ROOMS_INFO with the new one. } - let prior_items_changed = clear_cache || changed_indices.start <= curr_first_id; + let prior_items_changed = clear_cache || changed_indices.start <= curr_first_tl_idx; if new_items.len() == tl.items.len() { // log!("process_timeline_updates(): no jump necessary for updated timeline of same length: {}", items.len()); } - else if curr_first_id > new_items.len() { - log!("process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", curr_first_id, new_items.len()); - portal_list.set_first_id_and_scroll(new_items.len().saturating_sub(1), 0.0); + else if curr_first_tl_idx > new_items.len() { + log!("process_timeline_updates(): jumping to bottom: curr_first_tl_idx {} is out of bounds for {} new items", curr_first_tl_idx, new_items.len()); + portal_list.set_first_id_and_scroll( + item_id_from_tl_idx(new_items.len().saturating_sub(1), has_encryption_notice), + 0.0, + ); portal_list.set_tail_range(true); jump_to_bottom_button.update_visibility(cx, true); } @@ -6422,13 +6500,16 @@ impl RoomScreen { // which ensures that the timeline doesn't jump around unexpectedly and ruin the user's experience. else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) = prior_items_changed.then(|| - find_new_item_matching_current_item(cx, portal_list, curr_first_id, &tl.items, &new_items) + find_new_item_matching_current_item(cx, portal_list, curr_first_tl_idx, &tl.items, &new_items, has_encryption_notice) ) .flatten() { if curr_item_idx != new_item_idx { log!("process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"); - portal_list.set_first_id_and_scroll(new_item_idx, new_item_scroll); + portal_list.set_first_id_and_scroll( + item_id_from_tl_idx(new_item_idx, has_encryption_notice), + new_item_scroll, + ); tl.prev_first_index = Some(new_item_idx); // Set scrolled_past_read_marker false when we jump to a new event tl.scrolled_past_read_marker = false; @@ -6654,10 +6735,11 @@ impl RoomScreen { // NOTE: this code was copied from the `MessageAction::JumpToRelated` handler; // we should deduplicate them at some point. let speed = 50.0; - portal_list.smooth_scroll_to(cx, index, speed, None, 10.0); + let item_id = item_id_from_tl_idx(index, has_encryption_notice); + portal_list.smooth_scroll_to(cx, item_id, speed, None, 10.0); // start highlight animation. tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index + item_id }; } else { @@ -7092,8 +7174,10 @@ impl RoomScreen { let Some(media_source) = mxc_uri else { return; }; + let has_encryption_notice = self.current_has_encryption_notice(cx); let Some(tl_state) = self.tl_state.as_mut() else { return }; - let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) else { return }; + let Some(tl_idx) = tl_idx_from_item_id(item_id, has_encryption_notice) else { return }; + let Some(event_tl_item) = tl_state.items.get(tl_idx).and_then(|item| item.as_event()) else { return }; let timestamp_millis = event_tl_item.timestamp(); let (image_name, image_file_size) = get_image_name_and_filesize(event_tl_item); @@ -7125,9 +7209,11 @@ impl RoomScreen { fn find_event_in_timeline<'a>( items: &'a Vector>, details: &MessageDetails, + has_encryption_notice: bool, ) -> Option<&'a EventTimelineItem> { let target_event_id = details.event_id()?; - if let Some(event) = items.get(details.item_id) + let tl_idx = tl_idx_from_item_id(details.item_id, has_encryption_notice)?; + if let Some(event) = items.get(tl_idx) .and_then(|item| item.as_event()) .filter(|ev| ev.event_id().is_some_and(|id| id == target_event_id)) { @@ -7215,6 +7301,7 @@ impl RoomScreen { } let room_screen_widget_uid = self.widget_uid(); + let has_encryption_notice = self.current_has_encryption_notice(cx); for action in actions { match action.as_widget_action().widget_uid_eq(room_screen_widget_uid).cast_ref() { MessageAction::React { details, reaction } => { @@ -7227,7 +7314,7 @@ impl RoomScreen { } MessageAction::Reply(details) => { let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details).cloned() { + if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details, has_encryption_notice).cloned() { let replied_to_info = EmbeddedEvent::from_timeline_item(&event_tl_item); self.view.room_input_bar(cx, ids!(room_input_bar)) .show_replying_to(cx, (event_tl_item, replied_to_info), &tl.kind); @@ -7247,7 +7334,7 @@ impl RoomScreen { } MessageAction::Edit(details) => { let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { + if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details, has_encryption_notice) { self.view.room_input_bar(cx, ids!(room_input_bar)) .show_editing_pane( cx, @@ -7294,7 +7381,10 @@ impl RoomScreen { MessageAction::MessageSubmittedLocally => { let Some(tl) = self.tl_state.as_ref() else { continue }; let last_item_idx = tl.items.len().saturating_sub(1); - portal_list.set_first_id_and_scroll(last_item_idx, 0.0); + portal_list.set_first_id_and_scroll( + item_id_from_tl_idx(last_item_idx, has_encryption_notice), + 0.0, + ); portal_list.set_tail_range(true); self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)) .update_visibility(cx, true); @@ -7334,7 +7424,7 @@ impl RoomScreen { } MessageAction::CopyText(details) => { let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { + if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details, has_encryption_notice) { cx.copy_to_clipboard(&plaintext_body_of_timeline_item(event_tl_item)); } else { @@ -7355,7 +7445,7 @@ impl RoomScreen { // The logic for getting the formatted body of a message is the same // as the logic used in `populate_message_view()`. let mut success = false; - if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { + if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details, has_encryption_notice) { if let Some(message) = event_tl_item.content().as_message() { match message.msgtype() { MessageType::Text(TextMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) @@ -7407,7 +7497,7 @@ impl RoomScreen { } MessageAction::Forward(details) => { let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) + if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details, has_encryption_notice) && let Some(content) = Self::forward_message_content(&tl.kind, event_tl_item) { cx.action(ForwardMessageModalAction::Open(content)); @@ -7426,7 +7516,7 @@ impl RoomScreen { } MessageAction::ViewSource(details) => { let Some(tl) = self.tl_state.as_ref() else { continue }; - let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) else { + let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details, has_encryption_notice) else { enqueue_popup_notification( tr_key(self.app_language, "room_screen.popup.message.view_source_not_found"), PopupKind::Error, @@ -7610,8 +7700,11 @@ impl RoomScreen { portal_list: &PortalListRef, loading_pane: &LoadingPaneRef, ) { + let has_encryption_notice = self.current_has_encryption_notice(cx); let Some(tl) = self.tl_state.as_mut() else { return }; - let max_tl_idx = max_tl_idx.unwrap_or_else(|| tl.items.len()); + let max_tl_idx = max_tl_idx + .and_then(|item_id| tl_idx_from_item_id(item_id, has_encryption_notice)) + .unwrap_or_else(|| tl.items.len()); // Attempt to find the index of replied-to message in the timeline. // Start from the current item's index (`tl_idx`) and search backwards, @@ -7634,10 +7727,11 @@ impl RoomScreen { if let Some(index) = related_msg_tl_index { // log!("The related message {replied_to_event} was immediately found in room {}, scrolling to from index {reply_message_item_id} --> {index} (first ID {}).", tl.kind.room_id(), portal_list.first_id()); let speed = 50.0; - portal_list.smooth_scroll_to(cx, index, speed, None, 10.0); + let item_id = item_id_from_tl_idx(index, has_encryption_notice); + portal_list.smooth_scroll_to(cx, item_id, speed, None, 10.0); // start highlight animation. tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index + item_id }; } else { log!("The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", tl.kind.room_id()); @@ -8274,7 +8368,7 @@ impl RoomScreen { /// Sends read receipts based on the current scroll position of the timeline. fn send_user_read_receipts_based_on_scroll_pos( &mut self, - _cx: &mut Cx, + cx: &mut Cx, actions: &ActionsBuf, portal_list: &PortalListRef, ) { @@ -8282,7 +8376,9 @@ impl RoomScreen { if portal_list.scrolled(actions) { return; } - let first_index = portal_list.first_id(); + let has_encryption_notice = self.current_has_encryption_notice(cx); + let first_item_id = portal_list.first_id(); + let first_index = tl_idx_from_item_id(first_item_id, has_encryption_notice).unwrap_or(0); let Some(tl_state) = self.tl_state.as_mut() else { return }; if let Some(ref mut index) = tl_state.prev_first_index { @@ -8350,15 +8446,16 @@ impl RoomScreen { /// and is approaching the top of the timeline. fn send_pagination_request_based_on_scroll_pos( &mut self, - _cx: &mut Cx, + cx: &mut Cx, actions: &ActionsBuf, portal_list: &PortalListRef, ) { + let has_encryption_notice = self.current_has_encryption_notice(cx); let Some(tl) = self.tl_state.as_mut() else { return }; if tl.fully_paginated { return }; if !portal_list.scrolled(actions) { return }; - let first_index = portal_list.first_id(); + let first_index = tl_idx_from_item_id(portal_list.first_id(), has_encryption_notice).unwrap_or(0); if first_index == 0 && tl.last_scrolled_index > 0 && !tl.backwards_pagination_in_flight { tl.backwards_pagination_in_flight = true; log!("Scrolled up from item {} --> 0, sending back pagination request for room {}", @@ -8394,6 +8491,7 @@ pub struct RoomScreenProps { pub room_name_id: RoomNameId, pub timeline_kind: TimelineKind, pub room_members: Option>>, + pub is_encrypted: Option, pub is_direct_room: bool, pub room_bot_user_ids: Vec, pub room_members_sync_pending: bool, @@ -9038,6 +9136,7 @@ fn find_new_item_matching_current_item( starting_at_curr_idx: usize, curr_items: &Vector>, new_items: &Vector>, + has_encryption_notice: bool, ) -> Option<(usize, usize, f64, OwnedEventId)> { let mut curr_item_focus = curr_items.focus(); let mut idx_curr = starting_at_curr_idx; @@ -9071,7 +9170,10 @@ fn find_new_item_matching_current_item( // Not all items in the portal list are guaranteed to have a position offset, // some may be zeroed-out, so we need to account for that possibility by only // using events that have a real non-zero area - if let Some(pos_offset) = portal_list.position_of_item(cx, *idx_curr) { + if let Some(pos_offset) = portal_list.position_of_item( + cx, + item_id_from_tl_idx(*idx_curr, has_encryption_notice), + ) { log!("Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}"); return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned())); } @@ -11671,6 +11773,17 @@ mod tests { assert!(message.relates_to.is_none()); } + #[test] + fn test_notice_offset_actions() { + assert_eq!(tl_idx_from_item_id(0, true), None); + assert_eq!(tl_idx_from_item_id(1, true), Some(0)); + assert_eq!(tl_idx_from_item_id(7, true), Some(6)); + assert_eq!(tl_idx_from_item_id(7, false), Some(7)); + assert_eq!(item_id_from_tl_idx(0, true), 1); + assert_eq!(item_id_from_tl_idx(6, true), 7); + assert_eq!(item_id_from_tl_idx(6, false), 6); + } + #[test] fn test_streaming_scan_range() { // Incremental: clamp sentinel to new_len diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index b6f566449..e3edd0cdf 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -190,6 +190,11 @@ pub enum RoomsListUpdate { room_id: OwnedRoomId, is_direct: bool, }, + /// Update whether the given room is end-to-end encrypted. + UpdateIsEncrypted { + room_id: OwnedRoomId, + is_encrypted: bool, + }, /// Remove the given room from the rooms list RemoveRoom { room_id: OwnedRoomId, @@ -315,6 +320,10 @@ pub struct JoinedRoomInfo { pub is_selected: bool, /// Whether this a direct room. pub is_direct: bool, + /// Whether this room is end-to-end encrypted. + /// + /// `None` means the encryption state is not known yet or failed to load. + pub is_encrypted: Option, /// Whether this room is tombstoned (shut down and replaced with a successor room). pub is_tombstoned: bool, @@ -382,6 +391,15 @@ pub fn build_room_search_text( } search_text } + +pub fn merge_encryption_state(current: Option, incoming: bool) -> Option { + match (current, incoming) { + (Some(true), _) => Some(true), + (Some(false), true) => Some(true), + (Some(false), false) => Some(false), + (None, value) => Some(value), + } +} impl std::fmt::Debug for InviterInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InviterInfo") @@ -577,6 +595,11 @@ impl RoomsList { self.all_joined_rooms.get(room_id).map(|jr| jr.is_direct) } + /// Returns whether the given joined room is end-to-end encrypted. + pub fn joined_room_is_encrypted(&self, room_id: &OwnedRoomId) -> Option> { + self.all_joined_rooms.get(room_id).map(|jr| jr.is_encrypted) + } + fn upsert_created_room_placeholder( &mut self, cx: &mut Cx, @@ -609,6 +632,7 @@ impl RoomsList { has_been_paginated: false, is_selected: false, is_direct: false, + is_encrypted: None, is_tombstoned: false, }); } @@ -813,6 +837,18 @@ impl RoomsList { error!("Error: couldn't find room {room_id} to update is_direct"); } } + RoomsListUpdate::UpdateIsEncrypted { room_id, is_encrypted } => { + if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { + let next = merge_encryption_state(room.is_encrypted, is_encrypted); + if room.is_encrypted == next { + continue; + } + room.is_encrypted = next; + SignalToUI::set_ui_signal(); + } else { + error!("Error: couldn't find room {room_id} to update is_encrypted"); + } + } RoomsListUpdate::RemoveRoom { room_id, new_state } => { // TODO: once we have a dedicated LoadingScreen widget, we should emit an action // to replace this room (if it's currently open) with the LoadingScreen widget, @@ -1796,6 +1832,11 @@ impl RoomsListRef { self.borrow()?.is_direct_room(room_id) } + /// Returns whether the given joined room is end-to-end encrypted. + pub fn joined_room_is_encrypted(&self, room_id: &OwnedRoomId) -> Option> { + self.borrow()?.joined_room_is_encrypted(room_id) + } + /// Returns the name of the given room, if it is known and loaded. pub fn get_room_name(&self, room_id: &OwnedRoomId) -> Option { let inner = self.borrow()?; @@ -1913,4 +1954,19 @@ mod tests { vec![first_room_id, second_room_id], ); } + + #[test] + fn test_room_list_icon_live_update() { + assert_eq!(merge_encryption_state(Some(false), true), Some(true)); + } + + #[test] + fn test_room_list_icon_resolve_from_unknown() { + assert_eq!(merge_encryption_state(None, true), Some(true)); + } + + #[test] + fn encryption_state_does_not_demote_encrypted_room() { + assert_eq!(merge_encryption_state(Some(true), false), Some(true)); + } } diff --git a/src/home/rooms_list_entry.rs b/src/home/rooms_list_entry.rs index df9392ced..42b43f1cc 100644 --- a/src/home/rooms_list_entry.rs +++ b/src/home/rooms_list_entry.rs @@ -32,6 +32,21 @@ script_mod! { } } + mod.widgets.EncryptionIcon = View { + width: Fit, height: Fit, + visible: false, + + Icon { + width: 19, height: 19, + align: Align{x: 0.5, y: 0.5} + draw_icon +: { + svg: (ICON_LOCK_FILLED) + color: #888888 + } + icon_walk: Walk{ width: 15, height: 15 } + } + } + mod.widgets.RoomName = Label { width: Fill, height: Fit flow: Flow.Right{wrap: false}, @@ -157,6 +172,7 @@ script_mod! { align: Align{ x: 1.0 } avatar := Avatar {} unread_badge := UnreadBadge {} + encryption_icon := mod.widgets.EncryptionIcon {} tombstone_icon := mod.widgets.TombstoneIcon {} } } @@ -166,6 +182,7 @@ script_mod! { avatar := Avatar {} room_name := mod.widgets.RoomName {} unread_badge := UnreadBadge {} + encryption_icon := mod.widgets.EncryptionIcon {} tombstone_icon := mod.widgets.TombstoneIcon {} } FullPreview := mod.widgets.RoomsListEntryContent { @@ -193,6 +210,7 @@ script_mod! { width: Fit, height: Fit align: Align{ x: 1.0 } unread_badge := UnreadBadge {} + encryption_icon := mod.widgets.EncryptionIcon {} tombstone_icon := mod.widgets.TombstoneIcon {} } } @@ -356,7 +374,10 @@ impl RoomsListEntryContent { room_info.num_unread_messages, ); self.draw_common(cx, &room_info.room_avatar, room_info.is_selected); - // Show tombstone icon if the room is tombstoned + self.view.view(cx, ids!(encryption_icon)).set_visible( + cx, + should_show_encryption_icon(room_info.is_encrypted, room_info.is_tombstoned), + ); self.view.view(cx, ids!(tombstone_icon)).set_visible(cx, room_info.is_tombstoned); } @@ -409,6 +430,8 @@ impl RoomsListEntryContent { .unread_badge(cx, ids!(unread_badge)) .update_counts(false, 1, 0); + self.view.view(cx, ids!(encryption_icon)).set_visible(cx, false); + self.view.view(cx, ids!(tombstone_icon)).set_visible(cx, false); self.draw_common(cx, &room_info.room_avatar, room_info.is_selected); } @@ -511,3 +534,32 @@ impl RoomsListEntryContent { }); } } + +pub fn should_show_encryption_icon(is_encrypted: Option, is_tombstoned: bool) -> bool { + matches!(is_encrypted, Some(true)) && !is_tombstoned +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_room_list_icon_visible_when_encrypted() { + assert!(should_show_encryption_icon(Some(true), false)); + } + + #[test] + fn test_room_list_icon_hidden_when_unencrypted() { + assert!(!should_show_encryption_icon(Some(false), false)); + } + + #[test] + fn test_room_list_icon_hidden_when_unknown() { + assert!(!should_show_encryption_icon(None, false)); + } + + #[test] + fn test_room_list_icon_yields_to_tombstone() { + assert!(!should_show_encryption_icon(Some(true), true)); + } +} diff --git a/src/shared/styles.rs b/src/shared/styles.rs index 97b0e4fb6..a22825442 100644 --- a/src/shared/styles.rs +++ b/src/shared/styles.rs @@ -27,6 +27,8 @@ script_mod! { mod.widgets.ICON_INVITE = crate_resource("self://resources/icons/invite.svg") mod.widgets.ICON_JOIN_ROOM = crate_resource("self://resources/icons/join_room.svg") mod.widgets.ICON_JUMP = crate_resource("self://resources/icons/go_back.svg") + mod.widgets.ICON_LOCK_FILLED = crate_resource("self://resources/icons/lock_filled.svg") + mod.widgets.ICON_LOCK_OPEN = crate_resource("self://resources/icons/lock_open.svg") mod.widgets.ICON_LOGOUT = crate_resource("self://resources/icons/logout.svg") mod.widgets.ICON_LINK = crate_resource("self://resources/icons/link.svg") mod.widgets.ICON_PIN = crate_resource("self://resources/icons/pin.svg") diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 90bed5b42..8b6b7c825 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -4244,6 +4244,8 @@ struct JoinedRoomDetails { typing_notice_subscriber: Option, /// A drop guard for the event handler that represents a subscription to pinned events for this room. pinned_events_subscriber: Option, + /// The async task that listens for this room becoming encrypted. + room_encryption_subscriber_task: Option>, } impl Drop for JoinedRoomDetails { fn drop(&mut self) { @@ -4252,6 +4254,9 @@ impl Drop for JoinedRoomDetails { for thread_timeline in self.thread_timelines.values() { thread_timeline.timeline_subscriber_handler_task.abort(); } + if let Some(room_encryption_subscriber_task) = self.room_encryption_subscriber_task.take() { + room_encryption_subscriber_task.abort(); + } drop(self.typing_notice_subscriber.take()); drop(self.pinned_events_subscriber.take()); } @@ -5481,6 +5486,13 @@ async fn update_room( }); } + if let Some(is_encrypted) = fetch_room_is_encrypted(&new_room.room).await { + enqueue_rooms_list_update(RoomsListUpdate::UpdateIsEncrypted { + room_id: new_room_id.clone(), + is_encrypted, + }); + } + let mut __timeline_update_sender_opt = None; let mut get_timeline_update_sender = |room_id| { if __timeline_update_sender_opt.is_none() { @@ -5662,13 +5674,22 @@ async fn add_new_room( pending_thread_timelines: HashSet::new(), typing_notice_subscriber: None, pinned_events_subscriber: None, + room_encryption_subscriber_task: None, }, ); + if let Some(joined_room_details) = ALL_JOINED_ROOMS.lock().unwrap().get_mut(&new_room.room_id) { + joined_room_details.room_encryption_subscriber_task = Some( + spawn_room_encryption_subscriber(new_room.room.clone()) + ); + } else { + error!("BUG: could not find newly-added room {} to attach encryption subscriber", new_room.room_id); + } let latest = get_latest_event_details( &new_room.room.latest_event().await, room_list_service.client(), ).await; + let is_encrypted = fetch_room_is_encrypted(&new_room.room).await; let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); @@ -5690,6 +5711,7 @@ async fn add_new_room( has_been_paginated: false, is_selected: false, is_direct: new_room.is_direct, + is_encrypted, is_tombstoned: new_room.is_tombstoned, })); @@ -5713,6 +5735,41 @@ async fn add_new_room( Ok(()) } +async fn fetch_room_is_encrypted(room: &Room) -> Option { + match room.latest_encryption_state().await { + Ok(state) => Some(state.is_encrypted()), + Err(error) => { + error!("Failed to fetch encryption state for room {}: {error:?}", room.room_id()); + None + } + } +} + +fn spawn_room_encryption_subscriber(room: Room) -> JoinHandle<()> { + Handle::current().spawn(async move { + let room_id = room.room_id().to_owned(); + let mut room_info = room.subscribe_info(); + + if room_info.get().encryption_state().is_encrypted() { + enqueue_rooms_list_update(RoomsListUpdate::UpdateIsEncrypted { + room_id, + is_encrypted: true, + }); + return; + } + + while let Some(info) = room_info.next().await { + if info.encryption_state().is_encrypted() { + enqueue_rooms_list_update(RoomsListUpdate::UpdateIsEncrypted { + room_id, + is_encrypted: true, + }); + break; + } + } + }) +} + #[allow(unused)] async fn current_ignore_user_list(client: &Client) -> Option> { use matrix_sdk::ruma::events::ignored_user_list::IgnoredUserListEventContent; From 34de9581be561ebbb3d2af96ce5b25334349ea8f Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 14 May 2026 09:54:11 +0800 Subject: [PATCH 3/5] chore: remove .claude config from Month_1 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/user-prompt-submit.sh | 4 - .claude/settings.json | 14 ---- .claude/settings.local.json | 16 ---- .claude/skills/file-issue/SKILL.md | 116 ---------------------------- 4 files changed, 150 deletions(-) delete mode 100755 .claude/hooks/user-prompt-submit.sh delete mode 100644 .claude/settings.json delete mode 100644 .claude/settings.local.json delete mode 100644 .claude/skills/file-issue/SKILL.md diff --git a/.claude/hooks/user-prompt-submit.sh b/.claude/hooks/user-prompt-submit.sh deleted file mode 100755 index 8c39ff090..000000000 --- a/.claude/hooks/user-prompt-submit.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -# mempal cowork inbox drain — prepends partner handoff messages to user prompt -# Graceful degrade: any failure exits 0 with empty stdout -mempal cowork-drain --target claude --cwd "${CLAUDE_PROJECT_CWD:-$PWD}" 2>/dev/null || true diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index e4bfa3a26..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "command": "bash .claude/hooks/user-prompt-submit.sh", - "type": "command" - } - ] - } - ] - } -} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index effd1b5ba..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebSearch", - "Bash(gh api:*)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:element.io)", - "WebFetch(domain:github.com)", - "WebFetch(domain:matrix.org)", - "WebFetch(domain:spec.matrix.org)", - "Bash(agent-spec parse:*)", - "Bash(agent-spec help:*)", - "Bash(agent-spec:*)" - ] - } -} diff --git a/.claude/skills/file-issue/SKILL.md b/.claude/skills/file-issue/SKILL.md deleted file mode 100644 index ae70d188d..000000000 --- a/.claude/skills/file-issue/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: file-issue -description: Document a bug/fix locally in issues/ and create a matching GitHub issue -allowed-tools: - - Bash(ls:*) - - Bash(mkdir:*) - - Bash(gh:*) - - Glob - - Grep - - Read - - Write -when_to_use: | - Use when the user wants to document a discovered bug, applied fix, and remaining issues - as both a local issue file and a GitHub issue. Typically invoked after a debugging/fix session. - Examples: "file an issue for this", "record this bug", "create issue", "file-issue" ---- - -# File Issue - -Document a bug discovery and fix as a local issue file in `issues/` and a matching GitHub issue. -All output is written in English regardless of conversation language. - -## Goal - -Produce two artifacts: -1. A detailed local issue document at `issues/NNN-slug.md` -2. A GitHub issue with a summary version - -## Steps - -### 1. Scan for next issue number - -Check if `issues/` directory exists in the project root. Create it if missing. -List existing files to determine the next sequential number (e.g., if `001-*` exists, next is `002`). - -**Success criteria**: Know the next issue number (zero-padded to 3 digits) and confirmed `issues/` dir exists. - -### 2. Gather context from conversation - -Extract from the current conversation: -- **Summary**: One-line description of the bug -- **Severity**: Critical / High / Medium / Low -- **Symptoms**: What the user observed (UI behavior, error messages, logs) -- **Root Cause**: Technical explanation of why it happens -- **Reproduction**: Steps to reproduce -- **Fix Applied**: What was changed and why (include code snippets if relevant) -- **Remaining Issues**: Known limitations, follow-up work, upstream bugs -- **Files Changed**: List of modified files -- **Test Verification**: Before/after comparison table - -Generate a kebab-case slug from the summary (e.g., `dock-load-state-drawlist-corruption`). - -**Success criteria**: All template sections populated with specific, accurate details from the session. - -### 3. Write local issue document - -Write to `issues/NNN-slug.md` using this template: - -```markdown -# Issue #NNN: {Summary} - -**Date:** {YYYY-MM-DD} -**Severity:** {Critical|High|Medium|Low} -**Status:** Fixed (workaround applied) | Fixed | Open -**Affected component:** {file path(s)} - -## Summary -{One paragraph} - -## Symptoms -{Bullet list of what the user observed} - -## Root Cause -{Technical explanation with code snippets} - -## Reproduction -{Numbered steps} - -## Fix Applied -{Description + key code changes} - -## Remaining Issues -{Numbered list of known limitations and follow-up work} - -## Files Changed -{Bullet list} - -## Test Verification -{Before/after table} -``` - -**Success criteria**: File written, all sections filled, no placeholder text remaining. - -### 4. Create GitHub issue - -Detect the repo with `gh repo view --json nameWithOwner`. -Create a GitHub issue via `gh issue create` with: -- Title: same as local doc summary (concise, under 80 chars) -- Label: `bug` -- Body: condensed version with Summary, Symptoms, Root Cause, Fix Applied, Remaining Issues (as checklist), and Environment section -- Reference the local doc path in the body - -**Rules**: -- Use a HEREDOC for the body to preserve formatting -- Remaining Issues should be `- [ ]` checklist items -- Include a link/reference to the local issue doc - -**Success criteria**: GitHub issue created, URL returned. - -### 5. Report results - -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. From 63b8a46ce0dfca1f0538aa104521d080d31f299a Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 14 May 2026 09:54:52 +0800 Subject: [PATCH 4/5] Added Month1 for specs --- .gitignore | 1 - specs/month-1/encryption-indicator.spec.md | 344 +++++++++++++++++++++ specs/month-1/forward-messages.spec.md | 181 +++++++++++ 3 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 specs/month-1/encryption-indicator.spec.md create mode 100644 specs/month-1/forward-messages.spec.md diff --git a/.gitignore b/.gitignore index 55d7b0f37..9d61dcd77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ .vscode .DS_Store proxychains.conf -specs \ No newline at end of file diff --git a/specs/month-1/encryption-indicator.spec.md b/specs/month-1/encryption-indicator.spec.md new file mode 100644 index 000000000..6b6c735cb --- /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 000000000..8883e01de --- /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. From aad51b480b9b2881c7392dbf6f61e4191ddc709f Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 14 May 2026 10:06:22 +0800 Subject: [PATCH 5/5] Put back File Issue --- .claude/skills/file-issue/SKILL.md | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .claude/skills/file-issue/SKILL.md diff --git a/.claude/skills/file-issue/SKILL.md b/.claude/skills/file-issue/SKILL.md new file mode 100644 index 000000000..5af60e80b --- /dev/null +++ b/.claude/skills/file-issue/SKILL.md @@ -0,0 +1,116 @@ +--- +name: file-issue +description: Document a bug/fix locally in issues/ and create a matching GitHub issue +allowed-tools: + - Bash(ls:*) + - Bash(mkdir:*) + - Bash(gh:*) + - Glob + - Grep + - Read + - Write +when_to_use: | + Use when the user wants to document a discovered bug, applied fix, and remaining issues + as both a local issue file and a GitHub issue. Typically invoked after a debugging/fix session. + Examples: "file an issue for this", "record this bug", "create issue", "file-issue" +--- + +# File Issue + +Document a bug discovery and fix as a local issue file in `issues/` and a matching GitHub issue. +All output is written in English regardless of conversation language. + +## Goal + +Produce two artifacts: +1. A detailed local issue document at `issues/NNN-slug.md` +2. A GitHub issue with a summary version + +## Steps + +### 1. Scan for next issue number + +Check if `issues/` directory exists in the project root. Create it if missing. +List existing files to determine the next sequential number (e.g., if `001-*` exists, next is `002`). + +**Success criteria**: Know the next issue number (zero-padded to 3 digits) and confirmed `issues/` dir exists. + +### 2. Gather context from conversation + +Extract from the current conversation: +- **Summary**: One-line description of the bug +- **Severity**: Critical / High / Medium / Low +- **Symptoms**: What the user observed (UI behavior, error messages, logs) +- **Root Cause**: Technical explanation of why it happens +- **Reproduction**: Steps to reproduce +- **Fix Applied**: What was changed and why (include code snippets if relevant) +- **Remaining Issues**: Known limitations, follow-up work, upstream bugs +- **Files Changed**: List of modified files +- **Test Verification**: Before/after comparison table + +Generate a kebab-case slug from the summary (e.g., `dock-load-state-drawlist-corruption`). + +**Success criteria**: All template sections populated with specific, accurate details from the session. + +### 3. Write local issue document + +Write to `issues/NNN-slug.md` using this template: + +```markdown +# Issue #NNN: {Summary} + +**Date:** {YYYY-MM-DD} +**Severity:** {Critical|High|Medium|Low} +**Status:** Fixed (workaround applied) | Fixed | Open +**Affected component:** {file path(s)} + +## Summary +{One paragraph} + +## Symptoms +{Bullet list of what the user observed} + +## Root Cause +{Technical explanation with code snippets} + +## Reproduction +{Numbered steps} + +## Fix Applied +{Description + key code changes} + +## Remaining Issues +{Numbered list of known limitations and follow-up work} + +## Files Changed +{Bullet list} + +## Test Verification +{Before/after table} +``` + +**Success criteria**: File written, all sections filled, no placeholder text remaining. + +### 4. Create GitHub issue + +Detect the repo with `gh repo view --json nameWithOwner`. +Create a GitHub issue via `gh issue create` with: +- Title: same as local doc summary (concise, under 80 chars) +- Label: `bug` +- Body: condensed version with Summary, Symptoms, Root Cause, Fix Applied, Remaining Issues (as checklist), and Environment section +- Reference the local doc path in the body + +**Rules**: +- Use a HEREDOC for the body to preserve formatting +- Remaining Issues should be `- [ ]` checklist items +- Include a link/reference to the local issue doc + +**Success criteria**: GitHub issue created, URL returned. + +### 5. Report results + +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. \ No newline at end of file