diff --git a/.claude/skills/file-issue/SKILL.md b/.claude/skills/file-issue/SKILL.md new file mode 100644 index 000000000..ae70d188d --- /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. diff --git a/.gitignore b/.gitignore index 1f891a019..9d61dcd77 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,4 @@ .vscode .DS_Store -CLAUDE.md proxychains.conf diff --git a/AGENTS.md b/AGENTS.md index 9a393de6f..d0296f469 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,732 +1,124 @@ +# Robrix2 — Agent Instructions -# Makepad Project Guide +This file is intentionally short. Mirror `CLAUDE.md`, keep only project rules and high-value Makepad notes here, and use the codebase plus Makepad 2.0 skills as the detailed reference. -## Important: When Converting Syntax +## Required Reading -**Always search for existing usage patterns in the NEW crates (widgets, code_editor, studio) before making syntax changes.** The old `widgets` and `live_design!` syntax is deprecated. When unsure about the correct syntax for something, grep for similar usage in `widgets/src/` to find the correct pattern. +Before starting work, read these documents: -```bash -# Example: find how texture declarations work in new system -grep -r "texture_2d" widgets/src/ -``` - -**Critical: Always use `Name: value` syntax, never `Name = value`.** The old `Key = Value` syntax no longer works. For named widget instances, use `name := Type{...}` syntax. - -## Running UI Programs - -```bash -RUST_BACKTRACE=1 cargo run -p makepad-example-splash --release & PID=$!; sleep 15; kill $PID 2>/dev/null; echo "Process $PID killed" -``` - -## Cargo.toml Setup - -```toml -[package] -name = "makepad-example-myapp" -version = "0.1.0" -edition = "2021" - -[dependencies] -makepad-widgets = { path = "../../widgets" } -``` - - -## Widgets DSL (script_mod!) - -The new DSL uses `script_mod!` macro with runtime script evaluation instead of the old `live_design!` compile-time macros. - -### Imports and App Setup - -```rust -use makepad_widgets::*; - -app_main!(App); - -script_mod!{ - use mod.prelude.widgets.* - - load_all_resources() do #(App::script_component(vm)){ - ui: Root{ - main_window := Window{ - window.inner_size: vec2(800, 600) - body +: { - // UI content here - } - } - } - } -} - -impl App { - fn run(vm: &mut ScriptVm) -> Self { - crate::makepad_widgets::script_mod(vm); // Register all widgets - // Platform-specific initialization goes here (e.g., vm.cx().start_stdin_service() for macos) - App::from_script_mod(vm, self::script_mod) - } -} - -#[derive(Script, ScriptHook)] -pub struct App { - #[live] ui: WidgetRef, -} - -impl MatchEvent for App { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - // Handle widget actions - } -} - -impl AppMain for App { - fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - self.match_event(cx, event); - self.ui.handle_event(cx, event, &mut Scope::empty()); - } -} -``` - -### Available Widgets (widgets/src/lib.rs) - -Core: `View`, `SolidView`, `RoundedView`, `ScrollXView`, `ScrollYView`, `ScrollXYView` -Text: `Label`, `H1`, `H2`, `H3`, `LinkLabel`, `TextInput` -Buttons: `Button`, `ButtonFlat`, `ButtonFlatter` -Toggles: `CheckBox`, `Toggle`, `RadioButton` -Input: `Slider`, `DropDown` -Layout: `Splitter`, `FoldButton`, `FoldHeader`, `Hr` -Lists: `PortalList` -Navigation: `StackNavigation`, `ExpandablePanel` -Overlays: `Modal`, `Tooltip`, `PopupNotification` -Dock: `Dock`, `DockSplitter`, `DockTabs`, `DockTab` -Media: `Image`, `Icon`, `LoadingSpinner` -Special: `FileTree`, `PageFlip`, `CachedWidget` -Window: `Window`, `Root` -Markup: `Html`, `Markdown` (feature-gated) - -### Widget Definition Pattern - -```rust -// Rust struct -#[derive(Script, ScriptHook, Widget)] -pub struct MyWidget { - #[source] source: ScriptObjectRef, // Required for script integration - #[walk] walk: Walk, - #[layout] layout: Layout, - #[redraw] #[live] draw_bg: DrawQuad, - #[live] draw_text: DrawText, - #[rust] my_state: i32, // Runtime-only field -} - -// For widgets with animations, add Animator derive: -#[derive(Script, ScriptHook, Widget, Animator)] -pub struct AnimatedWidget { - #[source] source: ScriptObjectRef, - #[apply_default] animator: Animator, - // ... -} -``` - -### Script Module Structure - -```rust -script_mod!{ - use mod.prelude.widgets_internal.* // For internal widget definitions - use mod.widgets.* // Access other widgets - - // Register base widget (connects Rust struct to script) - mod.widgets.MyWidgetBase = #(MyWidget::register_widget(vm)) - - // Create styled variant with defaults - mod.widgets.MyWidget = set_type_default() do mod.widgets.MyWidgetBase{ - width: Fill - height: Fit - padding: theme.space_2 - - draw_bg +: { - color: theme.color_bg_app - } - } -} -``` - -### Key Syntax Differences (Old vs New) - -| Old (live_design!) | New (script_mod!) | -|-------------------|-------------------| -| `` | `mod.widgets.BaseWidget{ }` | -| `{{StructName}}` | `#(Struct::register_widget(vm))` | -| `(THEME_COLOR_X)` | `theme.color_x` | -| `` | `theme.font_regular` | -| `instance hover: 0.0` | `hover: instance(0.0)` | -| `uniform color: #fff` | `color: uniform(#fff)` | -| `draw_bg: { }` (replace) | `draw_bg +: { }` (merge) | -| `default: off` | `default: @off` | -| `fn pixel(self)` | `pixel: fn()` | -| `item.apply_over(cx, live!{...})` | `script_apply_eval!(cx, item, {...})` | - -### Runtime Property Updates with script_apply_eval! - -Use `script_apply_eval!` macro to dynamically update widget properties at runtime: -```rust -// Old system (live! macro with apply_over) -item.apply_over(cx, live!{ - height: (height) - draw_bg: {is_even: (if is_even {1.0} else {0.0})} -}); - -// New system (script_apply_eval! macro) -script_apply_eval!(cx, item, { - height: #(height) - draw_bg +: {is_even: #(if is_even {1.0} else {0.0})} -}); - -// For colors, use #(color) syntax -let color = self.color_focus; -script_apply_eval!(cx, item, { - draw_bg +: { - color: #(color) - } -}); -``` - -Note: In `script_apply_eval!`, use `#(expr)` for Rust expression interpolation instead of `(expr)`. - -### Theme Access - -Always use `theme.` prefix: -```rust -color: theme.color_bg_app -padding: theme.space_2 -font_size: theme.font_size_p -text_style: theme.font_regular -``` - -### Property Merging with `+:` - -The `+:` operator merges with parent instead of replacing: -```rust -mod.widgets.MyButton = mod.widgets.Button{ - draw_bg +: { - color: #f00 // Only overrides color, keeps other draw_bg properties - } -} -``` - -### Shader Instance vs Uniform - -- `instance(value)` - Per-draw-call value (can vary per widget instance) -- `uniform(value)` - Shared across all instances using same shader - -```rust -draw_bg +: { - hover: instance(0.0) // Each button has its own hover state - color: uniform(theme.color_x) // Shared base color - color_hover: instance(theme.color_y) // Per-instance if color varies -} -``` - -### Animator Definition - -```rust -animator: Animator{ - hover: { - default: @off - off: AnimatorState{ - from: {all: Forward {duration: 0.1}} - apply: { - draw_bg: {hover: 0.0} - draw_text: {hover: 0.0} - } - } - on: AnimatorState{ - from: {all: Snap} // Instant transition - apply: { - draw_bg: {hover: 1.0} - draw_text: {hover: 1.0} - } - } - } -} -``` - -### Shader Functions - -```rust -draw_bg +: { - pixel: fn() { - let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.box(0.0, 0.0, self.rect_size.x, self.rect_size.y, 4.0) - sdf.fill(self.color.mix(self.color_hover, self.hover)) - return sdf.result - } -} -``` - -Note: Use `.method()` not `::method()` in shaders. - -### Color Mixing (Method Chaining) - -```rust -// Old nested style (avoid) -mix(mix(mix(color1, color2, hover), color3, down), color4, focus) - -// New chained style (preferred) -color1.mix(color2, hover).mix(color3, down).mix(color4, focus) -``` +1. [DESIGN.md](DESIGN.md) — architecture overview, module organization, technology stack +2. [specs/project.spec.md](specs/project.spec.md) — project constraints, decisions, forbidden actions +3. [CLAUDE.md](CLAUDE.md) — project workflow rules and Makepad 2.0 guidance -### App Structure Pattern - -```rust -script_mod!{ - use mod.prelude.widgets.* - - load_all_resources() do #(App::script_component(vm)){ - ui: Root{ - main_window := Window{ - window.inner_size: vec2(1000, 700) - body +: { - // Your UI here - MyWidget{} - } - } - } - } -} - -impl App { - fn run(vm: &mut ScriptVm) -> Self { - crate::makepad_widgets::script_mod(vm); - // Platform-specific initialization (e.g., vm.cx().start_stdin_service() for macos) - App::from_script_mod(vm, self::script_mod) - } -} - -#[derive(Script, ScriptHook)] -pub struct App { - #[live] ui: WidgetRef, -} - -impl MatchEvent for App { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if self.ui.button(ids!(my_button)).clicked(actions) { - log!("Button clicked!"); - } - } -} - -impl AppMain for App { - fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - self.match_event(cx, event); - self.ui.handle_event(cx, event, &mut Scope::empty()); - } -} -``` - -### Widget ID References - -Use `:=` for named widget instances: -```rust -// In DSL -my_button := Button{text: "Click"} - -// In Rust code -self.ui.button(ids!(my_button)).clicked(actions) -``` - -### Template Definitions in Dock - -Templates inside Dock are local; use `let` bindings at script level for reusable components: -```rust -script_mod!{ - // Reusable at script level - let MyPanel = SolidView{ - width: Fill - height: Fill - // ... - } - - // Use directly - body +: { - MyPanel{} // Works because it's a let binding - } -} -``` - -### Custom Draw Widget Example - -```rust -#[derive(Script, ScriptHook, Widget)] -pub struct CustomDraw { - #[walk] walk: Walk, - #[layout] layout: Layout, - #[redraw] #[live] draw_quad: DrawQuad, - #[rust] area: Area, -} - -impl Widget for CustomDraw { - fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { - cx.begin_turtle(walk, self.layout); - let rect = cx.turtle().rect(); - self.draw_quad.draw_abs(cx, rect); - cx.end_turtle_with_area(&mut self.area); - DrawStep::done() - } - - fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} -} -``` - -### Script Object Storage: map vs vec - -In script objects, properties are stored in two different places: -- **`map`**: Contains `key: value` pairs (regular properties) -- **`vec`**: Contains named template items (via `:=` syntax) - -This distinction is important when working with `on_after_apply` or inspecting script objects directly. - -### Templates in List Widgets (PortalList, FlatList) - -In list widgets, named IDs (using `:=`) define **templates** that are stored in the widget's `templates` HashMap. These are NOT regular properties - they go into the script object's vec and are collected via `on_after_apply`. - -```rust -// In script_mod! - defining templates for a list -my_list := PortalList { - // Regular properties (go into struct fields) - width: Fill - height: Fill - scroll_bar: mod.widgets.ScrollBar {} - - // Templates (named with :=) - stored in templates HashMap, NOT struct fields - Item := View { - height: 40 - title := Label { text: "Default" } - } - Header := View { - draw_bg: { color: #333 } - } -} -``` - -The templates are collected in `on_after_apply`: -```rust -impl ScriptHook for PortalList { - fn on_after_apply(&mut self, vm: &mut ScriptVm, apply: &Apply, scope: &mut Scope, value: ScriptValue) { - if let Some(obj) = value.as_object() { - vm.vec_with(obj, |_vm, vec| { - for kv in vec { - if let Some(id) = kv.key.as_id() { - self.templates.insert(id, kv.value); - } - } - }); - } - } -} -``` - -Then used during drawing: -```rust -while let Some(item_id) = list.next_visible_item(cx) { - let item = list.item(cx, item_id, id!(Item)); - item.label(ids!(title)).set_text(cx, &format!("Item {}", item_id)); - item.draw_all(cx, &mut Scope::empty()); -} -``` - -**Key distinction**: Regular properties like `scroll_bar: mod.widgets.ScrollBar {}` are applied directly to struct fields. Template definitions like `Item := View {...}` are stored separately for dynamic instantiation. - -### PortalList Usage - -```rust -#[derive(Script, ScriptHook, Widget)] -pub struct MyList { - #[deref] view: View, -} - -impl Widget for MyList { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - while let Some(item) = self.view.draw_walk(cx, scope, walk).step() { - if let Some(mut list) = item.borrow_mut::() { - list.set_item_range(cx, 0, 100); // 100 items - - while let Some(item_id) = list.next_visible_item(cx) { - let item = list.item(cx, item_id, id!(Item)); - item.label(ids!(title)).set_text(cx, &format!("Item {}", item_id)); - item.draw_all(cx, &mut Scope::empty()); - } - } - } - DrawStep::done() - } -} -``` - -### FileTree Usage - -```rust -impl Widget for FileTreeDemo { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - while self.file_tree.draw_walk(cx, scope, walk).is_step() { - self.file_tree.set_folder_is_open(cx, live_id!(root), true, Animate::No); - // Draw nodes recursively - self.draw_node(cx, live_id!(root)); - } - DrawStep::done() - } -} -``` - -### Registering Custom Draw Shaders - -For custom draw types with shader fields, use `script_shader`: - -```rust -script_mod!{ - use mod.prelude.widgets_internal.* - - // Register custom draw shader - set_type_default() do #(DrawMyShader::script_shader(vm)){ - ..mod.draw.DrawQuad // Inherit from DrawQuad - } - - // Register widget that uses it - mod.widgets.MyWidgetBase = #(MyWidget::register_widget(vm)) -} - -#[derive(Script, ScriptHook)] -#[repr(C)] -struct DrawMyShader { - #[deref] draw_super: DrawQuad, - #[live] my_param: f32, -} -``` - -### Registering Components (non-Widget) - -For structs that aren't full widgets but need script registration: - -```rust -script_mod!{ - // For components (not widgets) - mod.widgets.MyComponentBase = #(MyComponent::script_component(vm)) - - // For widgets (implements Widget trait) - mod.widgets.MyWidgetBase = #(MyWidget::register_widget(vm)) -} -``` +## Critical Rules -### Script Prelude Modules - -Two prelude modules available: -- `mod.prelude.widgets_internal.*` - For internal widget library development -- `mod.prelude.widgets.*` - For app development (includes all widgets) - -```rust -script_mod!{ - // App development - use widgets prelude - use mod.prelude.widgets.* - - // Or for widget library internals - use mod.prelude.widgets_internal.* - use mod.widgets.* -} -``` +### Do NOT run `cargo fmt` or `rustfmt` -### Default Enum Values - -For enums with a `None` variant that need `Default`, use standard Rust `#[default]` attribute instead of `DefaultNone` derive: - -```rust -// Correct - use #[default] attribute on the None variant -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum MyAction { - SomeAction, - AnotherAction, - #[default] - None, -} - -// Wrong - don't use DefaultNone derive -#[derive(Clone, Copy, Debug, PartialEq, DefaultNone)] // Don't do this -pub enum MyAction { - SomeAction, - None, -} -``` +This project does not use automatic Rust formatting. Do not run `cargo fmt`, `rustfmt`, or formatter wrappers. Formatting churn creates noisy diffs and breaks the repo's hand-maintained style. -### Multi-Module Script Registration Pattern - -When refactoring a multi-file project (like studio) from `live_design!` to `script_mod!`: - -1. **Each widget module** defines its own `script_mod!` that registers to `mod.widgets.*`: -```rust -// In studio_editor.rs -script_mod! { - use mod.prelude.widgets_internal.* - use mod.widgets.* - - mod.widgets.StudioCodeEditorBase = #(StudioCodeEditor::register_widget(vm)) - mod.widgets.StudioCodeEditor = set_type_default() do mod.widgets.StudioCodeEditorBase { - editor := CodeEditor {} - } -} -``` +### Do NOT commit or create PRs without user testing -2. **The lib.rs** aggregates all widget script_mods: -```rust -pub fn script_mod(vm: &mut ScriptVm) { - crate::module1::script_mod(vm); - crate::module2::script_mod(vm); - // ... all widget modules -} -``` +Present changes for testing first. Wait for user confirmation before committing or opening a PR. -3. **The app.rs** calls them in correct order: -```rust -impl App { - fn run(vm: &mut ScriptVm) -> Self { - crate::makepad_widgets::script_mod(vm); // Base widgets first - crate::script_mod(vm); // Your widget modules - crate::app_ui::script_mod(vm); // UI that uses the widgets - App::from_script_mod(vm, self::script_mod) - } -} -``` +### Makepad 2.0 only -4. **The app_ui.rs** can then use registered widgets: -```rust -script_mod! { - use mod.prelude.widgets.* - // Now StudioCodeEditor is available from mod.widgets - - let EditorContent = View { - editor := StudioCodeEditor {} - } -} -``` +- Use `script_mod!`, not `live_design!` +- Use `#[derive(Script, ScriptHook, Widget)]`, not `Live` / `LiveHook` +- Use `:=` for named children, not `=` +- Use `+:` to merge properties; bare `:` replaces +- Use `script_apply_eval!` for runtime updates, not `apply_over` + `live!` -### Cross-Module Sharing via `mod` Object - -**IMPORTANT**: `use crate.module.*` does NOT work in script_mod. The `crate.` prefix is not available. - -To share definitions between script_mod blocks in different files, store them in the `mod` object: - -```rust -// In app_ui.rs - export to mod.widgets namespace -script_mod! { - use mod.prelude.widgets.* - - // This makes AppUI available as mod.widgets.AppUI - mod.widgets.AppUI = Window{ - // ... - } -} - -// In app.rs - import via mod.widgets -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* // Now AppUI is in scope - - load_all_resources() do #(App::script_component(vm)){ - ui: Root{ AppUI{} } - } -} -``` +### Converting syntax -The `mod` object is the only way to share data between script_mod blocks. +- Search the new crates first: `widgets`, `code_editor`, `studio` +- Prefer copying an existing Makepad 2.0 pattern over guessing syntax +- Always use `Name: value`, never `Name = value` +- Named widget instances use `name := Type{...}` -### Prelude Alias Syntax +### Dynamic widget state changes -When defining a prelude, use `name:mod.path` to create an alias: -```rust -mod.prelude.widgets = { - ..mod.std, // Spread all of mod.std into scope - theme:mod.theme, // Create 'theme' as alias for mod.theme - draw:mod.draw, // Create 'draw' as alias for mod.draw -} -``` +`script_apply_eval!` does not work on widgets created via `widget_ref_from_live_ptr()` because the backing `ScriptObject` is `ZERO`. For dynamic popup/list items, use Animator state plus shader instance variables instead. -Without the alias (just `mod.theme,`), the module is included but has no name - you can't access it! +### Async Matrix operations -### Let Bindings are Local +Always use `submit_async_request(MatrixRequest::*)`. Do not spawn raw tokio tasks for Matrix API calls from UI code. -`let` bindings in script_mod are LOCAL to that script_mod block. They cannot be: -- Accessed from other script_mod blocks -- Used as property values directly (e.g., `content +: MyLetBinding` won't work) +## Quick Makepad Notes -To use a `let` binding, instantiate it: `MyLetBinding{}` or store it in `mod.*` for cross-module access. +- `draw_bg +:` merges with the parent shader config; `draw_bg:` replaces it +- In `script_apply_eval!`, Rust expressions use `#(expr)` interpolation +- Runtime `script_apply_eval!` cannot rely on DSL constants like `Right`, `Fit`, or `Align` +- `Dock.load_state()` can corrupt DrawList references in this project -### Debug Logging with `~` +## Build & Test -Use `~expression` to log the value of an expression during script evaluation: -```rust -script_mod! { - ~mod.theme // Logs the theme object - ~mod.prelude.widgets // Logs what's in the prelude - ~some_variable // Logs a variable's value (or "not found" error) -} +```bash +cargo build +cargo run +cargo test ``` -### Common Pitfalls - -**Widget ID references**: Named widget instances use `:=` in the DSL and plain names in Rust id macros: -- DSL defines `code_block := View { ... }` → Rust uses `id!(code_block)` -- DSL defines `my_button := Button { ... }` → Rust uses `ids!(my_button)` - -1. **Missing `#[source]`**: All Script-derived structs need `#[source] source: ScriptObjectRef` - -2. **Template scope**: Templates defined inside Dock aren't available outside; use `let` at script level - -3. **Uniform vs Instance**: Use `instance()` for per-widget varying colors (like hover states on backgrounds) - -4. **Forgot `+:`**: Without `+:`, you replace the entire property instead of merging - -5. **Theme access**: Always `theme.color_x`, never `THEME_COLOR_X` or `(theme.color_x)` +## Key Entry Points -6. **Missing widget registration**: Call `crate::makepad_widgets::script_mod(vm)` in `App::run()` before your own `script_mod`. Note: the old `live_design!` system and its crates are archived under `old/` +- `src/app.rs` — root app and global state +- `src/sliding_sync.rs` — Matrix sync pipeline +- `src/home/room_screen.rs` — room timeline and input integration +- `src/shared/mentionable_text_input.rs` — `@mention` system -7. **Draw shader repr**: Custom draw shaders need `#[repr(C)]` for correct memory layout +## Specs -8. **DefaultNone derive**: Don't use `DefaultNone` derive - use standard `#[derive(Default)]` with `#[default]` attribute on the `None` variant +Task specs live in `specs/` and inherit from [specs/project.spec.md](specs/project.spec.md). -9. **Script_mod call order**: Widget modules must be registered BEFORE UI modules that use them. Always call `lib.rs::script_mod` before `app_ui::script_mod` +- `specs/task-mention-user.spec.md` — `@mention` autocomplete feature -10. **`pub` keyword invalid in script_mod**: Don't use `pub mod.widgets.X = ...`, just use `mod.widgets.X = ...`. Visibility is controlled by the Rust module system, not script_mod. +Use `agent-spec parse` and `agent-spec lint --min-score 0.7` when working on specs. -11. **Syntax for Inset/Align/Walk**: Use constructor syntax - `margin: Inset{left: 10}` not `margin: {left: 10}`, `align: Align{x: 0.5 y: 0.5}` not `align: {x: 0.5, y: 0.5}` +## Working Philosophy -12. **Cursor values**: Use `cursor: MouseCursor.Hand` not `cursor: Hand` or `cursor: @Hand` +You are an engineering collaborator on this project, not a standby assistant. Model your behavior on: -13. **Resource paths**: Use `crate_resource("self://path")` not `dep("crate://self/path")` +- **John Carmack's .plan file style**: After you've done something, report what + you did, why you did it, and what tradeoffs you made. You don't ask "would + you like me to do X"—you've already done it. +- **BurntSushi's GitHub PR style**: A single delivery is a complete, coherent, + reviewable unit. Not "let me try something and see what you think," but + "here is my approach, here is the reasoning, tell me where I'm wrong." +- **The Unix philosophy**: Do one thing, finish it, then shut up. Chatter + mid-work is noise, not politeness. Reports at the point of delivery are + engineering. -14. **Texture declarations in shaders**: Use `tex: texture_2d(float)` not `tex: texture2d` +## What You Submit To -15. **Enums not exposed to script**: Some Rust enums like `PopupMenuPosition::BelowInput` may not be exposed to script. If you get "not found" errors on enum variants, just remove the property and use the default +In priority order: -17. **Shader `mod` vs `modf`**: The Makepad shader language uses `modf(a, b)` for float modulo, NOT `mod(a, b)`. Similarly, use `atan2(y, x)` not `atan(y, x)` for two-argument arctangent. `atan(x)` (single arg) is also available. `fract(x)` works as expected. +1. **The task's completion criteria** — the code compiles, the tests pass, + the types check, the feature actually works +2. **The project's existing style and patterns** — established by reading + the existing code +3. **The user's explicit, unambiguous instructions** -16. **Draw shader struct field ordering**: In `#[repr(C)]` draw shader structs that extend another draw shader via `#[deref]`, NEVER place `#[rust]` or other non-instance data AFTER `DrawVars` and the instance fields. The system uses an unsafe pointer trick in `DrawVars::as_slice()` that reads contiguously past the end of `dyn_instances` into the subsequent `#[live]` fields. Any non-instance data between `DrawVars` and the instance fields will corrupt the GPU instance buffer. Put all extra data (like `#[rust]`, `#[live]` non-instance fields such as resource handles, booleans, etc.) BEFORE the `#[deref]` field, and only `#[live]` instance fields (the ones that map to shader inputs) AFTER. - ```rust - // CORRECT - non-instance data before deref, instance fields after - #[derive(Script, ScriptHook)] - #[repr(C)] - pub struct MyDrawShader { - #[live] pub svg: Option, // non-instance, BEFORE deref - #[rust] my_state: bool, // non-instance, BEFORE deref - #[deref] pub draw_super: DrawVector, // contains DrawVars + base instance fields - #[live] pub tint: Vec4f, // instance field, AFTER deref - OK - } +These three outrank the user's psychological need to feel respectfully +consulted. Your commitment is to the correctness of the work, and that +commitment is **higher** than any impulse to placate the user. Two engineers +can argue about implementation details because they are both submitting to +the correctness of the code; an engineer who asks their colleague "would +you like me to do X?" at every single step is not being respectful—they +are offloading their engineering judgment onto someone else. - // WRONG - rust data after instance fields breaks the memory layout - #[derive(Script, ScriptHook)] - #[repr(C)] - pub struct MyDrawShader { - #[deref] pub draw_super: DrawVector, - #[live] pub tint: Vec4f, // instance field - #[rust] my_state: bool, // BAD: sits between tint and the next shader's fields - } - ``` +## On Stopping to Ask -18. **Don't put comments or blank lines before the first real code in `script!`/`script_mod!`**: Rust's proc macro token stream strips comments entirely — they produce no tokens. This shifts error column/line info because the span tracking starts from the first actual token. Always start with real code (e.g., `use mod.std.assert`) immediately after the opening brace. +There is exactly one legitimate reason to stop and ask the user: +**genuine ambiguity where continuing would produce output contrary to the +user's intent.** -19. **WARNING: Hex colors containing the letter `e` in `script_mod!`**: The Rust tokenizer interprets `e` or `E` in hex color literals as a scientific notation exponent, causing parse errors like `expected at least one digit in exponent`. For example, `#2ecc71` fails because `2e` looks like the start of `2e`. **Use the `#x` prefix** to escape this: write `#x2ecc71` instead of `#x2ecc71`. This applies to any hex color where a digit is immediately followed by `e`/`E` (e.g., `#1e1e2e`, `#4466ee`, `#7799ee`, `#bb99ee`). Colors without `e` (like `#ff4444`, `#44cc44`) work fine with plain `#`. +Illegitimate reasons include: -20. **Shader enums**: Prefer `match` on enum values with `_ =>` as the catch-all arm, not `if/else` chains over integer-like values. If enum `match` fails in shader compilation, treat it as a compiler bug: add or extend a `platform/script/test` case and fix the shader compiler path instead of rewriting shader logic to `if/else`. \ No newline at end of file +- Asking about reversible implementation details—just do it; if it's wrong, + fix it +- Asking "should I do the next step"—if the next step is part of the task, + do it +- Dressing up a style choice you could have made yourself as "options for + the user" +- Following up completed work with "would you like me to also do X, Y, Z?" + —these are post-hoc confirmations. The user can say "no thanks," but the + default is to have done them diff --git a/Cargo.lock b/Cargo.lock index 89c2a4693..73e88fd6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "ab_glyph_rasterizer" version = "0.1.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "accessory" @@ -239,6 +239,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend 0.3.15", + "wayland-client 0.31.14", + "wayland-protocols 0.32.12", + "zbus", +] + [[package]] name = "askar-crypto" version = "0.3.7" @@ -334,6 +356,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -359,6 +393,49 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.1", +] + [[package]] name = "async-lock" version = "3.4.1" @@ -370,12 +447,52 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-once-cell" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "async-rx" version = "0.1.3" @@ -386,6 +503,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.1", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -408,6 +543,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -610,7 +751,7 @@ dependencies = [ [[package]] name = "bitflags" version = "2.10.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "bitmaps" @@ -691,6 +832,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bls12_381" version = "0.8.0" @@ -728,7 +882,7 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" version = "1.25.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "byteorder" @@ -739,7 +893,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "byteorder" version = "1.5.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "bytes" @@ -1459,7 +1613,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -1469,6 +1623,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "block2", + "libc", "objc2", ] @@ -1483,6 +1639,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1592,6 +1757,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1605,7 +1797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -1936,9 +2128,9 @@ dependencies = [ [[package]] name = "fxhash" version = "0.2.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "byteorder 1.5.0 (git+https://github.com/makepad/makepad?branch=dev)", + "byteorder 1.5.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] @@ -2953,7 +3145,7 @@ dependencies = [ [[package]] name = "makepad-apple-sys" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-objc-sys", ] @@ -2961,12 +3153,12 @@ dependencies = [ [[package]] name = "makepad-byteorder-lite" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-code-editor" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-widgets", ] @@ -2974,7 +3166,7 @@ dependencies = [ [[package]] name = "makepad-derive-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -2982,7 +3174,7 @@ dependencies = [ [[package]] name = "makepad-derive-widget" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id", "makepad-micro-proc-macro", @@ -2991,7 +3183,7 @@ dependencies = [ [[package]] name = "makepad-draw" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "ab_glyph_rasterizer", "fxhash", @@ -3005,15 +3197,15 @@ dependencies = [ "rustybuzz", "sdfer", "serde", - "unicode-bidi 0.3.18 (git+https://github.com/makepad/makepad?branch=dev)", + "unicode-bidi 0.3.18 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "unicode-linebreak", - "unicode-segmentation 1.12.0 (git+https://github.com/makepad/makepad?branch=dev)", + "unicode-segmentation 1.12.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] name = "makepad-error-log" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-serde", ] @@ -3021,22 +3213,22 @@ dependencies = [ [[package]] name = "makepad-filesystem-watcher" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-futures" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-futures-legacy" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-html" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id", ] @@ -3050,7 +3242,7 @@ checksum = "9775cbec5fa0647500c3e5de7c850280a88335d1d2d770e5aa2332b801ba7064" [[package]] name = "makepad-latex-math" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "ttf-parser", ] @@ -3058,7 +3250,7 @@ dependencies = [ [[package]] name = "makepad-live-id" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id-macros", "serde", @@ -3067,7 +3259,7 @@ dependencies = [ [[package]] name = "makepad-live-id-macros" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -3075,7 +3267,7 @@ dependencies = [ [[package]] name = "makepad-live-reload-core" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-filesystem-watcher", ] @@ -3083,7 +3275,7 @@ dependencies = [ [[package]] name = "makepad-math" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-serde", ] @@ -3091,12 +3283,12 @@ dependencies = [ [[package]] name = "makepad-micro-proc-macro" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-micro-serde" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id", "makepad-micro-serde-derive", @@ -3105,7 +3297,7 @@ dependencies = [ [[package]] name = "makepad-micro-serde-derive" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -3113,7 +3305,7 @@ dependencies = [ [[package]] name = "makepad-network" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-apple-sys", "makepad-error-log", @@ -3127,15 +3319,15 @@ dependencies = [ [[package]] name = "makepad-objc-sys" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-platform" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "ash", - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "hilog-sys", "makepad-android-state", "makepad-apple-sys", @@ -3155,10 +3347,10 @@ dependencies = [ "napi-derive-ohos", "napi-ohos", "ohos-sys", - "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", - "wayland-client", + "smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", + "wayland-client 0.31.12", "wayland-egl", - "wayland-protocols", + "wayland-protocols 0.32.10", "windows 0.62.2", "windows-core 0.62.2", "windows-targets 0.52.6", @@ -3167,12 +3359,12 @@ dependencies = [ [[package]] name = "makepad-regex" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-script" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-error-log", "makepad-html", @@ -3180,13 +3372,13 @@ dependencies = [ "makepad-math", "makepad-regex", "makepad-script-derive", - "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", + "smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] name = "makepad-script-derive" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -3194,7 +3386,7 @@ dependencies = [ [[package]] name = "makepad-script-std" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-network", "makepad-script", @@ -3203,14 +3395,14 @@ dependencies = [ [[package]] name = "makepad-shared-bytes" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-studio-protocol" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "makepad-error-log", "makepad-live-id", "makepad-micro-serde", @@ -3220,7 +3412,7 @@ dependencies = [ [[package]] name = "makepad-svg" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-html", "makepad-live-id", @@ -3229,7 +3421,7 @@ dependencies = [ [[package]] name = "makepad-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-derive-wasm-bridge", "makepad-live-id", @@ -3238,7 +3430,7 @@ dependencies = [ [[package]] name = "makepad-webp" version = "0.2.4" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-byteorder-lite", ] @@ -3246,7 +3438,7 @@ dependencies = [ [[package]] name = "makepad-widgets" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-derive-widget", "makepad-draw", @@ -3255,18 +3447,18 @@ dependencies = [ "pulldown-cmark 0.12.2", "serde", "ttf-parser", - "unicode-segmentation 1.12.0 (git+https://github.com/makepad/makepad?branch=dev)", + "unicode-segmentation 1.12.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] name = "makepad-zune-core" version = "0.5.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-zune-inflate" version = "0.2.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "simd-adler32", ] @@ -3274,7 +3466,7 @@ dependencies = [ [[package]] name = "makepad-zune-jpeg" version = "0.5.12" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-zune-core", ] @@ -3282,7 +3474,7 @@ dependencies = [ [[package]] name = "makepad-zune-png" version = "0.5.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-zune-core", "makepad-zune-inflate", @@ -3678,7 +3870,16 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memchr" version = "2.7.6" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] [[package]] name = "mime" @@ -3949,6 +4150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "block2", "objc2", "objc2-foundation", ] @@ -4077,6 +4279,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -4246,6 +4458,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -4273,6 +4496,26 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.1", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "poly1305" version = "0.8.0" @@ -4410,10 +4653,10 @@ dependencies = [ [[package]] name = "pulldown-cmark" version = "0.12.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", - "memchr 2.7.6 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", + "memchr 2.7.6 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "unicase 2.9.0", ] @@ -4435,6 +4678,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr 2.7.6 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4580,6 +4832,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "readlock" version = "0.1.9" @@ -4722,6 +4980,30 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -4847,11 +5129,13 @@ dependencies = [ "matrix-sdk", "matrix-sdk-base", "matrix-sdk-ui", + "mime", "percent-encoding", "quinn", "rand 0.8.5", "rangemap", "reqwest", + "rfd", "robius-directories", "robius-location", "robius-open", @@ -5101,7 +5385,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -5172,12 +5456,12 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" version = "0.18.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "bytemuck", "makepad-error-log", - "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", + "smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "ttf-parser", "unicode-bidi-mirroring", "unicode-ccc", @@ -5272,7 +5556,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdfer" version = "0.2.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "sealed" @@ -5448,6 +5732,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serde_spanned" version = "1.0.3" @@ -5571,7 +5866,7 @@ dependencies = [ [[package]] name = "simd-adler32" version = "0.3.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "siphasher" @@ -5597,7 +5892,7 @@ dependencies = [ [[package]] name = "smallvec" version = "1.15.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "socket2" @@ -5949,7 +6244,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -6363,7 +6658,7 @@ dependencies = [ [[package]] name = "ttf-parser" version = "0.24.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "tungstenite" @@ -6405,6 +6700,17 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.1", +] + [[package]] name = "ulid" version = "1.2.1" @@ -6424,7 +6730,7 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicase" version = "2.9.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-bidi" @@ -6435,17 +6741,17 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi" version = "0.3.18" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-bidi-mirroring" version = "0.3.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-ccc" version = "0.3.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-ident" @@ -6456,7 +6762,7 @@ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" version = "0.1.5" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-normalization" @@ -6476,12 +6782,12 @@ checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-properties" version = "0.1.4" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-script" version = "0.5.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-segmentation" @@ -6492,7 +6798,7 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-segmentation" version = "1.12.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-width" @@ -6772,53 +7078,113 @@ dependencies = [ [[package]] name = "wayland-backend" version = "0.3.12" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "downcast-rs", "libc", "scoped-tls", "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-sys", + "wayland-sys 0.31.8", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", + "wayland-sys 0.31.11", ] [[package]] name = "wayland-client" version = "0.31.12" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc", - "wayland-backend", + "wayland-backend 0.3.12", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rustix", + "wayland-backend 0.3.15", + "wayland-scanner", ] [[package]] name = "wayland-egl" version = "0.32.9" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "wayland-backend", - "wayland-sys", + "wayland-backend 0.3.12", + "wayland-sys 0.31.8", ] [[package]] name = "wayland-protocols" version = "0.32.10" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-backend", - "wayland-client", + "wayland-backend 0.3.12", + "wayland-client 0.31.12", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wayland-backend 0.3.15", + "wayland-client 0.31.14", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", ] [[package]] name = "wayland-sys" version = "0.31.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "log", "pkg-config", ] +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.84" @@ -6883,7 +7249,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.1", ] [[package]] @@ -6912,7 +7278,7 @@ dependencies = [ [[package]] name = "windows" version = "0.62.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-collections 0.3.2", "windows-core 0.62.2", @@ -6931,7 +7297,7 @@ dependencies = [ [[package]] name = "windows-collections" version = "0.3.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-core 0.62.2", ] @@ -6964,7 +7330,7 @@ dependencies = [ [[package]] name = "windows-core" version = "0.62.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-link 0.2.1", "windows-result 0.4.1", @@ -6985,7 +7351,7 @@ dependencies = [ [[package]] name = "windows-future" version = "0.3.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-core 0.62.2", ] @@ -7049,7 +7415,7 @@ checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-link" version = "0.2.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "windows-numerics" @@ -7093,7 +7459,7 @@ dependencies = [ [[package]] name = "windows-result" version = "0.4.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-link 0.2.1", ] @@ -7110,7 +7476,7 @@ dependencies = [ [[package]] name = "windows-strings" version = "0.5.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-link 0.2.1", ] @@ -7484,6 +7850,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.1", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -7583,3 +8010,44 @@ name = "zmij" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.106", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index daf7ba9e2..28eeb092f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,11 @@ version = "0.0.1-pre-alpha-4" metadata.makepad-auto-version = "zqpv-Yj-K7WNVK2I8h5Okhho46Q=" [dependencies] -makepad-widgets = { git = "https://github.com/makepad/makepad", branch = "dev", features = ["serde"] } -makepad-code-editor = { git = "https://github.com/makepad/makepad", branch = "dev" } +# makepad-widgets = { git = "https://github.com/makepad/makepad", branch = "dev", features = ["serde"] } +# makepad-code-editor = { git = "https://github.com/makepad/makepad", branch = "dev" } + +makepad-widgets = { git = "https://github.com/kevinaboos/makepad", branch = "stack_nav_improvements", features = ["serde"] } +makepad-code-editor = { git = "https://github.com/kevinaboos/makepad", branch = "stack_nav_improvements" } ## Including this crate automatically configures all `robius-*` crates to work with Makepad. @@ -41,6 +44,7 @@ hashbrown = { version = "0.16", features = ["raw-entry"] } htmlize = "1.0.5" indexmap = "2.6.0" imghdr = "0.7.0" +mime = "0.3" linkify = "0.10.0" matrix-sdk-base = { git = "https://github.com/matrix-org/matrix-rust-sdk", branch = "main" } matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", branch = "main", default-features = false, features = [ @@ -75,6 +79,9 @@ tracing-subscriber = "0.3.17" unicode-segmentation = "1.11.0" url = "2.5.0" +[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] +rfd = "0.15" + ## Dependencies for TSP support. ## Commit "f0bc4625dcd729e07e4a36257df2f1d94c81cef4" is the most recent one without the invalid change to pin serde to 1.0.219. diff --git a/issues/001-dock-load-state-drawlist-corruption.md b/issues/001-dock-load-state-drawlist-corruption.md new file mode 100644 index 000000000..4d72bda91 --- /dev/null +++ b/issues/001-dock-load-state-drawlist-corruption.md @@ -0,0 +1,111 @@ +# Issue #001: Dock.load_state() causes DrawList corruption and blank main page + +**Date:** 2026-04-04 +**Severity:** Critical (blocks all UI rendering) +**Status:** Fixed (workaround applied) +**Affected component:** `src/home/main_desktop_ui.rs` — `load_dock_state_from()` + +## Summary + +Restoring the Dock layout from persisted state via `Dock.load_state()` corrupts Makepad's internal DrawList references, causing the entire main content area (rooms list + room tabs) to render as a blank grey page. + +## Symptoms + +- Left navigation bar (NavigationTabBar) renders correctly +- Main content area (Dock with RoomsSideBar + room tabs) is completely blank/grey +- Console shows massive `Drawlist id generation wrong` errors: + ``` + [E] draw_list.rs:324: Drawlist id generation wrong index: 21 current gen:1 in pointer:0 + ``` +- Errors repeat continuously for draw list indices 21 and 22 + +## Root Cause + +`Dock.load_state()` in Makepad's `widgets/src/dock.rs:1310` destroys DrawList references during event handling: + +```rust +pub fn load_state(&mut self, cx: &mut Cx, dock_items: HashMap) { + self.dock_items = dock_items; + self.items.clear(); + self.tab_bars.clear(); // Drops TabBarWrap, freeing DrawList2d + self.splitters.clear(); + self.area.redraw(cx); // Marks redraw, but stale refs remain + self.create_all_items(cx); +} +``` + +The lifecycle issue: + +1. `tab_bars.clear()` drops `TabBarWrap` instances containing `contents_draw_list: DrawList2d` +2. Drop increments the DrawList pool entry generation (0 → 1) +3. Makepad's rendering pipeline still holds cached `DrawListId(index, gen=0)` from the previous frame +4. Next frame accesses stale references → generation mismatch → rendering failure + +This only triggers when the Dock already has live tab_bars (created during the first draw pass) and `load_state()` replaces them. On first startup with empty tab_bars, `clear()` is a no-op and causes no issue. + +## Reproduction + +1. Run the app, log in, open some room tabs +2. Close the app (state is persisted to `latest_app_state.json`) +3. Restart the app → blank main page + +**Verification:** Deleting `latest_app_state.json` before restart → UI renders correctly with 0 DrawList errors. + +## Fix Applied + +Modified `load_dock_state_from()` in `src/home/main_desktop_ui.rs` to avoid calling `dock.load_state()`. Instead, tabs are recreated programmatically: + +```rust +fn load_dock_state_from(&mut self, cx: &mut Cx, app_state: &mut AppState) { + // ... resolve which state to restore ... + + let room_order = to_restore.room_order.clone(); + let selected_room = to_restore.selected_room.clone(); + + // Close existing tabs using the Dock's normal API (safe) + self.close_all_tabs(cx); + + // Recreate each room tab in saved order (safe) + for room in &room_order { + self.focus_or_create_tab(cx, room.clone()); + } + + // Re-select the previously-selected room + let final_selected = selected_room.or_else(|| room_order.last().cloned()); + if let Some(selected) = final_selected.clone() { + self.focus_or_create_tab(cx, selected); + } + app_state.selected_room = final_selected; + self.redraw(cx); +} +``` + +This uses `close_all_tabs()` + `focus_or_create_tab()` which operate through the Dock's normal widget API, avoiding direct destruction of DrawList2d objects. + +## Remaining Issues + +1. **Splitter position not restored:** Custom sidebar width (if user dragged the splitter) resets to default 300px on restart. + +2. **Multi-pane layout not restored:** If the user created split-view arrangements by dragging tabs, those layouts are lost on restart. All tabs return to the single default tab bar. + +3. **Same issue exists in space switching:** `NavigationBarAction::TabSelected` also calls `load_dock_state_from()`, which previously used `dock.load_state()`. The fix applies to this path as well, but the same layout-loss trade-off exists. + +4. **Upstream Makepad bug:** `Dock.load_state()` should be fixed in Makepad to properly handle DrawList lifecycle when called during event handling. The fix should either: + - Defer the actual destruction to the next draw pass + - Properly invalidate cached DrawList references in the rendering pipeline + - Or use a two-phase approach: mark old DrawLists for cleanup, create new ones, then clean up + +5. **`SETTINGS_BUTTON_HEIGHT` undefined:** Unrelated but observed during debugging — `account_settings.rs:63,86` references `mod.widgets.SETTINGS_BUTTON_HEIGHT` which is never defined, causing DSL parse warnings at startup. + +## Files Changed + +- `src/home/main_desktop_ui.rs` — `load_dock_state_from()` rewritten + +## Test Verification + +| Scenario | Before Fix | After Fix | +|----------|-----------|-----------| +| Start with persisted state | Blank page, ~50+ DrawList errors | Rooms render, 0 DrawList errors | +| Start without persisted state | Works | Works | +| Room tabs restored | N/A (blank) | All saved tabs recreated correctly | +| Selected room restored | N/A (blank) | Correct room selected and loaded | diff --git a/resources/i18n/en.json b/resources/i18n/en.json new file mode 100644 index 000000000..ef2daeda5 --- /dev/null +++ b/resources/i18n/en.json @@ -0,0 +1,429 @@ +{ + "settings.all_settings_title": "All Settings", + "settings.category.account": "Account", + "settings.category.preferences": "Preferences", + "settings.category.labs": "Labs", + "settings.preferences.language.title": "Language", + "settings.preferences.language.application_label": "Application language", + "settings.preferences.language.reload_hint": "The app will reload after selecting another language", + "language.option.english": "English", + "language.option.chinese_simplified": "Simplified Chinese", + + "login.title.login_to_robrix": "Login to Robrix", + "login.title.create_account": "Create your Robrix account", + "login.input.user_id": "User ID", + "login.input.password": "Password", + "login.input.confirm_password": "Confirm password", + "login.input.homeserver": "matrix.org", + "login.label.homeserver_optional": "Homeserver URL (optional)", + "login.button.login": "Login", + "login.button.create_account": "Create account", + "login.sso.prompt": "Or, login with an SSO provider:", + "login.account_prompt.no_account": "Don't have an account?", + "login.account_prompt.already_have": "Already have an account?", + "login.mode_toggle.sign_up_here": "Sign up here", + "login.mode_toggle.back_to_login": "Back to login", + "login.status.missing_user_id.title": "Missing User ID", + "login.status.missing_user_id.body": "Please enter a valid User ID.", + "login.status.missing_password.title": "Missing Password", + "login.status.missing_password.body": "Please enter a valid password.", + "login.status.password_mismatch.title": "Passwords do not match", + "login.status.password_mismatch.body": "Please enter the same password in both password fields.", + "login.status.creating_account.title": "Creating account...", + "login.status.creating_account.body": "Waiting for the homeserver to create your account...", + "login.status.logging_in.title": "Logging in...", + "login.status.logging_in.body": "Waiting for a login response...", + "login.status.logging_in_cli.title": "Logging in via CLI...", + "login.status.auto_logging_in_as_user": "Auto-logging in as user {user_id}...", + "login.status.account_creation_failed": "Account Creation Failed.", + "login.status.login_failed": "Login Failed.", + "login.status.okay": "Okay", + "login.status.cancel": "Cancel", + "login_status_modal.title": "Login Status", + "login_status_modal.button.cancel": "Cancel", + + "room_context_menu.button.mark_unread": "Mark as Unread", + "room_context_menu.button.mark_read": "Mark as Read", + "room_context_menu.button.favorite": "Favorite", + "room_context_menu.button.unfavorite": "Un-favorite", + "room_context_menu.button.set_low_priority": "Set Low Priority", + "room_context_menu.button.unset_low_priority": "Un-set Low Priority", + "room_context_menu.button.copy_link_to_room": "Copy Link to Room", + "room_context_menu.button.settings": "Settings", + "room_context_menu.button.notifications": "Notifications", + "room_context_menu.button.invite": "Invite", + "room_context_menu.button.bind_botfather": "Bind BotFather", + "room_context_menu.button.unbind_botfather": "Unbind BotFather", + "room_context_menu.button.leave_room": "Leave Room", + "room_context_menu.popup.settings_not_implemented": "The room settings page is not yet implemented.", + "room_context_menu.popup.notifications_not_implemented": "The room notifications page is not yet implemented.", + "room_context_menu.popup.removing_botfather": "Removing BotFather {bot_user_id} from this room...", + "room_context_menu.popup.inviting_botfather": "Inviting BotFather {bot_user_id} into this room...", + "room_context_menu.popup.bot_settings_unavailable": "Bot settings are unavailable right now.", + + "add_room.title": "Add/Explore Rooms and Spaces", + "add_room.section.create_new_room": "Create a new room:", + "add_room.section.add_friend": "Add a friend:", + "add_room.section.join_existing": "Join an existing room or space:", + "add_room.create_room.help.default": "Create a standalone room, or attach it under a space where you can create child rooms.", + "add_room.create_room.help.fixed_parent": "Enter a room name. It will be created directly in this space.", + "add_room.create_room.dropdown.no_space": "Create without a space", + "add_room.create_room.dropdown.hint.choose_space": "Choose a space where you have permission to create child rooms.", + "add_room.create_room.dropdown.hint.no_creatable_spaces": "No joined space currently allows you to create child rooms.", + "add_room.create_room.dropdown.hint.new_room_under": "New room will be added under: {selected_name}", + "add_room.create_room.dropdown.hint.default": "Create a standalone room, or choose a space from the dropdown.", + "add_room.create_room.input.placeholder": "Enter the new room name...", + "add_room.create_room.button.create": "Create room", + "add_room.create_room.button.syncing": "Syncing...", + "add_room.create_room.modal.title": "Create New Room", + "add_room.create_room.modal.subtitle": "Create a new room directly inside the selected space.", + "add_room.button.cancel": "Cancel", + "add_room.add_friend.help": "Enter a Matrix user ID to open or create a direct message room.", + "add_room.add_friend.input.placeholder": "Enter a Matrix user ID, like @alice:matrix.org...", + "add_room.add_friend.button": "Add friend", + "add_room.join.input.placeholder": "Enter alias, ID, or Matrix link...", + "add_room.join.button.go": "Go", + "add_room.join.help_html": "

You can enter a room/space address using either:

  • An alias, starting with #, like #robrix:matrix.org.
  • An ID, starting with !, like !moVNEIUPxJZpxRHDUv:matrix.org.
  • A Matrix link, like https:matrix.to/... or matrix:....
", + "add_room.popup.cannot_add_self": "You cannot add yourself as a friend.", + "add_room.popup.invalid_user_id": "Invalid Matrix user ID.\n\nError: {error}", + "add_room.popup.parse_error": "Could not parse the text as a valid room address.\nError: {error}.", + "add_room.popup.fetch_error": "Failed to fetch room info.\n\nError: {error}.", + "add_room.popup.knock_success": "Successfully knocked on {room_type} {room_name}.", + "add_room.popup.knock_failed": "Failed to knock on room.\n\nError: {error}.", + "add_room.popup.join_success": "Successfully joined {room_type} {room_name}.", + "add_room.popup.join_failed": "Failed to join room.\n\nError: {error}.", + "add_room.popup.created_room_success": "Successfully created room \"{room_name}\".", + "add_room.popup.created_room_space_link_suffix": "\n\nThe room was created, but it could not be linked into the selected space.\nError: {error}", + "add_room.popup.create_room_failed": "Failed to create room \"{room_name}\".\n\nError: {error}", + "add_room.feedback.create_room_failed": "Failed to create room: {error}", + "add_room.feedback.creating_room": "Creating room...", + "add_room.feedback.room_created_syncing": "Room created. Syncing it into the space...", + "add_room.feedback.room_created_link_failed_opening": "Room created, but linking it into the space failed. Opening the room...", + "add_room.feedback.room_created_opening": "Room created. Opening the room...", + "add_room.loading.fetching": "Fetching {target}...", + "add_room.fetched.room_name.unnamed": "Unnamed {room_or_space_uc}, ID: {room_id}", + "add_room.fetched.main_alias_and_id": "Main {room_or_space_uc} Alias and ID", + "add_room.fetched.alias.not_set": "not set", + "add_room.fetched.alias": "Alias: {alias}", + "add_room.fetched.id": "ID: {room_id}", + "add_room.fetched.topic_title": "{room_or_space_uc} Topic", + "add_room.fetched.topic.not_set_html": "No topic set", + "add_room.summary.already_joined": "You have already joined this {room_or_space_lc}.", + "add_room.summary.banned": "You have been banned from this {room_or_space_lc}.", + "add_room.summary.already_invited": "You have already been invited to this {room_or_space_lc}.", + "add_room.summary.already_knocked": "You have already knocked on this {room_or_space_lc}.", + "add_room.summary.previously_left": "You previously left this {room_or_space_lc}.", + "add_room.summary.member_count": "This is a {directness} {room_or_space_lc} with {num_members} {member_word}.", + "add_room.summary.knocked_waiting": "You have knocked on this {room_or_space_lc} and must now wait for someone to invite you in.", + "add_room.summary.joined_loading": "You have joined this {room_or_space_lc}. It is now being loaded from the homeserver; please wait...", + "add_room.summary.loaded": "You have {verb} this {room_or_space_lc}.", + "add_room.button.go_to": "Go to {room_or_space_lc}", + "add_room.button.cannot_join_until_unbanned": "Cannot join until un-banned", + "add_room.button.go_to_invitation": "Go to invitation", + "add_room.button.knock_again": "Knock again (be nice!)", + "add_room.button.rejoin": "Re-join this {room_or_space_lc}", + "add_room.button.rejoin_requires_invite": "Re-joining {room_or_space_lc} requires an invite", + "add_room.button.knock_to_rejoin": "Knock to re-join {room_or_space_lc}", + "add_room.button.rejoin_requires_other_membership": "Re-joining {room_or_space_lc} requires an invite or other room membership", + "add_room.button.not_allowed_to_rejoin": "Not allowed to re-join this {room_or_space_lc}", + "add_room.button.join": "Join this {room_or_space_lc}", + "add_room.button.join_requires_invite": "Joining {room_or_space_lc} requires an invite", + "add_room.button.knock_to_join": "Knock to join {room_or_space_lc}", + "add_room.button.join_requires_other_membership": "Joining {room_or_space_lc} requires an invite or other room membership", + "add_room.button.not_allowed_to_join": "Not allowed to join this {room_or_space_lc}", + "add_room.button.successfully_knocked": "Successfully knocked!", + "add_room.button.successfully_joined": "Successfully joined!", + "add_room.button.go_to_loaded": "Go to {adj} {room_or_space_lc}", + "add_room.word.direct": "direct", + "add_room.word.regular": "regular", + "add_room.word.member": "member", + "add_room.word.members": "members", + "add_room.word.room_lc": "room", + "add_room.word.space_lc": "space", + "add_room.word.room_uc": "Room", + "add_room.word.space_uc": "Space", + "add_room.word.verb.invited": "been invited to", + "add_room.word.verb.joined": "fully joined", + "add_room.word.adj.invited": "invited", + "add_room.word.adj.joined": "joined", + + "settings.account.title": "Account Settings", + "settings.account.section.your_avatar": "Your Avatar:", + "settings.account.section.your_display_name": "Your Display Name:", + "settings.account.section.your_user_id": "Your User ID:", + "settings.account.section.multiple_accounts": "Multiple Accounts:", + "settings.account.section.other_actions": "Other actions:", + "settings.account.display_name.placeholder": "Add a display name...", + "settings.account.user_id.not_logged_in": "You are not logged in.", + "settings.account.active_status": "Active", + "settings.account.other_accounts": "Other accounts:", + "settings.account.button.upload_avatar": "Upload Avatar", + "settings.account.button.delete_avatar": "Delete Avatar", + "settings.account.button.cancel": "Cancel", + "settings.account.button.save_name": "Save Name", + "settings.account.button.switch": "Switch", + "settings.account.button.add_another_account": "Add Another Account", + "settings.account.button.manage_account": "Manage Account", + "settings.account.button.log_out": "Log out", + "settings.account.button.logging_out": "Logging out...", + "settings.account.account_count.none": "No accounts logged in", + "settings.account.account_count.one": "1 account logged in", + "settings.account.account_count.many": "{count} accounts logged in", + "settings.account.tooltip.copy_user_id": "Copy User ID", + "settings.account.popup.avatar_updated": "Successfully updated avatar.", + "settings.account.popup.avatar_deleted": "Successfully deleted avatar.", + "settings.account.popup.avatar_upload_not_implemented": "Avatar uploading is not yet implemented.", + "settings.account.popup.uploading_avatar": "Uploading avatar...", + "settings.account.popup.deleting_avatar": "Deleting your avatar...", + "settings.account.popup.display_name_updated": "Successfully updated display name.", + "settings.account.popup.display_name_removed": "Successfully removed display name.", + "settings.account.popup.uploading_display_name": "Uploading new display name...", + "settings.account.popup.copied_user_id": "Copied your User ID to the clipboard.", + "settings.account.popup.account_management_not_implemented": "Account management is not yet implemented.", + "settings.account.modal.delete_avatar.title": "Delete Avatar", + "settings.account.modal.delete_avatar.body": "Are you sure you want to delete your avatar?", + "settings.account.modal.delete_avatar.accept": "Delete", + + "settings.labs.app_service.title": "App Service", + "settings.labs.app_service.description": "Enable Matrix app service support here. Robrix stays a normal Matrix client: it binds BotFather to a room and sends the matching slash commands.", + "settings.labs.app_service.enable_label": "Enable App Service", + "settings.labs.app_service.botfather_user_id": "BotFather User ID:", + "settings.labs.app_service.botfather_placeholder": "bot or @bot:server", + "settings.labs.app_service.button.enable": "Enable App Service", + "settings.labs.app_service.button.disable": "Disable App Service", + "settings.labs.app_service.button.save": "Save", + "settings.labs.app_service.popup.saved": "Saved Matrix app service settings.", + + "invite_screen.message.invited_by": "has invited you to join:", + "invite_screen.message.invited_generic": "You have been invited to join:", + "invite_screen.button.reject": "Reject Invite", + "invite_screen.button.join": "Join Room", + "invite_screen.button.joining": "Joining...", + "invite_screen.button.rejecting": "Rejecting...", + "invite_screen.button.joined": "Joined!", + "invite_screen.popup.joined_success": "Successfully joined room.", + "invite_screen.popup.rejected_success": "Successfully rejected invite.", + "invite_screen.popup.reject_failed": "Failed to reject invite: {error}", + "invite_screen.completion.rejected": "Invite successfully rejected. You may close this invite.", + "invite_modal.title.invite_to_room_name": "Invite to {room_name}", + "invite_modal.input.placeholder": "@user:example.org", + "invite_modal.button.cancel": "Cancel", + "invite_modal.button.invite": "Invite", + "invite_modal.button.okay": "Okay", + "invite_modal.status.enter_user_id": "Please enter a user ID.", + "invite_modal.status.sending": "Sending invite...", + "invite_modal.status.invalid_user_id": "Invalid User ID. Expected format: @user:server.xyz", + "invite_modal.status.success_invited": "Successfully invited {user_id}!", + "invite_modal.status.send_failed": "Failed to send invite: {error}", + "rooms_list_entry.invited.by_name_and_user": "Invited by {display_name} ({user_id})", + "rooms_list_entry.invited.by_user": "Invited by {user_id}", + "rooms_list_entry.invited.generic": "You were invited", + + "loading_pane.title.default": "Loading content...", + "loading_pane.title.searching_older": "Searching older messages...", + "loading_pane.status.searching_event": "Looking for event {target_event_id}\n\nFetched {events_paginated} messages so far...", + "loading_pane.title.error": "Error loading content", + "loading_pane.button.cancel": "Cancel", + "loading_pane.button.okay": "Okay", + + "rooms_list_header.title.all_rooms": "All Rooms", + "rooms_list_header.popup.offline": "Cannot reach the Matrix homeserver. Please check your connection.", + "rooms_list_header.tooltip.syncing": "Syncing...", + "rooms_list_header.tooltip.offline": "Offline", + "rooms_list_header.tooltip.synced": "Fully synced", + + "room_filter_input.placeholder": "Filter rooms & spaces...", + "search_messages.button.todo": "Search (TODO)", + + "welcome_screen.title": "Welcome to Robrix!", + "welcome_screen.body_html": "

Our Matrix client is under heavy development. Currently, you can access the rooms and spaces that you've joined in other clients.


But don't worry, we're constantly expanding the featureset of Robrix!


Look for the latest announcements in our Matrix channel:

#robrix:matrix.org

", + + "room_screen.bot.delete.error.empty_user_id": "Please enter the bot Matrix user ID to delete.", + "room_screen.bot.delete.error.invalid_user_id": "Invalid Matrix user ID: {full_user_id}", + "room_screen.bot.delete.error.current_user_unavailable": "Current user ID is unavailable, so the bot homeserver cannot be resolved.", + "room_screen.tooltip.reacted_with_suffix": " reacted with: {reaction}", + "room_screen.modal.invite.title": "Send Invitation", + "room_screen.modal.invite.body": "Are you sure you want to invite {username} to this room?", + "room_screen.modal.invite.accept": "Invite", + "room_screen.popup.invite.sent_success": "Sent invite successfully.", + "room_screen.popup.invite.failed": "Failed to send invite.\n\nError: {error}", + "room_screen.popup.app_service.enable_before_create": "Enable App Service before creating bots in a room.", + "room_screen.popup.app_service.bind_before_create": "Bind BotFather to this room before creating a bot.", + "room_screen.popup.app_service.state_unavailable_create": "App state is unavailable, so bot creation is temporarily unavailable.", + "room_screen.popup.app_service.enable_before_delete": "Enable App Service before deleting bots in a room.", + "room_screen.popup.app_service.bind_before_delete": "Bind BotFather to this room before deleting a bot.", + "room_screen.popup.app_service.state_unavailable_delete": "App state is unavailable, so bot deletion is temporarily unavailable.", + "room_screen.popup.app_service.room_not_bound": "This room is not currently bound to BotFather.", + "room_screen.popup.app_service.removing_botfather": "Removing BotFather {bot_user_id} from this room...", + "room_screen.popup.app_service.state_unavailable_unbind": "App state is unavailable, so BotFather could not be removed from this room.", + "room_screen.popup.bot.main_timeline_only": "Bot commands are only supported in the main room timeline.", + "room_screen.popup.bot.enable_in_settings_before_bot": "Enable App Service in Settings before using /bot.", + "room_screen.popup.bot.bind_before_bot": "Bind BotFather to this room before using /bot.", + "room_screen.popup.bot.enable_before_commands": "Enable App Service before using BotFather commands in a room.", + "room_screen.popup.bot.bind_before_commands": "Bind BotFather to this room before using BotFather commands.", + "room_screen.popup.bot.creation_main_timeline_only": "Bot creation commands are only supported in the main room timeline.", + "room_screen.popup.bot.sent_listbots": "Sent `/listbots` to BotFather.", + "room_screen.popup.bot.sent_bothelp": "Sent `/bothelp` to BotFather.", + "room_screen.popup.bot.sent_createbot": "Sent `/createbot` for `{username}` to BotFather.", + "room_screen.popup.bot.sent_deletebot": "Sent `/deletebot` for {matrix_user_id} to BotFather.", + "room_screen.popup.bot.state_unavailable_create_command": "App state is unavailable, so the create-bot command was not sent.", + "room_screen.popup.bot.state_unavailable_delete_command": "App state is unavailable, so the delete-bot command was not sent.", + "room_screen.fallback.unnamed_room": "Unnamed Room", + "room_screen.unsupported.prefix": "[Unsupported]", + "room_screen.read_marker.new_messages": "New Messages", + "room_screen.top_space.loading_earlier": "Loading earlier messages...", + "room_screen.loading.found_related_message": "Successfully found replied-to message!", + "room_screen.loading.related_message_not_found": "Unable to find related message; it may have been deleted.", + "room_screen.popup.pin.pinned_success": "Successfully pinned event.", + "room_screen.popup.pin.unpinned_success": "Successfully unpinned event.", + "room_screen.popup.pin.already_pinned": "Message was already pinned.", + "room_screen.popup.pin.already_unpinned": "Message was already unpinned.", + "room_screen.popup.pin.pin_failed": "Failed to pin event. Error: {error}", + "room_screen.popup.pin.unpin_failed": "Failed to unpin event. Error: {error}", + "room_screen.popup.already_viewing_room": "You are already viewing that room.", + "room_screen.popup.open_url_failed": "Could not open URL: {url}", + "room_screen.popup.message.reply_not_found": "Could not find message in timeline to reply to. Please try again.", + "room_screen.popup.message.edit_not_found": "Could not find message in timeline to edit. Please try again.", + "room_screen.popup.message.no_recent_editable": "No recent message available to edit. Please manually select a message to edit.", + "room_screen.popup.message.cannot_pin": "This event cannot be pinned.", + "room_screen.popup.message.cannot_unpin": "This event cannot be unpinned.", + "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.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.", + "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.", + "room_screen.modal.delete_message.accept": "Delete", + "room_screen.server_notice.title": "Server notice:", + "room_screen.server_notice.notice_type": "Notice type", + "room_screen.server_notice.limit_type": "Limit type", + "room_screen.server_notice.admin_contact": "Admin contact", + "room_screen.server_notice.username": "Server notice", + "room_screen.verification.sent_prefix": "Sent a ", + "room_screen.verification.request": "verification request", + "room_screen.verification.sent_to_suffix": " to {user_id}.", + "room_screen.verification.supported_methods": "Supported methods", + "room_screen.image.unsupported_type": "{body}\n\nUnsupported type {mime}", + "room_screen.image.failed_to_display": "{body}\n\nFailed to display image: {error}", + "room_screen.image.failed_to_fetch": "{body}\n\nFailed to fetch image from {mxc_uri}", + "room_screen.image.encrypted_todo": "{body}\n\n[TODO] fetch encrypted image at {url}", + "room_screen.image.no_source_url": "{body}\n\nImage message had no source URL.", + "room_screen.file.preview_html": "{filename}{size}{caption}
File download not yet supported.", + "room_screen.audio.preview_html": "Audio: {filename}{mime}{duration}{size}{caption}
Audio playback not yet supported.", + "room_screen.video.preview_html": "Video: {filename}{mime}{duration}{size}{dimensions}{caption}
Video playback not yet supported.", + "room_screen.location.label": "Location:", + "room_screen.location.open_osm": "Open in OpenStreetMap", + "room_screen.location.open_google_maps": "Open in Google Maps", + "room_screen.location.open_apple_maps": "Open in Apple Maps", + "room_screen.location.invalid_html": "[Location invalid] {body}", + "room_screen.redacted.self_with_reason": "⛔ Deleted their own message. Reason: \"{reason}\".", + "room_screen.redacted.self": "⛔ Deleted their own message.", + "room_screen.redacted.other_with_reason": "⛔ {redactor} deleted this message. Reason: \"{reason}\".", + "room_screen.redacted.other": "⛔ {redactor} deleted this message.", + "room_screen.redacted.generic": "⛔ Message deleted.", + "room_screen.reply_preview.error_username": "[Error fetching username]", + "room_screen.reply_preview.error_event": "[Error fetching replied-to event]", + "room_screen.reply_preview.loading_username": "[Loading username...]", + "room_screen.reply_preview.loading_event": "[Loading replied-to message...]", + "room_screen.thread_summary.loading_latest_reply": "Loading latest reply...", + "room_screen.thread_summary.error_latest_reply": "Unable to load latest reply", + "room_screen.thread_summary.one_reply": "1 reply", + "room_screen.thread_summary.n_replies": "{n} replies", + "room_screen.small_state.invite_to_room": "Invite to Room", + "room_screen.app_service.sender_name": "BotFather", + "room_screen.app_service.sender_tag": "bot", + "room_screen.app_service.title": "App Service Actions", + "room_screen.app_service.subtitle": "Create a bot through BotFather. Robrix only sends the matching slash command.", + "room_screen.app_service.timestamp_now": "now", + "room_screen.app_service.button.create_bot": "Create Bot", + "room_screen.app_service.button.list_bots": "List Bots", + "room_screen.app_service.button.delete_bot": "Delete Bot", + "room_screen.app_service.button.bot_help": "Bot Help", + "room_screen.app_service.button.unbind": "Unbind", + + "spaces_bar.tooltip.unknown_space_name": "Unknown Space Name", + "spaces_bar.status.none_matching": "Found no\nmatching spaces.", + "spaces_bar.status.none_joined": "Found no\njoined spaces.", + "spaces_bar.status.one_matching": "Found 1\nmatching space.", + "spaces_bar.status.one_joined": "Found 1\njoined space.", + "spaces_bar.status.n_matching": "Found {count}\nmatching spaces.", + "spaces_bar.status.n_joined": "Found {count}\njoined spaces.", + "spaces_bar.status.many_matching": "Found 99+\nmatching spaces.", + "spaces_bar.status.many_joined": "Found 99+\njoined spaces.", + + "tsp.settings.title": "TSP Wallet Settings", + "tsp.settings.section.active_identity": "Your active identity:", + "tsp.settings.section.wallets": "Your Wallets:", + "tsp.settings.wallet.none": "No wallets found. Create or import a wallet.", + "tsp.settings.identity.none_set": "No default identity has been set.", + "tsp.settings.button.republish_identity": "Republish Current Identity to DID Server", + "tsp.settings.button.republishing_now": "Republishing DID now...", + "tsp.settings.button.create_identity": "Create New Identity (DID)", + "tsp.settings.button.create_wallet": "Create New Wallet", + "tsp.settings.button.import_wallet": "Import Existing Wallet", + "tsp.settings.popup.wallet.removed": "Removed wallet \"{wallet_name}\".", + "tsp.settings.popup.wallet.default_removed_warning": "The default wallet was removed.\n\nTSP features will not work properly until you set a default wallet.", + "tsp.settings.popup.wallet.set_default_failed": "Failed to set default wallet, could not find or open selected wallet.", + "tsp.settings.popup.wallet.open_failed": "Failed to open wallet: {error}", + "tsp.settings.popup.wallet.import_not_implemented": "Importing an existing wallet is not yet implemented.", + "tsp.settings.popup.wallet.none_found": "No TSP wallets found.\n\nPlease create or import a wallet.", + "tsp.settings.popup.wallet.no_default": "No default TSP wallet is set.\n\nPlease select or create a default wallet.", + "tsp.settings.popup.identity.republish_success": "Successfully republished identity \"{did}\" to the DID server.", + "tsp.settings.popup.identity.republish_failed": "Failed to republish identity to the DID server: {error}", + "tsp.settings.popup.identity.copied": "Copied your default TSP identity to the clipboard.", + "tsp.settings.popup.identity.none_set": "No default TSP identity has been set.", + "tsp.settings.popup.identity.must_set_default": "You must set a default TSP identity to be republished.", + "tsp_dummy.message.disabled": "TSP features are not included in this build.\nTo use TSP, build Robrix with the 'tsp' feature enabled.", + "tsp.wallet_entry.default_label": "✅ Default", + "tsp.wallet_entry.not_found": "Wallet not found!", + "tsp.wallet_entry.button.set_default": "Set As Default", + "tsp.wallet_entry.button.remove": "Remove From List", + "tsp.wallet_entry.button.delete": "Delete Wallet", + "tsp.wallet_entry.modal.remove.title": "Remove Wallet", + "tsp.wallet_entry.modal.remove.body": "Are you sure you want to remove the wallet \"{wallet_name}\" from the list?\n\nThis won't delete the actual wallet file.", + "tsp.wallet_entry.modal.remove.accept": "Remove", + "tsp.wallet_entry.popup.delete_not_implemented": "Delete wallet feature is not yet implemented.", + + "app.room_filter.search_results_title": "Search Results", + "app.room_filter.empty_hint": "Type to search rooms and spaces...", + "app.room_filter.no_local_results": "No local results for \"{keywords}\". Choose a type below to search server.", + "app.room_filter.searching_remote": "Searching {kind} on server...", + "app.room_filter.remote.people": "People", + "app.room_filter.remote.rooms": "Rooms", + "app.room_filter.remote.spaces": "Spaces", + "app.room_filter.remote.kind.people": "people", + "app.room_filter.remote.kind.rooms": "rooms", + "app.room_filter.remote.kind.spaces": "spaces", + + "rooms_list.category.invites": "Invites", + "rooms_list.category.favorites": "Favorites", + "rooms_list.category.rooms": "Rooms", + "rooms_list.category.people": "People", + "rooms_list.category.low_priority": "Low Priority", + "rooms_list.category.left_rooms": "Left Rooms", + + "space_lobby.entry.explore_space": "Explore this Space", + "space_lobby.header.welcome": "Welcome to the space:", + "space_lobby.header.public_space": "🌐 Public space", + "space_lobby.header.private_space": "🔒 Private space", + "space_lobby.header.member_one": "1 member", + "space_lobby.header.member_n": "{count} members", + "space_lobby.header.button.new_room": "New Room", + "space_lobby.header.button.invite": "Invite", + "space_lobby.status.loading_rooms_spaces": "Loading rooms and spaces...", + "space_lobby.status.no_rooms_spaces": "No rooms or spaces found.", + "space_lobby.status.loading": "Loading...", + "space_lobby.item.button.join": "Join", + "space_lobby.item.button.view": "View", + "space_lobby.item.button.leave": "Leave", + "space_lobby.item.state.joined": "✅ Joined", + "space_lobby.item.state.left": "Left", + "space_lobby.item.state.invited": "Invited", + "space_lobby.item.state.knocked": "Knocked", + "space_lobby.item.state.banned": "Banned", + "space_lobby.item.member_one": "1 member", + "space_lobby.item.member_n": "{count} members", + "space_lobby.item.child_room_one": "~{count} room", + "space_lobby.item.child_room_n": "~{count} rooms" +} diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json new file mode 100644 index 000000000..f7f4ee455 --- /dev/null +++ b/resources/i18n/zh-CN.json @@ -0,0 +1,429 @@ +{ + "settings.all_settings_title": "全部设置", + "settings.category.account": "账号", + "settings.category.preferences": "偏好", + "settings.category.labs": "实验室", + "settings.preferences.language.title": "语言", + "settings.preferences.language.application_label": "应用语言", + "settings.preferences.language.reload_hint": "选择其他语言后,应用将重新加载", + "language.option.english": "English", + "language.option.chinese_simplified": "简体中文", + + "login.title.login_to_robrix": "登录 Robrix", + "login.title.create_account": "创建你的 Robrix 账号", + "login.input.user_id": "用户 ID", + "login.input.password": "密码", + "login.input.confirm_password": "确认密码", + "login.input.homeserver": "matrix.org", + "login.label.homeserver_optional": "Homeserver URL(可选)", + "login.button.login": "登录", + "login.button.create_account": "创建账号", + "login.sso.prompt": "或者,使用 SSO 提供商登录:", + "login.account_prompt.no_account": "还没有账号?", + "login.account_prompt.already_have": "已经有账号了?", + "login.mode_toggle.sign_up_here": "去注册", + "login.mode_toggle.back_to_login": "返回登录", + "login.status.missing_user_id.title": "缺少用户 ID", + "login.status.missing_user_id.body": "请输入有效的用户 ID。", + "login.status.missing_password.title": "缺少密码", + "login.status.missing_password.body": "请输入有效密码。", + "login.status.password_mismatch.title": "两次密码不一致", + "login.status.password_mismatch.body": "请在两个密码输入框中输入相同的密码。", + "login.status.creating_account.title": "正在创建账号...", + "login.status.creating_account.body": "正在等待服务器创建你的账号...", + "login.status.logging_in.title": "正在登录...", + "login.status.logging_in.body": "正在等待登录响应...", + "login.status.logging_in_cli.title": "正在通过命令行自动登录...", + "login.status.auto_logging_in_as_user": "正在以用户 {user_id} 自动登录...", + "login.status.account_creation_failed": "账号创建失败。", + "login.status.login_failed": "登录失败。", + "login.status.okay": "确定", + "login.status.cancel": "取消", + "login_status_modal.title": "登录状态", + "login_status_modal.button.cancel": "取消", + + "room_context_menu.button.mark_unread": "标记为未读", + "room_context_menu.button.mark_read": "标记为已读", + "room_context_menu.button.favorite": "收藏", + "room_context_menu.button.unfavorite": "取消收藏", + "room_context_menu.button.set_low_priority": "设为低优先级", + "room_context_menu.button.unset_low_priority": "取消低优先级", + "room_context_menu.button.copy_link_to_room": "复制房间链接", + "room_context_menu.button.settings": "设置", + "room_context_menu.button.notifications": "通知", + "room_context_menu.button.invite": "邀请", + "room_context_menu.button.bind_botfather": "绑定 BotFather", + "room_context_menu.button.unbind_botfather": "解绑 BotFather", + "room_context_menu.button.leave_room": "离开房间", + "room_context_menu.popup.settings_not_implemented": "房间设置页面暂未实现。", + "room_context_menu.popup.notifications_not_implemented": "房间通知页面暂未实现。", + "room_context_menu.popup.removing_botfather": "正在将 BotFather {bot_user_id} 从该房间移除...", + "room_context_menu.popup.inviting_botfather": "正在邀请 BotFather {bot_user_id} 加入该房间...", + "room_context_menu.popup.bot_settings_unavailable": "当前无法获取机器人设置。", + + "add_room.title": "添加/探索房间与空间", + "add_room.section.create_new_room": "创建新房间:", + "add_room.section.add_friend": "添加好友:", + "add_room.section.join_existing": "加入已有房间或空间:", + "add_room.create_room.help.default": "你可以创建独立房间,或将房间创建到你有权限的空间下。", + "add_room.create_room.help.fixed_parent": "输入房间名后,将直接在当前空间中创建该房间。", + "add_room.create_room.dropdown.no_space": "不放入任何空间", + "add_room.create_room.dropdown.hint.choose_space": "选择一个你有权限创建子房间的空间。", + "add_room.create_room.dropdown.hint.no_creatable_spaces": "当前没有你可创建子房间的已加入空间。", + "add_room.create_room.dropdown.hint.new_room_under": "新房间将创建在:{selected_name}", + "add_room.create_room.dropdown.hint.default": "可创建独立房间,或在下拉框中选择一个空间。", + "add_room.create_room.input.placeholder": "输入新房间名称...", + "add_room.create_room.button.create": "创建房间", + "add_room.create_room.button.syncing": "同步中...", + "add_room.create_room.modal.title": "创建新房间", + "add_room.create_room.modal.subtitle": "在当前选中的空间中直接创建一个新房间。", + "add_room.button.cancel": "取消", + "add_room.add_friend.help": "输入 Matrix 用户 ID 以打开或创建一个私聊房间。", + "add_room.add_friend.input.placeholder": "输入 Matrix 用户 ID,例如 @alice:matrix.org...", + "add_room.add_friend.button": "添加好友", + "add_room.join.input.placeholder": "输入别名、ID 或 Matrix 链接...", + "add_room.join.button.go": "前往", + "add_room.join.help_html": "

你可以使用以下任一种方式输入房间/空间地址:

  • # 开头的别名,例如 #robrix:matrix.org
  • ! 开头的ID,例如 !moVNEIUPxJZpxRHDUv:matrix.org
  • Matrix 链接,例如 https:matrix.to/...matrix:...
", + "add_room.popup.cannot_add_self": "你不能把自己添加为好友。", + "add_room.popup.invalid_user_id": "无效的 Matrix 用户 ID。\n\n错误:{error}", + "add_room.popup.parse_error": "无法将输入解析为有效的房间地址。\n错误:{error}。", + "add_room.popup.fetch_error": "获取房间信息失败。\n\n错误:{error}。", + "add_room.popup.knock_success": "已成功向{room_type} {room_name} 发起敲门请求。", + "add_room.popup.knock_failed": "敲门请求失败。\n\n错误:{error}。", + "add_room.popup.join_success": "已成功加入{room_type} {room_name}。", + "add_room.popup.join_failed": "加入房间失败。\n\n错误:{error}。", + "add_room.popup.created_room_success": "已成功创建房间“{room_name}”。", + "add_room.popup.created_room_space_link_suffix": "\n\n房间已创建,但无法关联到所选空间。\n错误:{error}", + "add_room.popup.create_room_failed": "创建房间“{room_name}”失败。\n\n错误:{error}", + "add_room.feedback.create_room_failed": "创建房间失败:{error}", + "add_room.feedback.creating_room": "正在创建房间...", + "add_room.feedback.room_created_syncing": "房间已创建,正在同步到该空间...", + "add_room.feedback.room_created_link_failed_opening": "房间已创建,但关联到空间失败,正在打开房间...", + "add_room.feedback.room_created_opening": "房间已创建,正在打开房间...", + "add_room.loading.fetching": "正在获取 {target}...", + "add_room.fetched.room_name.unnamed": "未命名{room_or_space_uc},ID:{room_id}", + "add_room.fetched.main_alias_and_id": "主要{room_or_space_uc}别名与 ID", + "add_room.fetched.alias.not_set": "未设置", + "add_room.fetched.alias": "别名:{alias}", + "add_room.fetched.id": "ID:{room_id}", + "add_room.fetched.topic_title": "{room_or_space_uc}主题", + "add_room.fetched.topic.not_set_html": "未设置主题", + "add_room.summary.already_joined": "你已经加入此{room_or_space_lc}。", + "add_room.summary.banned": "你已被此{room_or_space_lc}封禁。", + "add_room.summary.already_invited": "你已被邀请到此{room_or_space_lc}。", + "add_room.summary.already_knocked": "你已经向此{room_or_space_lc}敲门过。", + "add_room.summary.previously_left": "你之前离开了此{room_or_space_lc}。", + "add_room.summary.member_count": "这是一个{directness}{room_or_space_lc},共有 {num_members} 位{member_word}。", + "add_room.summary.knocked_waiting": "你已向此{room_or_space_lc}敲门,正在等待对方邀请你加入。", + "add_room.summary.joined_loading": "你已加入此{room_or_space_lc}。正在从服务器加载,请稍候...", + "add_room.summary.loaded": "你已{verb}此{room_or_space_lc}。", + "add_room.button.go_to": "前往{room_or_space_lc}", + "add_room.button.cannot_join_until_unbanned": "解除封禁后才能加入", + "add_room.button.go_to_invitation": "前往邀请", + "add_room.button.knock_again": "再次敲门(礼貌点)", + "add_room.button.rejoin": "重新加入此{room_or_space_lc}", + "add_room.button.rejoin_requires_invite": "重新加入{room_or_space_lc}需要邀请", + "add_room.button.knock_to_rejoin": "敲门以重新加入{room_or_space_lc}", + "add_room.button.rejoin_requires_other_membership": "重新加入{room_or_space_lc}需要邀请或其他房间成员身份", + "add_room.button.not_allowed_to_rejoin": "不允许重新加入此{room_or_space_lc}", + "add_room.button.join": "加入此{room_or_space_lc}", + "add_room.button.join_requires_invite": "加入{room_or_space_lc}需要邀请", + "add_room.button.knock_to_join": "敲门以加入{room_or_space_lc}", + "add_room.button.join_requires_other_membership": "加入{room_or_space_lc}需要邀请或其他房间成员身份", + "add_room.button.not_allowed_to_join": "不允许加入此{room_or_space_lc}", + "add_room.button.successfully_knocked": "已成功敲门!", + "add_room.button.successfully_joined": "已成功加入!", + "add_room.button.go_to_loaded": "前往{adj}{room_or_space_lc}", + "add_room.word.direct": "私聊", + "add_room.word.regular": "普通", + "add_room.word.member": "成员", + "add_room.word.members": "成员", + "add_room.word.room_lc": "房间", + "add_room.word.space_lc": "空间", + "add_room.word.room_uc": "房间", + "add_room.word.space_uc": "空间", + "add_room.word.verb.invited": "被邀请到", + "add_room.word.verb.joined": "完整加入", + "add_room.word.adj.invited": "受邀", + "add_room.word.adj.joined": "已加入", + + "settings.account.title": "账号设置", + "settings.account.section.your_avatar": "你的头像:", + "settings.account.section.your_display_name": "你的显示名称:", + "settings.account.section.your_user_id": "你的用户 ID:", + "settings.account.section.multiple_accounts": "多账号:", + "settings.account.section.other_actions": "其他操作:", + "settings.account.display_name.placeholder": "添加显示名称...", + "settings.account.user_id.not_logged_in": "你尚未登录。", + "settings.account.active_status": "当前账号", + "settings.account.other_accounts": "其他账号:", + "settings.account.button.upload_avatar": "上传头像", + "settings.account.button.delete_avatar": "删除头像", + "settings.account.button.cancel": "取消", + "settings.account.button.save_name": "保存名称", + "settings.account.button.switch": "切换", + "settings.account.button.add_another_account": "添加另一个账号", + "settings.account.button.manage_account": "管理账号", + "settings.account.button.log_out": "退出登录", + "settings.account.button.logging_out": "正在退出登录...", + "settings.account.account_count.none": "当前没有已登录账号", + "settings.account.account_count.one": "当前已登录 1 个账号", + "settings.account.account_count.many": "当前已登录 {count} 个账号", + "settings.account.tooltip.copy_user_id": "复制用户 ID", + "settings.account.popup.avatar_updated": "头像更新成功。", + "settings.account.popup.avatar_deleted": "头像删除成功。", + "settings.account.popup.avatar_upload_not_implemented": "头像上传功能暂未实现。", + "settings.account.popup.uploading_avatar": "正在上传头像...", + "settings.account.popup.deleting_avatar": "正在删除你的头像...", + "settings.account.popup.display_name_updated": "显示名称更新成功。", + "settings.account.popup.display_name_removed": "显示名称已移除。", + "settings.account.popup.uploading_display_name": "正在上传新的显示名称...", + "settings.account.popup.copied_user_id": "已将你的用户 ID 复制到剪贴板。", + "settings.account.popup.account_management_not_implemented": "账号管理功能暂未实现。", + "settings.account.modal.delete_avatar.title": "删除头像", + "settings.account.modal.delete_avatar.body": "你确定要删除你的头像吗?", + "settings.account.modal.delete_avatar.accept": "删除", + + "settings.labs.app_service.title": "应用服务", + "settings.labs.app_service.description": "在这里启用 Matrix 应用服务支持。Robrix 仍然是普通 Matrix 客户端:它会把 BotFather 绑定到房间,并发送对应的斜杠命令。", + "settings.labs.app_service.enable_label": "启用应用服务", + "settings.labs.app_service.botfather_user_id": "BotFather 用户 ID:", + "settings.labs.app_service.botfather_placeholder": "bot 或 @bot:server", + "settings.labs.app_service.button.enable": "启用应用服务", + "settings.labs.app_service.button.disable": "禁用应用服务", + "settings.labs.app_service.button.save": "保存", + "settings.labs.app_service.popup.saved": "已保存 Matrix 应用服务设置。", + + "invite_screen.message.invited_by": "邀请你加入:", + "invite_screen.message.invited_generic": "你被邀请加入:", + "invite_screen.button.reject": "拒绝邀请", + "invite_screen.button.join": "加入房间", + "invite_screen.button.joining": "加入中...", + "invite_screen.button.rejecting": "拒绝中...", + "invite_screen.button.joined": "已加入!", + "invite_screen.popup.joined_success": "已成功加入房间。", + "invite_screen.popup.rejected_success": "已成功拒绝邀请。", + "invite_screen.popup.reject_failed": "拒绝邀请失败:{error}", + "invite_screen.completion.rejected": "已成功拒绝邀请。你现在可以关闭该邀请页面。", + "invite_modal.title.invite_to_room_name": "邀请加入 {room_name}", + "invite_modal.input.placeholder": "@user:example.org", + "invite_modal.button.cancel": "取消", + "invite_modal.button.invite": "邀请", + "invite_modal.button.okay": "确定", + "invite_modal.status.enter_user_id": "请输入用户 ID。", + "invite_modal.status.sending": "正在发送邀请...", + "invite_modal.status.invalid_user_id": "无效的用户 ID。应为格式:@user:server.xyz", + "invite_modal.status.success_invited": "已成功邀请 {user_id}!", + "invite_modal.status.send_failed": "发送邀请失败:{error}", + "rooms_list_entry.invited.by_name_and_user": "由 {display_name} ({user_id}) 邀请", + "rooms_list_entry.invited.by_user": "由 {user_id} 邀请", + "rooms_list_entry.invited.generic": "你收到了邀请", + + "loading_pane.title.default": "正在加载内容...", + "loading_pane.title.searching_older": "正在搜索更早的消息...", + "loading_pane.status.searching_event": "正在查找事件 {target_event_id}\n\n目前已拉取 {events_paginated} 条消息...", + "loading_pane.title.error": "内容加载失败", + "loading_pane.button.cancel": "取消", + "loading_pane.button.okay": "确定", + + "rooms_list_header.title.all_rooms": "全部房间", + "rooms_list_header.popup.offline": "无法连接 Matrix 服务器,请检查网络连接。", + "rooms_list_header.tooltip.syncing": "同步中...", + "rooms_list_header.tooltip.offline": "离线", + "rooms_list_header.tooltip.synced": "已完全同步", + + "room_filter_input.placeholder": "筛选房间与空间...", + "search_messages.button.todo": "搜索(待实现)", + + "welcome_screen.title": "欢迎来到 Robrix!", + "welcome_screen.body_html": "

我们的 Matrix 客户端仍在快速开发中。目前,你可以访问你在其他客户端中已加入的房间和空间。


不过别担心,我们正在持续扩展 Robrix 的功能!


欢迎在我们的 Matrix 频道查看最新公告:

#robrix:matrix.org

", + + "room_screen.bot.delete.error.empty_user_id": "请输入要删除的机器人 Matrix 用户 ID。", + "room_screen.bot.delete.error.invalid_user_id": "无效的 Matrix 用户 ID:{full_user_id}", + "room_screen.bot.delete.error.current_user_unavailable": "当前用户 ID 不可用,无法解析机器人的 homeserver。", + "room_screen.tooltip.reacted_with_suffix": " 反应:{reaction}", + "room_screen.modal.invite.title": "发送邀请", + "room_screen.modal.invite.body": "确认要邀请 {username} 加入这个房间吗?", + "room_screen.modal.invite.accept": "邀请", + "room_screen.popup.invite.sent_success": "邀请已发送。", + "room_screen.popup.invite.failed": "发送邀请失败。\n\n错误:{error}", + "room_screen.popup.app_service.enable_before_create": "请先启用 App Service,再在房间中创建机器人。", + "room_screen.popup.app_service.bind_before_create": "请先将 BotFather 绑定到此房间,再创建机器人。", + "room_screen.popup.app_service.state_unavailable_create": "应用状态当前不可用,暂时无法创建机器人。", + "room_screen.popup.app_service.enable_before_delete": "请先启用 App Service,再在房间中删除机器人。", + "room_screen.popup.app_service.bind_before_delete": "请先将 BotFather 绑定到此房间,再删除机器人。", + "room_screen.popup.app_service.state_unavailable_delete": "应用状态当前不可用,暂时无法删除机器人。", + "room_screen.popup.app_service.room_not_bound": "该房间当前未绑定 BotFather。", + "room_screen.popup.app_service.removing_botfather": "正在将 BotFather {bot_user_id} 从该房间移除...", + "room_screen.popup.app_service.state_unavailable_unbind": "应用状态当前不可用,无法从该房间移除 BotFather。", + "room_screen.popup.bot.main_timeline_only": "机器人命令仅支持在主房间时间线中使用。", + "room_screen.popup.bot.enable_in_settings_before_bot": "使用 /bot 前请先在设置中启用 App Service。", + "room_screen.popup.bot.bind_before_bot": "使用 /bot 前请先将 BotFather 绑定到此房间。", + "room_screen.popup.bot.enable_before_commands": "在房间中使用 BotFather 命令前请先启用 App Service。", + "room_screen.popup.bot.bind_before_commands": "使用 BotFather 命令前请先将 BotFather 绑定到此房间。", + "room_screen.popup.bot.creation_main_timeline_only": "创建机器人命令仅支持在主房间时间线中使用。", + "room_screen.popup.bot.sent_listbots": "已向 BotFather 发送 `/listbots`。", + "room_screen.popup.bot.sent_bothelp": "已向 BotFather 发送 `/bothelp`。", + "room_screen.popup.bot.sent_createbot": "已向 BotFather 发送 `/createbot`(`{username}`)。", + "room_screen.popup.bot.sent_deletebot": "已向 BotFather 发送 `/deletebot`({matrix_user_id})。", + "room_screen.popup.bot.state_unavailable_create_command": "应用状态当前不可用,未发送创建机器人命令。", + "room_screen.popup.bot.state_unavailable_delete_command": "应用状态当前不可用,未发送删除机器人命令。", + "room_screen.fallback.unnamed_room": "未命名房间", + "room_screen.unsupported.prefix": "[不支持]", + "room_screen.read_marker.new_messages": "新消息", + "room_screen.top_space.loading_earlier": "正在加载更早的消息...", + "room_screen.loading.found_related_message": "已成功找到被回复的消息!", + "room_screen.loading.related_message_not_found": "未找到关联消息,可能已被删除。", + "room_screen.popup.pin.pinned_success": "已成功置顶事件。", + "room_screen.popup.pin.unpinned_success": "已成功取消置顶事件。", + "room_screen.popup.pin.already_pinned": "该消息已置顶。", + "room_screen.popup.pin.already_unpinned": "该消息尚未置顶。", + "room_screen.popup.pin.pin_failed": "置顶事件失败。错误:{error}", + "room_screen.popup.pin.unpin_failed": "取消置顶事件失败。错误:{error}", + "room_screen.popup.already_viewing_room": "你已经在查看这个房间了。", + "room_screen.popup.open_url_failed": "无法打开 URL:{url}", + "room_screen.popup.message.reply_not_found": "在时间线中找不到要回复的消息,请重试。", + "room_screen.popup.message.edit_not_found": "在时间线中找不到要编辑的消息,请重试。", + "room_screen.popup.message.no_recent_editable": "没有可编辑的近期消息,请手动选择一条消息进行编辑。", + "room_screen.popup.message.cannot_pin": "该事件无法置顶。", + "room_screen.popup.message.cannot_unpin": "该事件无法取消置顶。", + "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.view_source_not_found": "在时间线中找不到要查看源码的消息。", + "room_screen.popup.message.related_not_found": "在时间线中找不到关联消息或事件。", + "room_screen.modal.delete_message.title": "删除消息", + "room_screen.modal.delete_message.body": "确认要删除这条消息吗?此操作无法撤销。", + "room_screen.modal.delete_message.accept": "删除", + "room_screen.server_notice.title": "服务器通知:", + "room_screen.server_notice.notice_type": "通知类型", + "room_screen.server_notice.limit_type": "限制类型", + "room_screen.server_notice.admin_contact": "管理员联系方式", + "room_screen.server_notice.username": "服务器通知", + "room_screen.verification.sent_prefix": "已发送", + "room_screen.verification.request": "验证请求", + "room_screen.verification.sent_to_suffix": " 给 {user_id}。", + "room_screen.verification.supported_methods": "支持的方法", + "room_screen.image.unsupported_type": "{body}\n\n不支持的类型:{mime}", + "room_screen.image.failed_to_display": "{body}\n\n显示图片失败:{error}", + "room_screen.image.failed_to_fetch": "{body}\n\n从 {mxc_uri} 获取图片失败", + "room_screen.image.encrypted_todo": "{body}\n\n[TODO] 获取加密图片:{url}", + "room_screen.image.no_source_url": "{body}\n\n图片消息缺少来源 URL。", + "room_screen.file.preview_html": "{filename}{size}{caption}
暂不支持文件下载。", + "room_screen.audio.preview_html": "音频:{filename}{mime}{duration}{size}{caption}
暂不支持音频播放。", + "room_screen.video.preview_html": "视频:{filename}{mime}{duration}{size}{dimensions}{caption}
暂不支持视频播放。", + "room_screen.location.label": "位置:", + "room_screen.location.open_osm": "在 OpenStreetMap 中打开", + "room_screen.location.open_google_maps": "在 Google 地图中打开", + "room_screen.location.open_apple_maps": "在 Apple 地图中打开", + "room_screen.location.invalid_html": "[位置无效] {body}", + "room_screen.redacted.self_with_reason": "⛔ 删除了自己的消息。原因:“{reason}”。", + "room_screen.redacted.self": "⛔ 删除了自己的消息。", + "room_screen.redacted.other_with_reason": "⛔ {redactor} 删除了这条消息。原因:“{reason}”。", + "room_screen.redacted.other": "⛔ {redactor} 删除了这条消息。", + "room_screen.redacted.generic": "⛔ 消息已删除。", + "room_screen.reply_preview.error_username": "[获取用户名失败]", + "room_screen.reply_preview.error_event": "[获取被回复事件失败]", + "room_screen.reply_preview.loading_username": "[正在加载用户名...]", + "room_screen.reply_preview.loading_event": "[正在加载被回复消息...]", + "room_screen.thread_summary.loading_latest_reply": "正在加载最新回复...", + "room_screen.thread_summary.error_latest_reply": "无法加载最新回复", + "room_screen.thread_summary.one_reply": "1 条回复", + "room_screen.thread_summary.n_replies": "{n} 条回复", + "room_screen.small_state.invite_to_room": "邀请加入房间", + "room_screen.app_service.sender_name": "BotFather", + "room_screen.app_service.sender_tag": "机器人", + "room_screen.app_service.title": "App Service 操作", + "room_screen.app_service.subtitle": "通过 BotFather 创建机器人。Robrix 只会发送对应的斜杠命令。", + "room_screen.app_service.timestamp_now": "刚刚", + "room_screen.app_service.button.create_bot": "创建机器人", + "room_screen.app_service.button.list_bots": "列出机器人", + "room_screen.app_service.button.delete_bot": "删除机器人", + "room_screen.app_service.button.bot_help": "Bot 帮助", + "room_screen.app_service.button.unbind": "解绑", + + "spaces_bar.tooltip.unknown_space_name": "未知空间名称", + "spaces_bar.status.none_matching": "未找到\n匹配的空间。", + "spaces_bar.status.none_joined": "未找到\n已加入的空间。", + "spaces_bar.status.one_matching": "找到 1 个\n匹配的空间。", + "spaces_bar.status.one_joined": "找到 1 个\n已加入的空间。", + "spaces_bar.status.n_matching": "找到 {count} 个\n匹配的空间。", + "spaces_bar.status.n_joined": "找到 {count} 个\n已加入的空间。", + "spaces_bar.status.many_matching": "找到 99+ 个\n匹配的空间。", + "spaces_bar.status.many_joined": "找到 99+ 个\n已加入的空间。", + + "tsp.settings.title": "TSP 钱包设置", + "tsp.settings.section.active_identity": "当前活跃身份:", + "tsp.settings.section.wallets": "你的钱包:", + "tsp.settings.wallet.none": "未找到钱包。请创建或导入钱包。", + "tsp.settings.identity.none_set": "尚未设置默认身份。", + "tsp.settings.button.republish_identity": "重新发布当前身份到 DID 服务器", + "tsp.settings.button.republishing_now": "正在重新发布 DID...", + "tsp.settings.button.create_identity": "创建新身份(DID)", + "tsp.settings.button.create_wallet": "创建新钱包", + "tsp.settings.button.import_wallet": "导入已有钱包", + "tsp.settings.popup.wallet.removed": "已移除钱包“{wallet_name}”。", + "tsp.settings.popup.wallet.default_removed_warning": "默认钱包已被移除。\n\n在重新设置默认钱包前,TSP 功能将无法正常工作。", + "tsp.settings.popup.wallet.set_default_failed": "设置默认钱包失败,找不到或无法打开所选钱包。", + "tsp.settings.popup.wallet.open_failed": "打开钱包失败:{error}", + "tsp.settings.popup.wallet.import_not_implemented": "导入已有钱包功能暂未实现。", + "tsp.settings.popup.wallet.none_found": "未找到 TSP 钱包。\n\n请创建或导入钱包。", + "tsp.settings.popup.wallet.no_default": "尚未设置默认 TSP 钱包。\n\n请选择或创建一个默认钱包。", + "tsp.settings.popup.identity.republish_success": "已成功将身份“{did}”重新发布到 DID 服务器。", + "tsp.settings.popup.identity.republish_failed": "重新发布身份到 DID 服务器失败:{error}", + "tsp.settings.popup.identity.copied": "已将默认 TSP 身份复制到剪贴板。", + "tsp.settings.popup.identity.none_set": "尚未设置默认 TSP 身份。", + "tsp.settings.popup.identity.must_set_default": "必须先设置默认 TSP 身份,才能重新发布。", + "tsp_dummy.message.disabled": "当前构建未包含 TSP 功能。\n如需使用 TSP,请使用启用 'tsp' feature 的方式构建 Robrix。", + "tsp.wallet_entry.default_label": "✅ 默认", + "tsp.wallet_entry.not_found": "未找到钱包!", + "tsp.wallet_entry.button.set_default": "设为默认", + "tsp.wallet_entry.button.remove": "从列表移除", + "tsp.wallet_entry.button.delete": "删除钱包", + "tsp.wallet_entry.modal.remove.title": "移除钱包", + "tsp.wallet_entry.modal.remove.body": "确认要将钱包“{wallet_name}”从列表中移除吗?\n\n这不会删除实际的钱包文件。", + "tsp.wallet_entry.modal.remove.accept": "移除", + "tsp.wallet_entry.popup.delete_not_implemented": "删除钱包功能暂未实现。", + + "app.room_filter.search_results_title": "搜索结果", + "app.room_filter.empty_hint": "输入关键词以搜索房间和空间...", + "app.room_filter.no_local_results": "本地未找到“{keywords}”相关结果。请选择下方类型以搜索服务器。", + "app.room_filter.searching_remote": "正在服务器上搜索{kind}...", + "app.room_filter.remote.people": "联系人", + "app.room_filter.remote.rooms": "房间", + "app.room_filter.remote.spaces": "空间", + "app.room_filter.remote.kind.people": "联系人", + "app.room_filter.remote.kind.rooms": "房间", + "app.room_filter.remote.kind.spaces": "空间", + + "rooms_list.category.invites": "邀请", + "rooms_list.category.favorites": "收藏", + "rooms_list.category.rooms": "房间", + "rooms_list.category.people": "联系人", + "rooms_list.category.low_priority": "低优先级", + "rooms_list.category.left_rooms": "已离开房间", + + "space_lobby.entry.explore_space": "探索此空间", + "space_lobby.header.welcome": "欢迎来到此空间:", + "space_lobby.header.public_space": "🌐 公开空间", + "space_lobby.header.private_space": "🔒 私有空间", + "space_lobby.header.member_one": "1 位成员", + "space_lobby.header.member_n": "{count} 位成员", + "space_lobby.header.button.new_room": "新建房间", + "space_lobby.header.button.invite": "邀请", + "space_lobby.status.loading_rooms_spaces": "正在加载房间和空间...", + "space_lobby.status.no_rooms_spaces": "未找到房间或空间。", + "space_lobby.status.loading": "加载中...", + "space_lobby.item.button.join": "加入", + "space_lobby.item.button.view": "查看", + "space_lobby.item.button.leave": "离开", + "space_lobby.item.state.joined": "✅ 已加入", + "space_lobby.item.state.left": "已离开", + "space_lobby.item.state.invited": "已邀请", + "space_lobby.item.state.knocked": "已敲门", + "space_lobby.item.state.banned": "已封禁", + "space_lobby.item.member_one": "1 位成员", + "space_lobby.item.member_n": "{count} 位成员", + "space_lobby.item.child_room_one": "~{count} 个房间", + "space_lobby.item.child_room_n": "~{count} 个房间" +} diff --git a/resources/icon_home.svg b/resources/icon_home.svg new file mode 100644 index 000000000..f5edd734b --- /dev/null +++ b/resources/icon_home.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/icons/add_user.svg b/resources/icons/add_user.svg index 640aa9d94..fad47b630 100644 --- a/resources/icons/add_user.svg +++ b/resources/icons/add_user.svg @@ -1,6 +1,4 @@ - - - - + + \ No newline at end of file diff --git a/resources/icons/home.svg b/resources/icons/home.svg index 5b5b85c8d..519a1bf2e 100644 --- a/resources/icons/home.svg +++ b/resources/icons/home.svg @@ -1,4 +1,10 @@ - - - + + + + + + + + \ No newline at end of file diff --git a/resources/icons/import.svg b/resources/icons/import.svg index b07d957e2..b23a1d1e6 100644 --- a/resources/icons/import.svg +++ b/resources/icons/import.svg @@ -1,4 +1,2 @@ - - - - + + \ No newline at end of file diff --git a/resources/icons/import2.svg b/resources/icons/import2.svg new file mode 100644 index 000000000..8eef3aa30 --- /dev/null +++ b/resources/icons/import2.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/account_manager.rs b/src/account_manager.rs new file mode 100644 index 000000000..099f2792f --- /dev/null +++ b/src/account_manager.rs @@ -0,0 +1,250 @@ +//! Multi-account management for Robrix. +//! +//! This module provides the infrastructure for managing multiple Matrix accounts +//! simultaneously, including: +//! - Storing and switching between multiple logged-in accounts +//! - Tracking the active (currently selected) account +//! - Managing account-specific state and sync connections + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use matrix_sdk::{Client, ruma::OwnedUserId}; +use crate::persistence::ClientSessionPersisted; + +/// Represents a logged-in Matrix account with its associated client and session info. +#[derive(Clone)] +pub struct Account { + /// The Matrix client for this account + pub client: Client, + /// The user ID for this account + pub user_id: OwnedUserId, + /// The persisted session data for rebuilding the client + pub session: ClientSessionPersisted, + /// Display name for the account (cached from profile) + pub display_name: Option, + /// Avatar URL for the account (cached from profile) + pub avatar_url: Option, +} + +impl std::fmt::Debug for Account { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Account") + .field("user_id", &self.user_id) + .field("display_name", &self.display_name) + .field("avatar_url", &self.avatar_url) + .finish_non_exhaustive() + } +} + +/// Manager for multiple Matrix accounts. +/// +/// This struct handles: +/// - Storing multiple logged-in accounts +/// - Tracking which account is currently active +/// - Providing access to account-specific clients +#[derive(Default, Debug)] +pub struct AccountManager { + /// Map of user_id to Account for all logged-in accounts + accounts: HashMap, + /// The currently active (selected) account's user_id + active_account_id: Option, +} + +impl AccountManager { + /// Creates a new empty AccountManager. + pub fn new() -> Self { + Self { + accounts: HashMap::new(), + active_account_id: None, + } + } + + /// Adds a new account to the manager. + /// + /// If this is the first account, it becomes the active account automatically. + /// Returns true if the account was newly added, false if it replaced an existing one. + pub fn add_account(&mut self, account: Account) -> bool { + let user_id = account.user_id.clone(); + let is_new = !self.accounts.contains_key(&user_id); + + // If this is the first account, make it active + if self.accounts.is_empty() { + self.active_account_id = Some(user_id.clone()); + } + + self.accounts.insert(user_id, account); + is_new + } + + /// Removes an account from the manager. + /// + /// If the removed account was active, switches to another available account. + /// Returns the removed account if it existed. + pub fn remove_account(&mut self, user_id: &OwnedUserId) -> Option { + let removed = self.accounts.remove(user_id); + + // If we removed the active account, switch to another one + if self.active_account_id.as_ref() == Some(user_id) { + self.active_account_id = self.accounts.keys().next().cloned(); + } + + removed + } + + /// Sets the active account by user_id. + /// + /// Returns true if the account exists and was made active, false otherwise. + pub fn set_active_account(&mut self, user_id: &OwnedUserId) -> bool { + if self.accounts.contains_key(user_id) { + self.active_account_id = Some(user_id.clone()); + true + } else { + false + } + } + + /// Gets the currently active account. + pub fn active_account(&self) -> Option<&Account> { + self.active_account_id + .as_ref() + .and_then(|id| self.accounts.get(id)) + } + + /// Gets the currently active account mutably. + pub fn active_account_mut(&mut self) -> Option<&mut Account> { + let id = self.active_account_id.clone()?; + self.accounts.get_mut(&id) + } + + /// Gets the client for the currently active account. + pub fn active_client(&self) -> Option { + self.active_account().map(|a| a.client.clone()) + } + + /// Gets the user_id of the currently active account. + pub fn active_user_id(&self) -> Option<&OwnedUserId> { + self.active_account_id.as_ref() + } + + /// Gets an account by user_id. + pub fn get_account(&self, user_id: &OwnedUserId) -> Option<&Account> { + self.accounts.get(user_id) + } + + /// Gets a client by user_id. + pub fn get_client(&self, user_id: &OwnedUserId) -> Option { + self.accounts.get(user_id).map(|a| a.client.clone()) + } + + /// Returns an iterator over all accounts. + pub fn accounts(&self) -> impl Iterator { + self.accounts.values() + } + + /// Returns the number of logged-in accounts. + pub fn account_count(&self) -> usize { + self.accounts.len() + } + + /// Returns true if there are no logged-in accounts. + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + /// Returns all user IDs of logged-in accounts. + pub fn user_ids(&self) -> Vec { + self.accounts.keys().cloned().collect() + } + + /// Updates the display name for an account. + pub fn update_display_name(&mut self, user_id: &OwnedUserId, display_name: Option) { + if let Some(account) = self.accounts.get_mut(user_id) { + account.display_name = display_name; + } + } + + /// Updates the avatar URL for an account. + pub fn update_avatar_url(&mut self, user_id: &OwnedUserId, avatar_url: Option) { + if let Some(account) = self.accounts.get_mut(user_id) { + account.avatar_url = avatar_url; + } + } +} + +// ============================================================================= +// Global Account Manager Singleton +// ============================================================================= + +/// Global singleton for the account manager. +static ACCOUNT_MANAGER: OnceLock> = OnceLock::new(); + +/// Gets the global account manager. +fn account_manager() -> &'static Mutex { + ACCOUNT_MANAGER.get_or_init(|| Mutex::new(AccountManager::new())) +} + +/// Adds an account to the global account manager. +pub fn add_account(account: Account) -> bool { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).add_account(account) +} + +/// Removes an account from the global account manager. +pub fn remove_account(user_id: &OwnedUserId) -> Option { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).remove_account(user_id) +} + +/// Sets the active account in the global account manager. +pub fn set_active_account(user_id: &OwnedUserId) -> bool { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).set_active_account(user_id) +} + +/// Gets the client for the currently active account. +pub fn get_active_client() -> Option { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).active_client() +} + +/// Gets the user_id of the currently active account. +pub fn get_active_user_id() -> Option { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).active_user_id().cloned() +} + +/// Gets a client by user_id. +pub fn get_client_for_user(user_id: &OwnedUserId) -> Option { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).get_client(user_id) +} + +/// Returns the number of logged-in accounts. +pub fn account_count() -> usize { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).account_count() +} + +/// Returns all user IDs of logged-in accounts. +pub fn get_all_user_ids() -> Vec { + account_manager().lock().unwrap_or_else(|e| e.into_inner()).user_ids() +} + +/// Executes a closure with access to the account manager. +pub fn with_account_manager(f: F) -> R +where + F: FnOnce(&AccountManager) -> R, +{ + let manager = account_manager().lock().unwrap_or_else(|e| e.into_inner()); + f(&manager) +} + +/// Executes a closure with mutable access to the account manager. +pub fn with_account_manager_mut(f: F) -> R +where + F: FnOnce(&mut AccountManager) -> R, +{ + let mut manager = account_manager().lock().unwrap_or_else(|e| e.into_inner()); + f(&mut manager) +} + +/// Clears all accounts from the global account manager. +/// This should only be used during logout of all accounts. +pub fn clear_all_accounts() { + let mut manager = account_manager().lock().unwrap_or_else(|e| e.into_inner()); + manager.accounts.clear(); + manager.active_account_id = None; +} diff --git a/src/app.rs b/src/app.rs index b5df23ff8..8c263d991 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,16 +2,19 @@ //! //! See `handle_startup()` for the first code that runs on app startup. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use std::{fs::{File, OpenOptions}, io::Write, sync::Mutex}; use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; -use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, RoomId}}; +use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId, events::room::message::RoomMessageEventContent}}; use serde::{Deserialize, Serialize}; use crate::{ - avatar_cache::clear_avatar_cache, home::{ - event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update} - }, join_leave_room_modal::{ + avatar_cache::{self, AvatarCacheEntry, clear_avatar_cache}, home::{ + add_room::{CreateRoomModalAction, CreateRoomModalWidgetRefExt}, + event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, 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_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, 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}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, AccountSwitchAction, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -21,6 +24,61 @@ script_mod! { use mod.prelude.widgets.* use mod.widgets.* + let RoomFilterResultItem = View { + visible: false + width: Fill + height: 48 + flow: Overlay + + row := View { + width: Fill + height: Fill + flow: Right + align: Align{y: 0.5} + spacing: 8 + padding: Inset{left: 8, right: 8, top: 5, bottom: 5} + + avatar := Avatar { width: 30, height: 30 } + + text_col := View { + width: Fill + height: Fit + flow: Down + spacing: 0 + + name_label := Label { + width: Fill + height: Fit + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 10} + } + } + + id_label := Label { + width: Fill + height: Fit + draw_text +: { + color: (COLOR_TEXT_INPUT_IDLE) + text_style: REGULAR_TEXT {font_size: 8.5} + } + } + } + } + + click_button := RobrixNeutralIconButton { + width: Fill + height: Fill + text: "" + icon_walk: Walk{width: 0, height: 0} + draw_bg +: { + color: #0000 + color_hover: #FFFFFF22 + color_down: #FFFFFF11 + } + } + } + load_all_resources() do #(App::script_component(vm)) { ui: Root { main_window := Window { @@ -104,6 +162,107 @@ script_mod! { invite_modal_inner := InviteModal {} } } + room_filter_modal := Modal { + content +: { + room_filter_modal_inner := RoundedShadowView { + width: 420, + height: Fit + flow: Down + spacing: 8 + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY_DARKER) + border_radius: 4.0 + border_size: 0.0 + shadow_color: #0005 + shadow_radius: 15.0 + shadow_offset: vec2(1.0, 0.0) + } + padding: Inset{top: 15, left: 15, right: 15, bottom: 15} + + room_filter_input_bar := RoomFilterInputBar {} + + search_results_title := Label { + width: Fill, + height: Fit, + margin: Inset{left: 4, top: 2} + text: "" + draw_text +: { + color: (COLOR_TEXT_INPUT_IDLE) + text_style: REGULAR_TEXT {font_size: 10} + } + } + + search_results_scroll := ScrollYView { + width: Fill, + height: 260 + show_bg: false + + search_results := View { + width: Fill, + height: Fit, + flow: Down + spacing: 4 + + search_results_empty := Label { + width: Fill, + height: Fit, + flow: Flow.Right{wrap: true}, + text: "" + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 10} + } + } + + remote_search_options := View { + visible: false + width: Fill, + height: Fit, + flow: Right + spacing: 6 + margin: Inset{top: 6} + + remote_search_people_button := RobrixNeutralIconButton { + width: Fit, + text: "" + } + remote_search_rooms_button := RobrixNeutralIconButton { + width: Fit, + text: "" + } + remote_search_spaces_button := RobrixNeutralIconButton { + width: Fit, + text: "" + } + } + + search_results_list := View { + width: Fill, + height: Fit, + flow: Down + spacing: 3 + + result_item_0 := RoomFilterResultItem {} + result_item_1 := RoomFilterResultItem {} + result_item_2 := RoomFilterResultItem {} + result_item_3 := RoomFilterResultItem {} + result_item_4 := RoomFilterResultItem {} + result_item_5 := RoomFilterResultItem {} + result_item_6 := RoomFilterResultItem {} + result_item_7 := RoomFilterResultItem {} + } + } + } + } + } + } + + create_room_modal := Modal { + content +: { + create_room_modal_inner := CreateRoomModal {} + } + } // Show the logout confirmation modal. logout_confirm_modal := Modal { @@ -162,6 +321,29 @@ script_mod! { app_main!(App); +#[derive(Clone)] +enum RoomFilterResultTarget { + LocalSpace { room_name_id: RoomNameId, avatar: FetchedRoomAvatar }, + LocalRoom { room_name_id: RoomNameId, avatar: FetchedRoomAvatar }, + RemoteSpace { space_name_id: RoomNameId, avatar_uri: Option }, + RemoteRoom { room_name_id: RoomNameId, avatar_uri: Option }, + RemoteUser(UserProfile), +} + +#[derive(Clone, Debug)] +pub enum RoomFilterRemoteSearchAction { + Results { + query: String, + kind: RemoteDirectorySearchKind, + results: Vec, + }, + Failed { + query: String, + kind: RemoteDirectorySearchKind, + error: String, + }, +} + #[derive(Script)] pub struct App { #[live] ui: WidgetRef, @@ -171,6 +353,12 @@ pub struct App { /// This can be either a room we're waiting to join, or one we're waiting to be invited to. /// Also includes an optional room ID to be closed once the awaited room has been loaded. #[rust] waiting_to_navigate_to_room: Option<(BasicRoomDetails, Option)>, + /// A stack of previously-selected rooms for mobile navigation. + /// When a view is popped off the stack, the previous `selected_room` is restored from here. + #[rust] mobile_room_nav_stack: Vec, + #[rust] room_filter_modal_results: Vec, + #[rust(Timer::empty())] room_filter_debounce_timer: Timer, + #[rust] pending_room_filter_keywords: String, } impl ScriptHook for App { @@ -189,11 +377,224 @@ impl ScriptHook for App { } } +// ============================================================================= +// File Logging for Packaged Builds (non-mobile platforms) +// ============================================================================= + +/// Global log file handle for packaged builds. +/// Only used on desktop platforms when running as a packaged application. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +static LOG_FILE: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +/// Detects if the application is running as a packaged build (not via `cargo run`). +/// +/// Detection methods per platform: +/// - macOS: Check if executable is inside a `.app/Contents/MacOS/` bundle +/// - Windows: Check if executable is in `Program Files` or similar installation directory +/// - Linux: Check if executable is in `/usr`, `/opt`, or is an AppImage +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn is_packaged_build() -> bool { + let Ok(exe_path) = std::env::current_exe() else { + return false; + }; + let exe_path_str = exe_path.to_string_lossy(); + + #[cfg(target_os = "macos")] + { + // Check if running from a .app bundle + exe_path_str.contains(".app/Contents/MacOS/") + } + + #[cfg(target_os = "windows")] + { + // Check if running from Program Files or a typical installation directory + let exe_lower = exe_path_str.to_lowercase(); + exe_lower.contains("program files") + || exe_lower.contains("programfiles") + || exe_lower.contains("appdata\\local\\programs") + } + + #[cfg(target_os = "linux")] + { + // Check if running from system directories or AppImage + exe_path_str.starts_with("/usr/") + || exe_path_str.starts_with("/opt/") + || exe_path_str.contains(".AppImage") + || std::env::var("APPIMAGE").is_ok() + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + false + } +} + +/// Initializes file logging for packaged builds. +/// Creates a log file in the app data directory with timestamp. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn init_file_logging() -> Option<()> { + if !is_packaged_build() { + LOG_FILE.get_or_init(|| None); + return None; + } + + // Get platform-specific logs directory + let logs_dir = logs_dir(); + std::fs::create_dir_all(&logs_dir).ok()?; + + // Create log file with timestamp + let now = chrono::Local::now(); + let log_filename = format!("robrix_{}.log", now.format("%Y-%m-%d_%H-%M-%S")); + let log_path = logs_dir.join(&log_filename); + + // Also create/update a symlink to the latest log file for convenience + let latest_log_path = logs_dir.join("robrix_latest.log"); + + // Remove old symlink if it exists (ignore errors) + #[cfg(unix)] + { + let _ = std::fs::remove_file(&latest_log_path); + let _ = std::os::unix::fs::symlink(&log_filename, &latest_log_path); + } + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .ok()?; + + LOG_FILE.get_or_init(|| Some(Mutex::new(file))); + + // Print to stderr so user knows where logs are going + eprintln!("[Robrix] Logging to file: {}", log_path.display()); + + Some(()) +} + +/// Writes a log message to the log file (if file logging is enabled). +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[allow(dead_code)] +fn write_to_log_file(message: &str) { + if let Some(Some(file_mutex)) = LOG_FILE.get() { + if let Ok(mut file) = file_mutex.lock() { + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let _ = writeln!(file, "[{}] {}", timestamp, message); + let _ = file.flush(); + } + } +} + +/// Returns the path to the logs directory using platform-standard locations. +/// +/// Platform-specific paths: +/// - macOS: `~/Library/Logs/Robrix/` +/// - Windows: `%APPDATA%/Robrix/logs/` +/// - Linux: `~/.local/share/robrix/logs/` (or `$XDG_DATA_HOME/robrix/logs/`) +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn logs_dir() -> std::path::PathBuf { + use std::path::PathBuf; + + #[cfg(target_os = "macos")] + { + // macOS standard log location: ~/Library/Logs/Robrix/ + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join("Library") + .join("Logs") + .join("Robrix"); + } + } + + #[cfg(target_os = "windows")] + { + // Windows: %APPDATA%/Robrix/logs/ + if let Ok(appdata) = std::env::var("APPDATA") { + return PathBuf::from(appdata).join("Robrix").join("logs"); + } + } + + #[cfg(target_os = "linux")] + { + // Linux: Use XDG_DATA_HOME if set, otherwise ~/.local/share/ + if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") { + return PathBuf::from(xdg_data).join("robrix").join("logs"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("robrix") + .join("logs"); + } + } + + // Fallback to app data directory + crate::app_data_dir().join("logs") +} + +/// Cleans up old log files, keeping only the most recent N log files. +/// This should be called periodically to prevent disk space issues. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn cleanup_old_logs(max_logs_to_keep: usize) { + let logs_dir = logs_dir(); + if !logs_dir.exists() { + return; + } + + // Collect all log files (excluding the symlink) + let mut log_files: Vec<_> = match std::fs::read_dir(&logs_dir) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + name_str.starts_with("robrix_") + && name_str.ends_with(".log") + && name_str != "robrix_latest.log" + }) + .collect(), + Err(_) => return, + }; + + // Sort by modification time (oldest first) + log_files.sort_by(|a, b| { + let a_time = a.metadata().and_then(|m| m.modified()).ok(); + let b_time = b.metadata().and_then(|m| m.modified()).ok(); + a_time.cmp(&b_time) + }); + + // Remove old log files + if log_files.len() > max_logs_to_keep { + let files_to_remove = log_files.len() - max_logs_to_keep; + for entry in log_files.into_iter().take(files_to_remove) { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +/// Maximum number of log files to keep +#[cfg(not(any(target_os = "android", target_os = "ios")))] +const MAX_LOG_FILES_TO_KEEP: usize = 10; + impl MatchEvent for App { fn handle_startup(&mut self, cx: &mut Cx) { // only init logging/tracing once - let _ = tracing_subscriber::fmt::try_init(); + let _ = tracing_subscriber::fmt() + .with_max_level(tracing_subscriber::filter::LevelFilter::ERROR) + .try_init(); + // Initialize the project directory here from the main UI thread + // such that background threads/tasks will be able to access it. + // This must be done before initializing file logging. + let _app_data_dir = crate::app_data_dir(); + // Initialize file logging for packaged builds (non-mobile platforms). + // This must be done before setting up the log handler. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + init_file_logging(); + // Clean up old log files to prevent disk space issues + cleanup_old_logs(MAX_LOG_FILES_TO_KEEP); + } // Override Makepad's new default-JSON logger. We just want regular formatting. fn regular_log(file_name: &str, line_start: u32, column_start: u32, _line_end: u32, _column_end: u32, message: String, level: LogLevel) { let l = match level { @@ -226,6 +627,7 @@ impl MatchEvent for App { } self.update_login_visibility(cx); + self.sync_app_language(cx); log!("App::Startup: starting matrix sdk loop"); let _tokio_rt_handle = crate::sliding_sync::start_matrix_tokio().unwrap(); @@ -236,7 +638,22 @@ impl MatchEvent for App { } } + fn handle_signal(&mut self, cx: &mut Cx) { + avatar_cache::process_avatar_updates(cx); + self.refresh_room_filter_modal_result_buttons(cx); + } + + fn handle_timer(&mut self, cx: &mut Cx, event: &TimerEvent) { + if self.room_filter_debounce_timer.is_timer(event).is_some() { + self.room_filter_debounce_timer = Timer::empty(); + let keywords = std::mem::take(&mut self.pending_room_filter_keywords); + self.update_room_filter_modal_results(cx, &keywords); + } + } + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + self.sync_app_language(cx); + let invite_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); if let Some(_accepted) = invite_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(invite_confirmation_modal)).close(cx); @@ -252,6 +669,67 @@ impl MatchEvent for App { self.ui.modal(cx, ids!(positive_confirmation_modal)).close(cx); } + if let Some(clicked_index) = self.clicked_room_filter_result_index(cx, actions) { + if let Some(target) = self.room_filter_modal_results.get(clicked_index).cloned() { + self.ui.modal(cx, ids!(room_filter_modal)).close(cx); + match target { + RoomFilterResultTarget::LocalSpace { room_name_id: space_name_id, .. } + => { + cx.action(NavigationBarAction::GoToSpace { space_name_id }); + } + RoomFilterResultTarget::LocalRoom { room_name_id, .. } + => { + self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id)); + } + RoomFilterResultTarget::RemoteSpace { space_name_id, .. } => { + self.open_join_from_search_result( + cx, + BasicRoomDetails::Name(space_name_id), + true, + ); + } + RoomFilterResultTarget::RemoteRoom { room_name_id, .. } => { + self.open_join_from_search_result( + cx, + BasicRoomDetails::Name(room_name_id), + false, + ); + } + RoomFilterResultTarget::RemoteUser(user_profile) => { + submit_async_request(MatrixRequest::OpenOrCreateDirectMessage { + user_profile, + allow_create: false, + }); + } + } + return; + } + } + + if let Some(kind) = self.clicked_room_filter_remote_option(cx, actions) { + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + let query = room_filter_input.text().trim().to_owned(); + if !query.is_empty() { + let kind_text = match &kind { + RemoteDirectorySearchKind::People => tr_key(self.app_state.app_language, "app.room_filter.remote.kind.people"), + RemoteDirectorySearchKind::Rooms => tr_key(self.app_state.app_language, "app.room_filter.remote.kind.rooms"), + RemoteDirectorySearchKind::Spaces => tr_key(self.app_state.app_language, "app.room_filter.remote.kind.spaces"), + }; + let searching_text = tr_fmt(self.app_state.app_language, "app.room_filter.searching_remote", &[("kind", kind_text)]); + self.set_room_filter_modal_empty_state( + cx, + &searching_text, + false, + ); + submit_async_request(MatrixRequest::SearchDirectory { + query, + kind, + limit: 16, + }); + } + return; + } + for action in actions { match action.downcast_ref() { Some(LogoutConfirmModalAction::Open) => { @@ -290,11 +768,76 @@ impl MatchEvent for App { if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { log!("Received LoginAction::LoginSuccess, hiding login view."); self.app_state.logged_in = true; + self.app_state.adding_account = false; self.update_login_visibility(cx); self.ui.redraw(cx); continue; } + // Handle request to show login screen for adding another account + if let Some(LoginAction::ShowAddAccountScreen) = action.downcast_ref() { + log!("Received LoginAction::ShowAddAccountScreen, showing login view for adding account."); + self.app_state.adding_account = true; + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, true); + self.ui.redraw(cx); + continue; + } + + // Handle successful addition of a new account + if let Some(LoginAction::AddAccountSuccess) = action.downcast_ref() { + log!("Received LoginAction::AddAccountSuccess, hiding login view."); + self.app_state.adding_account = false; + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, false); + self.ui.redraw(cx); + continue; + } + + // Handle cancellation of adding a new account - go back to previous screen + if let Some(LoginAction::CancelAddAccount) = action.downcast_ref() { + log!("Received LoginAction::CancelAddAccount, hiding login view."); + self.app_state.adding_account = false; + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, false); + self.ui.redraw(cx); + continue; + } + + // Handle account switch actions + match action.downcast_ref() { + Some(AccountSwitchAction::Starting(user_id)) => { + log!("Account switch starting to: {}", user_id); + // Clear UI state during account switch + clear_all_app_state(cx); + self.app_state.selected_room = None; + // Clear saved dock state so tabs will be closed + self.app_state.saved_dock_state_home = Default::default(); + // Reset navigation to Home tab + self.app_state.selected_tab = SelectedTab::Home; + cx.action(NavigationBarAction::TabSelected(SelectedTab::Home)); + self.ui.redraw(cx); + continue; + } + Some(AccountSwitchAction::Switched(user_id)) => { + log!("Account switch completed to: {}", user_id); + enqueue_popup_notification( + format!("Switched to account {}", user_id), + PopupKind::Success, + Some(3.0), + ); + self.ui.redraw(cx); + continue; + } + Some(AccountSwitchAction::Failed(error)) => { + log!("Account switch failed: {}", error); + enqueue_popup_notification( + format!("Failed to switch account: {}", error), + PopupKind::Error, + None, + ); + continue; + } + _ => {} + } + // If a login failure occurs mid-session (e.g., an expired/revoked token detected // by `handle_session_changes`), navigate back to the login screen. // When not yet logged in, the login_screen widget handles displaying the failure modal. @@ -308,6 +851,79 @@ impl MatchEvent for App { continue; } + if let RoomFilterAction::Changed(keywords) = action.as_widget_action().cast_ref() { + cx.stop_timer(self.room_filter_debounce_timer); + self.pending_room_filter_keywords = keywords.clone(); + self.room_filter_debounce_timer = cx.start_timeout(0.12); + continue; + } + + match action.downcast_ref() { + Some(RoomFilterRemoteSearchAction::Results { query, kind: _, results }) => { + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + if room_filter_input.text().trim() != query.trim() { + continue; + } + self.room_filter_modal_results.clear(); + for result in results { + match result { + RemoteDirectorySearchResult::User(user_profile) => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteUser(user_profile.clone())); + } + RemoteDirectorySearchResult::Room { room_name_id, avatar_uri } => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteRoom { + room_name_id: room_name_id.clone(), + avatar_uri: avatar_uri.clone(), + }); + } + RemoteDirectorySearchResult::Space { space_name_id, avatar_uri } => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteSpace { + space_name_id: space_name_id.clone(), + avatar_uri: avatar_uri.clone(), + }); + } + } + if self.room_filter_modal_results.len() >= Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + break; + } + } + if self.room_filter_modal_results.is_empty() { + self.set_room_filter_modal_empty_state( + cx, + &format!("No server results for \"{}\".", query), + true, + ); + } else { + self.set_room_filter_modal_empty_state(cx, "", false); + } + self.refresh_room_filter_modal_result_buttons(cx); + continue; + } + Some(RoomFilterRemoteSearchAction::Failed { query, kind: _, error }) => { + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + if room_filter_input.text().trim() != query.trim() { + continue; + } + self.room_filter_modal_results.clear(); + self.refresh_room_filter_modal_result_buttons(cx); + self.set_room_filter_modal_empty_state( + cx, + &format!("Server search failed: {}", error), + true, + ); + continue; + } + _ => {} + } + + if let Some(RoomsListHeaderAction::OpenRoomFilterModal) = action.downcast_ref() { + self.ui.modal(cx, ids!(room_filter_modal)).open(cx); + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + room_filter_input.set_key_focus(cx); + self.update_room_filter_modal_results(cx, &room_filter_input.text()); + continue; + } + // Handle an action requesting to open the new message context menu. if let MessageAction::OpenMessageContextMenu { details, abs_pos } = action.as_widget_action().cast() { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); @@ -335,7 +951,7 @@ impl MatchEvent for App { if let RoomsListAction::OpenRoomContextMenu { details, pos } = action.as_widget_action().cast() { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); let room_context_menu = self.ui.room_context_menu(cx, ids!(room_context_menu)); - let expected_dimensions = room_context_menu.show(cx, details); + let expected_dimensions = room_context_menu.show(cx, details, self.app_state.app_language); // Ensure the context menu does not spill over the window's bounds. let rect = self.ui.window(cx, ids!(main_window)).area().rect(cx); let pos_x = min(pos.x, rect.size.x - expected_dimensions.x); @@ -354,6 +970,33 @@ impl MatchEvent for App { continue; } + // A new room has been selected; push the appropriate view onto the mobile + // StackNavigation and update the app state. + // In Desktop mode, MainDesktopUI also handles this action to manage dock tabs; + // the mobile push is harmless there (the view isn't drawn). + match action.as_widget_action().cast() { + RoomsListAction::Selected(selected_room) => { + self.push_selected_room_view(cx, selected_room); + continue; + } + // An invite was accepted; upgrade the selected room from invite to joined. + // In Desktop mode, MainDesktopUI also handles this (harmless duplicate). + RoomsListAction::InviteAccepted { room_name_id } => { + cx.action(AppStateAction::UpgradedInviteToJoinedRoom(room_name_id.room_id().clone())); + continue; + } + _ => {} + } + + // When a stack navigation pop is initiated (back button pressed), + // pop the mobile nav stack so it stays in sync with StackNavigation. + if let StackNavigationAction::Pop = action.as_widget_action().cast() { + if self.app_state.selected_room.is_some() { + self.app_state.selected_room = self.mobile_room_nav_stack.pop(); + } + // Don't `continue` — let StackNavigation also process this Pop. + } + // Handle actions that instruct us to update the top-level app state. match action.downcast_ref() { Some(AppStateAction::RoomFocused(selected_room)) => { @@ -383,6 +1026,84 @@ impl MatchEvent for App { cx.action(MainDesktopUiAction::LoadDockFromAppState); continue; } + Some(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id, + warning, + }) => { + self.app_state.bot_settings.set_room_bound( + room_id.clone(), + bot_user_id.clone(), + *bound, + ); + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(self.app_state.clone(), user_id) { + error!("Failed to persist app state after updating BotFather room binding. Error: {e}"); + } + } + let message = match (*bound, bot_user_id.as_ref(), warning.as_deref()) { + (true, Some(bot_user_id), Some(warning)) => { + format!("BotFather {bot_user_id} is available for room {room_id}, but inviting it reported a warning: {warning}") + } + (true, Some(bot_user_id), None) => { + format!("Bound room {room_id} to BotFather {bot_user_id}.") + } + (false, Some(bot_user_id), Some(warning)) => { + format!("Unbound BotFather {bot_user_id} from room {room_id}, with warning: {warning}") + } + (false, Some(bot_user_id), None) => { + format!("Unbound BotFather {bot_user_id} from room {room_id}.") + } + (false, None, Some(warning)) => { + format!("Unbound room {room_id} from BotFather, with warning: {warning}") + } + (false, None, None) => { + format!("Unbound room {room_id} from BotFather.") + } + (true, None, Some(warning)) => { + format!("BotFather is available for room {room_id}, with warning: {warning}") + } + (true, None, None) => { + format!("Bound room {room_id} to BotFather.") + } + }; + submit_async_request(MatrixRequest::SendMessage { + timeline_kind: TimelineKind::MainRoom { room_id: room_id.clone() }, + message: RoomMessageEventContent::notice_plain(format!("[App Service] {message}")), + replied_to: None, + target_user_id: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + self.ui.redraw(cx); + continue; + } + Some(AppStateAction::BotRoomBindingDetected { + room_id, + bot_user_id, + }) => { + if self + .app_state + .bot_settings + .bound_bot_user_id(room_id.as_ref()) + .is_some_and(|existing_bot_user_id| existing_bot_user_id.as_str() == bot_user_id.as_str()) + { + continue; + } + self.app_state.bot_settings.set_room_bound( + room_id.clone(), + Some(bot_user_id.clone()), + true, + ); + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(self.app_state.clone(), user_id) { + error!("Failed to persist detected BotFather room binding. Error: {e}"); + } + } + self.ui.redraw(cx); + continue; + } Some(AppStateAction::NavigateToRoom { room_to_close, destination_room }) => { self.navigate_to_room(cx, room_to_close.as_ref(), destination_room); continue; @@ -516,7 +1237,7 @@ impl MatchEvent for App { // Handle InviteModalAction to open/close the invite modal. match action.downcast_ref() { Some(InviteModalAction::Open(room_name_id)) => { - self.ui.invite_modal(cx, ids!(invite_modal_inner)).show(cx, room_name_id.clone()); + self.ui.invite_modal(cx, ids!(invite_modal_inner)).show(cx, room_name_id.clone(), self.app_state.app_language); self.ui.modal(cx, ids!(invite_modal)).open(cx); continue; } @@ -527,6 +1248,19 @@ impl MatchEvent for App { _ => {} } + match action.downcast_ref() { + Some(CreateRoomModalAction::Open { parent_space_id }) => { + self.ui.create_room_modal(cx, ids!(create_room_modal_inner)).show(cx, parent_space_id.clone()); + self.ui.modal(cx, ids!(create_room_modal)).open(cx); + continue; + } + Some(CreateRoomModalAction::Close) => { + self.ui.modal(cx, ids!(create_room_modal)).close(cx); + continue; + } + _ => {} + } + // Handle EventSourceModalAction to open/close the event source modal. match action.downcast_ref() { Some(EventSourceModalAction::Open { room_id, event_id, original_json }) => { @@ -714,6 +1448,42 @@ impl AppMain for App { } impl App { + const ROOM_FILTER_RESULT_ITEM_IDS: [LiveId; 8] = [ + live_id!(result_item_0), live_id!(result_item_1), + live_id!(result_item_2), live_id!(result_item_3), + live_id!(result_item_4), live_id!(result_item_5), + live_id!(result_item_6), live_id!(result_item_7), + ]; + + fn sync_app_language(&self, cx: &mut Cx) { + let app_language = self.app_state.app_language; + self.ui.label(cx, ids!(room_filter_modal_inner.search_results_title)) + .set_text(cx, tr_key(app_language, "app.room_filter.search_results_title")); + self.ui.label(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_empty)) + .set_text(cx, tr_key(app_language, "app.room_filter.empty_hint")); + self.ui.button(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options.remote_search_people_button)) + .set_text(cx, tr_key(app_language, "app.room_filter.remote.people")); + self.ui.button(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options.remote_search_rooms_button)) + .set_text(cx, tr_key(app_language, "app.room_filter.remote.rooms")); + self.ui.button(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options.remote_search_spaces_button)) + .set_text(cx, tr_key(app_language, "app.room_filter.remote.spaces")); + } + + fn open_join_from_search_result( + &mut self, + cx: &mut Cx, + details: BasicRoomDetails, + is_space: bool, + ) { + cx.action(JoinLeaveRoomModalAction::Open { + kind: JoinLeaveModalKind::JoinRoom { + details, + is_space, + }, + show_tip: false, + }); + } + fn update_login_visibility(&self, cx: &mut Cx) { let show_login = !self.app_state.logged_in; if !show_login { @@ -725,6 +1495,214 @@ impl App { self.ui.view(cx, ids!(home_screen_view)).set_visible(cx, !show_login); } + fn clicked_room_filter_result_index(&self, cx: &mut Cx, actions: &Actions) -> Option { + let list_view = self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_list)); + for (index, item_id) in Self::ROOM_FILTER_RESULT_ITEM_IDS.iter().enumerate() { + if list_view.button(cx, &[*item_id, live_id!(click_button)]).clicked(actions) { + return Some(index); + } + } + None + } + + fn clicked_room_filter_remote_option(&self, cx: &mut Cx, actions: &Actions) -> Option { + let options_view = self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options)); + if options_view.button(cx, ids!(remote_search_people_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::People); + } + if options_view.button(cx, ids!(remote_search_rooms_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::Rooms); + } + if options_view.button(cx, ids!(remote_search_spaces_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::Spaces); + } + None + } + + fn set_room_filter_modal_empty_state( + &self, + cx: &mut Cx, + text: &str, + show_remote_options: bool, + ) { + let empty_label = self.ui.label(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_empty)); + empty_label.set_visible(cx, !text.is_empty()); + if !text.is_empty() { + empty_label.set_text(cx, text); + } + self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options)) + .set_visible(cx, show_remote_options); + } + + fn set_room_filter_result_avatar( + &self, + cx: &mut Cx, + avatar_ref: &crate::shared::avatar::AvatarRef, + fallback_text: &str, + local_avatar: Option<&FetchedRoomAvatar>, + remote_avatar_uri: Option<&OwnedMxcUri>, + remote_avatar_state: Option<&AvatarState>, + ) { + if let Some(local_avatar) = local_avatar { + match local_avatar { + FetchedRoomAvatar::Text(text) => { + avatar_ref.show_text(cx, None, None, text); + } + FetchedRoomAvatar::Image(image_data) => { + let res = avatar_ref.show_image( + cx, + None, + |cx, img_ref| crate::utils::load_png_or_jpg(&img_ref, cx, image_data), + ); + if res.is_err() { + avatar_ref.show_text(cx, None, None, fallback_text); + } + } + } + return; + } + + if let Some(avatar_state) = remote_avatar_state { + if let Some(image_data) = avatar_state.data() { + let res = avatar_ref.show_image( + cx, + None, + |cx, img_ref| crate::utils::load_png_or_jpg(&img_ref, cx, image_data), + ); + if res.is_ok() { + return; + } + } + if let Some(uri) = avatar_state.uri() { + if let AvatarCacheEntry::Loaded(image_data) = avatar_cache::get_or_fetch_avatar(cx, uri) { + let res = avatar_ref.show_image( + cx, + None, + |cx, img_ref| crate::utils::load_png_or_jpg(&img_ref, cx, &image_data), + ); + if res.is_ok() { + return; + } + } + } + } + + if let Some(uri) = remote_avatar_uri { + if let AvatarCacheEntry::Loaded(image_data) = avatar_cache::get_or_fetch_avatar(cx, uri) { + let res = avatar_ref.show_image( + cx, + None, + |cx, img_ref| crate::utils::load_png_or_jpg(&img_ref, cx, &image_data), + ); + if res.is_ok() { + return; + } + } + } + + avatar_ref.show_text(cx, None, None, fallback_text); + } + + fn refresh_room_filter_modal_result_buttons(&self, cx: &mut Cx) { + let list_view = self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_list)); + for (index, item_id) in Self::ROOM_FILTER_RESULT_ITEM_IDS.iter().enumerate() { + let item = list_view.view(cx, &[*item_id]); + if let Some(target) = self.room_filter_modal_results.get(index) { + let (name, raw_id) = match target { + RoomFilterResultTarget::LocalSpace { room_name_id, .. } + | RoomFilterResultTarget::LocalRoom { room_name_id, .. } => { + (room_name_id.to_string(), room_name_id.room_id().to_string()) + } + RoomFilterResultTarget::RemoteSpace { space_name_id, .. } + | RoomFilterResultTarget::RemoteRoom { room_name_id: space_name_id, .. } => { + (space_name_id.to_string(), space_name_id.room_id().to_string()) + } + RoomFilterResultTarget::RemoteUser(user_profile) => { + (user_profile.displayable_name().to_owned(), user_profile.user_id.to_string()) + } + }; + + item.label(cx, ids!(row.text_col.name_label)).set_text(cx, &name); + item.label(cx, ids!(row.text_col.id_label)).set_text(cx, &raw_id); + + let avatar_ref = item.avatar(cx, ids!(row.avatar)); + match target { + RoomFilterResultTarget::LocalSpace { avatar, .. } + | RoomFilterResultTarget::LocalRoom { avatar, .. } => { + self.set_room_filter_result_avatar(cx, &avatar_ref, &name, Some(avatar), None, None); + } + RoomFilterResultTarget::RemoteSpace { avatar_uri, .. } + | RoomFilterResultTarget::RemoteRoom { avatar_uri, .. } => { + self.set_room_filter_result_avatar(cx, &avatar_ref, &name, None, avatar_uri.as_ref(), None); + } + RoomFilterResultTarget::RemoteUser(user_profile) => { + self.set_room_filter_result_avatar( + cx, + &avatar_ref, + &name, + None, + None, + Some(&user_profile.avatar_state), + ); + } + } + + item.set_visible(cx, true); + } else { + item.set_visible(cx, false); + } + } + } + + fn update_room_filter_modal_results(&mut self, cx: &mut Cx, keywords: &str) { + let keywords = keywords.trim(); + self.room_filter_modal_results.clear(); + + if !keywords.is_empty() { + let space_items = cx.get_global::() + .get_matching_space_items(keywords, 4); + let room_items = cx.get_global::() + .get_matching_room_items(keywords, 8); + + for (room_name_id, avatar) in space_items { + self.room_filter_modal_results.push(RoomFilterResultTarget::LocalSpace { room_name_id, avatar }); + if self.room_filter_modal_results.len() >= Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + break; + } + } + if self.room_filter_modal_results.len() < Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + for (room_name_id, avatar) in room_items { + self.room_filter_modal_results.push(RoomFilterResultTarget::LocalRoom { room_name_id, avatar }); + if self.room_filter_modal_results.len() >= Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + break; + } + } + } + } + + if keywords.is_empty() { + self.set_room_filter_modal_empty_state( + cx, + tr_key(self.app_state.app_language, "app.room_filter.empty_hint"), + false, + ); + } else if self.room_filter_modal_results.is_empty() { + self.set_room_filter_modal_empty_state( + cx, + &tr_fmt( + self.app_state.app_language, + "app.room_filter.no_local_results", + &[("keywords", keywords)], + ), + true, + ); + } else { + self.set_room_filter_modal_empty_state(cx, "", false); + } + + self.refresh_room_filter_modal_result_buttons(cx); + } + /// Navigates to the given `destination_room`, optionally closing the `room_to_close`. fn navigate_to_room( &mut self, @@ -796,12 +1774,111 @@ impl App { } } + /// Room StackNavigationView instances, one per stack depth. + /// Each depth gets its own dedicated view widget to avoid + /// complex state save/restore when views would otherwise be reused. + const ROOM_VIEW_IDS: [LiveId; 16] = [ + live_id!(room_view_0), live_id!(room_view_1), + live_id!(room_view_2), live_id!(room_view_3), + live_id!(room_view_4), live_id!(room_view_5), + live_id!(room_view_6), live_id!(room_view_7), + live_id!(room_view_8), live_id!(room_view_9), + live_id!(room_view_10), live_id!(room_view_11), + live_id!(room_view_12), live_id!(room_view_13), + live_id!(room_view_14), live_id!(room_view_15), + ]; + + /// The RoomScreen widget IDs inside each room view, + /// corresponding 1:1 with [`Self::ROOM_VIEW_IDS`]. + const ROOM_SCREEN_IDS: [LiveId; 16] = [ + live_id!(room_screen_0), live_id!(room_screen_1), + live_id!(room_screen_2), live_id!(room_screen_3), + live_id!(room_screen_4), live_id!(room_screen_5), + live_id!(room_screen_6), live_id!(room_screen_7), + live_id!(room_screen_8), live_id!(room_screen_9), + live_id!(room_screen_10), live_id!(room_screen_11), + live_id!(room_screen_12), live_id!(room_screen_13), + live_id!(room_screen_14), live_id!(room_screen_15), + ]; + + /// Returns the room view and room screen LiveIds for the given stack depth. + /// Clamps to the last available view if depth exceeds the pool size. + fn room_ids_for_depth(depth: usize) -> (LiveId, LiveId) { + let index = depth.min(Self::ROOM_VIEW_IDS.len() - 1); + (Self::ROOM_VIEW_IDS[index], Self::ROOM_SCREEN_IDS[index]) + } + + /// Pushes the appropriate StackNavigationView for the given `SelectedRoom`, + /// configuring the view's content widget and header title. + /// + /// Each stack depth gets its own dedicated room view widget, + /// supporting deep navigation (room → thread → room → thread → ...). + /// + /// In Desktop mode, the StackNavigation isn't drawn, so the push and + /// screen configuration are effectively no-ops — MainDesktopUI handles + /// room display via dock tabs instead. + fn push_selected_room_view(&mut self, cx: &mut Cx, selected_room: SelectedRoom) { + // Use the actual StackNavigation depth to pick the next room view slot. + let new_depth = self.ui.stack_navigation(cx, ids!(view_stack)).depth(); + + // Determine which view to push and configure its content. + // The `set_displayed_room` / `set_displayed_invite` / `set_displayed_space` calls + // configure the screen widget inside the mobile StackNavigationView. + // In Desktop mode, these widgets exist but aren't drawn; the configuration + // consumes timeline endpoints, but Desktop's MainDesktopUI processes the same + // `RoomsListAction::Selected` in its own handler to set up dock tabs. + let view_id = match &selected_room { + SelectedRoom::JoinedRoom { room_name_id } + | SelectedRoom::Thread { room_name_id, .. } => { + let (view_id, room_screen_id) = Self::room_ids_for_depth(new_depth); + + let thread_root = if let SelectedRoom::Thread { thread_root_event_id, .. } = &selected_room { + Some(thread_root_event_id.clone()) + } else { + None + }; + self.ui + .room_screen(cx, &[room_screen_id]) + .set_displayed_room(cx, room_name_id, thread_root); + + view_id + } + SelectedRoom::InvitedRoom { room_name_id } => { + self.ui + .invite_screen(cx, ids!(invite_screen)) + .set_displayed_invite(cx, room_name_id); + id!(invite_view) + } + SelectedRoom::Space { space_name_id } => { + self.ui + .space_lobby_screen(cx, ids!(space_lobby_screen)) + .set_displayed_space(cx, space_name_id); + id!(space_lobby_view) + } + }; + + // Set the header title for the view being pushed. + let title_path = &[view_id, live_id!(header), live_id!(content), live_id!(title_container), live_id!(title)]; + self.ui.label(cx, title_path).set_text(cx, &selected_room.display_name()); + + // Save the current selected_room onto the navigation stack before replacing it. + if let Some(prev) = self.app_state.selected_room.take() { + self.mobile_room_nav_stack.push(prev); + } + // Update app state (used by both Desktop and Mobile paths). + self.app_state.selected_room = Some(selected_room); + + // Push the view onto the mobile navigation stack. + self.ui.stack_navigation(cx, ids!(view_stack)).push(cx, view_id); + self.ui.redraw(cx); + } } /// App-wide state that is stored persistently across multiple app runs /// and shared/updated across various parts of the app. #[derive(Clone, Default, Debug, Serialize, Deserialize)] +#[serde(default)] pub struct AppState { /// The currently-selected room, which is highlighted (selected) in the RoomsList /// and considered "active" in the main rooms screen. @@ -822,6 +1899,140 @@ pub struct AppState { pub saved_dock_state_per_space: HashMap, /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, + /// The preferred app language. + pub app_language: AppLanguage, + /// Whether the app is currently showing the login screen for adding another account. + /// This is transient state and not persisted. + #[serde(skip)] + pub adding_account: bool, + /// Local configuration and UI state for bot-assisted room binding. + pub bot_settings: BotSettingsState, +} + +/// Local bot integration settings persisted per Matrix account. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct BotSettingsState { + /// Whether bot-assisted room binding is enabled in the UI. + pub enabled: bool, + /// The configured botfather user, either as a full MXID or localpart. + pub botfather_user_id: String, + /// Rooms that Robrix currently considers bound to BotFather, + /// paired with the exact BotFather MXID used for that room. + pub room_bindings: Vec, +} + +/// A persisted room-level BotFather binding. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RoomBotBindingState { + pub room_id: OwnedRoomId, + pub bot_user_id: OwnedUserId, +} + +impl Default for BotSettingsState { + fn default() -> Self { + Self { + enabled: false, + botfather_user_id: Self::DEFAULT_BOTFATHER_LOCALPART.to_string(), + room_bindings: Vec::new(), + } + } +} + +impl BotSettingsState { + pub const DEFAULT_BOTFATHER_LOCALPART: &'static str = "bot"; + + fn room_binding_index(&self, room_id: &RoomId) -> Result { + self.room_bindings + .binary_search_by(|binding| binding.room_id.as_str().cmp(room_id.as_str())) + } + + /// Returns `true` if the given room is currently marked as bound locally. + pub fn is_room_bound(&self, room_id: &RoomId) -> bool { + self.room_binding_index(room_id).is_ok() + } + + /// Returns the persisted BotFather MXID for the given room, if any. + pub fn bound_bot_user_id(&self, room_id: &RoomId) -> Option<&UserId> { + self.room_binding_index(room_id) + .ok() + .map(|index| self.room_bindings[index].bot_user_id.as_ref()) + } + + /// Updates the local bound/unbound state for the given room. + pub fn set_room_bound( + &mut self, + room_id: OwnedRoomId, + bot_user_id: Option, + bound: bool, + ) { + if bound { + let Some(bot_user_id) = bot_user_id else { return }; + match self.room_binding_index(room_id.as_ref()) { + Ok(existing_index) => { + self.room_bindings[existing_index].bot_user_id = bot_user_id; + } + Err(insert_index) => { + self.room_bindings.insert(insert_index, RoomBotBindingState { + room_id, + bot_user_id, + }); + } + } + } else { + if let Ok(existing_index) = self.room_binding_index(room_id.as_ref()) { + self.room_bindings.remove(existing_index); + } + } + } + + /// Returns the configured botfather user ID, resolving a localpart against + /// the current user's homeserver when needed. + pub fn resolved_bot_user_id(&self, current_user_id: Option<&UserId>) -> Result { + let raw = self.botfather_user_id.trim(); + if raw.starts_with('@') || raw.contains(':') { + let full_user_id = if raw.starts_with('@') { + raw.to_string() + } else { + format!("@{raw}") + }; + return UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid bot user ID: {full_user_id}")); + } + + let Some(current_user_id) = current_user_id else { + return Err( + "Current user ID is unavailable, so the bot homeserver cannot be resolved.".into(), + ); + }; + + let localpart = if raw.is_empty() { + Self::DEFAULT_BOTFATHER_LOCALPART + } else { + raw + }; + let full_user_id = format!("@{localpart}:{}", current_user_id.server_name()); + UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid bot user ID: {full_user_id}")) + } + + /// Returns the BotFather MXID that should be used for a room action. + /// + /// If the room already has a persisted binding, that exact MXID wins. + /// Otherwise, the current global configuration is resolved. + pub fn resolved_bot_user_id_for_room( + &self, + room_id: &RoomId, + current_user_id: Option<&UserId>, + ) -> Result { + if let Some(bot_user_id) = self.bound_bot_user_id(room_id) { + return Ok(bot_user_id.to_owned()); + } + + self.resolved_bot_user_id(current_user_id) + } } /// A snapshot of the main dock: all state needed to restore the dock tabs/layout. @@ -965,6 +2176,18 @@ pub enum AppStateAction { /// The given app state was loaded from persistent storage /// and is ready to be restored. RestoreAppStateFromPersistentState(AppState), + /// A room-level BotFather bind or unbind action completed. + BotRoomBindingUpdated { + room_id: OwnedRoomId, + bound: bool, + bot_user_id: Option, + warning: Option, + }, + /// A room's member list indicates that the configured BotFather is already present. + BotRoomBindingDetected { + room_id: OwnedRoomId, + bot_user_id: OwnedUserId, + }, /// The given room was successfully loaded from the homeserver /// and is now known to our client. /// diff --git a/src/home/add_room.rs b/src/home/add_room.rs index 981369897..bac89be74 100644 --- a/src/home/add_room.rs +++ b/src/home/add_room.rs @@ -3,15 +3,139 @@ use makepad_widgets::*; use matrix_sdk::RoomState; -use ruma::{IdParseError, MatrixToUri, MatrixUri, OwnedRoomOrAliasId, OwnedServerName, matrix_uri::MatrixId, room::{JoinRuleSummary, RoomType}}; - -use crate::{app::AppStateAction, home::invite_screen::JoinRoomResultAction, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{avatar::AvatarWidgetRefExt, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{MatrixRequest, submit_async_request}, utils}; +use ruma::{IdParseError, MatrixToUri, MatrixUri, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, matrix_uri::MatrixId, room::{JoinRuleSummary, RoomType}}; + +use crate::{ + app::{AppState, AppStateAction}, + home::{invite_screen::JoinRoomResultAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef}, + i18n::{AppLanguage, tr_fmt, tr_key}, + profile::user_profile::UserProfile, + room::{BasicRoomDetails, FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, + shared::{ + avatar::{AvatarState, AvatarWidgetRefExt}, + popup_list::{PopupKind, enqueue_popup_notification}, + styles::COLOR_FG_DANGER_RED, + }, + sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, + space_service_sync::SpaceRequest, + utils::{self, RoomNameId}, +}; script_mod! { use mod.prelude.widgets.* use mod.widgets.* + mod.widgets.CreateRoomForm = set_type_default() do #(CreateRoomForm::register_widget(vm)) { + ..mod.widgets.View + + width: Fill + height: Fit + flow: Down + + create_room_help := Label { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "Create a standalone room, or attach it under a space where you can create child rooms." + } + + create_room_view := View { + width: Fill + height: Fit + margin: Inset{ top: 6, bottom: 10 } + spacing: 8 + flow: Down + + create_room_space_row := View { + width: Fill + height: Fit + margin: Inset{left: 5, right: 5} + spacing: 10 + align: Align{y: 0.5} + flow: Right + + create_room_space_dropdown := DropDownFlat { + width: Fill { max: 400 } + height: 40 + labels: ["Create without a space"] + } + + create_room_space_hint := Label { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "Choose a space where you have permission to create child rooms." + } + } + + create_room_name_input := RobrixTextInput { + margin: Inset{left: 5, right: 5} + padding: Inset{left: 12, right: 12, top: 11, bottom: 0} + width: Fill { max: 400 } + height: 40 + empty_text: "Enter the new room name..." + } + + create_room_feedback := View { + visible: false + width: Fill + height: Fit + margin: Inset{left: 5, right: 5, top: 6} + spacing: 8 + align: Align{y: 0.5} + flow: Right + + create_room_feedback_spinner_wrap := View { + width: Fit + height: Fit + + create_room_feedback_spinner := LoadingSpinner { + width: 16 + height: 16 + draw_bg +: { + color: (COLOR_ACTIVE_PRIMARY) + border_size: 2.0 + } + } + } + + create_room_feedback_label := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + } + } + + create_room_button_row := View { + width: Fill + height: Fit + margin: Inset{top: 4} + padding: Inset{left: 5} + flow: Right + + create_room_button := RobrixPositiveIconButton { + width: Fit + padding: Inset{top: 10, bottom: 10, left: 12, right: 14} + height: 40 + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16} + text: "Create room" + } + } + } + } + // The main view that allows the user to add (join) or explore new rooms/spaces. mod.widgets.AddRoomScreen = #(AddRoomScreen::register_widget(vm)) { @@ -35,7 +159,59 @@ script_mod! { LineH { padding: 10, margin: Inset{top: 10, right: 2} } - SubsectionLabel { + create_new_room_label := SubsectionLabel { + margin: Inset{top: 8} + text: "Create a new room:" + } + + create_room_form := mod.widgets.CreateRoomForm {} + + LineH { padding: 10, margin: Inset{right: 2} } + + add_friend_label := SubsectionLabel { + margin: Inset{top: 4} + text: "Add a friend:" + } + + add_friend_help := Label { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "Enter a Matrix user ID to open or create a direct message room." + } + + add_friend_view := View { + width: Fill + height: Fit + margin: Inset{ top: 6, bottom: 10 } + align: Align{y: 0.5} + spacing: 5 + flow: Right + + friend_user_id_input := RobrixTextInput { + align: Align{y: 0.5} + margin: Inset{top: 0, left: 5, right: 5, bottom: 0} + padding: Inset{left: 12, right: 12, top: 11, bottom: 0} + width: Fill { max: 400 } + height: 40 + empty_text: "Enter a Matrix user ID, like @alice:matrix.org..." + } + + add_friend_button := RobrixIconButton { + padding: Inset{top: 10, bottom: 10, left: 12, right: 14} + height: 40 + draw_icon.svg: (ICON_ADD_USER) + icon_walk: Walk{width: 16, height: 16} + text: "Add friend" + } + } + + LineH { padding: 10, margin: Inset{right: 2} } + + join_existing_label := SubsectionLabel { text: "Join an existing room or space:" } @@ -250,6 +426,85 @@ script_mod! { } } + + mod.widgets.CreateRoomModal = #(CreateRoomModal::register_widget(vm)) { + width: Fit + height: Fit + + RoundedView { + width: 500 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 24, right: 24, bottom: 20, left: 24} + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 4.0 + } + + title_view := View { + width: Fill + height: Fit + padding: Inset{top: 0, bottom: 20} + align: Align{x: 0.5, y: 0.0} + + title := Label { + width: Fill + height: Fit + align: Align{x: 0.5} + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: TITLE_TEXT {font_size: 13} + color: #000 + } + text: "Create New Room" + } + } + + subtitle := Label { + width: Fill + height: Fit + margin: Inset{bottom: 10} + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR) + text_style: MESSAGE_TEXT_STYLE { font_size: 11 } + } + text: "Create a new room directly inside the selected space." + } + + create_room_form := mod.widgets.CreateRoomForm {} + + buttons_view := View { + width: Fill + height: Fit + flow: Right + padding: Inset{top: 16, bottom: 5} + align: Align{x: 1.0, y: 0.5} + spacing: 12 + + create_button := RobrixPositiveIconButton { + width: 140 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } + text: "Create room" + } + + cancel_button := RobrixNeutralIconButton { + width: 120 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } + text: "Cancel" + } + } + } + } } #[derive(Script, ScriptHook, Widget)] @@ -258,6 +513,14 @@ pub struct AddRoomScreen { #[rust] state: AddRoomState, /// The function to perform when the user clicks the `join_room_button`. #[rust(JoinButtonFunction::None)] join_function: JoinButtonFunction, + #[rust(false)] adding_friend: bool, + #[rust] app_language: AppLanguage, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CreateRoomContext { + AddRoomPage, + SpaceLobbyModal, } #[derive(Default)] @@ -345,13 +608,522 @@ impl AddRoomState { } } +#[derive(Script, ScriptHook, Widget)] +pub struct CreateRoomForm { + #[deref] view: View, + #[rust(CreateRoomContext::AddRoomPage)] context: CreateRoomContext, + #[rust(false)] creating_room: bool, + #[rust(None)] pending_created_room: Option, + #[rust(Vec::new())] creatable_spaces: Vec, + #[rust(None)] preferred_parent_space_id: Option, + #[rust(None)] fixed_parent_space_id: Option, + #[rust] app_language: AppLanguage, +} + +impl Widget for CreateRoomForm { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } + 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 { + let create_room_text_is_empty = self.view + .text_input(cx, ids!(create_room_name_input)) + .text() + .trim() + .is_empty(); + self.view.button(cx, ids!(create_room_button)) + .set_enabled(cx, !self.is_busy() && !create_room_text_is_empty); + + let selected_space_id = self.selected_parent_space_id( + self.view.drop_down(cx, ids!(create_room_space_dropdown)).selected_item(), + ); + let create_room_space_hint = self.view.label(cx, ids!(create_room_space_hint)); + update_space_hint( + cx, + &create_room_space_hint, + &self.creatable_spaces, + selected_space_id.as_ref(), + self.app_language, + ); + + self.sync_mode_views(cx); + + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for CreateRoomForm { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let create_room_name_input = self.view.text_input(cx, ids!(create_room_name_input)); + let create_room_button = self.view.button(cx, ids!(create_room_button)); + let create_room_space_dropdown = self.view.drop_down(cx, ids!(create_room_space_dropdown)); + let create_room_space_hint = self.view.label(cx, ids!(create_room_space_hint)); + + if let Some(text) = create_room_name_input.changed(actions) { + if !self.is_busy() { + self.clear_feedback(cx); + } + create_room_button.set_enabled(cx, !self.is_busy() && !text.trim().is_empty()); + } + + if create_room_space_dropdown.changed(actions).is_some() { + self.preferred_parent_space_id = + selected_creatable_space(&self.creatable_spaces, create_room_space_dropdown.selected_item()); + update_space_hint( + cx, + &create_room_space_hint, + &self.creatable_spaces, + self.preferred_parent_space_id.as_ref(), + self.app_language, + ); + self.view.redraw(cx); + } + + let create_room_request = create_room_button.clicked(actions) + || create_room_name_input.returned(actions).is_some(); + if create_room_request { + let _ = self.submit(cx); + } + + for action in actions { + if let Some(create_room_action) = action.downcast_ref() { + match create_room_action { + CreateRoomAction::Created { room_name_id, parent_space_id, space_link_error, context } + if context == &self.context => + { + self.creating_room = false; + create_room_name_input.set_text(cx, ""); + create_room_button.set_enabled(cx, false); + + if let Some(space_id) = parent_space_id { + refresh_space_children(cx, space_id); + } + + let room_name_text = room_name_id.to_string(); + let mut popup_message = tr_fmt(self.app_language, "add_room.popup.created_room_success", &[ + ("room_name", room_name_text.as_str()), + ]); + let popup_kind = if let Some(link_error) = space_link_error { + popup_message.push_str(&tr_fmt(self.app_language, "add_room.popup.created_room_space_link_suffix", &[ + ("error", link_error.as_str()), + ])); + PopupKind::Warning + } else { + PopupKind::Success + }; + enqueue_popup_notification(popup_message, popup_kind, Some(5.0)); + + if cx.has_global::() + && cx.get_global::().is_room_loaded(room_name_id.room_id()) + { + self.clear_feedback(cx); + if self.context == CreateRoomContext::SpaceLobbyModal { + cx.action(CreateRoomModalAction::Close); + } + cx.action(AppStateAction::NavigateToRoom { + room_to_close: None, + destination_room: BasicRoomDetails::Name(room_name_id.clone()), + }); + } else { + self.pending_created_room = Some(room_name_id.clone()); + let feedback_text = match (parent_space_id.as_ref(), space_link_error.as_ref()) { + (Some(_), None) => tr_key(self.app_language, "add_room.feedback.room_created_syncing"), + (Some(_), Some(_)) => tr_key(self.app_language, "add_room.feedback.room_created_link_failed_opening"), + (None, _) => tr_key(self.app_language, "add_room.feedback.room_created_opening"), + }; + self.set_feedback(cx, feedback_text, true, false); + } + + self.view.redraw(cx); + } + CreateRoomAction::Failed { room_name, error, context } + if context == &self.context => + { + self.creating_room = false; + create_room_button.set_enabled(cx, !create_room_name_input.text().trim().is_empty()); + self.set_feedback( + cx, + &{ + let error_text = error.to_string(); + tr_fmt(self.app_language, "add_room.feedback.create_room_failed", &[ + ("error", error_text.as_str()), + ]) + }, + false, + true, + ); + enqueue_popup_notification( + { + let error_text = error.to_string(); + tr_fmt(self.app_language, "add_room.popup.create_room_failed", &[ + ("room_name", room_name.as_str()), + ("error", error_text.as_str()), + ]) + }, + PopupKind::Error, + None, + ); + self.view.redraw(cx); + } + _ => {} + } + } + + if let Some(CreatableSpacesAction::Loaded { spaces }) = action.downcast_ref() { + self.creatable_spaces = spaces.clone(); + sync_space_dropdown( + cx, + &create_room_space_dropdown, + &create_room_space_hint, + &self.creatable_spaces, + self.preferred_parent_space_id.as_ref(), + self.app_language, + ); + self.sync_mode_views(cx); + self.view.redraw(cx); + } + + if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) = action.downcast_ref() + && self.pending_created_room.as_ref().is_some_and(|pending| pending.room_id() == room_name_id.room_id()) + { + self.pending_created_room = None; + self.clear_feedback(cx); + if self.context == CreateRoomContext::SpaceLobbyModal { + cx.action(CreateRoomModalAction::Close); + } + cx.action(AppStateAction::NavigateToRoom { + room_to_close: None, + destination_room: BasicRoomDetails::Name(room_name_id.clone()), + }); + } + } + } +} + +impl CreateRoomForm { + fn can_submit(&self, cx: &mut Cx) -> bool { + !self.is_busy() + && !self.view + .text_input(cx, ids!(create_room_name_input)) + .text() + .trim() + .is_empty() + } + + fn is_busy(&self) -> bool { + self.creating_room || self.pending_created_room.is_some() + } + + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.text_input(cx, ids!(create_room_name_input)) + .set_empty_text(cx, tr_key(self.app_language, "add_room.create_room.input.placeholder").to_string()); + self.view.button(cx, ids!(create_room_button)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); + self.sync_mode_views(cx); + } + + fn set_feedback(&mut self, cx: &mut Cx, text: &str, show_spinner: bool, is_error: bool) { + self.view.view(cx, ids!(create_room_feedback)).set_visible(cx, true); + self.view.view(cx, ids!(create_room_feedback_spinner_wrap)) + .set_visible(cx, show_spinner); + let mut feedback_label = self.view.label(cx, ids!(create_room_feedback_label)); + feedback_label.set_text(cx, text); + script_apply_eval!(cx, feedback_label, { + draw_text +: { + color: #( + if is_error { + COLOR_FG_DANGER_RED + } else { + vec4(0.2, 0.2, 0.2, 1.0) + } + ) + } + }); + } + + fn clear_feedback(&mut self, cx: &mut Cx) { + self.view.view(cx, ids!(create_room_feedback)).set_visible(cx, false); + self.view.label(cx, ids!(create_room_feedback_label)).set_text(cx, ""); + } + + fn submit(&mut self, cx: &mut Cx) -> bool { + if !self.can_submit(cx) { + return false; + } + + let room_name = self.view.text_input(cx, ids!(create_room_name_input)).text(); + let room_name = room_name.trim(); + let parent_space_id = self.selected_parent_space_id( + self.view.drop_down(cx, ids!(create_room_space_dropdown)).selected_item(), + ); + + self.creating_room = true; + self.set_feedback(cx, tr_key(self.app_language, "add_room.feedback.creating_room"), true, false); + submit_async_request(MatrixRequest::CreateRoom { + room_name: room_name.to_owned(), + parent_space_id, + context: self.context.clone(), + }); + self.view.redraw(cx); + true + } + + pub fn prepare( + &mut self, + cx: &mut Cx, + preferred_parent_space_id: Option, + context: CreateRoomContext, + clear_room_name: bool, + ) { + self.context = context; + self.creating_room = false; + self.pending_created_room = None; + self.preferred_parent_space_id = preferred_parent_space_id; + self.fixed_parent_space_id = (self.context == CreateRoomContext::SpaceLobbyModal) + .then_some(self.preferred_parent_space_id.clone()) + .flatten(); + + let create_room_name_input = self.view.text_input(cx, ids!(create_room_name_input)); + let create_room_button = self.view.button(cx, ids!(create_room_button)); + let create_room_space_dropdown = self.view.drop_down(cx, ids!(create_room_space_dropdown)); + let create_room_space_hint = self.view.label(cx, ids!(create_room_space_hint)); + + if clear_room_name { + create_room_name_input.set_text(cx, ""); + } + self.clear_feedback(cx); + create_room_button.set_enabled(cx, !create_room_name_input.text().trim().is_empty()); + create_room_button.set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); + create_room_button.reset_hover(cx); + + sync_space_dropdown( + cx, + &create_room_space_dropdown, + &create_room_space_hint, + &self.creatable_spaces, + self.preferred_parent_space_id.as_ref(), + self.app_language, + ); + self.sync_mode_views(cx); + + if self.fixed_parent_space_id.is_none() { + submit_async_request(MatrixRequest::GetCreatableSpaces); + } + create_room_name_input.set_key_focus(cx); + self.view.redraw(cx); + } + + pub fn refresh_creatable_spaces(&mut self, _cx: &mut Cx) { + submit_async_request(MatrixRequest::GetCreatableSpaces); + } + + fn selected_parent_space_id(&self, dropdown_index: usize) -> Option { + self.fixed_parent_space_id.clone() + .or_else(|| selected_creatable_space(&self.creatable_spaces, dropdown_index)) + } + + fn sync_mode_views(&mut self, cx: &mut Cx) { + let show_fixed_parent = self.fixed_parent_space_id.is_some(); + self.view.view(cx, ids!(create_room_space_row)).set_visible(cx, !show_fixed_parent); + self.view.view(cx, ids!(create_room_button_row)).set_visible(cx, !show_fixed_parent); + + let help_text = if show_fixed_parent { + tr_key(self.app_language, "add_room.create_room.help.fixed_parent") + } else { + tr_key(self.app_language, "add_room.create_room.help.default") + }; + self.view.label(cx, ids!(create_room_help)).set_text(cx, help_text); + } +} + +impl CreateRoomFormRef { + pub fn set_app_language(&self, cx: &mut Cx, app_language: AppLanguage) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_app_language(cx, app_language); + } + + pub fn can_submit(&self, cx: &mut Cx) -> bool { + self.borrow().is_some_and(|inner| inner.can_submit(cx)) + } + + pub fn is_busy(&self) -> bool { + self.borrow().is_some_and(|inner| inner.is_busy()) + } + + pub fn submit(&self, cx: &mut Cx) -> bool { + self.borrow_mut().is_some_and(|mut inner| inner.submit(cx)) + } + + pub fn prepare( + &self, + cx: &mut Cx, + preferred_parent_space_id: Option, + context: CreateRoomContext, + clear_room_name: bool, + ) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.prepare(cx, preferred_parent_space_id, context, clear_room_name); + } + + pub fn refresh_creatable_spaces(&self, cx: &mut Cx) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.refresh_creatable_spaces(cx); + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct CreateRoomModal { + #[deref] view: View, + #[rust] app_language: AppLanguage, +} + +impl Widget for CreateRoomModal { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } + 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 { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } + let create_room_form = self.view.create_room_form(cx, ids!(create_room_form)); + let is_busy = create_room_form.is_busy(); + let create_button = self.view.button(cx, ids!(create_button)); + let can_submit = create_room_form.can_submit(cx); + create_button.set_enabled(cx, can_submit); + create_button.set_text(cx, if is_busy { + tr_key(self.app_language, "add_room.create_room.button.syncing") + } else { + tr_key(self.app_language, "add_room.create_room.button.create") + }); + self.view.button(cx, ids!(cancel_button)).set_enabled(cx, !is_busy); + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for CreateRoomModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let create_room_form = self.view.create_room_form(cx, ids!(create_room_form)); + let create_button = self.view.button(cx, ids!(create_button)); + let cancel_button = self.view.button(cx, ids!(cancel_button)); + if create_button.clicked(actions) { + let _ = create_room_form.submit(cx); + } + let cancel_clicked = cancel_button.clicked(actions); + if !create_room_form.is_busy() + && (cancel_clicked || actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed)))) + { + if cancel_clicked { + cx.action(CreateRoomModalAction::Close); + } + } + } +} + +impl CreateRoomModal { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.modal.title")); + self.view.label(cx, ids!(subtitle)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.modal.subtitle")); + self.view.button(cx, ids!(create_button)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); + self.view.button(cx, ids!(cancel_button)) + .set_text(cx, tr_key(self.app_language, "add_room.button.cancel")); + self.view.create_room_form(cx, ids!(create_room_form)) + .set_app_language(cx, app_language); + self.view.redraw(cx); + } + + pub fn show(&mut self, cx: &mut Cx, preferred_parent_space_id: Option) { + self.view.create_room_form(cx, ids!(create_room_form)) + .set_app_language(cx, self.app_language); + self.view.create_room_form(cx, ids!(create_room_form)).prepare( + cx, + preferred_parent_space_id, + CreateRoomContext::SpaceLobbyModal, + true, + ); + self.view.button(cx, ids!(create_button)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); + self.view.button(cx, ids!(create_button)).reset_hover(cx); + self.view.button(cx, ids!(cancel_button)).reset_hover(cx); + self.view.redraw(cx); + } +} + +impl CreateRoomModalRef { + pub fn show(&self, cx: &mut Cx, preferred_parent_space_id: Option) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.show(cx, preferred_parent_space_id); + } +} + +impl AddRoomScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "add_room.title")); + self.view.label(cx, ids!(create_new_room_label)) + .set_text(cx, tr_key(self.app_language, "add_room.section.create_new_room")); + self.view.label(cx, ids!(add_friend_label)) + .set_text(cx, tr_key(self.app_language, "add_room.section.add_friend")); + self.view.label(cx, ids!(join_existing_label)) + .set_text(cx, tr_key(self.app_language, "add_room.section.join_existing")); + self.view.label(cx, ids!(add_friend_help)) + .set_text(cx, tr_key(self.app_language, "add_room.add_friend.help")); + self.view.html(cx, ids!(help_info)) + .set_text(cx, tr_key(self.app_language, "add_room.join.help_html")); + self.view.text_input(cx, ids!(friend_user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "add_room.add_friend.input.placeholder").to_string()); + self.view.text_input(cx, ids!(room_alias_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "add_room.join.input.placeholder").to_string()); + self.view.button(cx, ids!(add_friend_button)) + .set_text(cx, tr_key(self.app_language, "add_room.add_friend.button")); + self.view.button(cx, ids!(search_for_room_button)) + .set_text(cx, tr_key(self.app_language, "add_room.join.button.go")); + self.view.button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)) + .set_text(cx, tr_key(self.app_language, "add_room.button.cancel")); + self.view.create_room_form(cx, ids!(create_room_form)) + .set_app_language(cx, app_language); + self.view.redraw(cx); + } +} + impl Widget for AddRoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.handle_event(cx, event, scope); if let Event::Actions(actions) = event { let room_alias_id_input = self.view.text_input(cx, ids!(room_alias_id_input)); let search_for_room_button = self.view.button(cx, ids!(search_for_room_button)); + let friend_user_id_input = self.view.text_input(cx, ids!(friend_user_id_input)); + let add_friend_button = self.view.button(cx, ids!(add_friend_button)); let cancel_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)); let join_room_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.join_room_button)); @@ -359,6 +1131,51 @@ impl Widget for AddRoomScreen { if let Some(text) = room_alias_id_input.changed(actions) { search_for_room_button.set_enabled(cx, !text.trim().is_empty()); } + if let Some(text) = friend_user_id_input.changed(actions) { + add_friend_button.set_enabled(cx, !self.adding_friend && !text.trim().is_empty()); + } + + let add_friend_request = add_friend_button.clicked(actions) + .then(|| friend_user_id_input.text()) + .or_else(|| friend_user_id_input.returned(actions).map(|(t, _)| t)); + if let Some(user_id_str) = add_friend_request { + let user_id_str = user_id_str.trim(); + if !user_id_str.is_empty() { + match user_id_str.parse::() { + Ok(user_id) => { + if current_user_id().as_ref().is_some_and(|current| current == &user_id) { + enqueue_popup_notification( + tr_key(self.app_language, "add_room.popup.cannot_add_self").to_string(), + PopupKind::Warning, + Some(4.0), + ); + } else { + self.adding_friend = true; + add_friend_button.set_enabled(cx, false); + submit_async_request(MatrixRequest::OpenOrCreateDirectMessage { + user_profile: UserProfile { + user_id, + username: None, + avatar_state: AvatarState::Unknown, + }, + allow_create: false, + }); + } + } + Err(e) => { + let error_text = e.to_string(); + enqueue_popup_notification( + tr_fmt(self.app_language, "add_room.popup.invalid_user_id", &[ + ("error", error_text.as_str()), + ]), + PopupKind::Error, + None, + ); + friend_user_id_input.set_key_focus(cx); + } + } + } + } // If the cancel button was clicked, hide the room preview and return to default state. if cancel_button.clicked(actions) { @@ -411,7 +1228,10 @@ impl Widget for AddRoomScreen { submit_async_request(MatrixRequest::GetRoomPreview { room_or_alias_id, via }); } Err(e) => { - let err_str = format!("Could not parse the text as a valid room address.\nError: {e}."); + let error_text = e.to_string(); + let err_str = tr_fmt(self.app_language, "add_room.popup.parse_error", &[ + ("error", error_text.as_str()), + ]); enqueue_popup_notification( err_str.clone(), PopupKind::Error, @@ -444,7 +1264,10 @@ impl Widget for AddRoomScreen { break; } Some(RoomPreviewAction::Fetched(Err(e))) => { - let err_str = format!("Failed to fetch room info.\n\nError: {e}."); + let error_text = e.to_string(); + let err_str = tr_fmt(self.app_language, "add_room.popup.fetch_error", &[ + ("error", error_text.as_str()), + ]); enqueue_popup_notification( err_str.clone(), PopupKind::Error, @@ -469,11 +1292,15 @@ impl Widget for AddRoomScreen { match action.downcast_ref() { Some(KnockResultAction::Knocked { room, .. }) if room.room_id() == frp.room_name_id.room_id() => { let room_type = match room.room_type() { - Some(RoomType::Space) => "space", - _ => "room", + Some(RoomType::Space) => tr_key(self.app_language, "add_room.word.space_lc"), + _ => tr_key(self.app_language, "add_room.word.room_lc"), }; + let room_name_text = frp.room_name_id.to_string(); enqueue_popup_notification( - format!("Successfully knocked on {room_type} {}.", frp.room_name_id), + tr_fmt(self.app_language, "add_room.popup.knock_success", &[ + ("room_type", room_type), + ("room_name", room_name_text.as_str()), + ]), PopupKind::Success, Some(4.0), ); @@ -481,8 +1308,11 @@ impl Widget for AddRoomScreen { break; } Some(KnockResultAction::Failed { error, room_or_alias_id: roai }) if room_or_alias_id == roai => { + let error_text = error.to_string(); enqueue_popup_notification( - format!("Failed to knock on room.\n\nError: {error}."), + tr_fmt(self.app_language, "add_room.popup.knock_failed", &[ + ("error", error_text.as_str()), + ]), PopupKind::Error, None, ); @@ -494,11 +1324,15 @@ impl Widget for AddRoomScreen { match action.downcast_ref() { Some(JoinRoomResultAction::Joined { room_id }) if room_id == frp.room_name_id.room_id() => { let room_type = match &frp.room_type { - Some(RoomType::Space) => "space", - _ => "room", + Some(RoomType::Space) => tr_key(self.app_language, "add_room.word.space_lc"), + _ => tr_key(self.app_language, "add_room.word.room_lc"), }; + let room_name_text = frp.room_name_id.to_string(); enqueue_popup_notification( - format!("Successfully joined {room_type} {}.", frp.room_name_id), + tr_fmt(self.app_language, "add_room.popup.join_success", &[ + ("room_type", room_type), + ("room_name", room_name_text.as_str()), + ]), PopupKind::Success, Some(4.0), ); @@ -506,8 +1340,11 @@ impl Widget for AddRoomScreen { break; } Some(JoinRoomResultAction::Failed { room_id, error }) if room_id == frp.room_name_id.room_id() => { + let error_text = error.to_string(); enqueue_popup_notification( - format!("Failed to join room.\n\nError: {error}."), + tr_fmt(self.app_language, "add_room.popup.join_failed", &[ + ("error", error_text.as_str()), + ]), PopupKind::Error, None, ); @@ -527,6 +1364,24 @@ impl Widget for AddRoomScreen { } for action in actions { + if matches!( + action.downcast_ref(), + Some( + DirectMessageRoomAction::FoundExisting { .. } + | DirectMessageRoomAction::DidNotExist { .. } + | DirectMessageRoomAction::NewlyCreated { .. } + | DirectMessageRoomAction::FailedToCreate { .. } + ) + ) { + self.adding_friend = false; + add_friend_button.set_enabled(cx, !friend_user_id_input.text().trim().is_empty()); + } + + if let Some(NavigationBarAction::TabSelected(SelectedTab::AddRoom)) = action.downcast_ref() { + self.view.create_room_form(cx, ids!(create_room_form)) + .prepare(cx, None, CreateRoomContext::AddRoomPage, false); + } + // If the room/space the user is searching for has been loaded from the homeserver // (e.g., by getting invited to it, or joining it in another client), // then update the state of @@ -542,6 +1397,21 @@ impl Widget for AddRoomScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } + + let add_friend_text_is_empty = self.view + .text_input(cx, ids!(friend_user_id_input)) + .text() + .trim() + .is_empty(); + self.view.button(cx, ids!(add_friend_button)) + .set_enabled(cx, !self.adding_friend && !add_friend_text_is_empty); + let loading_room_view = self.view.view(cx, ids!(loading_room_view)); let fetched_room_summary = self.view.view(cx, ids!(fetched_room_summary)); let error_view = self.view.view(cx, ids!(error_view)); @@ -562,7 +1432,9 @@ impl Widget for AddRoomScreen { loading_room_view.set_visible(cx, true); loading_room_view.label(cx, ids!(loading_text)).set_text( cx, - &format!("Fetching {room_or_alias_id}..."), + &tr_fmt(self.app_language, "add_room.loading.fetching", &[ + ("target", room_or_alias_id.as_str()), + ]), ); fetched_room_summary.set_visible(cx, false); error_view.set_visible(cx, false); @@ -599,81 +1471,113 @@ impl Widget for AddRoomScreen { } let (room_or_space_lc, room_or_space_uc) = match &frp.room_type { - Some(RoomType::Space) => ("space", "Space"), - _ => ("room", "Room"), + Some(RoomType::Space) => ( + tr_key(self.app_language, "add_room.word.space_lc"), + tr_key(self.app_language, "add_room.word.space_uc"), + ), + _ => ( + tr_key(self.app_language, "add_room.word.room_lc"), + tr_key(self.app_language, "add_room.word.room_uc"), + ), }; let room_name = fetched_room_summary.label(cx, ids!(room_name)); match frp.room_name_id.name_for_avatar() { Some(n) => room_name.set_text(cx, n), - _ => room_name.set_text(cx, &format!("Unnamed {room_or_space_uc}, ID: {}", frp.room_name_id.room_id())), + _ => room_name.set_text(cx, &tr_fmt(self.app_language, "add_room.fetched.room_name.unnamed", &[ + ("room_or_space_uc", room_or_space_uc), + ("room_id", frp.room_name_id.room_id().as_str()), + ])), } fetched_room_summary.label(cx, ids!(subsection_alias_id)).set_text( cx, - &format!("Main {room_or_space_uc} Alias and ID"), + &tr_fmt(self.app_language, "add_room.fetched.main_alias_and_id", &[ + ("room_or_space_uc", room_or_space_uc), + ]), ); fetched_room_summary.label(cx, ids!(room_alias)).set_text( cx, - &format!("Alias: {}", frp.canonical_alias.as_ref().map_or("not set", |a| a.as_str())), + &tr_fmt(self.app_language, "add_room.fetched.alias", &[ + ("alias", frp.canonical_alias.as_ref().map_or( + tr_key(self.app_language, "add_room.fetched.alias.not_set"), + |a| a.as_str() + )), + ]), ); fetched_room_summary.label(cx, ids!(room_id)).set_text( cx, - &format!("ID: {}", frp.room_name_id.room_id().as_str()), + &tr_fmt(self.app_language, "add_room.fetched.id", &[ + ("room_id", frp.room_name_id.room_id().as_str()), + ]), ); fetched_room_summary.label(cx, ids!(subsection_topic)).set_text( cx, - &format!("{room_or_space_uc} Topic"), + &tr_fmt(self.app_language, "add_room.fetched.topic_title", &[ + ("room_or_space_uc", room_or_space_uc), + ]), ); fetched_room_summary.html(cx, ids!(room_topic)).set_text( cx, - frp.topic.as_deref().unwrap_or("No topic set"), + frp.topic.as_deref().unwrap_or(tr_key(self.app_language, "add_room.fetched.topic.not_set_html")), ); let room_summary = fetched_room_summary.label(cx, ids!(room_summary)); let join_room_button = fetched_room_summary.button(cx, ids!(join_room_button)); let join_function = match (&frp.state, &frp.join_rule) { (Some(RoomState::Joined), _) => { - room_summary.set_text(cx, &format!("You have already joined this {room_or_space_lc}.")); - join_room_button.set_text(cx, &format!("Go to {room_or_space_lc}")); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.already_joined", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, &tr_fmt(self.app_language, "add_room.button.go_to", &[ + ("room_or_space_lc", room_or_space_lc), + ])); JoinButtonFunction::NavigateOrJoin } (Some(RoomState::Banned), _) => { - room_summary.set_text(cx, &format!("You have been banned from this {room_or_space_lc}.")); - join_room_button.set_text(cx, "Cannot join until un-banned"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.banned", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.cannot_join_until_unbanned")); JoinButtonFunction::None } (Some(RoomState::Invited), _) => { - room_summary.set_text(cx, &format!("You have already been invited to this {room_or_space_lc}.")); - join_room_button.set_text(cx, "Go to invitation"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.already_invited", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.go_to_invitation")); JoinButtonFunction::NavigateOrJoin } (Some(RoomState::Knocked), _) => { - room_summary.set_text(cx, &format!("You have already knocked on this {room_or_space_lc}.")); - join_room_button.set_text(cx, "Knock again (be nice!)"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.already_knocked", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.knock_again")); JoinButtonFunction::Knock } (Some(RoomState::Left), join_rule) => { - room_summary.set_text(cx, &format!("You previously left this {room_or_space_lc}.")); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.previously_left", &[ + ("room_or_space_lc", room_or_space_lc), + ])); let (join_room_text, join_function) = match join_rule { Some(JoinRuleSummary::Public) => ( - format!("Re-join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.rejoin", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::NavigateOrJoin, ), Some(JoinRuleSummary::Invite) => ( - format!("Re-joining {room_or_space_lc} requires an invite"), + tr_fmt(self.app_language, "add_room.button.rejoin_requires_invite", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), Some(JoinRuleSummary::Knock | JoinRuleSummary::KnockRestricted(_)) => ( - format!("Knock to re-join {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.knock_to_rejoin", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::Knock, ), // TODO: handle this after we update matrix-sdk to the new `JoinRule` enum. Some(JoinRuleSummary::Restricted(_)) => ( - format!("Re-joining {room_or_space_lc} requires an invite or other room membership"), + tr_fmt(self.app_language, "add_room.button.rejoin_requires_other_membership", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), _ => ( - format!("Not allowed to re-join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.not_allowed_to_rejoin", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), }; @@ -682,36 +1586,43 @@ impl Widget for AddRoomScreen { } // This room is not yet known to the user. (None, join_rule) => { - let direct = if frp.is_direct == Some(true) { "direct" } else { "regular" }; - room_summary.set_text(cx, &format!( - "This is a {direct} {room_or_space_lc} with {} {}.", - frp.num_joined_members, - match frp.num_joined_members { - 1 => "member", - _ => "members", - }, - )); + let directness = if frp.is_direct == Some(true) { + tr_key(self.app_language, "add_room.word.direct") + } else { + tr_key(self.app_language, "add_room.word.regular") + }; + let num_members = frp.num_joined_members.to_string(); + let member_word = match frp.num_joined_members { + 1 => tr_key(self.app_language, "add_room.word.member"), + _ => tr_key(self.app_language, "add_room.word.members"), + }; + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.member_count", &[ + ("directness", directness), + ("room_or_space_lc", room_or_space_lc), + ("num_members", num_members.as_str()), + ("member_word", member_word), + ])); let (join_room_text, join_function) = match join_rule { Some(JoinRuleSummary::Public) => ( - format!("Join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.join", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::NavigateOrJoin, ), Some(JoinRuleSummary::Invite) => ( - format!("Joining {room_or_space_lc} requires an invite"), + tr_fmt(self.app_language, "add_room.button.join_requires_invite", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), Some(JoinRuleSummary::Knock | JoinRuleSummary::KnockRestricted(_)) => ( - format!("Knock to join {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.knock_to_join", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::Knock, ), // TODO: handle this after we update matrix-sdk to the new `JoinRule` enum. Some(JoinRuleSummary::Restricted(_)) => ( - format!("Joining {room_or_space_lc} requires an invite or other room membership"), + tr_fmt(self.app_language, "add_room.button.join_requires_other_membership", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), _ => ( - format!("Not allowed to join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.not_allowed_to_join", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), }; @@ -726,20 +1637,38 @@ impl Widget for AddRoomScreen { self.join_function = join_function; } AddRoomState::Knocked { .. } => { - room_summary.set_text(cx, &format!("You have knocked on this {room_or_space_lc} and must now wait for someone to invite you in.")); - join_room_button.set_text(cx, "Successfully knocked!"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.knocked_waiting", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.successfully_knocked")); join_room_button.set_enabled(cx, false); } AddRoomState::Joined { .. } => { - room_summary.set_text(cx, &format!("You have joined this {room_or_space_lc}. It is now being loaded from the homeserver; please wait...")); - join_room_button.set_text(cx, "Successfully joined!"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.joined_loading", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.successfully_joined")); join_room_button.set_enabled(cx, false); } AddRoomState::Loaded { is_invite, .. } => { - let verb = if *is_invite { "been invited to" } else { "fully joined" }; - room_summary.set_text(cx, &format!("You have {verb} this {room_or_space_lc}.")); - let adj = if *is_invite { "invited" } else { "joined" }; - join_room_button.set_text(cx, &format!("Go to {adj} {room_or_space_lc}")); + let verb = if *is_invite { + tr_key(self.app_language, "add_room.word.verb.invited") + } else { + tr_key(self.app_language, "add_room.word.verb.joined") + }; + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.loaded", &[ + ("verb", verb), + ("room_or_space_lc", room_or_space_lc), + ])); + let adj = if *is_invite { + tr_key(self.app_language, "add_room.word.adj.invited") + } else { + tr_key(self.app_language, "add_room.word.adj.joined") + }; + join_room_button.set_text(cx, &tr_fmt(self.app_language, "add_room.button.go_to_loaded", &[ + ("adj", adj), + ("room_or_space_lc", room_or_space_lc), + ])); join_room_button.set_enabled(cx, true); self.join_function = JoinButtonFunction::NavigateOrJoin; } @@ -752,6 +1681,100 @@ impl Widget for AddRoomScreen { } } +fn refresh_space_children(cx: &mut Cx, space_id: &OwnedRoomId) { + let Some(rooms_list_ref) = cx.has_global::().then(|| cx.get_global::()) else { + return; + }; + let Some(space_request_sender) = rooms_list_ref.get_space_request_sender() else { + return; + }; + let parent_chain = rooms_list_ref.get_space_parent_chain(space_id).unwrap_or_default(); + if let Err(e) = space_request_sender.send(SpaceRequest::SubscribeToSpaceRoomList { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) { + error!("Failed to subscribe to space room list for {space_id}: {e}"); + return; + } + if let Err(e) = space_request_sender.send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) { + error!("Failed to paginate children for space {space_id}: {e}"); + } + if let Err(e) = space_request_sender.send(SpaceRequest::GetChildren { + space_id: space_id.clone(), + parent_chain, + }) { + error!("Failed to refresh children for space {space_id}: {e}"); + } +} + +fn creatable_space_labels(creatable_spaces: &[RoomNameId], app_language: AppLanguage) -> Vec { + let mut labels = Vec::with_capacity(creatable_spaces.len() + 1); + labels.push(tr_key(app_language, "add_room.create_room.dropdown.no_space").to_string()); + labels.extend(creatable_spaces.iter().map(ToString::to_string)); + labels +} + +fn selected_creatable_space(creatable_spaces: &[RoomNameId], dropdown_index: usize) -> Option { + dropdown_index.checked_sub(1) + .and_then(|index| creatable_spaces.get(index)) + .map(|space| space.room_id().clone()) +} + +fn apply_space_dropdown_selection( + cx: &mut Cx, + dropdown: &DropDownRef, + creatable_spaces: &[RoomNameId], + preferred_parent_space_id: Option<&OwnedRoomId>, +) { + let selected_index = preferred_parent_space_id + .and_then(|space_id| + creatable_spaces.iter().position(|space| space.room_id() == space_id) + ) + .map(|index| index + 1) + .unwrap_or_else(|| dropdown.selected_item().min(creatable_spaces.len())); + dropdown.set_selected_item(cx, selected_index); +} + +fn update_space_hint( + cx: &mut Cx, + hint_label: &LabelRef, + creatable_spaces: &[RoomNameId], + selected_space_id: Option<&OwnedRoomId>, + app_language: AppLanguage, +) { + if creatable_spaces.is_empty() { + hint_label.set_text(cx, tr_key(app_language, "add_room.create_room.dropdown.hint.no_creatable_spaces")); + } else if let Some(space_id) = selected_space_id { + let selected_name = creatable_spaces + .iter() + .find(|space| space.room_id() == space_id) + .map(ToString::to_string) + .unwrap_or_else(|| space_id.to_string()); + hint_label.set_text(cx, &tr_fmt(app_language, "add_room.create_room.dropdown.hint.new_room_under", &[ + ("selected_name", selected_name.as_str()), + ])); + } else { + hint_label.set_text(cx, tr_key(app_language, "add_room.create_room.dropdown.hint.default")); + } +} + +fn sync_space_dropdown( + cx: &mut Cx, + dropdown: &DropDownRef, + hint_label: &LabelRef, + creatable_spaces: &[RoomNameId], + preferred_parent_space_id: Option<&OwnedRoomId>, + app_language: AppLanguage, +) { + dropdown.set_labels(cx, creatable_space_labels(creatable_spaces, app_language)); + apply_space_dropdown_selection(cx, dropdown, creatable_spaces, preferred_parent_space_id); + let selected_space_id = selected_creatable_space(creatable_spaces, dropdown.selected_item()); + update_space_hint(cx, hint_label, creatable_spaces, selected_space_id.as_ref(), app_language); +} + /// The function to perform when the user clicks the join button in the fetched room preview. enum JoinButtonFunction { @@ -781,6 +1804,43 @@ pub enum KnockResultAction { } } +/// Actions sent from the backend task as a result of a [`MatrixRequest::CreateRoom`]. +#[derive(Debug)] +pub enum CreateRoomAction { + /// A new room was created. + Created { + room_name_id: RoomNameId, + parent_space_id: Option, + /// If set, the room was created but couldn't be linked into the requested space. + space_link_error: Option, + context: CreateRoomContext, + }, + /// There was an error creating the room. + Failed { + room_name: String, + error: matrix_sdk::Error, + context: CreateRoomContext, + }, +} + +/// Actions emitted by other widgets to show or hide the create-room modal. +#[derive(Debug)] +pub enum CreateRoomModalAction { + Open { + parent_space_id: Option, + }, + Close, +} + +/// Actions sent from the backend task containing the spaces where the current user +/// can create child rooms. +#[derive(Debug)] +pub enum CreatableSpacesAction { + Loaded { + spaces: Vec, + }, +} + /// Tries to extract a room address (Alias or ID) from the given text. /// diff --git a/src/home/create_bot_modal.rs b/src/home/create_bot_modal.rs new file mode 100644 index 000000000..a151cec5f --- /dev/null +++ b/src/home/create_bot_modal.rs @@ -0,0 +1,303 @@ +//! A modal dialog for creating a Matrix child bot through BotFather slash commands. + +use makepad_widgets::*; + +use crate::utils::RoomNameId; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.CreateBotModalLabel = Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #333 + } + text: "" + } + + mod.widgets.CreateBotModal = #(CreateBotModal::register_widget(vm)) { + width: Fit + height: Fit + + RoundedView { + width: 448 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 28, right: 24, bottom: 20, left: 24} + spacing: 16 + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 6.0 + } + + title := Label { + width: Fill + height: Fit + draw_text +: { + text_style: TITLE_TEXT { font_size: 13 } + color: #000 + } + text: "Create Bot" + } + + body := mod.widgets.CreateBotModalLabel { + text: "" + } + + form := RoundedView { + width: Fill + height: Fit + flow: Down + spacing: 12 + padding: 14 + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + } + + username_label := mod.widgets.CreateBotModalLabel { + text: "Username" + } + + username_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "weather" + } + + username_hint := mod.widgets.CreateBotModalLabel { + draw_text +: { + text_style: REGULAR_TEXT { font_size: 9.5 } + color: #666 + } + text: "Lowercase letters, digits, and underscores only. BotFather will create @bot_:server." + } + + display_name_label := mod.widgets.CreateBotModalLabel { + text: "Display Name" + } + + display_name_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "Weather Bot" + } + + prompt_label := mod.widgets.CreateBotModalLabel { + text: "System Prompt (Optional)" + } + + prompt_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "You are a weather assistant." + } + } + + status_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #000 + } + text: "" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + spacing: 16 + + cancel_button := RobrixNeutralIconButton { + width: 110 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Cancel" + } + + create_button := RobrixPositiveIconButton { + width: 130 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Create Bot" + } + } + } + } +} + +fn is_valid_bot_username(username: &str) -> bool { + !username.is_empty() + && username + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateBotRequest { + pub username: String, + pub display_name: String, + pub system_prompt: Option, +} + +#[derive(Clone, Debug)] +pub enum CreateBotModalAction { + Close, + Submit(CreateBotRequest), +} + +#[derive(Script, ScriptHook, Widget)] +pub struct CreateBotModal { + #[deref] + view: View, + #[rust] + is_showing_error: bool, +} + +impl Widget for CreateBotModal { + 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 CreateBotModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let cancel_button = self.view.button(cx, ids!(buttons.cancel_button)); + let create_button = self.view.button(cx, ids!(buttons.create_button)); + let username_input = self.view.text_input(cx, ids!(form.username_input)); + let display_name_input = self.view.text_input(cx, ids!(form.display_name_input)); + let prompt_input = self.view.text_input(cx, ids!(form.prompt_input)); + let mut status_label = self.view.label(cx, ids!(status_label)); + + if cancel_button.clicked(actions) + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + { + cx.action(CreateBotModalAction::Close); + return; + } + + if self.is_showing_error + && (username_input.changed(actions).is_some() + || display_name_input.changed(actions).is_some() + || prompt_input.changed(actions).is_some()) + { + self.is_showing_error = false; + status_label.set_text(cx, ""); + self.view.redraw(cx); + } + + if create_button.clicked(actions) || prompt_input.returned(actions).is_some() { + let username = username_input.text().trim().to_string(); + if !is_valid_bot_username(&username) { + self.is_showing_error = true; + script_apply_eval!(cx, status_label, { + text: "Username must use lowercase letters, digits, or underscores." + draw_text +: { + color: mod.widgets.COLOR_FG_DANGER_RED + } + }); + self.view.redraw(cx); + return; + } + + let display_name = display_name_input.text().trim().to_string(); + let system_prompt = prompt_input.text().trim().to_string(); + + cx.action(CreateBotModalAction::Submit(CreateBotRequest { + username: username.clone(), + display_name: if display_name.is_empty() { + username + } else { + display_name + }, + system_prompt: (!system_prompt.is_empty()).then_some(system_prompt), + })); + } + } +} + +impl CreateBotModal { + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { + self.is_showing_error = false; + + self.view + .label(cx, ids!(title)) + .set_text(cx, "Create Room Bot"); + self.view.label(cx, ids!(body)).set_text( + cx, + &format!( + "Robrix will send `/createbot` to BotFather in {}. The bot becomes available immediately after octos creates it.", + room_name_id + ), + ); + self.view + .text_input(cx, ids!(form.username_input)) + .set_text(cx, ""); + self.view + .text_input(cx, ids!(form.display_name_input)) + .set_text(cx, ""); + self.view + .text_input(cx, ids!(form.prompt_input)) + .set_text(cx, ""); + self.view.label(cx, ids!(status_label)).set_text(cx, ""); + self.view + .button(cx, ids!(buttons.create_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.cancel_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.create_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(buttons.cancel_button)) + .reset_hover(cx); + self.view.redraw(cx); + } +} + +impl CreateBotModalRef { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.show(cx, room_name_id); + } +} diff --git a/src/home/delete_bot_modal.rs b/src/home/delete_bot_modal.rs new file mode 100644 index 000000000..e5fb406d2 --- /dev/null +++ b/src/home/delete_bot_modal.rs @@ -0,0 +1,243 @@ +//! A modal dialog for deleting a Matrix bot through BotFather slash commands. + +use makepad_widgets::*; + +use crate::utils::RoomNameId; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.DeleteBotModalLabel = Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #333 + } + text: "" + } + + mod.widgets.DeleteBotModal = #(DeleteBotModal::register_widget(vm)) { + width: Fit + height: Fit + + RoundedView { + width: 448 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 28, right: 24, bottom: 20, left: 24} + spacing: 16 + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 6.0 + } + + title := Label { + width: Fill + height: Fit + draw_text +: { + text_style: TITLE_TEXT { font_size: 13 } + color: #000 + } + text: "Delete Bot" + } + + body := mod.widgets.DeleteBotModalLabel { + text: "" + } + + form := RoundedView { + width: Fill + height: Fit + flow: Down + spacing: 12 + padding: 14 + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + } + + user_id_label := mod.widgets.DeleteBotModalLabel { + text: "Bot Matrix User ID" + } + + user_id_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "@bot_weather:server or bot_weather" + } + + user_id_hint := mod.widgets.DeleteBotModalLabel { + draw_text +: { + text_style: REGULAR_TEXT { font_size: 9.5 } + color: #666 + } + text: "Use the full Matrix user ID when possible. A plain localpart like `bot_weather` will be resolved on your current homeserver." + } + } + + status_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #000 + } + text: "" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + spacing: 16 + + cancel_button := RobrixNeutralIconButton { + width: 110 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Cancel" + } + + delete_button := RobrixNegativeIconButton { + width: 130 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Delete Bot" + } + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeleteBotRequest { + pub user_id_or_localpart: String, +} + +#[derive(Clone, Debug)] +pub enum DeleteBotModalAction { + Close, + Submit(DeleteBotRequest), +} + +#[derive(Script, ScriptHook, Widget)] +pub struct DeleteBotModal { + #[deref] + view: View, + #[rust] + is_showing_error: bool, +} + +impl Widget for DeleteBotModal { + 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 DeleteBotModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let cancel_button = self.view.button(cx, ids!(buttons.cancel_button)); + let delete_button = self.view.button(cx, ids!(buttons.delete_button)); + let user_id_input = self.view.text_input(cx, ids!(form.user_id_input)); + let mut status_label = self.view.label(cx, ids!(status_label)); + + if cancel_button.clicked(actions) + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + { + cx.action(DeleteBotModalAction::Close); + return; + } + + if self.is_showing_error && user_id_input.changed(actions).is_some() { + self.is_showing_error = false; + status_label.set_text(cx, ""); + self.view.redraw(cx); + } + + if delete_button.clicked(actions) || user_id_input.returned(actions).is_some() { + let user_id_or_localpart = user_id_input.text().trim().to_string(); + if user_id_or_localpart.is_empty() { + self.is_showing_error = true; + script_apply_eval!(cx, status_label, { + text: "Enter the bot Matrix user ID or localpart to delete." + draw_text +: { + color: mod.widgets.COLOR_FG_DANGER_RED + } + }); + self.view.redraw(cx); + return; + } + + cx.action(DeleteBotModalAction::Submit(DeleteBotRequest { + user_id_or_localpart, + })); + } + } +} + +impl DeleteBotModal { + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { + self.is_showing_error = false; + + self.view + .label(cx, ids!(title)) + .set_text(cx, "Delete Room Bot"); + self.view.label(cx, ids!(body)).set_text( + cx, + &format!( + "Robrix will send `/deletebot` to BotFather in {}. This only removes bots already managed by octos.", + room_name_id + ), + ); + self.view + .text_input(cx, ids!(form.user_id_input)) + .set_text(cx, ""); + self.view.label(cx, ids!(status_label)).set_text(cx, ""); + self.view + .button(cx, ids!(buttons.delete_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.cancel_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.delete_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(buttons.cancel_button)) + .reset_hover(cx); + self.view.redraw(cx); + } +} + +impl DeleteBotModalRef { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.show(cx, room_name_id); + } +} diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index 5fcd8c297..b13c2f0c3 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -1,16 +1,6 @@ use makepad_widgets::*; -use crate::{ - app::{AppState, AppStateAction, SelectedRoom}, - home::{ - invite_screen::InviteScreenWidgetExt, - navigation_tab_bar::{NavigationBarAction, SelectedTab}, - room_screen::RoomScreenWidgetExt, - rooms_list::RoomsListAction, - space_lobby::SpaceLobbyScreenWidgetExt, - }, - settings::settings_screen::SettingsScreenWidgetRefExt, -}; +use crate::{app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, settings::settings_screen::SettingsScreenWidgetRefExt}; script_mod! { use mod.prelude.widgets.* @@ -216,26 +206,6 @@ script_mod! { width: Fill, height: Fill flow: Down - View { - width: Fill, - height: 39, - flow: Right - padding: Inset{top: 2, bottom: 2} - margin: Inset{right: 2} - spacing: 2 - align: Align{y: 0.5} - - CachedWidget { - room_filter_input_bar := RoomFilterInputBar {} - } - - search_messages_button := SearchMessagesButton { - // make this button match/align with the RoomFilterInputBar - height: 32.5, - margin: Inset{right: 2} - } - } - mod.widgets.MainDesktopUI {} } @@ -416,10 +386,6 @@ pub struct HomeScreen { /// other widgets can easily access it. #[rust] previous_selection: SelectedTab, #[rust] is_spaces_bar_shown: bool, - - /// A stack of previously-selected rooms for mobile stack navigation. - /// When a view is popped off the stack, the previous `selected_room` is restored. - #[rust] mobile_room_nav_stack: Vec, } impl Widget for HomeScreen { @@ -465,7 +431,7 @@ impl Widget for HomeScreen { if let Some(settings_page) = self.update_active_page_from_selection(cx, app_state) { settings_page .settings_screen(cx, ids!(settings_screen)) - .populate(cx, None); + .populate(cx, None, &app_state.bot_settings, app_state.app_language); self.view.redraw(cx); } else { error!("BUG: failed to set active page to show settings screen."); @@ -489,29 +455,6 @@ impl Widget for HomeScreen { Some(NavigationBarAction::TabSelected(_)) | None => { } } - - // Handle mobile stack navigation actions (push/pop room views). - // In Desktop mode, MainDesktopUI also handles RoomsListAction::Selected - // to manage dock tabs; the mobile push is harmless there (views aren't drawn). - match action.as_widget_action().cast() { - RoomsListAction::Selected(selected_room) => { - self.push_selected_room_view(cx, app_state, selected_room); - } - RoomsListAction::InviteAccepted { room_name_id } => { - cx.action(AppStateAction::UpgradedInviteToJoinedRoom( - room_name_id.room_id().clone(), - )); - } - _ => {} - } - - // When a stack navigation pop is initiated (back button pressed), - // pop the mobile nav stack so it stays in sync with StackNavigation. - if let StackNavigationAction::Pop = action.as_widget_action().cast() { - if app_state.selected_room.is_some() { - app_state.selected_room = self.mobile_room_nav_stack.pop(); - } - } } } @@ -548,95 +491,4 @@ impl HomeScreen { }, ) } - - /// Room StackNavigationView instances, one per stack depth. - /// Each depth gets its own dedicated view widget to avoid - /// complex state save/restore when views would otherwise be reused. - const ROOM_VIEW_IDS: [LiveId; 16] = [ - live_id!(room_view_0), live_id!(room_view_1), - live_id!(room_view_2), live_id!(room_view_3), - live_id!(room_view_4), live_id!(room_view_5), - live_id!(room_view_6), live_id!(room_view_7), - live_id!(room_view_8), live_id!(room_view_9), - live_id!(room_view_10), live_id!(room_view_11), - live_id!(room_view_12), live_id!(room_view_13), - live_id!(room_view_14), live_id!(room_view_15), - ]; - - /// The RoomScreen widget IDs inside each room view, - /// corresponding 1:1 with [`Self::ROOM_VIEW_IDS`]. - const ROOM_SCREEN_IDS: [LiveId; 16] = [ - live_id!(room_screen_0), live_id!(room_screen_1), - live_id!(room_screen_2), live_id!(room_screen_3), - live_id!(room_screen_4), live_id!(room_screen_5), - live_id!(room_screen_6), live_id!(room_screen_7), - live_id!(room_screen_8), live_id!(room_screen_9), - live_id!(room_screen_10), live_id!(room_screen_11), - live_id!(room_screen_12), live_id!(room_screen_13), - live_id!(room_screen_14), live_id!(room_screen_15), - ]; - - /// Returns the room view and room screen LiveIds for the given stack depth. - /// Clamps to the last available view if depth exceeds the pool size. - fn room_ids_for_depth(depth: usize) -> (LiveId, LiveId) { - let index = depth.min(Self::ROOM_VIEW_IDS.len() - 1); - (Self::ROOM_VIEW_IDS[index], Self::ROOM_SCREEN_IDS[index]) - } - - /// Pushes the appropriate StackNavigationView for the given `SelectedRoom`, - /// configuring the view's content widget and header title. - /// - /// Each stack depth gets its own dedicated room view widget, - /// supporting deep navigation (room → thread → room → thread → ...). - fn push_selected_room_view( - &mut self, - cx: &mut Cx, - app_state: &mut AppState, - selected_room: SelectedRoom, - ) { - let new_depth = self.view.stack_navigation(cx, ids!(view_stack)).depth(); - - let view_id = match &selected_room { - SelectedRoom::JoinedRoom { room_name_id } - | SelectedRoom::Thread { room_name_id, .. } => { - let (view_id, room_screen_id) = Self::room_ids_for_depth(new_depth); - let thread_root = if let SelectedRoom::Thread { thread_root_event_id, .. } = &selected_room { - Some(thread_root_event_id.clone()) - } else { - None - }; - self.view - .room_screen(cx, &[room_screen_id]) - .set_displayed_room(cx, room_name_id, thread_root); - view_id - } - SelectedRoom::InvitedRoom { room_name_id } => { - self.view - .invite_screen(cx, ids!(invite_screen)) - .set_displayed_invite(cx, room_name_id); - id!(invite_view) - } - SelectedRoom::Space { space_name_id } => { - self.view - .space_lobby_screen(cx, ids!(space_lobby_screen)) - .set_displayed_space(cx, space_name_id); - id!(space_lobby_view) - } - }; - - // Set the header title for the view being pushed. - let title_path = &[view_id, live_id!(header), live_id!(content), live_id!(title_container), live_id!(title)]; - self.view.label(cx, title_path).set_text(cx, &selected_room.display_name()); - - // Save the current selected_room onto the navigation stack before replacing it. - if let Some(prev) = app_state.selected_room.take() { - self.mobile_room_nav_stack.push(prev); - } - app_state.selected_room = Some(selected_room); - - // Push the view onto the mobile navigation stack. - self.view.stack_navigation(cx, ids!(view_stack)).push(cx, view_id); - self.view.redraw(cx); - } } - diff --git a/src/home/invite_modal.rs b/src/home/invite_modal.rs index d644bf542..2bcd32850 100644 --- a/src/home/invite_modal.rs +++ b/src/home/invite_modal.rs @@ -3,6 +3,8 @@ use makepad_widgets::*; use ruma::OwnedUserId; +use crate::app::AppState; +use crate::i18n::{AppLanguage, tr_fmt, tr_key}; use crate::home::room_screen::InviteResultAction; use crate::sliding_sync::{MatrixRequest, submit_async_request}; use crate::utils::RoomNameId; @@ -45,7 +47,7 @@ script_mod! { text_style: TITLE_TEXT {font_size: 13}, color: #000 } - text: "Invite to Room" + text: "" } } @@ -54,7 +56,7 @@ script_mod! { text_style: REGULAR_TEXT {font_size: 11}, color: #000 } - empty_text: "@user:example.org", + empty_text: "", } View { @@ -70,7 +72,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_FORBIDDEN) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Cancel" + text: "" } confirm_button := RobrixPositiveIconButton { @@ -79,7 +81,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_ADD_USER) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Invite" + text: "" } okay_button := RobrixIconButton { @@ -89,7 +91,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_CHECKMARK) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Okay" + text: "" } } @@ -144,10 +146,20 @@ pub struct InviteModal { #[deref] view: View, #[rust] state: InviteModalState, #[rust] room_name_id: Option, + #[rust] app_language: AppLanguage, } impl Widget for InviteModal { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + if let Some(app_state) = scope.data.get::() + && self.app_language != app_state.app_language + { + self.app_language = app_state.app_language; + self.update_static_texts(cx); + if let Some(room_name_id) = self.room_name_id.clone() { + self.set_invite_title(cx, &room_name_id); + } + } self.view.handle_event(cx, event, scope); self.widget_match_event(cx, event, scope); } @@ -195,7 +207,7 @@ impl WidgetMatchEvent for InviteModal { // Validate the user ID if user_id_str.is_empty() { script_apply_eval!(cx, status_label, { - text: "Please enter a user ID.", + text: #(tr_key(self.app_language, "invite_modal.status.enter_user_id")), draw_text +: { color: mod.widgets.COLOR_FG_DANGER_RED, }, @@ -215,7 +227,7 @@ impl WidgetMatchEvent for InviteModal { }); self.state = InviteModalState::WaitingForInvite(user_id.to_owned()); script_apply_eval!(cx, status_label, { - text: "Sending invite...", + text: #(tr_key(self.app_language, "invite_modal.status.sending")), draw_text +: { color: mod.widgets.COLOR_ACTIVE_PRIMARY_DARKER, }, @@ -227,7 +239,7 @@ impl WidgetMatchEvent for InviteModal { } Err(_) => { script_apply_eval!(cx, status_label, { - text: "Invalid User ID. Expected format: @user:server.xyz", + text: #(tr_key(self.app_language, "invite_modal.status.invalid_user_id")), draw_text +: { color: mod.widgets.COLOR_FG_DANGER_RED, }, @@ -247,7 +259,11 @@ impl WidgetMatchEvent for InviteModal { if self.room_name_id.as_ref().is_some_and(|rni| rni.room_id() == room_id) && invited_user_id == user_id => { - let status = format!("Successfully invited {user_id}!"); + let status = tr_fmt( + self.app_language, + "invite_modal.status.success_invited", + &[("user_id", user_id.as_str())], + ); script_apply_eval!(cx, status_label, { text: #(status), draw_text +: { @@ -264,7 +280,12 @@ impl WidgetMatchEvent for InviteModal { if self.room_name_id.as_ref().is_some_and(|rni| rni.room_id() == room_id) && invited_user_id == user_id => { - let status = format!("Failed to send invite: {error}"); + let error_text = error.to_string(); + let status = tr_fmt( + self.app_language, + "invite_modal.status.send_failed", + &[("error", error_text.as_str())], + ); script_apply_eval!(cx, status_label, { text: #(status), draw_text +: { @@ -290,11 +311,31 @@ impl WidgetMatchEvent for InviteModal { } impl InviteModal { - pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { - self.view.label(cx, ids!(title)).set_text( - cx, - &format!("Invite to {room_name_id}"), + fn set_invite_title(&mut self, cx: &mut Cx, room_name_id: &RoomNameId) { + let room_name = room_name_id.to_string(); + let title = tr_fmt( + self.app_language, + "invite_modal.title.invite_to_room_name", + &[("room_name", room_name.as_str())], ); + self.view.label(cx, ids!(title)).set_text(cx, &title); + } + + fn update_static_texts(&mut self, cx: &mut Cx) { + self.view.button(cx, ids!(cancel_button)) + .set_text(cx, tr_key(self.app_language, "invite_modal.button.cancel")); + self.view.button(cx, ids!(confirm_button)) + .set_text(cx, tr_key(self.app_language, "invite_modal.button.invite")); + self.view.button(cx, ids!(okay_button)) + .set_text(cx, tr_key(self.app_language, "invite_modal.button.okay")); + self.view.text_input(cx, ids!(user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "invite_modal.input.placeholder").to_string()); + } + + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId, app_language: AppLanguage) { + self.app_language = app_language; + self.set_invite_title(cx, &room_name_id); + self.update_static_texts(cx); self.state = InviteModalState::WaitingForUserInput; self.room_name_id = Some(room_name_id); @@ -321,8 +362,8 @@ impl InviteModal { } impl InviteModalRef { - pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId, app_language: AppLanguage) { let Some(mut inner) = self.borrow_mut() else { return }; - inner.show(cx, room_name_id); + inner.show(cx, room_name_id, app_language); } } diff --git a/src/home/invite_screen.rs b/src/home/invite_screen.rs index 672b6d1ba..0bcd46acc 100644 --- a/src/home/invite_screen.rs +++ b/src/home/invite_screen.rs @@ -8,7 +8,7 @@ use std::ops::Deref; use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{app::AppStateAction, home::rooms_list::RoomsListRef, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::AvatarWidgetRefExt, popup_list::{enqueue_popup_notification, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt}, sliding_sync::{submit_async_request, MatrixRequest}, utils::{self, RoomNameId}}; +use crate::{app::{AppState, AppStateAction}, home::rooms_list::RoomsListRef, i18n::{AppLanguage, tr_fmt, tr_key}, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::AvatarWidgetRefExt, popup_list::{enqueue_popup_notification, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt}, sliding_sync::{submit_async_request, MatrixRequest}, utils::{self, RoomNameId}}; use super::rooms_list::{InviteState, InviterInfo}; @@ -251,10 +251,16 @@ pub struct InviteScreen { #[rust] room_name_id: Option, #[rust] is_loaded: bool, #[rust] all_rooms_loaded: bool, + #[rust] app_language: AppLanguage, } impl Widget for InviteScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + self.app_language = app_language; + // Currently, a Signal event is only used to tell this widget // to check if the room has been loaded from the homeserver yet. if let Event::Signal = event { @@ -324,7 +330,11 @@ impl Widget for InviteScreen { Some(JoinRoomResultAction::Joined { room_id }) if room_id == info.room_id() => { self.invite_state = InviteState::WaitingForJoinedRoom; if !self.has_shown_confirmation { - enqueue_popup_notification("Successfully joined room.", PopupKind::Success, Some(5.0)); + enqueue_popup_notification( + tr_key(self.app_language, "invite_screen.popup.joined_success"), + PopupKind::Success, + Some(5.0), + ); } continue; } @@ -343,14 +353,23 @@ impl Widget for InviteScreen { Some(LeaveRoomResultAction::Left { room_id }) if room_id == info.room_id() => { self.invite_state = InviteState::RoomLeft; if !self.has_shown_confirmation { - enqueue_popup_notification("Successfully rejected invite.", PopupKind::Success, Some(5.0)); + enqueue_popup_notification( + tr_key(self.app_language, "invite_screen.popup.rejected_success"), + PopupKind::Success, + Some(5.0), + ); } continue; } Some(LeaveRoomResultAction::Failed { room_id, error }) if room_id == info.room_id() => { self.invite_state = InviteState::WaitingOnUserInput; if !self.has_shown_confirmation { - enqueue_popup_notification(format!("Failed to reject invite: {error}"), PopupKind::Error, None); + let error_text = error.to_string(); + enqueue_popup_notification( + tr_fmt(self.app_language, "invite_screen.popup.reject_failed", &[("error", error_text.as_str())]), + PopupKind::Error, + None, + ); } continue; } @@ -375,6 +394,11 @@ impl Widget for InviteScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + self.app_language = app_language; + if !self.is_loaded { let mut restore_status_view = self.view.restore_status_view(cx, ids!(restore_status_view)); if let Some(room_name) = &self.room_name_id { @@ -421,10 +445,10 @@ impl Widget for InviteScreen { inviter_name.set_text(cx, inviter.user_id.as_str()); inviter_user_id.set_visible(cx, false); } - (true, "has invited you to join:") + (true, tr_key(self.app_language, "invite_screen.message.invited_by")) } else { - (false, "You have been invited to join:") + (false, tr_key(self.app_language, "invite_screen.message.invited_generic")) }; inviter_view.set_visible(cx, is_visible); self.view.label(cx, ids!(invite_message)).set_text(cx, invite_text); @@ -459,33 +483,33 @@ impl Widget for InviteScreen { InviteState::WaitingOnUserInput => { cancel_button.set_enabled(cx, true); accept_button.set_enabled(cx, true); - cancel_button.set_text(cx, "Reject Invite"); - accept_button.set_text(cx, "Join Room"); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.reject")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.join")); } InviteState::WaitingForJoinResult => { cancel_button.set_enabled(cx, false); accept_button.set_enabled(cx, false); - cancel_button.set_text(cx, "Reject Invite"); - accept_button.set_text(cx, "Joining..."); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.reject")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.joining")); } InviteState::WaitingForLeaveResult => { cancel_button.set_enabled(cx, false); accept_button.set_enabled(cx, false); - cancel_button.set_text(cx, "Rejecting..."); - accept_button.set_text(cx, "Join Room"); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.rejecting")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.join")); } InviteState::WaitingForJoinedRoom => { cancel_button.set_enabled(cx, false); accept_button.set_enabled(cx, false); - cancel_button.set_text(cx, "Reject Invite"); - accept_button.set_text(cx, "Joined!"); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.reject")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.joined")); } InviteState::RoomLeft => { cancel_button.set_visible(cx, false); accept_button.set_visible(cx, false); self.view.label(cx, ids!(completion_label)).set_text( cx, - "Invite successfully rejected. You may close this invite.", + tr_key(self.app_language, "invite_screen.completion.rejected"), ); } } diff --git a/src/home/loading_pane.rs b/src/home/loading_pane.rs index baa975a3d..5e6901572 100644 --- a/src/home/loading_pane.rs +++ b/src/home/loading_pane.rs @@ -1,7 +1,7 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedEventId; -use crate::sliding_sync::TimelineRequestSender; +use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, sliding_sync::TimelineRequestSender}; script_mod! { @@ -115,6 +115,7 @@ pub enum LoadingPaneState { pub struct LoadingPane { #[deref] view: View, #[rust] state: LoadingPaneState, + #[rust] app_language: AppLanguage, } impl Drop for LoadingPane { fn drop(&mut self) { @@ -134,6 +135,12 @@ impl Drop for LoadingPane { impl Widget for LoadingPane { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.visible = true; if matches!(self.state, LoadingPaneState::None) { self.visible = false; @@ -144,6 +151,12 @@ impl Widget for LoadingPane { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } if !self.visible { return; } self.view.handle_event(cx, event, scope); @@ -196,6 +209,49 @@ impl Widget for LoadingPane { impl LoadingPane { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_state_text(cx); + self.view.redraw(cx); + } + + fn sync_state_text(&mut self, cx: &mut Cx) { + let (title, status, cancel_text) = match &self.state { + LoadingPaneState::BackwardsPaginateUntilEvent { + target_event_id, + events_paginated, + .. + } => { + let events_paginated_str = events_paginated.to_string(); + ( + tr_key(self.app_language, "loading_pane.title.searching_older").to_string(), + Some(tr_fmt(self.app_language, "loading_pane.status.searching_event", &[ + ("target_event_id", target_event_id.as_str()), + ("events_paginated", events_paginated_str.as_str()), + ])), + tr_key(self.app_language, "loading_pane.button.cancel").to_string(), + ) + } + LoadingPaneState::Error(error_message) => ( + tr_key(self.app_language, "loading_pane.title.error").to_string(), + Some(error_message.clone()), + tr_key(self.app_language, "loading_pane.button.okay").to_string(), + ), + LoadingPaneState::None => ( + tr_key(self.app_language, "loading_pane.title.default").to_string(), + None, + tr_key(self.app_language, "loading_pane.button.cancel").to_string(), + ), + }; + + self.set_title(cx, &title); + if let Some(status) = status { + self.set_status(cx, &status); + } + let cancel_button = self.button(cx, ids!(cancel_button)); + cancel_button.set_text(cx, &cancel_text); + } + /// Returns `true` if this pane is currently being shown. pub fn is_currently_shown(&self, _cx: &mut Cx) -> bool { self.visible @@ -208,29 +264,8 @@ impl LoadingPane { } pub fn set_state(&mut self, cx: &mut Cx, state: LoadingPaneState) { - let cancel_button = self.button(cx, ids!(cancel_button)); - match &state { - LoadingPaneState::BackwardsPaginateUntilEvent { - target_event_id, - events_paginated, - .. - } => { - self.set_title(cx, "Searching older messages..."); - self.set_status(cx, &format!( - "Looking for event {target_event_id}\n\n\ - Fetched {events_paginated} messages so far...", - )); - cancel_button.set_text(cx, "Cancel"); - } - LoadingPaneState::Error(error_message) => { - self.set_title(cx, "Error loading content"); - self.set_status(cx, error_message); - cancel_button.set_text(cx, "Okay"); - } - LoadingPaneState::None => { } - } - self.state = state; + self.sync_state_text(cx); self.redraw(cx); } diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 628d477bf..3aef9e008 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -3,7 +3,7 @@ use ruma::OwnedRoomId; use tokio::sync::Notify; use std::{collections::HashMap, sync::Arc}; -use crate::{app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef, space_lobby::SpaceLobbyScreenWidgetRefExt}, utils::RoomNameId}; +use crate::{app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef, space_lobby::SpaceLobbyScreenWidgetRefExt}, logout::logout_confirm_modal::LogoutAction, sliding_sync::AccountSwitchAction, utils::RoomNameId}; use super::{invite_screen::InviteScreenWidgetRefExt, room_screen::RoomScreenWidgetRefExt, rooms_list::RoomsListAction}; script_mod! { @@ -131,7 +131,17 @@ impl Widget for MainDesktopUI { // We must set `selected_space` first before the load operation occurs, in order for // the proper space-specific instance of the saved dock UI layout/state to be selected. self.selected_space = cx.get_global::().get_selected_space_id(); - cx.action(MainDesktopUiAction::LoadDockFromAppState); + let app_state = scope.data.get::().unwrap(); + let has_saved_dock_state = if let Some(space_id) = self.selected_space.as_ref() { + app_state.saved_dock_state_per_space + .get(space_id) + .is_some_and(|saved| !saved.open_rooms.is_empty()) + } else { + !app_state.saved_dock_state_home.open_rooms.is_empty() + }; + if has_saved_dock_state { + cx.action(MainDesktopUiAction::LoadDockFromAppState); + } self.drawn_previously = true; } self.view.draw_walk(cx, scope, walk) @@ -139,6 +149,37 @@ impl Widget for MainDesktopUI { } impl MainDesktopUI { + fn sync_tab_widget(cx: &mut Cx, widget: &WidgetRef, room: &SelectedRoom) { + match room { + SelectedRoom::JoinedRoom { room_name_id } => { + widget.as_room_screen().set_displayed_room( + cx, + room_name_id, + None, + ); + } + SelectedRoom::Thread { room_name_id, thread_root_event_id } => { + widget.as_room_screen().set_displayed_room( + cx, + room_name_id, + Some(thread_root_event_id.clone()), + ); + } + SelectedRoom::InvitedRoom { room_name_id } => { + widget.as_invite_screen().set_displayed_invite( + cx, + room_name_id, + ); + } + SelectedRoom::Space { space_name_id } => { + widget.as_space_lobby_screen().set_displayed_space( + cx, + space_name_id, + ); + } + } + } + /// Focuses on a room if it is already open, otherwise creates a new tab for the room. fn focus_or_create_tab(&mut self, cx: &mut Cx, room: SelectedRoom) { // Do nothing if the room to select is already created and focused. @@ -151,6 +192,11 @@ impl MainDesktopUI { // If the room is already open, select (jump to) its existing tab let room_tab_id = room.tab_id(); if self.open_rooms.contains_key(&room_tab_id) { + if let Some(mut dock_inner) = dock.borrow_mut() { + if let Some((_, widget)) = dock_inner.items().get(&room_tab_id) { + Self::sync_tab_widget(cx, widget, &room); + } + } dock.select_tab(cx, room_tab_id); self.most_recently_selected_room = Some(room); return; @@ -183,34 +229,7 @@ impl MainDesktopUI { // if the tab was created, set the room screen and add the room to the room order if let Some(new_widget) = new_tab_widget { self.room_order.push(room.clone()); - match &room { - SelectedRoom::JoinedRoom { room_name_id } => { - new_widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - None, - ); - } - SelectedRoom::Thread { room_name_id, thread_root_event_id } => { - new_widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - Some(thread_root_event_id.clone()), - ); - } - SelectedRoom::InvitedRoom { room_name_id } => { - new_widget.as_invite_screen().set_displayed_invite( - cx, - room_name_id, - ); - } - SelectedRoom::Space { space_name_id } => { - new_widget.as_space_lobby_screen().set_displayed_space( - cx, - space_name_id, - ); - } - } + Self::sync_tab_widget(cx, &new_widget, &room); cx.action(MainDesktopUiAction::SaveDockIntoAppState); } else { error!("BUG: failed to create tab for {room:?}"); @@ -270,6 +289,24 @@ impl MainDesktopUI { self.most_recently_selected_room = None; } + fn reset_to_default_layout(&mut self, cx: &mut Cx) { + self.open_rooms.clear(); + self.tab_to_close = None; + self.room_order.clear(); + self.most_recently_selected_room = None; + self.selected_space = None; + self.drawn_previously = false; + + if let Some(mut dock) = self.view.dock(cx, ids!(dock)).borrow_mut() { + dock.load_state(cx, self.default_layout.dock_items.clone()); + } else { + error!("BUG: failed to borrow dock widget to reset desktop UI to its default layout."); + } + + cx.action(AppStateAction::FocusNone); + self.redraw(cx); + } + /// Replaces an invite with a joined room in the dock. fn replace_invite_with_joined_room( &mut self, @@ -340,8 +377,11 @@ impl MainDesktopUI { /// /// If the saved state is empty (has no open rooms), we use the default dock layout /// defined in the DSL: one splitter with the RoomsList on the left and a Welcome tab on the right. + /// + /// Instead of calling `dock.load_state()` directly (which can corrupt Makepad's + /// internal DrawList references and cause blank rendering), we recreate each tab + /// programmatically via `focus_or_create_tab()`. fn load_dock_state_from(&mut self, cx: &mut Cx, app_state: &mut AppState) { - let dock = self.view.dock(cx, ids!(dock)); let to_restore_opt = if let Some(ss) = self.selected_space.as_ref() { app_state.saved_dock_state_per_space.get(ss) } else { @@ -352,59 +392,24 @@ impl MainDesktopUI { Some(sds) if sds.open_rooms.is_empty() => &self.default_layout, Some(sds) => sds, }; - let SavedDockState { dock_items, open_rooms, room_order, selected_room } = to_restore; - - self.room_order = room_order.clone(); - self.open_rooms = open_rooms.clone(); - - if let Some(mut dock) = dock.borrow_mut() { - dock.load_state(cx, dock_items.clone()); - // Populate the content within each restored dock tab. - if !self.open_rooms.is_empty() { - for (head_live_id, (_, widget)) in dock.items().iter() { - match self.open_rooms.get(head_live_id) { - Some(SelectedRoom::JoinedRoom { room_name_id }) => { - widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - None, - ); - } - Some(SelectedRoom::InvitedRoom { room_name_id }) => { - widget.as_invite_screen().set_displayed_invite( - cx, - room_name_id, - ); - } - Some(SelectedRoom::Space { space_name_id }) => { - widget.as_space_lobby_screen().set_displayed_space( - cx, - space_name_id, - ); - } - Some(SelectedRoom::Thread { room_name_id, thread_root_event_id }) => { - widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - Some(thread_root_event_id.clone()), - ); - } - None => { } - } - } - } - } else { - error!("BUG: failed to borrow dock widget to restore state upon LoadDockFromAppState action."); - return; + + let room_order = to_restore.room_order.clone(); + let selected_room = to_restore.selected_room.clone(); + + // Close any existing tabs first, starting from the default layout. + self.close_all_tabs(cx); + + // Recreate each room tab in the saved order. + for room in &room_order { + self.focus_or_create_tab(cx, room.clone()); } - // Note: the borrow of `dock` must end here *before* we call `self.focus_or_create_tab()`. - // Now that we've loaded the dock content, we can re-select the selected room. - let selected_room = selected_room.clone(); - if let Some(selected_room) = selected_room.clone() { - self.focus_or_create_tab(cx, selected_room); + // Re-select the previously-selected room (or the last one if not set). + let final_selected = selected_room.or_else(|| room_order.last().cloned()); + if let Some(selected) = final_selected.clone() { + self.focus_or_create_tab(cx, selected); } - app_state.selected_room = selected_room; + app_state.selected_room = final_selected; self.redraw(cx); } } @@ -421,6 +426,17 @@ impl WidgetMatchEvent for MainDesktopUI { continue; } + if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + self.reset_to_default_layout(cx); + continue; + } + + // When switching accounts, close all room tabs (keeping only the home tab) + if let Some(AccountSwitchAction::Starting(_)) = action.downcast_ref() { + self.reset_to_default_layout(cx); + continue; + } + // If the currently-selected space has been changed, we must handle that // by switching the dock to show the layout for another space. if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { @@ -450,6 +466,11 @@ impl WidgetMatchEvent for MainDesktopUI { self.most_recently_selected_room = None; } else if let Some(selected_room) = self.open_rooms.get(&tab_id) { + if let Some(mut dock) = self.view.dock(cx, ids!(dock)).borrow_mut() { + if let Some((_, widget)) = dock.items().get(&tab_id) { + Self::sync_tab_widget(cx, widget, selected_room); + } + } cx.action(AppStateAction::RoomFocused(selected_room.clone())); self.most_recently_selected_room = Some(selected_room.clone()); } diff --git a/src/home/mod.rs b/src/home/mod.rs index 23a1de96d..1176c3723 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -1,6 +1,8 @@ use makepad_widgets::ScriptVm; pub mod add_room; +pub mod create_bot_modal; +pub mod delete_bot_modal; pub mod edited_indicator; pub mod editing_pane; pub mod event_source_modal; @@ -29,12 +31,15 @@ pub mod new_message_context_menu; pub mod room_context_menu; pub mod link_preview; pub mod room_image_viewer; +pub mod streaming_animation; pub fn script_mod(vm: &mut ScriptVm) { search_messages::script_mod(vm); loading_pane::script_mod(vm); location_preview::script_mod(vm); add_room::script_mod(vm); + create_bot_modal::script_mod(vm); + delete_bot_modal::script_mod(vm); space_lobby::script_mod(vm); link_preview::script_mod(vm); event_reaction_list::script_mod(vm); diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index 95cec1317..1766fa943 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -34,9 +34,9 @@ use crate::{ avatar_cache::{self, AvatarCacheEntry}, login::login_screen::LoginAction, logout::logout_confirm_modal::LogoutAction, profile::{ user_profile::UserProfile, user_profile_cache::{self, UserProfileUpdate}, - }, shared::{ + }, home::spaces_bar::SpacesBarWidgetExt, shared::{ avatar::{AvatarState, AvatarWidgetExt}, styles::*, verification_badge::VerificationBadgeWidgetExt - }, sliding_sync::{current_user_id, AccountDataAction}, utils::{self, RoomNameId} + }, sliding_sync::{current_user_id, AccountDataAction, AccountSwitchAction}, utils::{self, RoomNameId} }; script_mod! { @@ -289,6 +289,13 @@ impl Widget for ProfileIcon { continue; } + // Handle account switch - refresh profile with new account's data + if let Some(AccountSwitchAction::Switched(_new_user_id)) = action.downcast_ref() { + self.own_profile = get_own_profile(cx); + self.view.redraw(cx); + continue; + } + // Handle account data changes (e.g., avatar updated/removed) match action.downcast_ref() { Some(AccountDataAction::AvatarChanged(None)) => { @@ -425,6 +432,7 @@ impl ScriptHook for NavigationTabBar { if let Some(mut rb) = self.view.radio_button(cx, ids!(home_button)).borrow_mut() { rb.animator_play(cx, ids!(active.on)); } + cx.set_global(self.view.spaces_bar(cx, ids!(root_spaces_bar))); }); } } diff --git a/src/home/new_message_context_menu.rs b/src/home/new_message_context_menu.rs index 06c963fb3..b7552b733 100644 --- a/src/home/new_message_context_menu.rs +++ b/src/home/new_message_context_menu.rs @@ -116,6 +116,11 @@ script_mod! { text: "Reply" } + thread_button := mod.widgets.NewMessageContextMenuButton { + draw_icon +: { svg: crate_resource("self://resources/icons/double_chat.svg") } + text: "" + } + divider_after_react_reply := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -272,6 +277,8 @@ pub struct MessageDetails { pub thread_root_event_id: Option, /// The widget ID of the RoomScreen that contains this message. pub room_screen_widget_uid: WidgetUid, + /// Whether this message is currently being shown in a thread-focused timeline. + pub is_thread_timeline: bool, /// Whether this message should be highlighted, i.e., /// if it mentions the room/current user or is a reply to the current user. pub should_be_highlighted: bool, @@ -382,6 +389,15 @@ impl WidgetMatchEvent for NewMessageContextMenu { ); close_menu = true; } + else if self.button(cx, ids!(thread_button)).clicked(actions) { + if let Some(thread_root_event_id) = details.thread_root_event_id.as_ref().or_else(|| details.event_id()) { + cx.widget_action( + details.room_screen_widget_uid, + MessageAction::OpenThread(thread_root_event_id.clone()), + ); + } + close_menu = true; + } else if self.button(cx, ids!(edit_message_button)).clicked(actions) { cx.widget_action( details.room_screen_widget_uid, @@ -497,6 +513,7 @@ impl NewMessageContextMenu { let react_button = self.view.button(cx, ids!(react_button)); let reply_button = self.view.button(cx, ids!(reply_button)); + let thread_button = self.view.button(cx, ids!(thread_button)); let edit_button = self.view.button(cx, ids!(edit_message_button)); let pin_button = self.view.button(cx, ids!(pin_button)); let copy_text_button = self.view.button(cx, ids!(copy_text_button)); @@ -512,7 +529,8 @@ impl NewMessageContextMenu { // `copy_text_button`, `copy_link_to_message_button`, and `view_source_button` let show_react = details.abilities.contains(MessageAbilities::CanReact); let show_reply_to = details.abilities.contains(MessageAbilities::CanReplyTo); - let show_divider_after_react_reply = show_react || show_reply_to; + let show_thread = !details.is_thread_timeline && details.event_id().is_some(); + let show_divider_after_react_reply = show_react || show_reply_to || show_thread; let show_edit = details.abilities.contains(MessageAbilities::CanEdit); let show_pin: bool; let show_copy_text = true; @@ -528,8 +546,14 @@ impl NewMessageContextMenu { self.view.view(cx, ids!(react_view)).set_visible(cx, show_react); react_button.set_visible(cx, show_react); reply_button.set_visible(cx, show_reply_to); + thread_button.set_visible(cx, show_thread); self.view.view(cx, ids!(divider_after_react_reply)).set_visible(cx, show_divider_after_react_reply); edit_button.set_visible(cx, show_edit); + if details.thread_root_event_id.is_some() { + thread_button.set_text(cx, "Open Thread"); + } else { + thread_button.set_text(cx, "Reply in Thread"); + } if details.abilities.contains(MessageAbilities::CanPin) { pin_button.set_text(cx, "Pin Message"); show_pin = true; @@ -549,6 +573,7 @@ impl NewMessageContextMenu { // Reset the hover state of each button. react_button.reset_hover(cx); reply_button.reset_hover(cx); + thread_button.reset_hover(cx); edit_button.reset_hover(cx); pin_button.reset_hover(cx); copy_text_button.reset_hover(cx); @@ -568,6 +593,7 @@ impl NewMessageContextMenu { let num_visible_buttons = show_react as u8 + show_reply_to as u8 + + show_thread as u8 + show_edit as u8 + show_pin as u8 + show_copy_text as u8 diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index c55b7fa54..06d2abcc8 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -3,7 +3,7 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{home::invite_modal::InviteModalAction, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, submit_async_request}, utils::RoomNameId}; +use crate::{app::AppState, home::invite_modal::InviteModalAction, i18n::{AppLanguage, tr_fmt, tr_key}, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId}; const BUTTON_HEIGHT: f64 = 35.0; const MENU_WIDTH: f64 = 215.0; @@ -99,6 +99,11 @@ script_mod! { text: "Invite" } + bot_binding_button := mod.widgets.RoomContextMenuButton { + draw_icon +: { svg: (ICON_HIERARCHY) } + text: "Bind BotFather" + } + divider2 := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -123,6 +128,8 @@ pub struct RoomContextMenuDetails { pub is_favorite: bool, pub is_low_priority: bool, pub is_marked_unread: bool, + pub app_service_enabled: bool, + pub is_bot_bound: bool, } /// Actions emitted from the RoomContextMenu widget, as they must be handled @@ -140,6 +147,7 @@ pub struct RoomContextMenu { #[deref] view: View, #[source] source: ScriptObjectRef, #[rust] details: Option, + #[rust] app_language: AppLanguage, } impl Widget for RoomContextMenu { @@ -152,6 +160,14 @@ impl Widget for RoomContextMenu { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { if !self.visible { return; } + if let Some(app_state) = scope.data.get::() + && self.app_language != app_state.app_language + { + self.app_language = app_state.app_language; + if let Some(details) = self.details.clone() { + self.update_buttons(cx, &details); + } + } self.view.handle_event(cx, event, scope); // Close logic similar to NewMessageContextMenu @@ -178,7 +194,7 @@ impl Widget for RoomContextMenu { } impl WidgetMatchEvent for RoomContextMenu { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) { let Some(details) = self.details.as_ref() else { return }; let mut close_menu = false; @@ -212,10 +228,10 @@ impl WidgetMatchEvent for RoomContextMenu { }); close_menu = true; } - else if self.button(cx, ids!(room_settings_button)).clicked(actions) { + else if self.button(cx, ids!(room_settings_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( - "The room settings page is not yet implemented.", + tr_key(self.app_language, "room_context_menu.popup.settings_not_implemented"), PopupKind::Warning, Some(5.0), ); @@ -224,7 +240,7 @@ impl WidgetMatchEvent for RoomContextMenu { else if self.button(cx, ids!(notifications_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( - "The room notifications page is not yet implemented.", + tr_key(self.app_language, "room_context_menu.popup.notifications_not_implemented"), PopupKind::Warning, Some(5.0), ); @@ -234,6 +250,55 @@ impl WidgetMatchEvent for RoomContextMenu { cx.action(InviteModalAction::Open(details.room_name_id.clone())); close_menu = true; } + else if self.button(cx, ids!(bot_binding_button)).clicked(actions) { + if let Some(app_state) = scope.data.get::() { + let room_id = details.room_name_id.room_id().clone(); + match app_state.bot_settings.resolved_bot_user_id_for_room( + &room_id, + current_user_id().as_deref(), + ) { + Ok(bot_user_id) => { + if details.is_bot_bound { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id, + bound: false, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + tr_fmt(self.app_language, "room_context_menu.popup.removing_botfather", &[ + ("bot_user_id", bot_user_id.as_str()), + ]), + PopupKind::Info, + Some(4.0), + ); + } else { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id, + bound: true, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + tr_fmt(self.app_language, "room_context_menu.popup.inviting_botfather", &[ + ("bot_user_id", bot_user_id.as_str()), + ]), + PopupKind::Info, + Some(5.0), + ); + } + } + Err(error) => { + enqueue_popup_notification(error, PopupKind::Error, Some(5.0)); + } + } + } else { + enqueue_popup_notification( + tr_key(self.app_language, "room_context_menu.popup.bot_settings_unavailable"), + PopupKind::Error, + Some(5.0), + ); + } + close_menu = true; + } else if self.button(cx, ids!(leave_button)).clicked(actions) { use crate::join_leave_room_modal::{JoinLeaveRoomModalAction, JoinLeaveModalKind}; use crate::room::BasicRoomDetails; @@ -256,7 +321,8 @@ impl RoomContextMenu { self.visible } - pub fn show(&mut self, cx: &mut Cx, details: RoomContextMenuDetails) -> DVec2 { + pub fn show(&mut self, cx: &mut Cx, details: RoomContextMenuDetails, app_language: AppLanguage) -> DVec2 { + self.app_language = app_language; let height = self.update_buttons(cx, &details); self.details = Some(details); self.visible = true; @@ -267,23 +333,42 @@ impl RoomContextMenu { fn update_buttons(&mut self, cx: &mut Cx, details: &RoomContextMenuDetails) -> f64 { let mark_unread_button = self.button(cx, ids!(mark_unread_button)); if details.is_marked_unread { - mark_unread_button.set_text(cx, "Mark as Read"); + mark_unread_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.mark_read")); } else { - mark_unread_button.set_text(cx, "Mark as Unread"); + mark_unread_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.mark_unread")); } let favorite_button = self.button(cx, ids!(favorite_button)); if details.is_favorite { - favorite_button.set_text(cx, "Un-favorite"); + favorite_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.unfavorite")); } else { - favorite_button.set_text(cx, "Favorite"); + favorite_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.favorite")); } let priority_button = self.button(cx, ids!(priority_button)); if details.is_low_priority { - priority_button.set_text(cx, "Un-set Low Priority"); + priority_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.unset_low_priority")); + } else { + priority_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.set_low_priority")); + } + + self.button(cx, ids!(copy_link_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.copy_link_to_room")); + self.button(cx, ids!(room_settings_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.settings")); + self.button(cx, ids!(notifications_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.notifications")); + self.button(cx, ids!(invite_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.invite")); + self.button(cx, ids!(leave_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.leave_room")); + + let bot_binding_button = self.button(cx, ids!(bot_binding_button)); + bot_binding_button.set_visible(cx, details.app_service_enabled); + if details.is_bot_bound { + bot_binding_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.unbind_botfather")); } else { - priority_button.set_text(cx, "Set Low Priority"); + bot_binding_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.bind_botfather")); } // Reset hover states @@ -294,13 +379,14 @@ impl RoomContextMenu { self.button(cx, ids!(room_settings_button)).reset_hover(cx); self.button(cx, ids!(notifications_button)).reset_hover(cx); self.button(cx, ids!(invite_button)).reset_hover(cx); + bot_binding_button.reset_hover(cx); self.button(cx, ids!(leave_button)).reset_hover(cx); self.redraw(cx); // Calculate height (rudimentary) - sum of visible buttons + padding - // 8 buttons * 35.0 + 2 dividers * ~10.0 + padding - (8.0 * BUTTON_HEIGHT) + 20.0 + 10.0 // approx + // 8 or 9 buttons * 35.0 + 2 dividers * ~10.0 + padding + ((if details.app_service_enabled { 9.0 } else { 8.0 }) * BUTTON_HEIGHT) + 20.0 + 10.0 // approx } fn close(&mut self, cx: &mut Cx) { @@ -317,8 +403,8 @@ impl RoomContextMenuRef { inner.is_currently_shown(cx) } - pub fn show(&self, cx: &mut Cx, details: RoomContextMenuDetails) -> DVec2 { + pub fn show(&self, cx: &mut Cx, details: RoomContextMenuDetails, app_language: AppLanguage) -> DVec2 { let Some(mut inner) = self.borrow_mut() else { return DVec2::default()}; - inner.show(cx, details) + inner.show(cx, details, app_language) } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 61f20ced9..81e62106e 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,19 +1,19 @@ //! The `RoomScreen` widget is the UI view that displays a single room or thread's timeline //! of events (messages,state changes, etc.), along with an input bar at the bottom. -use std::{borrow::Cow, cell::RefCell, ops::{DerefMut, Range}, sync::Arc}; +use std::{borrow::Cow, cell::RefCell, ops::{DerefMut, Range}, sync::Arc, time::Duration}; use bytesize::ByteSize; use hashbrown::{HashMap, HashSet}; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - OwnedServerName, RoomDisplayName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ + OwnedServerName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, events::{ receipt::Receipt, room::{ ImageInfo, MediaSource, message::{ - AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent + AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, RoomMessageEventContent, TextMessageEventContent, VideoMessageEventContent } }, sticker::{StickerEventContent, StickerMediaSource}, @@ -26,7 +26,7 @@ use matrix_sdk_ui::timeline::{ use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, owned_room_id}; use crate::{ - app::{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::{edited_indicator::EditedIndicatorWidgetRefExt, 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}, tombstone_footer::SuccessorRoomDetails}, 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::{create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, edited_indicator::EditedIndicatorWidgetRefExt, 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}, 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, }, @@ -34,7 +34,7 @@ use crate::{ shared::{ avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, 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, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} + 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} }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; @@ -55,13 +55,257 @@ const MAX_ITEMS_TO_SEARCH_THROUGH: usize = 100; /// The max size (width or height) of a blurhash image to decode. const BLURHASH_IMAGE_MAX_SIZE: u32 = 500; -static UNNAMED_ROOM: &str = "Unnamed Room"; +/// Use a larger batch when we are trying to fill the initial viewport, +/// otherwise many short messages can trigger a long chain of tiny paginations. +const VIEWPORT_FILL_PAGINATION_SIZE: u16 = 150; + /// #FFF4E5 const COLOR_THREAD_SUMMARY_BG: Vec4 = vec4(1.0, 0.957, 0.898, 1.0); /// #FFEACC const COLOR_THREAD_SUMMARY_BG_HOVER: Vec4 = vec4(1.0, 0.918, 0.8, 1.0); +fn item_event_id(item: &Arc) -> Option<&EventId> { + let TimelineItemKind::Event(event) = item.kind() else { + return None; + }; + event.event_id() +} + +/// Check if an event carries the MSC4357 `org.matrix.msc4357.live` field, +/// indicating that the message content is still being streamed. +/// +/// For edit events (`m.replace`), the live field lives inside `m.new_content` +/// rather than at the top level of `content`, so we check both locations. +fn content_has_msc4357_live_marker(content: &serde_json::Value) -> bool { + let effective = content.get("m.new_content").unwrap_or(content); + match effective.get("org.matrix.msc4357.live") { + Some(serde_json::Value::Bool(value)) => *value, + Some(_) => true, + None => false, + } +} + +fn is_msc4357_live(event_tl_item: &EventTimelineItem) -> bool { + let message_is_edited = event_tl_item + .content() + .as_message() + .is_some_and(|message| message.is_edited()); + event_tl_item.latest_edit_json() + .or_else(|| (!message_is_edited).then(|| event_tl_item.original_json()).flatten()) + .and_then(|raw| raw.get_field::("content").ok()) + .flatten() + .map(|content| content_has_msc4357_live_marker(&content)) + .unwrap_or(false) +} + +fn streaming_scan_range( + clear_cache: bool, + changed_indices: &Range, + _old_len: usize, + new_len: usize, +) -> Range { + if clear_cache { + 0..new_len + } else { + let start = changed_indices.start.min(new_len); + let end = changed_indices.end.min(new_len); + start..end + } +} + +fn refresh_stream_indices<'a, I>( + event_ids: I, + streaming_messages: &mut HashMap, +) +where + I: IntoIterator>, +{ + for state in streaming_messages.values_mut() { + state.timeline_index = None; + } + + for (idx, event_id) in event_ids.into_iter().enumerate() { + let Some(event_id) = event_id else { + continue; + }; + if let Some(state) = streaming_messages.get_mut(event_id) { + state.timeline_index = Some(idx); + } + } +} + +fn streaming_candidates_from_items<'a>( + items: &'a Vector>, +) -> impl Iterator + 'a { + items.iter().filter_map(|item| { + let TimelineItemKind::Event(event) = item.kind() else { + return None; + }; + let event_id = event.event_id()?.to_owned(); + let text = RoomScreen::extract_message_text(item)?; + Some((event_id, text, is_msc4357_live(event))) + }) +} + +fn rebuild_streaming_messages_for_full_snapshot( + items: I, + previous_streaming_messages: Option<&HashMap>, +) -> (HashMap, bool) +where + I: IntoIterator, +{ + use crate::home::streaming_animation::StreamingAnimState; + + let mut rebuilt = HashMap::new(); + let mut should_schedule_frame = false; + + for (event_id, new_text, live) in items { + if !live { + continue; + } + + // Only restore animations that were already tracked before the + // snapshot reset. Never create brand-new animations here — during + // initial/reconnect loads the SDK may not have aggregated edits yet, + // so completed messages can still appear as `live`. Genuinely new + // streams will be picked up on the next live sync update. + if let Some(previous_state) = previous_streaming_messages + .and_then(|states| states.get(&event_id)) + { + let state = StreamingAnimState::restore(previous_state, &new_text, true); + should_schedule_frame |= state.needs_frame(); + rebuilt.insert(event_id, state); + } + } + + (rebuilt, should_schedule_frame) +} + +fn next_stream_timeout<'a>( + states: impl IntoIterator, +) -> Option { + states + .into_iter() + .map(|state| state.timeout_after().saturating_sub(state.last_update_time.elapsed())) + .min() +} + +fn escape_slash_command_arg(value: &str) -> String { + value.trim().replace('\\', "\\\\").replace('"', "\\\"") +} + +fn format_create_bot_command( + username: &str, + display_name: &str, + system_prompt: Option<&str>, +) -> String { + let mut command = format!("/createbot {} {}", username.trim(), display_name.trim()); + if let Some(system_prompt) = system_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + { + command.push_str(" --prompt \""); + command.push_str(&escape_slash_command_arg(system_prompt)); + command.push('"'); + } + command +} + +fn format_delete_bot_command(matrix_user_id: &UserId) -> String { + format!("/deletebot {matrix_user_id}") +} + +fn resolve_delete_bot_user_id( + user_id_or_localpart: &str, + current_user_id: Option<&UserId>, + app_language: AppLanguage, +) -> Result { + let raw = user_id_or_localpart.trim(); + if raw.is_empty() { + return Err(tr_key(app_language, "room_screen.bot.delete.error.empty_user_id").into()); + } + + if raw.starts_with('@') || raw.contains(':') { + let full_user_id = if raw.starts_with('@') { + raw.to_string() + } else { + format!("@{raw}") + }; + return UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| tr_fmt(app_language, "room_screen.bot.delete.error.invalid_user_id", &[ + ("full_user_id", full_user_id.as_str()), + ])); + } + + let Some(current_user_id) = current_user_id else { + return Err( + tr_key(app_language, "room_screen.bot.delete.error.current_user_unavailable").into(), + ); + }; + + let full_user_id = format!("@{raw}:{}", current_user_id.server_name()); + UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| tr_fmt(app_language, "room_screen.bot.delete.error.invalid_user_id", &[ + ("full_user_id", full_user_id.as_str()), + ])) +} + +fn detected_bot_binding_for_members( + app_state: &AppState, + room_id: &OwnedRoomId, + members: &[RoomMember], +) -> Option { + if app_state.bot_settings.is_room_bound(room_id) { + return None; + } + + let Ok(bot_user_id) = app_state + .bot_settings + .resolved_bot_user_id_for_room(room_id, current_user_id().as_deref()) + else { + return None; + }; + + members + .iter() + .any(|room_member| room_member.user_id() == bot_user_id) + .then_some(bot_user_id) +} + +fn is_likely_bot_user_id( + user_id: &UserId, + resolved_parent_bot_user_id: Option<&UserId>, +) -> bool { + if resolved_parent_bot_user_id.is_some_and(|parent| parent == user_id) { + return true; + } + + let localpart = user_id.localpart().to_ascii_lowercase(); + localpart == "bot" + || localpart.starts_with("bot_") + || localpart.ends_with("_bot") + || (localpart.ends_with("bot") && localpart.len() > 3) +} + +fn is_likely_bot_member( + room_member: &RoomMember, + resolved_parent_bot_user_id: Option<&UserId>, +) -> bool { + if is_likely_bot_user_id(room_member.user_id(), resolved_parent_bot_user_id) { + return true; + } + + room_member.display_name().is_some_and(|display_name| { + let display_name = display_name.trim().to_ascii_lowercase(); + display_name == "bot" + || display_name.starts_with("bot ") + || display_name.ends_with(" bot") + || display_name.contains(" bot ") + }) +} script_mod! { use mod.prelude.widgets.* @@ -256,7 +500,7 @@ script_mod! { text_style: USERNAME_TEXT_STYLE {}, color: (USERNAME_TEXT_COLOR) } - text: "" + text: "" } } @@ -416,7 +660,7 @@ script_mod! { draw_icon.svg: (ICON_ADD_USER) draw_text.text_style: SMALL_STATE_TEXT_STYLE {} icon_walk: Walk{width: 15, height: Fit, margin: Inset{right: -4}} - text: "Invite to Room" + text: "" } content := Label { @@ -456,7 +700,7 @@ script_mod! { text_style: TEXT_SUB {}, color: (COLOR_DIVIDER_DARK) } - text: "" + text: "" } right_line := LineH { } @@ -471,7 +715,7 @@ script_mod! { date := Label { draw_text.color: (mod.widgets.COLOR_READ_MARKER) - text: "New Messages" + text: "" } right_line := LineH { @@ -500,7 +744,413 @@ script_mod! { text_style: MESSAGE_TEXT_STYLE { font_size: 10 }, color: (TIMESTAMP_TEXT_COLOR) } - text: "Loading earlier messages..." + text: "" + } + } + + mod.widgets.ThreadsPaneEntry = #(ThreadsPaneEntry::register_widget(vm)) { + ..mod.widgets.RoundedView + + width: Fill + height: Fit + flow: Down + spacing: 5 + padding: Inset{top: 12, right: 12, bottom: 12, left: 12} + margin: Inset{left: 12, right: 12, top: 6, bottom: 0} + cursor: MouseCursor.Hand + + show_bg: true + draw_bg +: { + color: #F8FAFD + border_radius: 4.0 + border_size: 1.0 + border_color: #D8E0EA + } + + title_row := View { + width: Fill + height: Fit + flow: Right + spacing: 8 + + title := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 10.8 } + color: #1F1F1F + } + text: "" + } + + time := Label { + width: Fit + height: Fit + draw_text +: { + text_style: TIMESTAMP_TEXT_STYLE { font_size: 7.5 } + color: (TIMESTAMP_TEXT_COLOR) + } + text: "" + } + } + + subtitle := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 9.8 } + color: #7B7B7B + } + text: "" + } + + preview := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.0 } + color: (COLOR_TEXT) + } + text: "" + } + } + + mod.widgets.ThreadsSlidingPane = #(ThreadsSlidingPane::register_widget(vm)) { + visible: false, + flow: Overlay, + width: Fill, + height: Fill, + align: Align{x: 1.0, y: 0} + + bg_view := SolidView { + width: Fill + height: Fill + visible: false, + show_bg: true + draw_bg.color: #000000BB + } + + main_content := SolidView { + width: 320, + height: Fill + flow: Down, + align: Align{x: 1.0} + + show_bg: true, + draw_bg.color: (COLOR_PRIMARY) + + header := View { + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + padding: Inset{top: 12, right: 10, bottom: 12, left: 15} + + title := Label { + width: Fit + height: Fit + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 12.5 } + color: #000 + } + text: "Threads" + } + + spacer := View { + width: Fill + height: Fit + } + + close_button := RobrixNeutralIconButton { + width: Fit, + height: Fit, + spacing: 0, + padding: 15, + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 14, height: 14} + text: "" + } + } + + room_name := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + padding: Inset{left: 15, right: 15, bottom: 10} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + color: #6E6E6E + } + text: "" + } + + loading_indicator := View { + visible: false + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + spacing: 8 + padding: Inset{left: 15, right: 15, top: 6, bottom: 10} + + spinner := LoadingSpinner { + width: 18 + height: 18 + } + + loading_label := Label { + width: Fit + height: Fit + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + color: #7B7B7B + } + text: "Loading threads..." + } + } + + empty_state := Label { + visible: false + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + padding: Inset{left: 15, right: 15, top: 20, bottom: 20} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + color: #7B7B7B + } + text: "No threads yet." + } + + threads_list := PortalList { + width: Fill + height: Fill + flow: Down + max_pull_down: 0.0 + + ThreadEntry := mod.widgets.ThreadsPaneEntry {} + } + } + + slide: 1.0, + + animator: Animator { + panel: { + default: @hide + show: AnimatorState{ + redraw: true, + from: {all: Forward {duration: 0.5}} + ease: Ease.ExpDecay {d1: 0.80, d2: 0.97} + apply: { + slide: 0.0 + } + } + hide: AnimatorState{ + redraw: true, + from: {all: Forward {duration: 0.5}} + ease: Ease.ExpDecay {d1: 0.80, d2: 0.97} + apply: { + slide: 1.0 + } + } + } + } + } + + mod.widgets.AppServicePanel = #(AppServicePanel::register_widget(vm)) { + width: Fill + height: Fit + margin: Inset{left: 14, right: 54, top: 10, bottom: 16} + flow: Down + align: Align{x: 0.0, y: 0.0} + spacing: 8 + + sender_row := View { + width: Fit + height: Fit + flow: Right + spacing: 6 + + sender_name := Label { + width: Fit + height: Fit + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 10.8 } + color: (COLOR_ACTIVE_PRIMARY) + } + text: "" + } + + sender_tag := Label { + width: Fit + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 9.5 } + color: #8A8A8A + } + text: "" + } + } + + bubble := RoundedView { + width: 408 + height: Fit + flow: Down + spacing: 8 + padding: Inset{top: 14, right: 14, bottom: 12, left: 14} + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 0.0 + border_size: 1.0 + border_color: (COLOR_SECONDARY_DARKER) + } + + header := View { + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + + title := Label { + width: Fit + height: Fit + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 11.2 } + color: #1F1F1F + } + text: "" + } + + spacer := View { + width: Fill + height: Fit + } + + dismiss_button := RobrixNeutralIconButton { + width: 28 + height: 24 + align: Align{x: 0.5, y: 0.5} + spacing: 0 + padding: 0 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 12, height: 12} + text: "" + } + } + + subtitle := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: (COLOR_TEXT) + } + text: "" + } + + footer := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + + timestamp := Label { + width: Fit + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 8.8 } + color: #9A9A9A + } + text: "" + } + } + } + + keyboard := View { + width: Fit + height: Fit + flow: Down + spacing: 8 + + first_row := View { + width: Fit + height: Fit + flow: Right + spacing: 8 + + create_button := RobrixPositiveIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "" + } + + list_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_SEARCH) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "" + } + } + + second_row := View { + width: Fit + height: Fit + flow: Right + spacing: 8 + + delete_button := RobrixNegativeIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "" + } + + help_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_INFO) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "" + } + } + + third_row := View { + width: Fit + height: Fit + flow: Right + spacing: 8 + + view_bound_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_SEARCH) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "View Bound Bots" + } + + unbind_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "" + } + } } } @@ -527,6 +1177,7 @@ script_mod! { Empty := mod.widgets.Empty {} DateDivider := mod.widgets.DateDivider {} ReadMarker := mod.widgets.ReadMarker {} + AppServicePanel := mod.widgets.AppServicePanel {} } // A jump to bottom button (with an unread message badge) that is shown @@ -578,10 +1229,24 @@ script_mod! { // (on top of all other views that are always visible). user_profile_sliding_pane := mod.widgets.UserProfileSlidingPane { } + threads_sliding_pane := mod.widgets.ThreadsSlidingPane { } + // The loading pane appears while the user is waiting for something in the room screen // to finish loading, e.g., when loading an older replied-to message. loading_pane := LoadingPane { } + create_bot_modal := Modal { + content +: { + create_bot_modal_inner := mod.widgets.CreateBotModal {} + } + } + + delete_bot_modal := Modal { + content +: { + delete_bot_modal_inner := mod.widgets.DeleteBotModal {} + } + } + /* * TODO: add the action bar back in as a series of floating buttons. @@ -605,6 +1270,258 @@ script_mod! { } } +#[derive(Clone, Default, Debug)] +pub enum ThreadsPaneAction { + OpenThread(OwnedEventId), + LoadMoreRequested, + #[default] + None, +} + +impl ActionDefaultRef for ThreadsPaneAction { + fn default_ref() -> &'static Self { + static DEFAULT: ThreadsPaneAction = ThreadsPaneAction::None; + &DEFAULT + } +} + +#[derive(Clone, Debug)] +struct ThreadsPaneEntryInfo { + thread_root_event_id: OwnedEventId, + title: String, + subtitle: String, + time: String, + preview: String, +} + +#[derive(Clone, Debug)] +struct ThreadsPaneInfo { + room_name: String, + entries: Vec, + status_text: String, + show_entries: bool, + loading_text: String, + show_loading: bool, +} + +#[derive(Default)] +struct ThreadsPaneState { + room_id: Option, + entries: Vec, + prev_batch_token: Option, + is_loading: bool, + initialized: bool, + status_text: String, +} + +#[derive(Script, ScriptHook, Widget)] +pub struct ThreadsPaneEntry { + #[source] source: ScriptObjectRef, + #[deref] view: View, + + #[rust] thread_root_event_id: Option, +} + +impl Widget for ThreadsPaneEntry { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + + let Some(thread_root_event_id) = self.thread_root_event_id.clone() else { return }; + match event.hits(cx, self.view.area()) { + Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { + cx.widget_action( + self.widget_uid(), + ThreadsPaneAction::OpenThread(thread_root_event_id), + ); + } + _ => {} + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl ThreadsPaneEntry { + fn set_entry(&mut self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) { + self.thread_root_event_id = Some(entry.thread_root_event_id.clone()); + self.label(cx, ids!(title)).set_text(cx, &entry.title); + self.label(cx, ids!(time)).set_text(cx, &entry.time); + self.label(cx, ids!(subtitle)).set_text(cx, &entry.subtitle); + self.label(cx, ids!(preview)).set_text(cx, &entry.preview); + } +} + +impl ThreadsPaneEntryRef { + fn set_entry(&self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_entry(cx, entry); + } +} + +#[derive(Script, ScriptHook, Widget, Animator)] +pub struct ThreadsSlidingPane { + #[source] source: ScriptObjectRef, + #[deref] view: View, + #[apply_default] animator: Animator, + #[live] slide: f32, + + #[rust] info: Option, + #[rust] is_animating_out: bool, +} + +impl Widget for ThreadsSlidingPane { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + + if !self.visible { return; } + + let animator_action = self.animator_handle_event(cx, event); + if animator_action.must_redraw() { + self.redraw(cx); + } + + if self.is_animating_out && !self.animator.is_track_animating(id!(panel)) { + self.visible = false; + self.is_animating_out = false; + cx.revert_key_focus(); + self.view(cx, ids!(bg_view)).set_visible(cx, false); + self.redraw(cx); + return; + } + + let area = self.view.area(); + let close_pane = { + matches!( + event, + Event::Actions(actions) if self.button(cx, ids!(close_button)).clicked(actions) + ) + || event.back_pressed() + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(_fde) => { + cx.set_key_focus(area); + false + } + Hit::FingerUp(fue) if fue.is_over => { + fue.mouse_button().is_some_and(|b| b.is_back()) + || !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) + } + _ => false, + } + }; + if close_pane { + self.hide(cx); + } + + if let Event::Actions(actions) = event { + let threads_list = self.portal_list(cx, ids!(threads_list)); + if threads_list.scrolled(actions) + && threads_list.first_id() == 0 + && threads_list.scroll_position() >= -0.5 + { + cx.widget_action( + self.widget_uid(), + ThreadsPaneAction::LoadMoreRequested, + ); + } + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let Some(info) = self.info.as_ref() else { + self.visible = false; + return self.view.draw_walk(cx, scope, walk); + }; + + let panel_width = 320.0; + let right_margin = -(self.slide * panel_width); + let mut main_content = self.view(cx, ids!(main_content)); + script_apply_eval!(cx, main_content, { + margin.right: #(right_margin) + }); + let bg_alpha = (1.0 - self.slide) * 0.733; + let bg_color = vec4(0.0, 0.0, 0.0, bg_alpha); + let mut bg_view = self.view(cx, ids!(bg_view)); + script_apply_eval!(cx, bg_view, { + draw_bg +: { color: #(bg_color) } + }); + + self.label(cx, ids!(room_name)).set_text(cx, &info.room_name); + self.label(cx, ids!(loading_label)).set_text(cx, &info.loading_text); + self.view(cx, ids!(loading_indicator)).set_visible(cx, info.show_loading); + self.label(cx, ids!(empty_state)).set_text(cx, &info.status_text); + self.view(cx, ids!(empty_state)).set_visible(cx, !info.show_entries && !info.show_loading); + self.view(cx, ids!(threads_list)).set_visible(cx, info.show_entries); + + while let Some(widget) = self.view.draw_walk(cx, scope, walk).step() { + let portal_list_ref = widget.as_portal_list(); + let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; + + list.set_item_range(cx, 0, info.entries.len()); + while let Some(item_id) = list.next_visible_item(cx) { + let Some(entry) = info.entries.get(item_id) else { continue }; + let item = list.item(cx, item_id, id!(ThreadEntry)); + item.as_threads_pane_entry().set_entry(cx, entry); + item.draw_all(cx, &mut Scope::empty()); + } + } + DrawStep::done() + } +} + +impl ThreadsSlidingPane { + pub fn is_currently_shown(&self, _cx: &mut Cx) -> bool { + self.visible + } + + fn set_info(&mut self, _cx: &mut Cx, info: ThreadsPaneInfo) { + self.info = Some(info); + } + + pub fn show(&mut self, cx: &mut Cx) { + self.visible = true; + self.is_animating_out = false; + cx.set_key_focus(self.view.area()); + self.animator_play(cx, ids!(panel.show)); + self.view(cx, ids!(bg_view)).set_visible(cx, true); + self.view.button(cx, ids!(close_button)).reset_hover(cx); + self.redraw(cx); + } + + pub fn hide(&mut self, cx: &mut Cx) { + if !self.visible { + return; + } + self.is_animating_out = true; + self.animator_play(cx, ids!(panel.hide)); + self.redraw(cx); + } +} + +impl ThreadsSlidingPaneRef { + pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { + let Some(inner) = self.borrow() else { return false }; + inner.is_currently_shown(cx) + } + + fn set_info(&self, cx: &mut Cx, info: ThreadsPaneInfo) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_info(cx, info); + } + + pub fn show(&self, cx: &mut Cx) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.show(cx); + } + + pub fn hide(&self, cx: &mut Cx) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.hide(cx); + } +} + /// The main widget that displays a single Matrix room. #[derive(Script, Widget)] pub struct RoomScreen { @@ -612,6 +1529,8 @@ pub struct RoomScreen { /// The name and ID of the currently-shown room, if any. #[rust] room_name_id: Option, + /// The avatar URL of the currently-shown room, if any. + #[rust] room_avatar_url: Option, /// The timeline currently displayed by this RoomScreen, if any. #[rust] timeline_kind: Option, /// The persistent UI-relevant states for the room that this widget is currently displaying. @@ -622,6 +1541,17 @@ pub struct RoomScreen { #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). #[rust] all_rooms_loaded: bool, + /// NextFrame subscription for driving streaming typewriter animation. + #[rust] + streaming_next_frame: NextFrame, + /// Timeout used to evict stalled streaming states without per-frame polling. + #[rust] + streaming_timeout_timer: Timer, + /// Whether the in-room app service quick actions card is currently visible. + #[rust] show_app_service_actions: bool, + #[rust] threads_pane_state: ThreadsPaneState, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, } impl Drop for RoomScreen { @@ -651,11 +1581,110 @@ impl ScriptHook for RoomScreen { impl Widget for RoomScreen { // Handle events and actions for the RoomScreen widget and its inner Timeline view. fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(cx, ids!(timeline.list)); let user_profile_sliding_pane = self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); + let threads_sliding_pane = self.threads_sliding_pane(cx, ids!(threads_sliding_pane)); let loading_pane = self.loading_pane(cx, ids!(loading_pane)); + // Streaming animation frame handler + if let Some(_ne) = self.streaming_next_frame.is_event(event) { + #[cfg(debug_assertions)] + #[allow(unused_variables)] + let frame_start = std::time::Instant::now(); + + if let Some(tl) = self.tl_state.as_mut() { + let mut any_active = false; + let mut needs_another_frame = false; + let mut completed_ids = Vec::new(); + + for (event_id, state) in tl.streaming_messages.iter_mut() { + if state.needs_frame() { + if state.tick() { + any_active = true; + // Invalidate draw cache so item gets re-populated + if let Some(idx) = state.timeline_index { + tl.content_drawn_since_last_update.remove(idx..idx + 1); + } + } + needs_another_frame |= state.needs_frame(); + } + + if state.is_complete() || state.is_timed_out() { + completed_ids.push(event_id.clone()); + } + } + + for id in &completed_ids { + tl.streaming_messages.remove(id); + } + + // Safety cap: max 50 streaming entries + while tl.streaming_messages.len() > 50 { + if let Some(oldest_id) = tl.streaming_messages.iter() + .min_by_key(|(_, s)| s.animation_start_time) + .map(|(id, _)| id.clone()) + { + tl.streaming_messages.remove(&oldest_id); + any_active = true; + } + } + + if needs_another_frame { + self.streaming_next_frame = cx.new_next_frame(); + } + + if any_active || !completed_ids.is_empty() { + self.redraw(cx); + } + } + + #[cfg(debug_assertions)] + { + if let Some(tl) = self.tl_state.as_ref() { + let elapsed = frame_start.elapsed(); + if elapsed.as_millis() > 2 { + log!("Streaming animation frame took {}ms ({} active streams)", + elapsed.as_millis(), tl.streaming_messages.len()); + } + } + } + + self.schedule_stream_timeout(cx); + } + + if self.streaming_timeout_timer.is_event(event).is_some() { + if let Some(tl) = self.tl_state.as_mut() { + let timed_out_ids: Vec = tl + .streaming_messages + .iter() + .filter_map(|(event_id, state)| { + if state.is_timed_out() || state.is_complete() { + Some(event_id.clone()) + } else { + None + } + }) + .collect(); + + for event_id in &timed_out_ids { + tl.streaming_messages.remove(event_id); + } + + if !timed_out_ids.is_empty() { + self.redraw(cx); + } + } + + self.schedule_stream_timeout(cx); + } + // Handle actions here before processing timeline updates. // Normally (in most other widgets), the order of event handling doesn't matter much. // However, since actions may refer to a specific timeline item's index, @@ -685,7 +1714,9 @@ impl Widget for RoomScreen { .collect(); let mut tooltip_text = utils::human_readable_list(&tooltip_text_arr, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT); - tooltip_text.push_str(&format!(" reacted with: {}", reaction_data.reaction)); + tooltip_text.push_str(&tr_fmt(self.app_language, "room_screen.tooltip.reacted_with_suffix", &[ + ("reaction", reaction_data.reaction.as_str()), + ])); cx.widget_action( room_screen_widget_uid, TooltipAction::HoverIn { @@ -754,10 +1785,11 @@ impl Widget for RoomScreen { user_id.as_str() }; let room_id = tl.kind.room_id().clone(); + let app_language = self.app_language; let content = ConfirmationModalContent { - title_text: "Send Invitation".into(), - body_text: format!("Are you sure you want to invite {username} to this room?").into(), - accept_button_text: Some("Invite".into()), + title_text: tr_key(app_language, "room_screen.modal.invite.title").into(), + body_text: tr_fmt(app_language, "room_screen.modal.invite.body", &[("username", username)]).into(), + accept_button_text: Some(tr_key(app_language, "room_screen.modal.invite.accept").into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_async_request(MatrixRequest::InviteUser { room_id, user_id }); })), @@ -788,7 +1820,7 @@ impl Widget for RoomScreen { // Only handle if this is for the current room. if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { enqueue_popup_notification( - "Sent invite successfully.", + tr_key(self.app_language, "room_screen.popup.invite.sent_success"), PopupKind::Success, Some(4.0), ); @@ -797,14 +1829,51 @@ impl Widget for RoomScreen { if let Some(InviteResultAction::Failed { room_id, error, .. }) = action.downcast_ref() { // Only handle if this is for the current room. if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { + let error_text = error.to_string(); enqueue_popup_notification( - format!("Failed to send invite.\n\nError: {error}"), + tr_fmt(self.app_language, "room_screen.popup.invite.failed", &[ + ("error", error_text.as_str()), + ]), PopupKind::Error, None, ); } } + match action.as_widget_action().cast_ref() { + ThreadsPaneAction::OpenThread(thread_root_event_id) => { + let Some(room_name_id) = self.room_name_id.as_ref().cloned() else { continue }; + threads_sliding_pane.hide(cx); + cx.widget_action( + room_screen_widget_uid, + RoomsListAction::Selected(SelectedRoom::Thread { + room_name_id, + thread_root_event_id: thread_root_event_id.clone(), + }), + ); + } + ThreadsPaneAction::LoadMoreRequested => { + self.request_more_threads(cx, true); + } + ThreadsPaneAction::None => {} + } + + if let Some(RoomThreadsAction::Loaded { room_id, from, threads, prev_batch_token }) = action.downcast_ref() { + if self.threads_pane_state.room_id.as_ref().is_some_and(|current| current == room_id) { + self.on_threads_loaded( + cx, + from.as_ref(), + threads, + prev_batch_token.clone(), + ); + } + } + if let Some(RoomThreadsAction::Failed { room_id, from: _, error }) = action.downcast_ref() { + if self.threads_pane_state.room_id.as_ref().is_some_and(|current| current == room_id) { + self.on_threads_failed(cx, error); + } + } + // Handle the highlight animation for a message. let Some(tl) = self.tl_state.as_mut() else { continue }; if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { @@ -868,7 +1937,10 @@ impl Widget for RoomScreen { self.show_timeline(cx); } - self.process_timeline_updates(cx, &portal_list); + self.process_timeline_updates(cx, &portal_list, scope.data.get::()); + if threads_sliding_pane.is_currently_shown(cx) { + self.refresh_threads_pane(cx); + } // Ideally we would do this elsewhere on the main thread, because it's not room-specific, // but it doesn't hurt to do it here. @@ -892,6 +1964,12 @@ impl Widget for RoomScreen { loading_pane.handle_event(cx, event, scope); } } + else if threads_sliding_pane.is_currently_shown(cx) { + is_pane_shown = true; + if is_interactive_hit { + threads_sliding_pane.handle_event(cx, event, scope); + } + } else if user_profile_sliding_pane.is_currently_shown(cx) { is_pane_shown = true; if is_interactive_hit { @@ -913,22 +1991,34 @@ impl Widget for RoomScreen { let room_props = if let Some(tl) = self.tl_state.as_ref() { let room_id = tl.kind.room_id().clone(); let room_members = tl.room_members.clone(); - - // Fetch room data once to avoid duplicate expensive lookups - let (room_display_name, room_avatar_url) = get_client() - .and_then(|client| client.get_room(&room_id)) - .map(|room| ( - room.cached_display_name().unwrap_or(RoomDisplayName::Empty), - room.avatar_url() - )) - .unwrap_or((RoomDisplayName::Empty, None)); + let (app_service_enabled, app_service_room_bound, bound_bot_user_id) = scope + .data + .get::() + .map(|app_state| { + let app_service_enabled = app_state.bot_settings.enabled; + let app_service_room_bound = self.is_app_service_room_bound(app_state, &room_id); + let bound_bot_user_id = if app_service_enabled && app_service_room_bound { + app_state.bot_settings.bound_bot_user_id(&room_id).map(ToOwned::to_owned) + } else { + None + }; + ( + app_service_enabled, + app_service_room_bound, + bound_bot_user_id, + ) + }) + .unwrap_or((false, false, None)); RoomScreenProps { room_screen_widget_uid, - room_name_id: RoomNameId::new(room_display_name, room_id), + room_name_id: self.room_name_id.clone().unwrap_or_else(|| RoomNameId::empty(room_id)), timeline_kind: tl.kind.clone(), room_members, - room_avatar_url, + room_avatar_url: self.room_avatar_url.clone(), + app_service_enabled, + app_service_room_bound, + bound_bot_user_id, } } else if let Some(room_name) = &self.room_name_id { // Fallback case: we have a room_name but no tl_state yet @@ -939,6 +2029,9 @@ impl Widget for RoomScreen { .expect("BUG: room_name_id was set but timeline_kind was missing"), room_members: None, room_avatar_url: None, + app_service_enabled: false, + app_service_room_bound: false, + bound_bot_user_id: None, } } else { // No room selected yet, skip event handling that requires room context @@ -954,6 +2047,9 @@ impl Widget for RoomScreen { timeline_kind: TimelineKind::MainRoom { room_id }, room_members: None, room_avatar_url: None, + app_service_enabled: false, + app_service_room_bound: false, + bound_bot_user_id: None, } }; let mut room_scope = Scope::with_props(&room_props); @@ -972,6 +2068,256 @@ impl Widget for RoomScreen { return false; } + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast() + { + AppServicePanelAction::Dismiss => { + self.set_app_service_actions_visible(cx, false); + return false; + } + AppServicePanelAction::OpenCreateBotModal => { + if let Some(app_state) = scope.data.get::() { + if !app_state.bot_settings.enabled { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.enable_before_create"), + ); + self.set_app_service_actions_visible(cx, false); + } else if !room_props.app_service_room_bound { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.bind_before_create"), + ); + self.set_app_service_actions_visible(cx, false); + } else { + self.open_create_bot_modal(cx); + } + } else { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.state_unavailable_create"), + ); + self.set_app_service_actions_visible(cx, false); + } + return false; + } + AppServicePanelAction::OpenDeleteBotModal => { + if let Some(app_state) = scope.data.get::() { + if !app_state.bot_settings.enabled { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.enable_before_delete"), + ); + self.set_app_service_actions_visible(cx, false); + } else if !room_props.app_service_room_bound { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.bind_before_delete"), + ); + self.set_app_service_actions_visible(cx, false); + } else { + self.open_delete_bot_modal(cx); + } + } else { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.state_unavailable_delete"), + ); + self.set_app_service_actions_visible(cx, false); + } + return false; + } + AppServicePanelAction::SendListBots => { + if let Some(app_state) = scope.data.get::() { + self.send_botfather_command( + cx, + app_state, + "/listbots", + tr_key(self.app_language, "room_screen.popup.bot.sent_listbots").to_string(), + ); + } + return false; + } + AppServicePanelAction::SendBotHelp => { + if let Some(app_state) = scope.data.get::() { + self.send_botfather_command( + cx, + app_state, + "/bothelp", + tr_key(self.app_language, "room_screen.popup.bot.sent_bothelp").to_string(), + ); + } + return false; + } + AppServicePanelAction::ShowBoundBots => { + let room_id = room_props.room_name_id.room_id(); + let own_user_id = current_user_id(); + let mut bound_bots = Vec::::new(); + let mut push_unique_bot = |bot_user_id: OwnedUserId| { + if !bound_bots.iter().any(|existing| existing == &bot_user_id) { + bound_bots.push(bot_user_id); + } + }; + + if let Some(bound_bot_user_id) = room_props.bound_bot_user_id.as_ref() { + push_unique_bot(bound_bot_user_id.clone()); + } + + let mut resolved_parent_bot_user_id: Option = None; + if let Some(app_state) = scope.data.get::() { + for room_binding in &app_state.bot_settings.room_bindings { + if &room_binding.room_id == room_id { + push_unique_bot(room_binding.bot_user_id.clone()); + } + } + + resolved_parent_bot_user_id = app_state + .bot_settings + .resolved_bot_user_id_for_room(room_id, current_user_id().as_deref()) + .ok(); + if let Some(bot_user_id) = resolved_parent_bot_user_id.as_ref() { + push_unique_bot(bot_user_id.clone()); + } + } + + if let Some(room_members) = room_props.room_members.as_ref() { + for room_member in room_members.iter() { + if own_user_id + .as_deref() + .is_some_and(|own_user_id| own_user_id == room_member.user_id()) + { + continue; + } + if is_likely_bot_member( + room_member, + resolved_parent_bot_user_id.as_deref(), + ) { + push_unique_bot(room_member.user_id().to_owned()); + } + } + } + + if bound_bots.is_empty() { + self.send_app_service_feedback_message( + "No bots are currently bound to this room.", + ); + } else { + let mut message = String::from("Bots bound to this room:"); + for bot_user_id in &bound_bots { + message.push('\n'); + message.push_str(bot_user_id.as_str()); + } + self.send_app_service_feedback_message(message); + } + return false; + } + AppServicePanelAction::Unbind => { + if let Some(app_state) = scope.data.get::() { + if !room_props.app_service_room_bound { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.room_not_bound"), + ); + } else { + match app_state + .bot_settings + .resolved_bot_user_id_for_room( + room_props.room_name_id.room_id(), + current_user_id().as_deref(), + ) + { + Ok(bot_user_id) => { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id: room_props.room_name_id.room_id().clone(), + bound: false, + bot_user_id: bot_user_id.clone(), + }); + self.send_app_service_feedback_message( + tr_fmt(self.app_language, "room_screen.popup.app_service.removing_botfather", &[ + ("bot_user_id", bot_user_id.as_str()), + ]), + ); + } + Err(error) => { + self.send_app_service_feedback_message( + error, + ); + } + } + } + } else { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.state_unavailable_unbind"), + ); + } + self.set_app_service_actions_visible(cx, false); + return false; + } + _ => {} + } + + match action.downcast_ref::() { + Some(CreateBotModalAction::Close) => { + self.close_create_bot_modal(cx); + return false; + } + Some(CreateBotModalAction::Submit(request)) => { + let Some(app_state) = scope.data.get::() else { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.state_unavailable_create_command"), + ); + self.close_create_bot_modal(cx); + return false; + }; + self.send_create_bot_command( + cx, + app_state, + &request.username, + &request.display_name, + request.system_prompt.as_deref(), + ); + return false; + } + None => {} + } + + match action.downcast_ref::() { + Some(DeleteBotModalAction::Close) => { + self.close_delete_bot_modal(cx); + return false; + } + Some(DeleteBotModalAction::Submit(request)) => { + let Some(app_state) = scope.data.get::() else { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.state_unavailable_delete_command"), + ); + self.close_delete_bot_modal(cx); + return false; + }; + self.send_delete_bot_command(cx, app_state, &request.user_id_or_localpart); + return false; + } + None => {} + } + + if let MessageAction::ToggleAppServiceActions = action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast() + { + if room_props.timeline_kind.thread_root_event_id().is_some() { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.main_timeline_only"), + ); + } else if !room_props.app_service_enabled { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.enable_in_settings_before_bot"), + ); + } else if !room_props.app_service_room_bound { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.bind_before_bot"), + ); + } else { + self.toggle_app_service_actions(cx); + } + return false; + } + // Handle the action that requests to show the user profile sliding pane. if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() { self.show_user_profile( @@ -980,7 +2326,7 @@ impl Widget for RoomScreen { UserProfilePaneInfo { profile_and_room_id, room_name: self.room_name_id.as_ref().map_or_else( - || UNNAMED_ROOM.to_string(), + || tr_key(self.app_language, "room_screen.fallback.unnamed_room").to_string(), |r| r.to_string(), ), room_member: None, @@ -1035,6 +2381,12 @@ impl Widget for RoomScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } // If the room isn't loaded yet, we show the restore status label only. if !self.is_loaded { let Some(room_name) = &self.room_name_id else { @@ -1056,17 +2408,14 @@ impl Widget for RoomScreen { while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the portal list. let portal_list_ref = subview.as_portal_list(); - let Some(mut list_ref) = portal_list_ref.borrow_mut() else { - error!("!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else"); - continue; - }; + let Some(mut list_ref) = portal_list_ref.borrow_mut() else { continue }; let Some(tl_state) = self.tl_state.as_mut() else { return DrawStep::done(); }; // 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(); + let last_item_id = tl_items.len() + usize::from(self.show_app_service_actions); let list = list_ref.deref_mut(); list.set_item_range(cx, 0, last_item_id); @@ -1074,6 +2423,9 @@ impl Widget for RoomScreen { while let Some(item_id) = list.next_visible_item(cx) { let item = { let tl_idx = item_id; + if self.show_app_service_actions && tl_idx == tl_items.len() { + list.item(cx, item_id, id!(AppServicePanel)) + } else { let Some(timeline_item) = tl_items.get(tl_idx) else { // This shouldn't happen (unless the timeline gets corrupted or some other weird error), // but we can always safely fill the item with an empty widget that takes up no space. @@ -1107,6 +2459,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, msg_like_content, prev_event, @@ -1118,6 +2471,7 @@ impl Widget for RoomScreen { &self.pinned_events, item_drawn_status, room_screen_widget_uid, + &mut tl_state.streaming_messages, ) }, // TODO: properly implement `Poll` as a regular Message-like timeline item. @@ -1126,6 +2480,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, poll_state, item_drawn_status, @@ -1135,6 +2490,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, utd, item_drawn_status, @@ -1144,6 +2500,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, other, item_drawn_status, @@ -1156,6 +2513,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, membership_change, item_drawn_status, @@ -1165,6 +2523,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, profile_change, item_drawn_status, @@ -1174,13 +2533,17 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, other, item_drawn_status, ), unhandled => { let item = list.item(cx, item_id, id!(SmallStateEvent)); - item.label(cx, ids!(content)).set_text(cx, &format!("[Unsupported] {:?}", unhandled)); + item.label(cx, ids!(content)).set_text( + cx, + &format!("{} {:?}", tr_key(self.app_language, "room_screen.unsupported.prefix"), unhandled), + ); (item, ItemDrawnStatus::both_drawn()) } } @@ -1195,6 +2558,10 @@ impl Widget for RoomScreen { } TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => { let item = list.item(cx, item_id, id!(ReadMarker)); + item.label(cx, ids!(date)).set_text( + cx, + tr_key(self.app_language, "room_screen.read_marker.new_messages"), + ); (item, ItemDrawnStatus::both_drawn()) } TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => { @@ -1203,42 +2570,270 @@ impl Widget for RoomScreen { } }; - // Now that we've drawn the item, add its index to the set of drawn items. - if item_new_draw_status.content_drawn { - tl_state.content_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); - } - if item_new_draw_status.profile_drawn { - tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); - } - item - }; - item.draw_all(cx, scope); - } + // Now that we've drawn the item, add its index to the set of drawn items. + if item_new_draw_status.content_drawn { + tl_state.content_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + } + if item_new_draw_status.profile_drawn { + tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + } + item + } + }; + item.draw_all(cx, scope); + } + + // If the list is not filling the viewport, we need to back paginate the timeline + // until we have enough events items to fill the viewport. + if tl_state.kind.thread_root_event_id().is_none() + && !tl_state.fully_paginated + && !tl_state.backwards_pagination_in_flight + && !list.is_filling_viewport() + { + tl_state.backwards_pagination_in_flight = true; + log!("Automatically paginating timeline to fill viewport for room {:?}", self.room_name_id); + submit_async_request(MatrixRequest::PaginateTimeline { + timeline_kind: tl_state.kind.clone(), + num_events: VIEWPORT_FILL_PAGINATION_SIZE, + direction: PaginationDirection::Backwards, + }); + } + } + DrawStep::done() + } +} + +impl RoomScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(top_space.label)) + .set_text(cx, tr_key(self.app_language, "room_screen.top_space.loading_earlier")); + self.view.redraw(cx); + } + + fn room_id(&self) -> Option<&OwnedRoomId> { + self.room_name_id.as_ref().map(|r| r.room_id()) + } + + /// 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 }; + let TimelineItemContent::MsgLike(_) = event.content() else { return None }; + Some(plaintext_body_of_timeline_item(event)) + } + + fn schedule_stream_timeout(&mut self, cx: &mut Cx) { + cx.stop_timer(self.streaming_timeout_timer); + self.streaming_timeout_timer = next_stream_timeout( + self.tl_state + .as_ref() + .into_iter() + .flat_map(|tl| tl.streaming_messages.values()), + ) + .map(|duration| cx.start_timeout(duration.as_secs_f64())) + .unwrap_or_else(Timer::empty); + } + + fn set_app_service_actions_visible(&mut self, cx: &mut Cx, visible: bool) { + self.show_app_service_actions = visible; + self.redraw(cx); + } + + fn toggle_app_service_actions(&mut self, cx: &mut Cx) { + self.set_app_service_actions_visible(cx, !self.show_app_service_actions); + } + + fn close_create_bot_modal(&self, cx: &mut Cx) { + self.view.modal(cx, ids!(create_bot_modal)).close(cx); + } + + fn close_delete_bot_modal(&self, cx: &mut Cx) { + self.view.modal(cx, ids!(delete_bot_modal)).close(cx); + } + + fn open_create_bot_modal(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.clone() else { + return; + }; + self.set_app_service_actions_visible(cx, false); + self.view + .create_bot_modal(cx, ids!(create_bot_modal_inner)) + .show(cx, room_name_id); + self.view.modal(cx, ids!(create_bot_modal)).open(cx); + } + + fn open_delete_bot_modal(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.clone() else { + return; + }; + self.set_app_service_actions_visible(cx, false); + self.view + .delete_bot_modal(cx, ids!(delete_bot_modal_inner)) + .show(cx, room_name_id); + self.view.modal(cx, ids!(delete_bot_modal)).open(cx); + } + + fn reset_app_service_ui(&mut self, cx: &mut Cx) { + self.set_app_service_actions_visible(cx, false); + self.close_create_bot_modal(cx); + self.close_delete_bot_modal(cx); + } + + fn is_app_service_room_bound(&self, app_state: &AppState, room_id: &OwnedRoomId) -> bool { + app_state.bot_settings.is_room_bound(room_id) + } + + fn send_app_service_feedback_message(&self, message: impl Into) { + let Some(room_id) = self.room_id().cloned() else { + return; + }; + let message = format!("[App Service] {}", message.into()); + submit_async_request(MatrixRequest::SendMessage { + timeline_kind: TimelineKind::MainRoom { room_id }, + message: RoomMessageEventContent::notice_plain(message), + replied_to: None, + target_user_id: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + } + + fn send_botfather_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + command: &str, + success_message: String, + ) -> bool { + let Some(timeline_kind) = self.timeline_kind.clone() else { + return false; + }; + if timeline_kind.thread_root_event_id().is_some() { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.main_timeline_only"), + ); + return false; + } + + let Some(room_id) = self.room_id().cloned() else { + return false; + }; + if !app_state.bot_settings.enabled { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.enable_before_commands"), + ); + return false; + } + if !self.is_app_service_room_bound(app_state, &room_id) { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.bind_before_commands"), + ); + return false; + } + + submit_async_request(MatrixRequest::SendMessage { + timeline_kind, + message: RoomMessageEventContent::text_plain(command), + replied_to: None, + target_user_id: app_state + .bot_settings + .bound_bot_user_id(room_id.as_ref()) + .map(ToOwned::to_owned), + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + + self.send_app_service_feedback_message(success_message); + self.set_app_service_actions_visible(cx, false); + true + } + + fn send_create_bot_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + username: &str, + display_name: &str, + system_prompt: Option<&str>, + ) { + let Some(timeline_kind) = self.timeline_kind.clone() else { + return; + }; + if timeline_kind.thread_root_event_id().is_some() { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.bot.creation_main_timeline_only"), + ); + return; + } + + let Some(room_id) = self.room_id().cloned() else { + return; + }; + if !app_state.bot_settings.enabled { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.enable_before_create"), + ); + return; + } + if !self.is_app_service_room_bound(app_state, &room_id) { + self.send_app_service_feedback_message( + tr_key(self.app_language, "room_screen.popup.app_service.bind_before_create"), + ); + return; + } - // If the list is not filling the viewport, we need to back paginate the timeline - // until we have enough events items to fill the viewport. - if !tl_state.fully_paginated && !list.is_filling_viewport() { - log!("Automatically paginating timeline to fill viewport for room {:?}", self.room_name_id); - submit_async_request(MatrixRequest::PaginateTimeline { - timeline_kind: tl_state.kind.clone(), - num_events: 50, - direction: PaginationDirection::Backwards, - }); - } + let command = format_create_bot_command(username, display_name, system_prompt); + if self.send_botfather_command( + cx, + app_state, + &command, + tr_fmt(self.app_language, "room_screen.popup.bot.sent_createbot", &[("username", username)]), + ) { + self.close_create_bot_modal(cx); } - DrawStep::done() } -} -impl RoomScreen { - fn room_id(&self) -> Option<&OwnedRoomId> { - self.room_name_id.as_ref().map(|r| r.room_id()) + fn send_delete_bot_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + user_id_or_localpart: &str, + ) { + let matrix_user_id = + match resolve_delete_bot_user_id(user_id_or_localpart, current_user_id().as_deref(), self.app_language) { + Ok(user_id) => user_id, + Err(error) => { + self.send_app_service_feedback_message(error); + return; + } + }; + + let command = format_delete_bot_command(matrix_user_id.as_ref()); + if self.send_botfather_command( + cx, + app_state, + &command, + tr_fmt(self.app_language, "room_screen.popup.bot.sent_deletebot", &[("matrix_user_id", matrix_user_id.as_str())]), + ) { + self.close_delete_bot_modal(cx); + } } /// Processes all pending background updates to the currently-shown timeline. /// /// Redraws this RoomScreen view if any updates were applied. - fn process_timeline_updates(&mut self, cx: &mut Cx, portal_list: &PortalListRef) { + fn process_timeline_updates( + &mut self, + cx: &mut Cx, + portal_list: &PortalListRef, + app_state: Option<&AppState>, + ) { 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 curr_first_id = portal_list.first_id(); @@ -1261,7 +2856,22 @@ impl RoomScreen { portal_list.set_tail_range(true); jump_to_bottom_button.update_visibility(cx, true); + let previous_streaming_messages = std::mem::take(&mut tl.streaming_messages); + let (rebuilt_streaming_messages, should_schedule_frame) = + rebuild_streaming_messages_for_full_snapshot( + streaming_candidates_from_items(&initial_items), + Some(&previous_streaming_messages), + ); + tl.items = initial_items; + tl.streaming_messages = rebuilt_streaming_messages; + refresh_stream_indices( + tl.items.iter().map(item_event_id), + &mut tl.streaming_messages, + ); + if should_schedule_frame { + self.streaming_next_frame = cx.new_next_frame(); + } done_loading = true; } TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { @@ -1379,7 +2989,66 @@ impl RoomScreen { tl.profile_drawn_since_last_update.remove(changed_indices.clone()); // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } + + // --- MSC4357 streaming detection --- + if clear_cache { + let previous_streaming_messages = std::mem::take(&mut tl.streaming_messages); + let (rebuilt_streaming_messages, should_schedule_frame) = + rebuild_streaming_messages_for_full_snapshot( + streaming_candidates_from_items(&new_items), + Some(&previous_streaming_messages), + ); + tl.streaming_messages = rebuilt_streaming_messages; + if should_schedule_frame { + self.streaming_next_frame = cx.new_next_frame(); + } + } else if !new_items.is_empty() { + use crate::home::streaming_animation::StreamingAnimState; + + let mut should_schedule_frame = false; + let scan_range = streaming_scan_range( + clear_cache, + &changed_indices, + tl.items.len(), + new_items.len(), + ); + + let old_event_ids: HashSet<&EventId> = tl.items.iter() + .filter_map(|item| item_event_id(item)) + .collect(); + + for idx in scan_range { + let Some(new_item) = new_items.get(idx) else { continue }; + let TimelineItemKind::Event(new_evt) = new_item.kind() else { continue }; + let Some(event_id) = new_evt.event_id().map(|id| id.to_owned()) else { continue }; + let live = is_msc4357_live(new_evt); + let Some(new_text) = Self::extract_message_text(new_item) else { continue }; + + if let Some(state) = tl.streaming_messages.get_mut(&event_id) { + state.update_target(&new_text, live); + // Schedule frame for animation OR for cleanup of just-completed state + should_schedule_frame |= state.needs_frame() || state.is_complete(); + continue; + } + + if live && !old_event_ids.contains(&*event_id) { + let state = StreamingAnimState::new(&new_text, true); + should_schedule_frame |= state.needs_frame(); + tl.streaming_messages.insert(event_id, state); + } + } + + if should_schedule_frame { + self.streaming_next_frame = cx.new_next_frame(); + } + } + // --- End streaming detection --- + tl.items = new_items; + refresh_stream_indices( + tl.items.iter().map(item_event_id), + &mut tl.streaming_messages, + ); done_loading = true; } TimelineUpdate::NewUnreadMessagesCount(unread_messages_count) => { @@ -1409,7 +3078,7 @@ impl RoomScreen { if is_valid { // We successfully found the target event, so we can close the loading pane, // reset the loading panestate to `None`, and stop issuing backwards pagination requests. - loading_pane.set_status(cx, "Successfully found replied-to message!"); + loading_pane.set_status(cx, tr_key(self.app_language, "room_screen.loading.found_related_message")); loading_pane.set_state(cx, LoadingPaneState::None); // NOTE: this code was copied from the `MessageAction::JumpToRelated` handler; @@ -1432,7 +3101,7 @@ impl RoomScreen { error!("Target event index {index} of {} is out of bounds for room {}", tl.items.len(), tl.kind.room_id()); // Show this error in the loading pane, which should already be open. loading_pane.set_state(cx, LoadingPaneState::Error( - String::from("Unable to find related message; it may have been deleted.") + tr_key(self.app_language, "room_screen.loading.related_message_not_found").to_string() )); } @@ -1443,6 +3112,7 @@ impl RoomScreen { } TimelineUpdate::PaginationRunning(direction) => { if direction == PaginationDirection::Backwards { + tl.backwards_pagination_in_flight = true; top_space.set_visible(cx, true); done_loading = false; } else { @@ -1450,10 +3120,18 @@ impl RoomScreen { } } TimelineUpdate::PaginationError { error, direction } => { + if direction == PaginationDirection::Backwards { + tl.backwards_pagination_in_flight = false; + } error!("Pagination error ({direction}) in {:?}: {error:?}", self.room_name_id); let room_name = self.room_name_id.as_ref().map(|r| r.to_string()); enqueue_popup_notification( - utils::stringify_pagination_error(&error, room_name.as_deref().unwrap_or(UNNAMED_ROOM)), + utils::stringify_pagination_error( + &error, + room_name + .as_deref() + .unwrap_or(tr_key(self.app_language, "room_screen.fallback.unnamed_room")), + ), PopupKind::Error, Some(10.0), ); @@ -1461,6 +3139,7 @@ impl RoomScreen { } TimelineUpdate::PaginationIdle { fully_paginated, direction } => { if direction == PaginationDirection::Backwards { + tl.backwards_pagination_in_flight = false; // Don't set `done_loading` to `true` here, because we want to keep the top space visible // (with the "loading" message) until the corresponding `NewItems` update is received. tl.fully_paginated = fully_paginated; @@ -1510,8 +3189,21 @@ impl RoomScreen { // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } TimelineUpdate::RoomMembersListFetched { members } => { - // Store room members directly in TimelineUiState - tl.room_members = Some(Arc::new(members)); + let members = Arc::new(members); + if let Some(app_state) = app_state { + let room_id = tl.kind.room_id().clone(); + if let Some(bot_user_id) = detected_bot_binding_for_members( + app_state, + &room_id, + members.as_ref(), + ) { + Cx::post_action(AppStateAction::BotRoomBindingDetected { + room_id, + bot_user_id, + }); + } + } + tl.room_members = Some(members); }, TimelineUpdate::MediaFetched(request) => { log!("process_timeline_updates(): media fetched for room {}", tl.kind.room_id()); @@ -1529,17 +3221,29 @@ impl RoomScreen { TimelineUpdate::PinResult { result, pin, .. } => { let (message, auto_dismissal_duration, kind) = match &result { Ok(true) => ( - format!("Successfully {} event.", if pin { "pinned" } else { "unpinned" }), + if pin { + tr_key(self.app_language, "room_screen.popup.pin.pinned_success").to_string() + } else { + tr_key(self.app_language, "room_screen.popup.pin.unpinned_success").to_string() + }, Some(4.0), PopupKind::Success ), Ok(false) => ( - format!("Message was already {}.", if pin { "pinned" } else { "unpinned" }), + if pin { + tr_key(self.app_language, "room_screen.popup.pin.already_pinned").to_string() + } else { + tr_key(self.app_language, "room_screen.popup.pin.already_unpinned").to_string() + }, Some(4.0), PopupKind::Info ), Err(e) => ( - format!("Failed to {} event. Error: {e}", if pin { "pin" } else { "unpin" }), + tr_fmt(self.app_language, if pin { + "room_screen.popup.pin.pin_failed" + } else { + "room_screen.popup.pin.unpin_failed" + }, &[("error", &e.to_string())]), None, PopupKind::Error ), @@ -1552,6 +3256,7 @@ impl RoomScreen { // Then, we "process" it later (by turning it into a string) after the // update loop has completed, which avoids unnecessary expensive work // if the list of typing users gets updated many times in a row. + typing_users = Some(users); } TimelineUpdate::PinnedEvents(pinned_events) => { @@ -1590,9 +3295,10 @@ impl RoomScreen { } if should_continue_backwards_pagination { + tl.backwards_pagination_in_flight = true; submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl.kind.clone(), - num_events: 50, + num_events: VIEWPORT_FILL_PAGINATION_SIZE, direction: PaginationDirection::Backwards, }); } @@ -1608,6 +3314,7 @@ impl RoomScreen { } if num_updates > 0 { + self.schedule_stream_timeout(cx); // log!("Applied {} timeline updates for room {}, redrawing with {} items...", num_updates, tl.kind.room_id(), tl.items.len()); self.redraw(cx); } @@ -1659,7 +3366,7 @@ impl RoomScreen { MatrixId::Room(room_id) => { if self.room_name_id.as_ref().is_some_and(|r| r.room_id() == room_id) { enqueue_popup_notification( - "You are already viewing that room.", + tr_key(self.app_language, "room_screen.popup.already_viewing_room"), PopupKind::Info, Some(4.0), ); @@ -1709,7 +3416,7 @@ impl RoomScreen { if let Err(e) = robius_open::Uri::new(&url).open() { error!("Failed to open URL {:?}. Error: {:?}", url, e); enqueue_popup_notification( - format!("Could not open URL: {url}"), + tr_fmt(self.app_language, "room_screen.popup.open_url_failed", &[("url", url.as_str())]), PopupKind::Error, Some(10.0), ); @@ -1724,7 +3431,7 @@ impl RoomScreen { if let Err(e) = robius_open::Uri::new(&url).open() { error!("Failed to open URL {:?}. Error: {:?}", url, e); enqueue_popup_notification( - format!("Could not open URL: {url}"), + tr_fmt(self.app_language, "room_screen.popup.open_url_failed", &[("url", url.as_str())]), PopupKind::Error, Some(10.0), ); @@ -1824,7 +3531,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "Could not find message in timeline to reply to. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.reply_not_found"), PopupKind::Error, Some(5.0), ); @@ -1847,7 +3554,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "Could not find message in timeline to edit. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.edit_not_found"), PopupKind::Error, Some(5.0), ); @@ -1875,7 +3582,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "No recent message available to edit. Please manually select a message to edit.", + tr_key(self.app_language, "room_screen.popup.message.no_recent_editable"), PopupKind::Warning, Some(5.0), ); @@ -1891,7 +3598,7 @@ impl RoomScreen { }); } else { enqueue_popup_notification( - "This event cannot be pinned.", + tr_key(self.app_language, "room_screen.popup.message.cannot_pin"), PopupKind::Error, Some(5.0), ); @@ -1907,7 +3614,7 @@ impl RoomScreen { }); } else { enqueue_popup_notification( - "This event cannot be unpinned.", + tr_key(self.app_language, "room_screen.popup.message.cannot_unpin"), PopupKind::Error, Some(5.0), ); @@ -1920,7 +3627,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "Could not find message in timeline to copy text from. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.copy_text_not_found"), PopupKind::Error, Some(5.0), ); @@ -1957,7 +3664,7 @@ impl RoomScreen { } if !success { enqueue_popup_notification( - "Could not find message in timeline to copy HTML from. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.copy_html_not_found"), PopupKind::Error, Some(5.0), ); @@ -1975,7 +3682,7 @@ impl RoomScreen { cx.copy_to_clipboard(&matrix_to_uri.to_string()); } else { enqueue_popup_notification( - "Couldn't create permalink to message. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.copy_link_failed"), PopupKind::Error, Some(5.0), ); @@ -1990,7 +3697,7 @@ impl RoomScreen { let Some(tl) = self.tl_state.as_ref() else { continue }; let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) else { enqueue_popup_notification( - "Could not find message in timeline to view source.", + tr_key(self.app_language, "room_screen.popup.message.view_source_not_found"), PopupKind::Error, Some(5.0), ); @@ -2014,7 +3721,7 @@ impl RoomScreen { let Some(related_event_id) = details.related_event_id.as_ref() else { error!("BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}"); enqueue_popup_notification( - "Could not find related message or event in timeline.", + tr_key(self.app_language, "room_screen.popup.message.related_not_found"), PopupKind::Error, Some(5.0), ); @@ -2050,15 +3757,19 @@ impl RoomScreen { }), ); } + MessageAction::ShowThreadsPane => { + self.show_threads_pane(cx); + } MessageAction::Redact { details, reason } => { let Some(tl) = self.tl_state.as_ref() else { return }; let timeline_event_id = details.timeline_event_id.clone(); let timeline_kind = tl.kind.clone(); let reason = reason.clone(); + let app_language = self.app_language; let content = ConfirmationModalContent { - title_text: "Delete Message".into(), - body_text: "Are you sure you want to delete this message? This cannot be undone.".into(), - accept_button_text: Some("Delete".into()), + title_text: tr_key(app_language, "room_screen.modal.delete_message.title").into(), + body_text: tr_key(app_language, "room_screen.modal.delete_message.body").into(), + accept_button_text: Some(tr_key(app_language, "room_screen.modal.delete_message.accept").into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_async_request(MatrixRequest::RedactMessage { timeline_kind, @@ -2082,6 +3793,7 @@ impl RoomScreen { MessageAction::ActionBarOpen { .. } => { } // This isn't yet handled, as we need to completely redesign it. MessageAction::ActionBarClose => { } + MessageAction::ToggleAppServiceActions => { } MessageAction::None => { } } } @@ -2184,6 +3896,123 @@ impl RoomScreen { self.redraw(cx); } + fn show_threads_pane(&mut self, cx: &mut Cx) { + self.ensure_threads_state_for_current_room(); + if !self.threads_pane_state.initialized && !self.threads_pane_state.is_loading { + self.request_more_threads(cx, false); + } + self.refresh_threads_pane(cx); + self.threads_sliding_pane(cx, ids!(threads_sliding_pane)).show(cx); + self.redraw(cx); + } + + fn refresh_threads_pane(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.as_ref() else { return }; + self.threads_sliding_pane(cx, ids!(threads_sliding_pane)).set_info( + cx, + ThreadsPaneInfo { + room_name: room_name_id.to_string(), + entries: self.threads_pane_state.entries.iter() + .map(|entry| ThreadsPaneEntryInfo { + thread_root_event_id: entry.thread_root_event_id.clone(), + title: entry.title.clone(), + subtitle: match entry.reply_count { + 1 => String::from("1 reply"), + n => format!("{n} replies"), + }, + time: utils::relative_format(entry.timestamp) + .unwrap_or_else(|| String::from("")), + preview: entry.latest_reply_preview.clone().unwrap_or_else(|| String::from("Tap to open thread")), + }) + .collect(), + status_text: self.threads_pane_state.status_text.clone(), + show_entries: !self.threads_pane_state.entries.is_empty(), + loading_text: if self.threads_pane_state.entries.is_empty() { + String::from("Loading threads...") + } else { + String::from("Loading more threads...") + }, + show_loading: self.threads_pane_state.is_loading, + }, + ); + } + + fn hide_threads_pane(&mut self, cx: &mut Cx) { + self.threads_sliding_pane(cx, ids!(threads_sliding_pane)).hide(cx); + } + + fn ensure_threads_state_for_current_room(&mut self) { + let Some(room_id) = self.room_id().cloned() else { return }; + if self.threads_pane_state.room_id.as_ref().is_some_and(|current| current == &room_id) { + return; + } + self.threads_pane_state = ThreadsPaneState { + room_id: Some(room_id), + status_text: String::from("Loading threads..."), + ..Default::default() + }; + } + + fn request_more_threads(&mut self, _cx: &mut Cx, load_more: bool) { + self.ensure_threads_state_for_current_room(); + let Some(room_id) = self.threads_pane_state.room_id.clone() else { return }; + if self.threads_pane_state.is_loading { + return; + } + let from = if load_more { + let Some(from) = self.threads_pane_state.prev_batch_token.clone() else { return }; + Some(from) + } else { + None + }; + self.threads_pane_state.is_loading = true; + if !self.threads_pane_state.initialized { + self.threads_pane_state.status_text = String::from("Loading threads..."); + } + submit_async_request(MatrixRequest::ListRoomThreads { + room_id, + from, + }); + } + + fn on_threads_loaded( + &mut self, + cx: &mut Cx, + _from: Option<&String>, + threads: &[FetchedRoomThread], + prev_batch_token: Option, + ) { + self.threads_pane_state.is_loading = false; + self.threads_pane_state.initialized = true; + self.threads_pane_state.prev_batch_token = prev_batch_token; + self.threads_pane_state.entries.extend_from_slice(threads); + self.threads_pane_state.entries.sort_by_key(|entry| u64::from(entry.timestamp.0)); + self.threads_pane_state.entries.dedup_by(|a, b| a.thread_root_event_id == b.thread_root_event_id); + self.threads_pane_state.status_text = if self.threads_pane_state.entries.is_empty() { + String::from("No threads yet.") + } else { + String::new() + }; + self.refresh_threads_pane(cx); + self.redraw(cx); + } + + fn on_threads_failed(&mut self, cx: &mut Cx, error: &str) { + self.threads_pane_state.is_loading = false; + self.threads_pane_state.initialized = true; + if self.threads_pane_state.entries.is_empty() { + self.threads_pane_state.status_text = format!("Failed to load threads.\n\nError: {error}"); + } else { + enqueue_popup_notification( + format!("Failed to load more threads.\n\nError: {error}"), + PopupKind::Error, + Some(5.0), + ); + } + self.refresh_threads_pane(cx); + self.redraw(cx); + } + /// Invoke this when this timeline is being shown, /// e.g., when the user navigates to this timeline. fn show_timeline(&mut self, cx: &mut Cx) { @@ -2238,6 +4067,7 @@ impl RoomScreen { room_members: None, // We assume timelines being viewed for the first time haven't been fully paginated. fully_paginated: false, + backwards_pagination_in_flight: false, items: Vector::new(), content_drawn_since_last_update: RangeSet::new(), profile_drawn_since_last_update: RangeSet::new(), @@ -2249,6 +4079,7 @@ impl RoomScreen { pending_thread_summary_fetches: HashSet::new(), saved_state: SavedState::default(), message_highlight_animation_state: MessageHighlightAnimationState::default(), + streaming_messages: HashMap::new(), last_scrolled_index: usize::MAX, prev_first_index: None, scrolled_past_read_marker: false, @@ -2285,10 +4116,11 @@ impl RoomScreen { // when they first open the room, and there might not be any messages yet. if is_first_time_being_loaded { if !tl_state.fully_paginated { + tl_state.backwards_pagination_in_flight = true; log!("Sending a first-time backwards pagination request for {}", tl_state.kind); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), - num_events: 50, + num_events: VIEWPORT_FILL_PAGINATION_SIZE, direction: PaginationDirection::Backwards, }); } @@ -2344,10 +4176,11 @@ impl RoomScreen { // Store the tl_state for this room into this RoomScreen widget, // such that it can be accessed in future functions like event/draw handlers. self.tl_state = Some(tl_state); + self.schedule_stream_timeout(cx); // Now that we have restored the TimelineUiState into this RoomScreen widget, // we can proceed to processing pending background updates. - self.process_timeline_updates(cx, &self.portal_list(cx, ids!(list))); + self.process_timeline_updates(cx, &self.portal_list(cx, ids!(list)), None); self.redraw(cx); } @@ -2355,6 +4188,7 @@ impl RoomScreen { /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. fn hide_timeline(&mut self) { let Some(timeline_kind) = self.timeline_kind.clone() else { return }; + self.streaming_timeout_timer = Timer::empty(); self.save_state(); @@ -2378,6 +4212,7 @@ impl RoomScreen { timeline_kind, subscribe: false, }); + self.room_avatar_url = None; } /// Removes the current room's visual UI state from this widget @@ -2441,6 +4276,17 @@ impl RoomScreen { tl_state.user_power, tl_state.tombstone_info.as_ref(), ); + + refresh_stream_indices( + tl_state.items.iter().map(item_event_id), + &mut tl_state.streaming_messages, + ); + + // 3. If there are active streaming animations that can still reveal text, + // re-request the NextFrame event so the animation loop resumes. + if tl_state.streaming_messages.values().any(|state| state.needs_frame()) { + self.streaming_next_frame = cx.new_next_frame(); + } } /// Sets this `RoomScreen` widget to display the timeline for the given room. @@ -2465,14 +4311,23 @@ impl RoomScreen { // but we do need update the `room_name_id` in case it has changed, or it has been cleared. if self.timeline_kind.as_ref().is_some_and(|kind| kind == &timeline_kind) { self.room_name_id = Some(room_name_id.clone()); + self.room_avatar_url = get_client() + .and_then(|client| client.get_room(room_name_id.room_id())) + .and_then(|room| room.avatar_url()); return; } self.hide_timeline(); + self.reset_app_service_ui(cx); + self.hide_threads_pane(cx); + self.threads_pane_state = Default::default(); // Reset the the state of the inner loading pane. self.loading_pane(cx, ids!(loading_pane)).take_state(); self.room_name_id = Some(room_name_id.clone()); + self.room_avatar_url = get_client() + .and_then(|client| client.get_room(room_name_id.room_id())) + .and_then(|room| room.avatar_url()); self.timeline_kind = Some(timeline_kind.clone()); // We initially tell every MentionableTextInput widget that the current user @@ -2574,7 +4429,8 @@ impl RoomScreen { if !portal_list.scrolled(actions) { return }; let first_index = portal_list.first_id(); - if first_index == 0 && tl.last_scrolled_index > 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 {}", tl.last_scrolled_index, tl.kind, ); @@ -2609,6 +4465,9 @@ pub struct RoomScreenProps { pub timeline_kind: TimelineKind, pub room_members: Option>>, pub room_avatar_url: Option, + pub app_service_enabled: bool, + pub app_service_room_bound: bool, + pub bound_bot_user_id: Option, } @@ -2718,7 +4577,7 @@ pub enum TimelineUpdate { MediaFetched(MediaRequestParameters), /// A notice that one or more members of a this room are currently typing. TypingUsers { - /// The list of users (their displayable name) who are currently typing in this room. + /// The list of display names of users who are currently typing in this room. users: Vec, }, /// The result of a pin/unpin request ([`MatrixRequest::PinEvent`]). @@ -2771,6 +4630,10 @@ struct TimelineUiState { /// This must be reset to `false` whenever the timeline is fully cleared. fully_paginated: bool, + /// Whether a backwards pagination request has already been submitted + /// and is still in flight. + backwards_pagination_in_flight: bool, + /// The list of items (events) in this room's timeline that our client currently knows about. items: Vector>, @@ -2828,6 +4691,10 @@ struct TimelineUiState { /// If the animation was triggered, the state goes back to Off. message_highlight_animation_state: MessageHighlightAnimationState, + /// Active streaming animations, keyed by event ID. + /// Stores the typewriter animation state for messages being streamed by bots. + streaming_messages: HashMap, + /// The index of the timeline item that was most recently scrolled up past it. /// This is used to detect when the user has scrolled up past the second visible item (index 1) /// upwards to the first visible item (index 0), which is the top of the timeline, @@ -2950,6 +4817,7 @@ struct FetchedThreadSummary { num_replies: u32, latest_reply_preview_text: Option, } + impl ItemDrawnStatus { /// Returns a new `ItemDrawnStatus` with both `profile_drawn` and `content_drawn` set to `false`. const fn new() -> Self { @@ -2977,6 +4845,7 @@ fn populate_message_view( list: &mut PortalList, item_id: usize, timeline_kind: &TimelineKind, + app_language: AppLanguage, event_tl_item: &EventTimelineItem, msg_like_content: &MsgLikeContent, prev_event: Option<&Arc>, @@ -2988,6 +4857,7 @@ fn populate_message_view( pinned_events: &[OwnedEventId], item_drawn_status: ItemDrawnStatus, room_screen_widget_uid: WidgetUid, + streaming_messages: &mut HashMap, ) -> (WidgetRef, ItemDrawnStatus) { let mut new_drawn_status = item_drawn_status; let ts_millis = event_tl_item.timestamp(); @@ -3032,17 +4902,31 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - let mut link_preview_ref = - item.link_preview(cx, ids!(content.link_preview_view)); - new_drawn_status.content_drawn = populate_text_message_content( - cx, - &html_or_plaintext_ref, - body, - formatted.as_ref(), - Some(&mut link_preview_ref), - Some(media_cache), - Some(link_preview_cache), - ); + + // Check if this message is being streamed + let is_streaming = event_tl_item.event_id() + .and_then(|eid| streaming_messages.get_mut(&eid.to_owned())); + + if let Some(state) = is_streaming { + // STREAMING MODE: show partial plaintext with cursor + state.fill_display_buffer(); + html_or_plaintext_ref.show_plaintext(cx, &state.display_buffer); + new_drawn_status.content_drawn = false; // force re-render + } else { + // NORMAL MODE: existing logic + let mut link_preview_ref = + item.link_preview(cx, ids!(content.link_preview_view)); + new_drawn_status.content_drawn = populate_text_message_content( + cx, + &html_or_plaintext_ref, + app_language, + body, + formatted.as_ref(), + Some(&mut link_preview_ref), + Some(media_cache), + Some(link_preview_cache), + ); + } (item, false) } } @@ -3074,6 +4958,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, body, formatted.as_ref(), Some(&mut link_preview_ref), @@ -3102,14 +4987,16 @@ fn populate_message_view( } }); let formatted = format!( - "Server notice: {}\n\nNotice type:: {}{}{}", + "{} {}\n\n{}: {}{}{}", + tr_key(app_language, "room_screen.server_notice.title"), sn.body, + tr_key(app_language, "room_screen.server_notice.notice_type"), sn.server_notice_type.as_str(), sn.limit_type.as_ref() - .map(|l| format!("\nLimit type: {}", l.as_str())) + .map(|l| format!("\n{} {}", tr_key(app_language, "room_screen.server_notice.limit_type"), l.as_str())) .unwrap_or_default(), sn.admin_contact.as_ref() - .map(|c| format!("\nAdmin contact: {}", c)) + .map(|c| format!("\n{} {}", tr_key(app_language, "room_screen.server_notice.admin_contact"), c)) .unwrap_or_default(), ); let mut link_preview_ref = @@ -3117,6 +5004,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, &sn.body, Some(&FormattedBody { format: MessageFormat::Html, @@ -3171,6 +5059,7 @@ fn populate_message_view( let link_previews_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, &body, formatted.as_ref(), Some(&mut link_preview_ref), @@ -3199,6 +5088,7 @@ fn populate_message_view( let is_image_fully_drawn = populate_image_message_content( cx, &text_or_image_ref, + app_language, image_info, image.source.clone(), msg.body(), @@ -3224,6 +5114,7 @@ fn populate_message_view( let is_location_fully_drawn = populate_location_message_content( cx, &html_or_plaintext_ref, + app_language, location, ); new_drawn_status.content_drawn = is_location_fully_drawn; @@ -3246,6 +5137,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_file_message_content( cx, &html_or_plaintext_ref, + app_language, file_content, ); (item, false) @@ -3267,6 +5159,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_audio_message_content( cx, &html_or_plaintext_ref, + app_language, audio, ); (item, false) @@ -3288,6 +5181,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_video_message_content( cx, &html_or_plaintext_ref, + app_language, video, ); (item, false) @@ -3304,8 +5198,11 @@ fn populate_message_view( let formatted = FormattedBody { format: MessageFormat::Html, body: format!( - "Sent a verification request to {}.
(Supported methods: {})
", - verification.to, + "{}{}{}
({}: {})
", + tr_key(app_language, "room_screen.verification.sent_prefix"), + tr_key(app_language, "room_screen.verification.request"), + tr_fmt(app_language, "room_screen.verification.sent_to_suffix", &[("user_id", verification.to.as_str())]), + tr_key(app_language, "room_screen.verification.supported_methods"), verification.methods .iter() .map(|m| m.as_str()) @@ -3321,6 +5218,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, &verification.body, Some(&formatted), Some(&mut link_preview_ref), @@ -3338,7 +5236,7 @@ fn populate_message_view( } else { item.label(cx, ids!(content.message)).set_text( cx, - &format!("[Unsupported {:?}]", msg_like_content.kind), + &format!("{} {:?}", tr_key(app_language, "room_screen.unsupported.prefix"), msg_like_content.kind), ); new_drawn_status.content_drawn = true; (item, false) @@ -3367,6 +5265,7 @@ fn populate_message_view( let is_image_fully_drawn = populate_image_message_content( cx, &text_or_image_ref, + app_language, Some(Box::new(image_info.clone())), MediaSource::Plain(owned_mxc_url.clone()), body, @@ -3405,6 +5304,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_redacted_message_content( cx, &html_or_plaintext_ref, + app_language, event_tl_item, timeline_kind.room_id(), ); @@ -3419,7 +5319,7 @@ fn populate_message_view( } else { item.label(cx, ids!(content.message)).set_text( cx, - &format!("[Unsupported {:?}] ", other), + &format!("{} {:?} ", tr_key(app_language, "room_screen.unsupported.prefix"), other), ); new_drawn_status.content_drawn = true; (item, false) @@ -3444,6 +5344,7 @@ fn populate_message_view( cx, &item.view(cx, ids!(replied_to_message)), timeline_kind, + app_language, msg_like_content.in_reply_to.as_ref(), event_tl_item.event_id(), ); @@ -3452,6 +5353,7 @@ fn populate_message_view( &item, item_id, timeline_kind, + app_language, msg_like_content, event_tl_item, fetched_thread_summaries, @@ -3477,6 +5379,7 @@ fn populate_message_view( item_id, related_event_id: msg_like_content.in_reply_to.as_ref().map(|r| r.event_id.clone()), room_screen_widget_uid, + is_thread_timeline: timeline_kind.thread_root_event_id().is_some(), abilities: MessageAbilities::from_user_power_and_event( user_power_levels, event_tl_item, @@ -3524,7 +5427,7 @@ fn populate_message_view( // Server notices are drawn with a red color avatar background and username. let avatar = item.avatar(cx, ids!(profile.avatar)); avatar.show_text(cx, Some(COLOR_FG_DANGER_RED), None, "⚠"); - username_label.set_text(cx, "Server notice"); + username_label.set_text(cx, tr_key(app_language, "room_screen.server_notice.username")); script_apply_eval!(cx, username_label, { draw_text +: { color: (mod.widgets.COLOR_FG_DANGER_RED) @@ -3544,12 +5447,12 @@ fn populate_message_view( item.timestamp(cx, ids!(profile.timestamp)).set_date_time(cx, dt); } - // Set the "edited" indicator if this message was edited. - if msg_like_content.as_message().is_some_and(|m| m.is_edited()) { - item.edited_indicator(cx, ids!(profile.edited_indicator)).set_latest_edit( - cx, - event_tl_item, - ); + // Suppress "edited" indicator for actively streaming messages. + let is_streaming = event_tl_item.event_id() + .is_some_and(|eid| streaming_messages.contains_key(&eid.to_owned())); + if msg_like_content.as_message().is_some_and(|m| m.is_edited()) && !is_streaming { + item.edited_indicator(cx, ids!(profile.edited_indicator)) + .set_latest_edit(cx, event_tl_item); } #[cfg(feature = "tsp")] { @@ -3594,6 +5497,7 @@ fn populate_message_view( fn populate_text_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, body: &str, formatted_body: Option<&FormattedBody>, link_preview_ref: Option<&mut LinkPreviewRef>, @@ -3630,7 +5534,17 @@ fn populate_text_message_content( &links, media_cache, link_preview_cache, - &populate_image_message_content, + &|cx, text_or_image_ref, image_info_source, original_source, body, media_cache| { + populate_image_message_content( + cx, + text_or_image_ref, + app_language, + image_info_source, + original_source, + body, + media_cache, + ) + }, ) } else { true @@ -3643,6 +5557,7 @@ fn populate_text_message_content( fn populate_image_message_content( cx: &mut Cx, text_or_image_ref: &TextOrImageRef, + app_language: AppLanguage, image_info_source: Option>, original_source: MediaSource, body: &str, @@ -3660,7 +5575,7 @@ fn populate_image_message_content( if ImageFormat::from_mimetype(mime).is_none() { text_or_image_ref.show_text( cx, - format!("{body}\n\nUnsupported type {mime:?}"), + tr_fmt(app_language, "room_screen.image.unsupported_type", &[("body", body), ("mime", mime)]), ); return true; // consider this as fully drawn } @@ -3678,7 +5593,7 @@ fn populate_image_message_content( .map(|()| img.size_in_pixels(cx).unwrap_or_default()) }); if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + let err_str = tr_fmt(app_language, "room_screen.image.failed_to_display", &[("body", body), ("error", &format!("{e:?}"))]); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); } @@ -3726,7 +5641,7 @@ fn populate_image_message_content( } }); if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + let err_str = tr_fmt(app_language, "room_screen.image.failed_to_display", &[("body", body), ("error", &format!("{e:?}"))]); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); } @@ -3739,7 +5654,7 @@ fn populate_image_message_content( return; } text_or_image_ref - .show_text(cx, format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri)); + .show_text(cx, tr_fmt(app_language, "room_screen.image.failed_to_fetch", &[("body", body), ("mxc_uri", &format!("{mxc_uri:?}"))])); // For now, we consider this as being "complete". In the future, we could support // retrying to fetch thumbnail of the image on a user click/tap. fully_drawn = true; @@ -3753,7 +5668,7 @@ fn populate_image_message_content( // We consider this as "fully drawn" since we don't yet support encryption. text_or_image_ref.show_text( cx, - format!("{body}\n\n[TODO] fetch encrypted image at {:?}", encrypted.url) + tr_fmt(app_language, "room_screen.image.encrypted_todo", &[("body", body), ("url", &format!("{:?}", encrypted.url))]) ); }, MediaSource::Plain(mxc_uri) => { @@ -3770,7 +5685,7 @@ fn populate_image_message_content( fetch_and_show_media_source(cx, media_source, image_info); } None => { - text_or_image_ref.show_text(cx, "{body}\n\nImage message had no source URL."); + text_or_image_ref.show_text(cx, tr_fmt(app_language, "room_screen.image.no_source_url", &[("body", body)])); fully_drawn = true; } } @@ -3785,6 +5700,7 @@ fn populate_image_message_content( fn populate_file_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, file_content: &FileMessageEventContent, ) -> bool { // Display the file name, human-readable size, caption, and a button to download it. @@ -3804,7 +5720,11 @@ fn populate_file_message_content( message_content_widget.show_html( cx, - format!("{filename}{size}{caption}
File download not yet supported."), + tr_fmt(app_language, "room_screen.file.preview_html", &[ + ("filename", &filename), + ("size", size.as_str()), + ("caption", caption.as_str()), + ]), ); true } @@ -3815,6 +5735,7 @@ fn populate_file_message_content( fn populate_audio_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, audio: &AudioMessageEventContent, ) -> bool { // Display the file name, human-readable size, caption, and a button to download it. @@ -3844,7 +5765,13 @@ fn populate_audio_message_content( message_content_widget.show_html( cx, - format!("Audio: {filename}{mime}{duration}{size}{caption}
Audio playback not yet supported."), + tr_fmt(app_language, "room_screen.audio.preview_html", &[ + ("filename", &filename), + ("mime", mime.as_str()), + ("duration", duration.as_str()), + ("size", size.as_str()), + ("caption", caption.as_str()), + ]), ); true } @@ -3856,6 +5783,7 @@ fn populate_audio_message_content( fn populate_video_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, video: &VideoMessageEventContent, ) -> bool { // Display the file name, human-readable size, caption, and a button to download it. @@ -3888,7 +5816,14 @@ fn populate_video_message_content( message_content_widget.show_html( cx, - format!("Video: {filename}{mime}{duration}{size}{dimensions}{caption}
Video playback not yet supported."), + tr_fmt(app_language, "room_screen.video.preview_html", &[ + ("filename", &filename), + ("mime", mime.as_str()), + ("duration", duration.as_str()), + ("size", size.as_str()), + ("dimensions", dimensions.as_str()), + ("caption", caption.as_str()), + ]), ); true } @@ -3901,6 +5836,7 @@ fn populate_video_message_content( fn populate_location_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, location: &LocationMessageEventContent, ) -> bool { let coords = location.geo_uri @@ -3922,19 +5858,26 @@ fn populate_location_message_content( let safe_short_lat = htmlize::escape_text(short_lat); let safe_short_long = htmlize::escape_text(short_long); let html_body = format!( - "Location: {safe_short_lat},{safe_short_long}
\ + "{} {safe_short_lat},{safe_short_long}
\ ", + tr_key(app_language, "room_screen.location.label"), safe_geo_uri, + tr_key(app_language, "room_screen.location.open_osm"), + tr_key(app_language, "room_screen.location.open_google_maps"), + tr_key(app_language, "room_screen.location.open_apple_maps"), ); message_content_widget.show_html(cx, html_body); } else { + let escaped_body = htmlize::escape_text(&location.body); message_content_widget.show_html( cx, - format!("[Location invalid] {}", htmlize::escape_text(&location.body)) + tr_fmt(app_language, "room_screen.location.invalid_html", &[ + ("body", &escaped_body), + ]) ); } @@ -3951,6 +5894,7 @@ fn populate_location_message_content( fn populate_redacted_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, event_tl_item: &EventTimelineItem, room_id: &OwnedRoomId, ) -> bool { @@ -3975,8 +5919,13 @@ fn populate_redacted_message_content( if redactor == event_tl_item.sender() { fully_drawn = true; match reason { - Some(r) => format!("⛔ Deleted their own message. Reason: \"{}\".", htmlize::escape_text(r)), - None => String::from("⛔ Deleted their own message."), + Some(r) => { + let escaped_reason = htmlize::escape_text(r); + tr_fmt(app_language, "room_screen.redacted.self_with_reason", &[ + ("reason", &escaped_reason), + ]) + } + None => tr_key(app_language, "room_screen.redacted.self").to_string(), } } else { // Try to get the displayable name of the user who redacted this message. @@ -3989,16 +5938,21 @@ fn populate_redacted_message_content( fully_drawn = redactor_name.was_found(); let redactor_name_esc = htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); match reason { - Some(r) => format!("⛔ {} deleted this message. Reason: \"{}\".", - redactor_name_esc, - htmlize::escape_text(r), - ), - None => format!("⛔ {} deleted this message.", redactor_name_esc), + Some(r) => { + let escaped_reason = htmlize::escape_text(r); + tr_fmt(app_language, "room_screen.redacted.other_with_reason", &[ + ("redactor", &redactor_name_esc), + ("reason", &escaped_reason), + ]) + } + None => tr_fmt(app_language, "room_screen.redacted.other", &[ + ("redactor", &redactor_name_esc), + ]), } } } else { fully_drawn = true; - String::from("⛔ Message deleted.") + tr_key(app_language, "room_screen.redacted.generic").to_string() }; message_content_widget.show_html(cx, html); fully_drawn @@ -4021,6 +5975,7 @@ fn draw_replied_to_message( cx: &mut Cx2d, replied_to_message_view: &ViewRef, timeline_kind: &TimelineKind, + app_language: AppLanguage, in_reply_to: Option<&InReplyToDetails>, message_event_id: Option<&EventId>, ) -> bool { @@ -4052,6 +6007,7 @@ fn draw_replied_to_message( populate_preview_of_timeline_item( cx, &msg_body, + app_language, &replied_to_event.content, &replied_to_event.sender, &in_reply_to_username, @@ -4061,26 +6017,26 @@ fn draw_replied_to_message( fully_drawn = true; replied_to_message_view .label(cx, ids!(replied_to_message_content.reply_preview_username)) - .set_text(cx, "[Error fetching username]"); + .set_text(cx, tr_key(app_language, "room_screen.reply_preview.error_username")); replied_to_message_view .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) .show_text(cx, None, None, "?"); replied_to_message_view .html_or_plaintext(cx, ids!(replied_to_message_content.reply_preview_body)) - .show_plaintext(cx, "[Error fetching replied-to event]"); + .show_plaintext(cx, tr_key(app_language, "room_screen.reply_preview.error_event")); } td @ TimelineDetails::Pending | td @ TimelineDetails::Unavailable => { // We don't have the replied-to message yet, so we can't fully draw the preview. fully_drawn = false; replied_to_message_view .label(cx, ids!(replied_to_message_content.reply_preview_username)) - .set_text(cx, "[Loading username...]"); + .set_text(cx, tr_key(app_language, "room_screen.reply_preview.loading_username")); replied_to_message_view .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) .show_text(cx, None, None, "?"); replied_to_message_view .html_or_plaintext(cx, ids!(replied_to_message_content.reply_preview_body)) - .show_plaintext(cx, "[Loading replied-to message...]"); + .show_plaintext(cx, tr_key(app_language, "room_screen.reply_preview.loading_event")); // Confusingly, we need to fetch the details of the `message` (the event that is the reply), // not the details of the original event that this `message` is replying to. @@ -4113,6 +6069,7 @@ fn populate_thread_root_summary( item: &WidgetRef, timeline_item_index: usize, timeline_kind: &TimelineKind, + app_language: AppLanguage, msg_like_content: &MsgLikeContent, event_tl_item: &EventTimelineItem, fetched_thread_summaries: &HashMap, @@ -4182,18 +6139,18 @@ fn populate_thread_root_summary( } } fetched_summary.and_then(|fs| fs.latest_reply_preview_text.as_deref()) - .unwrap_or("Loading latest reply...") + .unwrap_or(tr_key(app_language, "room_screen.thread_summary.loading_latest_reply")) .into() } TimelineDetails::Error(_) => { fully_drawn = true; // consider this fully drawn since there's no point retrying. - "Unable to load latest reply".into() + tr_key(app_language, "room_screen.thread_summary.error_latest_reply").into() } }; let replies_count_text = match replies_count { - 1 => Cow::Borrowed("1 reply"), - n => Cow::Owned(format!("{n} replies")) + 1 => Cow::Borrowed(tr_key(app_language, "room_screen.thread_summary.one_reply")), + n => Cow::Owned(tr_fmt(app_language, "room_screen.thread_summary.n_replies", &[("n", &n.to_string())])) }; item.label(cx, ids!(thread_summary_count)) .set_text(cx, &replies_count_text); @@ -4207,6 +6164,7 @@ fn populate_thread_root_summary( pub fn populate_preview_of_timeline_item( cx: &mut Cx, widget_out: &HtmlOrPlaintextRef, + app_language: AppLanguage, timeline_item_content: &TimelineItemContent, sender_user_id: &UserId, sender_username: &str, @@ -4215,7 +6173,7 @@ pub fn populate_preview_of_timeline_item( match m.msgtype() { MessageType::Text(TextMessageEventContent { body, formatted, .. }) | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => { - let _ = populate_text_message_content(cx, widget_out, body, formatted.as_ref(), None, None, None); + let _ = populate_text_message_content(cx, widget_out, app_language, body, formatted.as_ref(), None, None, None); return; } _ => { } // fall through to the general case for all timeline items below. @@ -4419,6 +6377,7 @@ fn populate_small_state_event( list: &mut PortalList, item_id: usize, timeline_kind: &TimelineKind, + app_language: AppLanguage, event_tl_item: &EventTimelineItem, event_content: &impl SmallStateEventContent, item_drawn_status: ItemDrawnStatus, @@ -4461,7 +6420,7 @@ fn populate_small_state_event( }); // Proceed to draw the actual event content. - event_content.populate_item_content( + let (item, new_drawn_status) = event_content.populate_item_content( cx, list, item_id, @@ -4470,7 +6429,12 @@ fn populate_small_state_event( &username, item_drawn_status, new_drawn_status, - ) + ); + + item.button(cx, ids!(invite_user_button)) + .set_text(cx, tr_key(app_language, "room_screen.small_state.invite_to_room")); + + (item, new_drawn_status) } @@ -4583,6 +6547,9 @@ pub enum MessageAction { }, /// The user requested closing the message action bar ActionBarClose, + /// The user requested toggling the in-room app service quick actions card. + ToggleAppServiceActions, + ShowThreadsPane, #[default] None, } @@ -4594,6 +6561,177 @@ impl ActionDefaultRef for MessageAction { } } +#[derive(Clone, Default, Debug)] +pub enum AppServicePanelAction { + Dismiss, + OpenCreateBotModal, + OpenDeleteBotModal, + SendListBots, + SendBotHelp, + ShowBoundBots, + Unbind, + #[default] + None, +} + +impl ActionDefaultRef for AppServicePanelAction { + fn default_ref() -> &'static Self { + static DEFAULT: AppServicePanelAction = AppServicePanelAction::None; + &DEFAULT + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct AppServicePanel { + #[deref] view: View, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, +} + +impl Widget for AppServicePanel { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.handle_event(cx, event, scope); + + let room_screen_props = scope + .props + .get::() + .expect("BUG: RoomScreenProps should be available in Scope::props for AppServicePanel"); + + if let Event::Actions(actions) = event { + if self + .view + .button(cx, ids!(bubble.header.dismiss_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::Dismiss, + ); + } + + if self + .view + .button(cx, ids!(keyboard.first_row.create_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::OpenCreateBotModal, + ); + } + + if self + .view + .button(cx, ids!(keyboard.first_row.list_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::SendListBots, + ); + } + + if self + .view + .button(cx, ids!(keyboard.second_row.delete_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::OpenDeleteBotModal, + ); + } + + if self + .view + .button(cx, ids!(keyboard.second_row.help_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::SendBotHelp, + ); + } + + if self + .view + .button(cx, ids!(keyboard.third_row.view_bound_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::ShowBoundBots, + ); + } + + if self + .view + .button(cx, ids!(keyboard.third_row.unbind_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::Unbind, + ); + } + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.draw_walk(cx, scope, walk) + } +} + +impl AppServicePanel { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(sender_row.sender_name)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.sender_name")); + self.view + .label(cx, ids!(sender_row.sender_tag)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.sender_tag")); + self.view + .label(cx, ids!(bubble.header.title)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.title")); + self.view + .label(cx, ids!(bubble.subtitle)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.subtitle")); + self.view + .label(cx, ids!(bubble.footer.timestamp)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.timestamp_now")); + self.view + .button(cx, ids!(keyboard.first_row.create_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.create_bot")); + self.view + .button(cx, ids!(keyboard.first_row.list_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.list_bots")); + self.view + .button(cx, ids!(keyboard.second_row.delete_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.delete_bot")); + self.view + .button(cx, ids!(keyboard.second_row.help_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.bot_help")); + self.view + .button(cx, ids!(keyboard.third_row.unbind_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.unbind")); + self.view.redraw(cx); + } +} + /// A widget representing a single message of any kind within a room timeline. #[derive(Script, ScriptHook, Widget, Animator)] pub struct Message { @@ -4796,3 +6934,108 @@ pub fn clear_timeline_states(_cx: &mut Cx) { states.clear(); }); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::home::streaming_animation::StreamingAnimState; + use std::time::{Duration, Instant}; + + fn make_state(text: &str) -> StreamingAnimState { + StreamingAnimState::new(text, true) + } + + #[test] + fn test_streaming_scan_range() { + // Incremental: clamp sentinel to new_len + assert_eq!(streaming_scan_range(false, &(5..usize::MAX), 8, 9), 5..9); + // Append: new item at end is scanned + assert_eq!(streaming_scan_range(false, &(8..9), 8, 9), 8..9); + // No changes: empty range + assert_eq!(streaming_scan_range(false, &(8..8), 8, 8), 8..8); + // Clear cache: full scan + assert_eq!(streaming_scan_range(true, &(5..usize::MAX), 8, 9), 0..9); + } + + #[test] + fn test_refresh_stream_indices() { + let event_id_a: OwnedEventId = "$event-a:example.com".try_into().unwrap(); + let event_id_b: OwnedEventId = "$event-b:example.com".try_into().unwrap(); + let missing_event_id: OwnedEventId = "$missing:example.com".try_into().unwrap(); + + let mut streaming_messages = HashMap::new(); + streaming_messages.insert(event_id_a.clone(), make_state("alpha")); + streaming_messages.insert(missing_event_id.clone(), make_state("missing")); + + let event_ids = vec![None, Some(event_id_a.as_ref()), Some(event_id_b.as_ref())]; + refresh_stream_indices(event_ids.into_iter(), &mut streaming_messages); + + assert_eq!(streaming_messages[&event_id_a].timeline_index, Some(1)); + assert_eq!(streaming_messages[&missing_event_id].timeline_index, None); + } + + #[test] + fn test_timeout_picks_earliest() { + let mut live = make_state("alpha"); + live.last_update_time = Instant::now() - Duration::from_secs(40); + let mut finished = make_state("beta"); + finished.is_live = false; + finished.last_update_time = Instant::now() - Duration::from_secs(29); + + let timeout = next_stream_timeout([&live, &finished].into_iter()).unwrap(); + + assert!(timeout <= Duration::from_secs(1)); + } + + #[test] + fn test_full_snapshot_rebuild_drops_finished_cached_streams() { + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let mut previous = HashMap::new(); + let mut previous_state = make_state("hello live"); + previous_state.advance_displayed(4); + previous.insert(event_id.clone(), previous_state); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_full_snapshot( + [(event_id, String::from("hello final"), false)], + Some(&previous), + ); + + assert!(rebuilt.is_empty()); + assert!(!should_schedule_frame); + } + + #[test] + fn test_full_snapshot_rebuild_restores_live_cached_streams() { + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let mut previous = HashMap::new(); + let mut previous_state = make_state("hello"); + previous_state.advance_displayed(3); + previous.insert(event_id.clone(), previous_state); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_full_snapshot( + [(event_id.clone(), String::from("hello world"), true)], + Some(&previous), + ); + + let restored = rebuilt.get(&event_id).unwrap(); + assert_eq!(restored.displayed_char_count, 3); + assert!(restored.is_live); + assert!(should_schedule_frame); + } + + #[test] + fn test_full_snapshot_rebuild_skips_live_without_cached_state() { + // Without previous state, full-snapshot rebuild must NOT create new + // animations — the SDK may not have aggregated edits yet, so + // completed messages can still appear as `live`. + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_full_snapshot( + [(event_id.clone(), String::from("hello world"), true)], + None, + ); + + assert!(rebuilt.is_empty()); + assert!(!should_schedule_frame); + } +} diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 7cf7d5106..d69dda95a 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -26,7 +26,11 @@ use matrix_sdk::{RoomState, ruma::{events::tag::Tags, MilliSecondsSinceUnixEpoch use crate::{ app::{AppState, SelectedRoom}, home::{ - navigation_tab_bar::{NavigationBarAction, SelectedTab}, room_context_menu::RoomContextMenuDetails, rooms_list_entry::RoomsListEntryAction, space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt} + add_room::CreateRoomAction, + navigation_tab_bar::{NavigationBarAction, SelectedTab}, + room_context_menu::RoomContextMenuDetails, + rooms_list_entry::RoomsListEntryAction, + space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt}, }, room::{ FetchedRoomAvatar, @@ -38,6 +42,7 @@ use crate::{ popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction, }, + logout::logout_confirm_modal::LogoutAction, sliding_sync::{MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request}, space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, utils::{RoomNameId, VecDiff}, }; @@ -268,6 +273,8 @@ impl ActionDefaultRef for RoomsListAction { pub struct JoinedRoomInfo { /// The displayable name of this room (includes room ID for fallback). pub room_name_id: RoomNameId, + /// Lowercased searchable text cached for fast local search. + pub search_text: String, /// The number of unread messages in this room. pub num_unread_messages: u64, /// The number of unread mentions in this room. @@ -310,6 +317,8 @@ pub struct JoinedRoomInfo { pub struct InvitedRoomInfo { /// The displayable name of this room (includes room ID for fallback). pub room_name_id: RoomNameId, + /// Lowercased searchable text cached for fast local search. + pub search_text: String, /// The canonical alias for this room, if any. pub canonical_alias: Option, /// The alternative aliases for this room, if any. @@ -340,6 +349,27 @@ pub struct InviterInfo { pub display_name: Option, pub avatar: Option>, } + +pub fn build_room_search_text( + room_name_id: &RoomNameId, + canonical_alias: &Option, + alt_aliases: &[OwnedRoomAliasId], +) -> String { + let mut search_text = format!( + "{} {}", + room_name_id.to_string().to_lowercase(), + room_name_id.room_id().as_str().to_lowercase(), + ); + if let Some(alias) = canonical_alias { + search_text.push(' '); + search_text.push_str(&alias.as_str().to_lowercase()); + } + for alias in alt_aliases { + search_text.push(' '); + search_text.push_str(&alias.as_str().to_lowercase()); + } + search_text +} impl std::fmt::Debug for InviterInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InviterInfo") @@ -454,6 +484,9 @@ pub struct RoomsList { /// The latest status message that should be displayed in the bottom status label. #[rust] status: String, + /// Whether the cached portal-list indexes need to be recalculated before drawing. + #[rust(true)] indexes_dirty: bool, + /// The currently-selected room. #[rust] current_active_room: Option, @@ -512,6 +545,67 @@ impl RoomsList { None } + fn upsert_created_room_placeholder( + &mut self, + cx: &mut Cx, + room_name_id: &RoomNameId, + parent_space_id: Option<&OwnedRoomId>, + should_link_into_space: bool, + ) { + let room_id = room_name_id.room_id().clone(); + let room_avatar = FetchedRoomAvatar::Text( + room_name_id.name_for_avatar().unwrap_or("?").to_owned(), + ); + + match self.all_joined_rooms.entry(room_id.clone()) { + Entry::Occupied(mut occ) => { + occ.get_mut().room_name_id = room_name_id.clone(); + occ.get_mut().room_avatar = room_avatar; + } + Entry::Vacant(vac) => { + vac.insert(JoinedRoomInfo { + room_name_id: room_name_id.clone(), + search_text: build_room_search_text(room_name_id, &None, &[]), + num_unread_messages: 0, + num_unread_mentions: 0, + is_marked_unread: false, + canonical_alias: None, + alt_aliases: Vec::new(), + tags: Tags::default(), + latest: None, + room_avatar, + has_been_paginated: false, + is_selected: false, + is_direct: false, + is_tombstoned: false, + }); + } + } + + if should_link_into_space { + if let Some(parent_space_id) = parent_space_id { + match self.space_map.entry(parent_space_id.clone()) { + Entry::Occupied(mut occ) => { + let value = occ.get_mut(); + let mut direct_child_rooms = (*value.direct_child_rooms).clone(); + direct_child_rooms.insert(room_id.clone()); + value.direct_child_rooms = Arc::new(direct_child_rooms); + } + Entry::Vacant(vac) => { + let mut direct_child_rooms = HashSet::new(); + direct_child_rooms.insert(room_id.clone()); + vac.insert(SpaceMapValue { + direct_child_rooms: Arc::new(direct_child_rooms), + ..Default::default() + }); + } + } + } + } + + self.update_displayed_rooms(cx, false); + } + /// Handle all pending updates to the list of all rooms. fn handle_rooms_list_updates(&mut self, cx: &mut Cx, _event: &Event, _scope: &mut Scope) { let mut num_updates: usize = 0; @@ -536,8 +630,10 @@ impl RoomsList { let _replaced = self.all_joined_rooms.insert(room_id.clone(), joined_room); if should_display { if is_direct { - self.displayed_direct_rooms.push(room_id.clone()); - } else { + if !self.displayed_direct_rooms.contains(&room_id) { + self.displayed_direct_rooms.push(room_id.clone()); + } + } else if !self.displayed_regular_rooms.contains(&room_id) { self.displayed_regular_rooms.push(room_id.clone()); } } @@ -603,6 +699,7 @@ impl RoomsList { // Try to update joined room first if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.room_name_id = new_room_name; + room.search_text = build_room_search_text(&room.room_name_id, &room.canonical_alias, &room.alt_aliases); let is_direct = room.is_direct; let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { @@ -629,6 +726,7 @@ impl RoomsList { let mut invited_rooms = self.invited_rooms.borrow_mut(); if let Some(invited_room) = invited_rooms.get_mut(&room_id) { invited_room.room_name_id = new_room_name; + invited_room.search_text = build_room_search_text(&invited_room.room_name_id, &invited_room.canonical_alias, &invited_room.alt_aliases); let should_display = should_display_room!(self, &room_id, invited_room); let pos_in_list = self.displayed_invited_rooms.iter() .position(|r| r == &room_id); @@ -779,7 +877,10 @@ impl RoomsList { } RoomsListUpdate::ScrollToRoom(room_id) => { // Ensure indexes are fresh in case rooms were added/removed in this batch of updates. - self.recalculate_indexes(); + if self.indexes_dirty { + self.recalculate_indexes(); + self.indexes_dirty = false; + } let portal_list = self.view.portal_list(cx, ids!(list)); let speed = 50.0; let portal_list_index = if let Some(regular_index) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { @@ -860,6 +961,7 @@ impl RoomsList { } } if num_updates > 0 { + self.indexes_dirty = true; self.redraw(cx); } } @@ -912,10 +1014,23 @@ impl RoomsList { /// If `false`, the scroll position is preserved, unless it exceeds the new list length, /// in which case the logic in `draw_walk()` will limit it to the max valid index. fn update_displayed_rooms(&mut self, cx: &mut Cx, reset_scroll: bool) { - let (invited, regular, direct) = self.generate_displayed_rooms(); + let (mut invited, mut regular, mut direct) = self.generate_displayed_rooms(); + if self.display_filter.is_some() + && invited.is_empty() + && regular.is_empty() + && direct.is_empty() + { + self.display_filter = RoomDisplayFilter::default(); + self.sort_fn = None; + let (fallback_invited, fallback_regular, fallback_direct) = self.generate_displayed_rooms(); + invited = fallback_invited; + regular = fallback_regular; + direct = fallback_direct; + } self.displayed_invited_rooms = invited; self.displayed_regular_rooms = regular; self.displayed_direct_rooms = direct; + self.indexes_dirty = true; self.update_status(); @@ -970,17 +1085,32 @@ impl RoomsList { } // Otherwise, if no sort function was provided (default), use the `all_known_rooms_order`. else { + let mut seen_joined = HashSet::new(); + let mut seen_invited = HashSet::new(); for room_id in &self.all_known_rooms_order { if let Some(jr) = self.all_joined_rooms.get(room_id) { if should_display_room!(self, room_id, jr) { + seen_joined.insert(room_id.clone()); push_joined_room(room_id, jr); } } else if let Some(ir) = invited_rooms_ref.get(room_id) { if should_display_room!(self, room_id, ir) { + seen_invited.insert(room_id.clone()); new_displayed_invited_rooms.push(room_id.clone()); } } } + + for (room_id, jr) in &self.all_joined_rooms { + if !seen_joined.contains(room_id) && should_display_room!(self, room_id, jr) { + push_joined_room(room_id, jr); + } + } + for (room_id, ir) in invited_rooms_ref.iter() { + if !seen_invited.contains(room_id) && should_display_room!(self, room_id, ir) { + new_displayed_invited_rooms.push(room_id.clone()); + } + } } (new_displayed_invited_rooms, new_displayed_regular_rooms, new_displayed_direct_rooms) @@ -1219,11 +1349,14 @@ impl Widget for RoomsList { error!("BUG: couldn't find right-clicked room details for room {room_id}"); continue; }; + let app_state = scope.data.get::().unwrap(); let details = RoomContextMenuDetails { room_name_id: jr.room_name_id.clone(), is_favorite: jr.tags.contains_key(&TagName::Favorite), is_low_priority: jr.tags.contains_key(&TagName::LowPriority), is_marked_unread: jr.is_marked_unread, + app_service_enabled: app_state.bot_settings.enabled, + is_bot_bound: app_state.bot_settings.is_room_bound(&room_id), }; cx.widget_action( self.widget_uid(), @@ -1256,6 +1389,7 @@ impl Widget for RoomsList { } _todo => todo!("Handle other header categories"), } + self.indexes_dirty = true; self.redraw(cx); } } @@ -1263,6 +1397,35 @@ impl Widget for RoomsList { // Second, handle any other actions that came from other widgets/components. if let Event::Actions(actions) = event { for action in actions { + if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + while PENDING_ROOM_UPDATES.pop().is_some() {} + self.invited_rooms.borrow_mut().clear(); + self.all_joined_rooms.clear(); + self.all_known_rooms_order.clear(); + self.selected_space = None; + self.space_request_sender = None; + self.space_map.clear(); + self.hidden_rooms.clear(); + self.displayed_invited_rooms.clear(); + self.is_invited_rooms_header_expanded = false; + self.invited_rooms_indexes = RoomCategoryIndexes::default(); + self.displayed_direct_rooms.clear(); + self.is_direct_rooms_header_expanded = false; + self.direct_rooms_indexes = RoomCategoryIndexes::default(); + self.displayed_regular_rooms.clear(); + self.is_regular_rooms_header_expanded = true; + self.regular_rooms_indexes = RoomCategoryIndexes::default(); + self.display_filter = RoomDisplayFilter::default(); + self.sort_fn = None; + self.status.clear(); + self.current_active_room = None; + self.max_known_rooms = None; + self.indexes_dirty = true; + self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, false); + self.redraw(cx); + continue; + } + if let RoomFilterAction::Changed(keywords) = action.as_widget_action().cast_ref() { self.regenerate_display_filter_and_sort_fn(keywords); self.update_displayed_rooms(cx, true); @@ -1350,6 +1513,16 @@ impl Widget for RoomsList { _ => {} } + if let Some(CreateRoomAction::Created { room_name_id, parent_space_id, space_link_error, .. }) = action.downcast_ref() { + self.upsert_created_room_placeholder( + cx, + room_name_id, + parent_space_id.as_ref(), + space_link_error.is_none(), + ); + continue; + } + if let Some(space_room_list_action) = action.downcast_ref() { self.handle_space_room_list_action(cx, space_room_list_action); continue; @@ -1359,13 +1532,17 @@ impl Widget for RoomsList { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - let app_state = scope.data.get_mut::().unwrap(); + let app_state = scope.data.get::().unwrap(); // Update the currently-selected room from the AppState data. self.current_active_room = app_state.selected_room.clone(); + let mut app_state_for_item_scope = app_state.clone(); // Based on the various displayed room lists and is_expanded state of each room header, // calculate the indexes in the PortalList where the headers and rooms should be drawn. - self.recalculate_indexes(); + if self.indexes_dirty { + self.recalculate_indexes(); + self.indexes_dirty = false; + } let status_label_id = self.regular_rooms_indexes.after_rooms_index; // Add one for the status label @@ -1407,8 +1584,7 @@ impl Widget for RoomsList { list.set_item_range(cx, 0, total_count); while let Some(portal_list_index) = list.next_visible_item(cx) { - let mut scope = Scope::empty(); - + let mut item_scope = Scope::with_data(&mut app_state_for_item_scope); if self.invited_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( @@ -1417,7 +1593,7 @@ impl Widget for RoomsList { HeaderCategory::Invites, self.displayed_invited_rooms.len() as u64, ); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { let mut invited_rooms_mut = self.invited_rooms.borrow_mut(); @@ -1426,11 +1602,12 @@ impl Widget for RoomsList { invited_room.is_selected = self.current_active_room.as_ref() .is_some_and(|sel_room| sel_room.room_id() == invited_room_id); // Pass the room info down to the RoomsListEntry widget via Scope. - scope = Scope::with_props(&*invited_room); - item.draw_all(cx, &mut scope); + item_scope.override_props(&*invited_room, |scope| { + item.draw_all(cx, scope); + }); } else { list.item(cx, portal_list_index, id!(empty)) - .draw_all(cx, &mut scope); + .draw_all(cx, &mut item_scope); } } else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { @@ -1443,7 +1620,7 @@ impl Widget for RoomsList { // TODO: sum up all the unread mentions in rooms // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { if let Some(direct_room) = self.all_joined_rooms.get_mut(direct_room_id) { @@ -1463,11 +1640,12 @@ impl Widget for RoomsList { }); } // Pass the room info down to the RoomsListEntry widget via Scope. - scope = Scope::with_props(&*direct_room); - item.draw_all(cx, &mut scope); + item_scope.override_props(&*direct_room, |scope| { + item.draw_all(cx, scope); + }); } else { list.item(cx, portal_list_index, id!(empty)) - .draw_all(cx, &mut scope); + .draw_all(cx, &mut item_scope); } } else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { @@ -1480,7 +1658,7 @@ impl Widget for RoomsList { // TODO: sum up all the unread mentions in rooms. // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { if let Some(regular_room) = self.all_joined_rooms.get_mut(regular_room_id) { @@ -1500,22 +1678,23 @@ impl Widget for RoomsList { }); } // Pass the room info down to the RoomsListEntry widget via Scope. - scope = Scope::with_props(&*regular_room); - item.draw_all(cx, &mut scope); + item_scope.override_props(&*regular_room, |scope| { + item.draw_all(cx, scope); + }); } else { - list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut scope); + list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut item_scope); } } // Draw the status label as the bottom entry. else if portal_list_index == status_label_id { let item = list.item(cx, portal_list_index, id!(status_label)); item.label(cx, ids!(label)).set_text(cx, &self.status); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } // Draw a filler entry to take up space at the bottom of the portal list. else { list.item(cx, portal_list_index, id!(bottom_filler)) - .draw_all(cx, &mut scope); + .draw_all(cx, &mut item_scope); } } } @@ -1579,6 +1758,35 @@ impl RoomsListRef { .get(space_id) .map(|smv| smv.parent_chain.clone()) } + + /// Returns local room results matching `keywords`, up to `max_results`. + pub fn get_matching_room_items(&self, keywords: &str, max_results: usize) -> Vec<(RoomNameId, FetchedRoomAvatar)> { + let Some(inner) = self.borrow() else { return Vec::new(); }; + let keywords = keywords.trim().to_lowercase(); + if keywords.is_empty() { + return Vec::new(); + } + let mut items = Vec::new(); + let invited_rooms = inner.invited_rooms.borrow(); + for ir in invited_rooms.values() { + if ir.search_text.contains(&keywords) { + items.push((ir.room_name_id.clone(), ir.room_avatar.clone())); + if items.len() >= max_results { + return items; + } + } + } + drop(invited_rooms); + for jr in inner.all_joined_rooms.values() { + if jr.search_text.contains(&keywords) { + items.push((jr.room_name_id.clone(), jr.room_avatar.clone())); + if items.len() >= max_results { + return items; + } + } + } + items + } } pub struct RoomsListScopeProps { diff --git a/src/home/rooms_list_entry.rs b/src/home/rooms_list_entry.rs index d421a12ac..5870fc08a 100644 --- a/src/home/rooms_list_entry.rs +++ b/src/home/rooms_list_entry.rs @@ -2,6 +2,8 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; use crate::{ + app::AppState, + i18n::{AppLanguage, tr_fmt, tr_key}, room::FetchedRoomAvatar, shared::{ avatar::AvatarWidgetExt, html_or_plaintext::HtmlOrPlaintextWidgetExt, unread_badge::UnreadBadgeWidgetExt as _, @@ -224,10 +226,16 @@ impl RoomsListEntry { fn set_adaptive_variant_selector(&self, cx: &mut Cx) { self.view .adaptive_view(cx, ids!(adaptive_preview)) - .set_variant_selector(|_cx, parent_size| match parent_size.x { - width if width <= 70.0 => id!(OnlyIcon), - width if width <= 200.0 => id!(IconAndName), - _ => id!(FullPreview), + .set_variant_selector(|cx, parent_size| { + if cx.display_context.is_desktop() { + id!(FullPreview) + } else { + match parent_size.x { + width if width <= 70.0 => id!(OnlyIcon), + width if width <= 200.0 => id!(IconAndName), + _ => id!(FullPreview), + } + } }); } } @@ -296,10 +304,13 @@ impl Widget for RoomsListEntryContent { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); if let Some(joined_room_info) = scope.props.get::() { self.draw_joined_room(cx, joined_room_info); } else if let Some(invited_room_info) = scope.props.get::() { - self.draw_invited_room(cx, invited_room_info); + self.draw_invited_room(cx, invited_room_info, app_language); } self.view.draw_walk(cx, scope, walk) @@ -340,14 +351,30 @@ impl RoomsListEntryContent { &mut self, cx: &mut Cx, room_info: &InvitedRoomInfo, + app_language: AppLanguage, ) { self.view.label(cx, ids!(room_name)).set_text(cx, &room_info.room_name_id.to_string()); // Hide the timestamp field, and use the latest message field to show the inviter. self.view.label(cx, ids!(timestamp)).set_text(cx, ""); let inviter_string = match &room_info.inviter_info { - Some(InviterInfo { user_id, display_name: Some(dn), .. }) => format!("Invited by {} ({})", htmlize::escape_text(dn), htmlize::escape_text(user_id.as_str())), - Some(InviterInfo { user_id, .. }) => format!("Invited by {}", htmlize::escape_text(user_id.as_str())), - None => String::from("You were invited"), + Some(InviterInfo { user_id, display_name: Some(dn), .. }) => { + let display_name = htmlize::escape_text(dn); + let user_id = htmlize::escape_text(user_id.as_str()); + tr_fmt( + app_language, + "rooms_list_entry.invited.by_name_and_user", + &[("display_name", display_name.as_ref()), ("user_id", user_id.as_ref())], + ) + } + Some(InviterInfo { user_id, .. }) => { + let user_id = htmlize::escape_text(user_id.as_str()); + tr_fmt( + app_language, + "rooms_list_entry.invited.by_user", + &[("user_id", user_id.as_ref())], + ) + } + None => tr_key(app_language, "rooms_list_entry.invited.generic").to_string(), }; self.view.html_or_plaintext(cx, ids!(latest_message)).show_html(cx, &inviter_string); diff --git a/src/home/rooms_list_header.rs b/src/home/rooms_list_header.rs index eac4372a4..085031c55 100644 --- a/src/home/rooms_list_header.rs +++ b/src/home/rooms_list_header.rs @@ -9,7 +9,9 @@ use makepad_widgets::*; use matrix_sdk_ui::sync_service::State; use crate::{ + app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, + i18n::{AppLanguage, tr_key}, shared::{ image_viewer::{ImageViewerAction, ImageViewerError, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, @@ -26,13 +28,14 @@ script_mod! { height: Fit, padding: Inset{bottom: 4} flow: Right, + align: Align{y: 0.5} spacing: 3, header_title := Label { width: Fill, height: Fit, padding: 0 - margin: Inset{left: 5, top: -1} + margin: Inset{left: 5} flow: Right, // do not wrap text: "All Rooms" draw_text +: { @@ -41,6 +44,47 @@ script_mod! { } }, + open_room_filter_modal_button := View { + width: Fit, + height: Fit + margin: Inset{right: 1} + flow: Overlay, + + Icon { + draw_icon +: { + svg: (ICON_SEARCH) + color: (COLOR_TEXT) + } + icon_walk: Walk{width: 18, height: Fit, margin: Inset{bottom: 2}} + } + + click_area := Button { + width: Fill, + height: Fill + padding: Inset{top: 6, bottom: 6, left: 6, right: 6} + spacing: 0, + text: "" + draw_bg +: { + color: #0000 + color_hover: #0000 + color_down: #0000 + border_color: #0000 + border_color_hover: #0000 + border_color_down: #0000 + border_color_focus: #0000 + border_size: 0.0 + border_radius: 0.0 + } + draw_text +: { + color: #0000 + color_hover: #0000 + color_down: #0000 + color_focus: #0000 + } + icon_walk: Walk{width: 0, height: 0} + } + } + View { width: Fit, height: Fit, margin: Inset{right: 3} @@ -88,11 +132,23 @@ pub struct RoomsListHeader { #[deref] view: View, #[rust(State::Idle)] sync_state: State, + #[rust] app_language: AppLanguage, + #[rust] showing_space_title: bool, } impl Widget for RoomsListHeader { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } if let Event::Actions(actions) = event { + if self.view.button(cx, ids!(open_room_filter_modal_button.click_area)).clicked(actions) { + cx.action(RoomsListHeaderAction::OpenRoomFilterModal); + } + for action in actions { match action.downcast_ref() { Some(RoomsListHeaderAction::SetSyncStatus(is_syncing)) => { @@ -116,7 +172,7 @@ impl Widget for RoomsListHeader { self.view.view(cx, ids!(synced_icon)).set_visible(cx, false); self.view.view(cx, ids!(offline_icon)).set_visible(cx, true); enqueue_popup_notification( - "Cannot reach the Matrix homeserver. Please check your connection.", + tr_key(self.app_language, "rooms_list_header.popup.offline"), PopupKind::Error, None, ); @@ -135,8 +191,12 @@ impl Widget for RoomsListHeader { match tab { SelectedTab::Space { space_name_id } => { header_title.set_text(cx, &space_name_id.to_string()); + self.showing_space_title = true; + } + _ => { + header_title.set_text(cx, tr_key(self.app_language, "rooms_list_header.title.all_rooms")); + self.showing_space_title = false; } - _ => header_title.set_text(cx, "All Rooms"), } continue; } @@ -145,9 +205,9 @@ impl Widget for RoomsListHeader { // Show tooltips for the sync status icons. for (view, text, bg_color) in [ - (self.view.view(cx, ids!(loading_spinner)), "Syncing...", vec4(0.059, 0.533, 0.996, 1.0)), // COLOR_ACTIVE_PRIMARY #0f88fe - (self.view.view(cx, ids!(offline_icon)), "Offline", vec4(0.863, 0.0, 0.020, 1.0)), // COLOR_FG_DANGER_RED #DC0005 - (self.view.view(cx, ids!(synced_icon)), "Fully synced", vec4(0.075, 0.533, 0.031, 1.0)), // COLOR_FG_ACCEPT_GREEN #138808 + (self.view.view(cx, ids!(loading_spinner)), tr_key(self.app_language, "rooms_list_header.tooltip.syncing"), vec4(0.059, 0.533, 0.996, 1.0)), // COLOR_ACTIVE_PRIMARY #0f88fe + (self.view.view(cx, ids!(offline_icon)), tr_key(self.app_language, "rooms_list_header.tooltip.offline"), vec4(0.863, 0.0, 0.020, 1.0)), // COLOR_FG_DANGER_RED #DC0005 + (self.view.view(cx, ids!(synced_icon)), tr_key(self.app_language, "rooms_list_header.tooltip.synced"), vec4(0.075, 0.533, 0.031, 1.0)), // COLOR_FG_ACCEPT_GREEN #138808 ] { if !view.visible() { continue; @@ -179,13 +239,33 @@ impl Widget for RoomsListHeader { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.draw_walk(cx, scope, walk) } } +impl RoomsListHeader { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + if !self.showing_space_title { + self.view + .label(cx, ids!(header_title)) + .set_text(cx, tr_key(self.app_language, "rooms_list_header.title.all_rooms")); + } + self.view.redraw(cx); + } +} + /// Actions that can be handled by the `RoomsListHeader`. #[derive(Debug)] pub enum RoomsListHeaderAction { + /// Open the rooms/spaces filter modal. + OpenRoomFilterModal, /// An action received by the RoomsListHeader that will show or hide /// its sync status indicator (and loading spinner) based on the given boolean. SetSyncStatus(bool), diff --git a/src/home/rooms_sidebar.rs b/src/home/rooms_sidebar.rs index c50ca5695..79e99abe4 100644 --- a/src/home/rooms_sidebar.rs +++ b/src/home/rooms_sidebar.rs @@ -54,7 +54,11 @@ script_mod! { View { height: 23 } CachedWidget { - rooms_list_header := RoomsListHeader {} + rooms_list_header := RoomsListHeader { + open_room_filter_modal_button +: { + visible: false + } + } } View { diff --git a/src/home/search_messages.rs b/src/home/search_messages.rs index 5228ca129..75ac7afa9 100644 --- a/src/home/search_messages.rs +++ b/src/home/search_messages.rs @@ -2,6 +2,7 @@ //! UI widgets for searching messages in one or more rooms. use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* @@ -47,10 +48,17 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct SearchMessagesButton { #[deref] button: Button, + #[rust] app_language: AppLanguage, } impl Widget for SearchMessagesButton { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.button.handle_event(cx, event, scope); if let Event::Actions(actions) = event { @@ -61,10 +69,23 @@ impl Widget for SearchMessagesButton { } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.button.draw_walk(cx, scope, walk) } } +impl SearchMessagesButton { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.button.set_text(cx, tr_key(self.app_language, "search_messages.button.todo")); + } +} + #[derive(Debug)] pub enum AddRoomAction { SearchMessagesButtonClicked, diff --git a/src/home/space_lobby.rs b/src/home/space_lobby.rs index 42bca8635..487c8be52 100644 --- a/src/home/space_lobby.rs +++ b/src/home/space_lobby.rs @@ -19,12 +19,14 @@ use crate::shared::avatar::AvatarState; use crate::shared::expand_arrow::ExpandArrow; use crate::utils::replace_linebreaks_separators; use crate::{ - app::AppStateAction, + app::{AppState, AppStateAction}, avatar_cache::{self, AvatarCacheEntry}, home::{ + add_room::{CreateRoomAction, CreateRoomModalAction}, invite_modal::InviteModalAction, rooms_list::RoomsListRef, }, + i18n::{AppLanguage, tr_fmt, tr_key}, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::BasicRoomDetails, shared::avatar::{AvatarWidgetExt, AvatarWidgetRefExt}, @@ -151,7 +153,7 @@ script_mod! { ) } } - text: "Explore this Space" + text: "" } animator: Animator{ @@ -322,7 +324,7 @@ script_mod! { spacing: 0 icon_walk: Walk{width: 0, height: 0} draw_text.text_style: REGULAR_TEXT {font_size: 9.5} - text: "Join" + text: "" } view_button := RobrixIconButton { @@ -331,7 +333,7 @@ script_mod! { spacing: 0 icon_walk: Walk{width: 0, height: 0} draw_text.text_style: REGULAR_TEXT {font_size: 9.5} - text: "View" + text: "" } leave_button := RobrixNegativeIconButton { @@ -340,7 +342,7 @@ script_mod! { spacing: 0 icon_walk: Walk{width: 0, height: 0} draw_text.text_style: REGULAR_TEXT {font_size: 9.5} - text: "Leave" + text: "" } } @@ -388,7 +390,7 @@ script_mod! { color: #737373, text_style: REGULAR_TEXT {font_size: 10} } - text: "Loading rooms and spaces..." + text: "" } } @@ -419,7 +421,7 @@ script_mod! { text_style: REGULAR_TEXT {font_size: 9}, color: #888, } - text: "Loading..." + text: "" } } @@ -454,7 +456,7 @@ script_mod! { text_style: REGULAR_TEXT {font_size: 10}, color: #737373, } - text: "Welcome to the space:" + text: "" } parent_space_row := View { @@ -482,6 +484,16 @@ script_mod! { text: "" } + create_room_button := RobrixPositiveIconButton { + width: Fit + align: Align{x: 0.5, y: 0.5} + margin: Inset{left: 6} + padding: 12, + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } + text: "" + } + invite_button := RobrixPositiveIconButton { width: Fit align: Align{x: 0.5, y: 0.5} @@ -489,7 +501,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_ADD_USER) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Invite" + text: "" } } } @@ -573,6 +585,11 @@ impl Widget for SpaceLobbyEntry { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + self.view.label(cx, ids!(space_lobby_label)) + .set_text(cx, tr_key(app_language, "space_lobby.entry.explore_space")); self.view.draw_walk(cx, scope, walk) } } @@ -866,10 +883,21 @@ pub struct SpaceLobbyScreen { /// Whether we are currently loading the initial data. #[rust] is_loading: bool, + #[rust] top_level_join_rule: Option, + #[rust] top_level_member_count: Option, + #[rust] app_language: AppLanguage, } impl Widget for SpaceLobbyScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.app_language = app_language; + self.update_space_info_label(cx, app_language); + self.redraw(cx); + } self.view.handle_event(cx, event, scope); // Handle Signal events for avatar cache updates @@ -891,15 +919,9 @@ impl Widget for SpaceLobbyScreen { if self.space_name_id.as_ref().is_some_and(|sni| sni.room_id() == &sr.room_id) { self.space_avatar_state = AvatarState::Known(sr.avatar_url.clone()); self.space_avatar_state.update_from_cache(cx); // prefetch the avatar image - self.view.label(cx, ids!(header.space_info_label)).set_text(cx, &format!( - "{} · {} {}", - match sr.join_rule { - Some(JoinRuleSummary::Public) => "🌐 Public space", - _ => "🔒 Private space", - }, - sr.num_joined_members, - if sr.num_joined_members == 1 { "member" } else { "members" } - )); + self.top_level_join_rule = sr.join_rule.clone(); + self.top_level_member_count = Some(sr.num_joined_members); + self.update_space_info_label(cx, app_language); self.redraw(cx); } } @@ -921,6 +943,14 @@ impl Widget for SpaceLobbyScreen { _ => { } } + if let Some(CreateRoomAction::Created { room_name_id, parent_space_id, space_link_error, .. }) = action.downcast_ref() { + if space_link_error.is_none() + && parent_space_id.as_ref() == self.space_name_id.as_ref().map(RoomNameId::room_id) + { + self.insert_created_room_placeholder(cx, room_name_id); + } + } + // Handle SubspaceEntry clicks match action.as_widget_action().cast_ref() { SubspaceEntryAction::SpaceClicked { space_id } => { @@ -969,6 +999,14 @@ impl Widget for SpaceLobbyScreen { } } + if self.view.button(cx, ids!(header.parent_space_row.create_room_button)).clicked(actions) { + if let Some(space_name_id) = self.space_name_id.as_ref() { + cx.action(CreateRoomModalAction::Open { + parent_space_id: Some(space_name_id.room_id().clone()), + }); + } + } + // Handle the invite button being clicked in the header. if self.view.button(cx, ids!(header.parent_space_row.invite_button)).clicked(actions) { if let Some(space_name_id) = self.space_name_id.as_ref() { @@ -979,6 +1017,11 @@ impl Widget for SpaceLobbyScreen { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + self.app_language = app_language; + // Draw parent avatar from the SpaceRoom's avatar URL, or show initials. let parent_avatar_ref = self.view.avatar(cx, ids!(parent_avatar)); if self.space_avatar_state.update_from_cache(cx).is_none_or(|data| { @@ -992,6 +1035,12 @@ impl Widget for SpaceLobbyScreen { .and_then(|name| utils::user_name_first_letter(name)); parent_avatar_ref.show_text(cx, None, None, first_char.unwrap_or("S")); } + + self.update_space_info_label(cx, app_language); + self.view.button(cx, ids!(header.parent_space_row.create_room_button)) + .set_text(cx, tr_key(app_language, "space_lobby.header.button.new_room")); + self.view.button(cx, ids!(header.parent_space_row.invite_button)) + .set_text(cx, tr_key(app_language, "space_lobby.header.button.invite")); while let Some(widget_to_draw) = self.view.draw_walk(cx, scope, walk).step() { let portal_list_ref = widget_to_draw.as_portal_list(); @@ -1014,13 +1063,20 @@ impl Widget for SpaceLobbyScreen { // Draw loading indicator let item = if self.is_loading && item_id == 0 { let item = list.item(cx, item_id, id!(status_label)); - item.child_by_path(ids!(label)).as_label().set_text(cx, "Loading rooms and spaces..."); + item.child_by_path(ids!(label)).as_label().set_text( + cx, + tr_key(app_language, "space_lobby.status.loading_rooms_spaces"), + ); + item.child_by_path(ids!(loading_spinner)).set_visible(cx, true); item } // No entries found else if entry_count == 0 && item_id == 0 { let item = list.item(cx, item_id, id!(status_label)); - item.child_by_path(ids!(label)).as_label().set_text(cx, "No rooms or spaces found."); + item.child_by_path(ids!(label)).as_label().set_text( + cx, + tr_key(app_language, "space_lobby.status.no_rooms_spaces"), + ); item.child_by_path(ids!(loading_spinner)).set_visible(cx, false); item } @@ -1075,6 +1131,18 @@ impl Widget for SpaceLobbyScreen { item.child_by_path(ids!(buttons_view.join_button)).set_visible(cx, show_join_button); item.child_by_path(ids!(buttons_view.leave_button)).set_visible(cx, show_leave_button); item.child_by_path(ids!(buttons_view.view_button)).set_visible(cx, show_view_button); + item.child_by_path(ids!(buttons_view.join_button)).as_button().set_text( + cx, + tr_key(app_language, "space_lobby.item.button.join"), + ); + item.child_by_path(ids!(buttons_view.leave_button)).as_button().set_text( + cx, + tr_key(app_language, "space_lobby.item.button.leave"), + ); + item.child_by_path(ids!(buttons_view.view_button)).as_button().set_text( + cx, + tr_key(app_language, "space_lobby.item.button.view"), + ); // Below, draw things that are common to child rooms and subspaces. item.child_by_path(ids!(content.name_label)).as_label().set_text(cx, &info.name); @@ -1130,29 +1198,31 @@ impl Widget for SpaceLobbyScreen { // Add join status for rooms we haven't joined if let Some(state) = &info.state { match state { - RoomState::Joined => info_parts.push("✅ Joined".to_string()), - RoomState::Left => info_parts.push("Left".to_string()), - RoomState::Invited => info_parts.push("Invited".to_string()), - RoomState::Knocked => info_parts.push("Knocked".to_string()), - RoomState::Banned => info_parts.push("Banned".to_string()), + RoomState::Joined => info_parts.push(tr_key(app_language, "space_lobby.item.state.joined").to_string()), + RoomState::Left => info_parts.push(tr_key(app_language, "space_lobby.item.state.left").to_string()), + RoomState::Invited => info_parts.push(tr_key(app_language, "space_lobby.item.state.invited").to_string()), + RoomState::Knocked => info_parts.push(tr_key(app_language, "space_lobby.item.state.knocked").to_string()), + RoomState::Banned => info_parts.push(tr_key(app_language, "space_lobby.item.state.banned").to_string()), } } // Add member count - info_parts.push(format!( - "{} {}", - info.num_joined_members, - if info.num_joined_members == 1 { "member" } else { "members" } - )); + let member_count = info.num_joined_members.to_string(); + info_parts.push(if info.num_joined_members == 1 { + tr_key(app_language, "space_lobby.item.member_one").to_string() + } else { + tr_fmt(app_language, "space_lobby.item.member_n", &[("count", member_count.as_str())]) + }); // Add children count for spaces if let Some(c) = info.children_count { if c > 0 { - info_parts.push(format!( - "~{} {}", - c, - if c == 1 { "room" } else { "rooms" } - )); + let child_count = c.to_string(); + info_parts.push(if c == 1 { + tr_fmt(app_language, "space_lobby.item.child_room_one", &[("count", child_count.as_str())]) + } else { + tr_fmt(app_language, "space_lobby.item.child_room_n", &[("count", child_count.as_str())]) + }); } } @@ -1168,6 +1238,10 @@ impl Widget for SpaceLobbyScreen { TreeEntry::Loading { level, parent_mask } => { // Draw loading indicator for subspace let item = list.item(cx, item_id, id!(subspace_loading)); + item.child_by_path(ids!(label)).as_label().set_text( + cx, + tr_key(app_language, "space_lobby.status.loading"), + ); // Configure tree lines if let Some(mut lines) = item.child_by_path(ids!(tree_lines)).borrow_mut::() { lines.draw_bg.level = *level as f32; @@ -1202,6 +1276,72 @@ impl SpaceLobbyScreen { BasicRoomDetails::Name(room_name_id) } + fn update_space_info_label(&mut self, cx: &mut Cx, app_language: AppLanguage) { + let text = if self.is_loading { + tr_key(app_language, "space_lobby.header.welcome").to_string() + } else if let Some(member_count) = self.top_level_member_count { + let member_count_str = member_count.to_string(); + format!( + "{} · {}", + match self.top_level_join_rule.as_ref() { + Some(JoinRuleSummary::Public) => tr_key(app_language, "space_lobby.header.public_space"), + _ => tr_key(app_language, "space_lobby.header.private_space"), + }, + if member_count == 1 { + tr_key(app_language, "space_lobby.header.member_one").to_string() + } else { + tr_fmt(app_language, "space_lobby.header.member_n", &[("count", member_count_str.as_str())]) + } + ) + } else { + String::new() + }; + self.view.label(cx, ids!(header.space_info_label)).set_text(cx, &text); + } + + fn insert_created_room_placeholder(&mut self, cx: &mut Cx, room_name_id: &RoomNameId) { + let Some(space_id) = self.space_name_id.as_ref().map(|space| space.room_id().clone()) else { + return; + }; + let room_id = room_name_id.room_id().clone(); + let display_name = room_name_id.to_string(); + let mut children = self.children_cache.get(&space_id).cloned().unwrap_or_default(); + + if let Some(existing_index) = children.iter().position(|child| child.room_id == room_id) { + if let Some(existing_child) = children.get_mut(existing_index) { + existing_child.name = Some(display_name.clone()); + existing_child.display_name = display_name; + existing_child.state = Some(RoomState::Joined); + existing_child.num_joined_members = existing_child.num_joined_members.max(1); + } + } else { + children.push_back(SpaceRoom { + room_id, + canonical_alias: None, + name: Some(display_name.clone()), + display_name, + topic: None, + avatar_url: None, + room_type: None, + num_joined_members: 1, + join_rule: None, + world_readable: None, + guest_can_join: false, + is_direct: Some(false), + children_count: 0, + state: Some(RoomState::Joined), + heroes: None, + via: Vec::new(), + }); + } + + self.children_cache.insert(space_id.clone(), children); + self.is_loading = false; + self.expanded_spaces.insert(space_id); + self.rebuild_tree_entries(); + self.redraw(cx); + } + /// Handle receiving detailed children for a space. fn update_children_in_space(&mut self, cx: &mut Cx, space_id: &OwnedRoomId, children: &Vector) { self.children_cache.insert(space_id.clone(), children.clone()); @@ -1380,6 +1520,8 @@ impl SpaceLobbyScreen { // Clear the main content until we receive the async space info responses. self.tree_entries.clear(); + self.top_level_join_rule = None; + self.top_level_member_count = None; self.view.label(cx, ids!(header.space_info_label)).set_text(cx, ""); self.is_loading = true; diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 8f613dc93..1eeb19156 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -13,7 +13,7 @@ use matrix_sdk::{RoomDisplayName, RoomState}; use ruma::{OwnedRoomAliasId, OwnedRoomId, room::JoinRuleSummary}; use crate::{ - home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, utils::{self, RoomNameId} + app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, i18n::{AppLanguage, tr_fmt, tr_key}, login::login_screen::LoginAction, logout::logout_confirm_modal::LogoutAction, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, sliding_sync::AccountSwitchAction, utils::{self, RoomNameId} }; script_mod! { @@ -257,6 +257,7 @@ pub struct SpacesBarEntry { #[apply_default] animator: Animator, #[rust] space_name_id: Option, + #[rust] app_language: AppLanguage, } impl Widget for SpacesBarEntry { @@ -273,7 +274,7 @@ impl Widget for SpacesBarEntry { TooltipAction::HoverIn { widget_rect: area.rect(cx), text: this.space_name_id.as_ref().map_or( - String::from("Unknown Space Name"), + String::from(tr_key(this.app_language, "spaces_bar.tooltip.unknown_space_name")), |sni| sni.to_string(), ), options: CalloutTooltipOptions { @@ -343,21 +344,24 @@ impl Widget for SpacesBarEntry { } impl SpacesBarEntry { - fn set_metadata(&mut self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool) { + fn set_metadata(&mut self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool, app_language: AppLanguage) { self.space_name_id = Some(space_name_id); + self.app_language = app_language; self.animator_toggle(cx, is_selected, Animate::No, ids!(active.on), ids!(active.off)); } } impl SpacesBarEntryRef { - pub fn set_metadata(&self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool) { + pub fn set_metadata(&self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool, app_language: AppLanguage) { let Some(mut inner) = self.borrow_mut() else { return }; - inner.set_metadata(cx, space_name_id, is_selected); + inner.set_metadata(cx, space_name_id, is_selected, app_language); } } pub struct JoinedSpaceInfo { /// The display name and ID of the space. pub space_name_id: RoomNameId, + /// Lowercased searchable text cached for fast local search. + pub search_text: String, /// The canonical alias of the space, if any. pub canonical_alias: Option, /// The topic of the space, if any. @@ -376,6 +380,27 @@ pub struct JoinedSpaceInfo { pub children_count: u64, } +pub fn build_space_search_text( + space_name_id: &RoomNameId, + canonical_alias: &Option, + topic: &Option, +) -> String { + let mut search_text = format!( + "{} {}", + space_name_id.to_string().to_lowercase(), + space_name_id.room_id().as_str().to_lowercase(), + ); + if let Some(alias) = canonical_alias { + search_text.push(' '); + search_text.push_str(&alias.as_str().to_lowercase()); + } + if let Some(topic) = topic { + search_text.push(' '); + search_text.push_str(&topic.to_lowercase()); + } + search_text +} + /// The possible updates that should be displayed by the single list of all spaces. @@ -497,6 +522,17 @@ impl Widget for SpacesBar { if let Event::Actions(actions) = event { for action in actions { + if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + while PENDING_SPACE_UPDATES.pop().is_some() {} + self.all_joined_spaces.clear(); + self.display_filter = RoomDisplayFilter::default(); + self.displayed_spaces.clear(); + self.is_filtered = false; + self.selected_space = None; + self.redraw(cx); + continue; + } + // The room filter input bar is also used to filter which spaces are visible. if let RoomFilterAction::Changed(keywords) = action.as_widget_action().cast() { self.update_displayed_spaces(cx, &keywords); @@ -526,11 +562,33 @@ impl Widget for SpacesBar { } continue; } + + // Handle login success - clear and redraw spaces + if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { + self.all_joined_spaces.clear(); + self.displayed_spaces.clear(); + self.selected_space = None; + self.redraw(cx); + continue; + } + + // Handle account switch - clear and redraw spaces + if let Some(AccountSwitchAction::Switched(_)) = action.downcast_ref() { + self.all_joined_spaces.clear(); + self.displayed_spaces.clear(); + self.selected_space = None; + self.redraw(cx); + continue; + } } } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + while let Some(widget_to_draw) = self.view.draw_walk(cx, scope, walk).step() { // We only care about drawing the portal list. let portal_list_ref = widget_to_draw.as_portal_list(); @@ -557,9 +615,9 @@ impl Widget for SpacesBar { item.label(cx, ids!(label)).set_text( cx, if self.is_filtered { - "Found no\nmatching spaces." + tr_key(app_language, "spaces_bar.status.none_matching") } else { - "Found no\njoined spaces." + tr_key(app_language, "spaces_bar.status.none_joined") } ); item @@ -605,17 +663,41 @@ impl Widget for SpacesBar { cx, space.space_name_id.clone(), self.selected_space.as_ref().is_some_and(|id| id == space.space_name_id.room_id()), + app_language, ); item } else if portal_list_index == len { let item = list.item(cx, portal_list_index, id!(StatusLabel)); - let descriptor = if self.is_filtered { "matching" } else { "joined" }; let text = match len { - 0 => format!("Found no\n{descriptor} spaces."), - 1 => format!("Found 1\n{descriptor} space."), - 2..100 => format!("Found {len}\n{descriptor} spaces."), - 100.. => format!("Found 99+\n{descriptor} spaces."), + 0 => { + if self.is_filtered { + tr_key(app_language, "spaces_bar.status.none_matching").to_string() + } else { + tr_key(app_language, "spaces_bar.status.none_joined").to_string() + } + } + 1 => { + if self.is_filtered { + tr_key(app_language, "spaces_bar.status.one_matching").to_string() + } else { + tr_key(app_language, "spaces_bar.status.one_joined").to_string() + } + } + 2..100 => { + if self.is_filtered { + tr_fmt(app_language, "spaces_bar.status.n_matching", &[("count", &len.to_string())]) + } else { + tr_fmt(app_language, "spaces_bar.status.n_joined", &[("count", &len.to_string())]) + } + } + 100.. => { + if self.is_filtered { + tr_key(app_language, "spaces_bar.status.many_matching").to_string() + } else { + tr_key(app_language, "spaces_bar.status.many_joined").to_string() + } + } }; item.label(cx, ids!(label)).set_text(cx, &text); item @@ -678,6 +760,7 @@ impl SpacesBar { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { let was_displayed = (self.display_filter)(space); space.canonical_alias = new_canonical_alias; + space.search_text = build_space_search_text(&space.space_name_id, &space.canonical_alias, &space.topic); let should_display = (self.display_filter)(space); adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); } else { @@ -692,6 +775,7 @@ impl SpacesBar { RoomDisplayName::Named(new_space_name), space_id.clone(), ); + space.search_text = build_space_search_text(&space.space_name_id, &space.canonical_alias, &space.topic); let should_display = (self.display_filter)(space); adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); } else { @@ -704,6 +788,7 @@ impl SpacesBar { // We don't currently support filtering by topic. // let was_displayed = (self.display_filter)(space); space.topic = topic; + space.search_text = build_space_search_text(&space.space_name_id, &space.canonical_alias, &space.topic); // let should_display = (self.display_filter)(space); // adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); } else { @@ -820,8 +905,34 @@ impl SpacesBar { } else { filtered_spaces_iter.map(|(space_id, _)| space_id.clone()).collect() }; + if self.displayed_spaces.is_empty() { + self.is_filtered = false; + self.display_filter = RoomDisplayFilter::default(); + self.displayed_spaces = self.all_joined_spaces.keys().cloned().collect(); + } portal_list.set_first_id_and_scroll(0, 0.0); self.redraw(cx); } } + +impl SpacesBarRef { + /// Returns local spaces matching `keywords`, up to `max_results`. + pub fn get_matching_space_items(&self, keywords: &str, max_results: usize) -> Vec<(RoomNameId, FetchedRoomAvatar)> { + let Some(inner) = self.borrow() else { return Vec::new(); }; + let keywords = keywords.trim().to_lowercase(); + if keywords.is_empty() { + return Vec::new(); + } + let mut items = Vec::new(); + for space in inner.all_joined_spaces.values() { + if space.search_text.contains(&keywords) { + items.push((space.space_name_id.clone(), space.space_avatar.clone())); + if items.len() >= max_results { + break; + } + } + } + items + } +} diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs new file mode 100644 index 000000000..2846885d6 --- /dev/null +++ b/src/home/streaming_animation.rs @@ -0,0 +1,416 @@ +use std::time::{Duration, Instant}; + +const FINISHED_STREAM_TIMEOUT: Duration = Duration::from_secs(30); +const LIVE_STREAM_STALL_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +/// Characters to reveal per amortized chunk, closer to Moly's small-block growth. +const REVEAL_CHUNK_SIZE: usize = 2; +/// Fixed cadence for releasing each chunk. +const REVEAL_INTERVAL: Duration = Duration::from_millis(55); +/// Characters to reveal immediately when new content arrives after the UI had caught up. +const ARRIVAL_BURST: usize = 1; +/// When the stream is finished and this few chars remain, snap to the end. +const FINISH_SNAP_THRESHOLD: usize = 20; + +/// Animation state for a single streaming message. +/// Tracks an MSC4357 live message and drives character-by-character reveal. +pub struct StreamingAnimState { + pub target_text: String, + pub target_char_count: usize, + pub displayed_char_count: usize, + pub displayed_byte_offset: usize, + pub fractional_chunks: f64, + pub last_update_time: Instant, + pub last_tick_time: Instant, + pub animation_start_time: Instant, + pub display_buffer: String, + /// Whether the message currently carries the MSC4357 `live` field. + pub is_live: bool, + pub timeline_index: Option, +} + +impl StreamingAnimState { + pub fn new(initial_text: &str, is_live: bool) -> Self { + let char_count = initial_text.chars().count(); + let now = Instant::now(); + Self { + target_text: initial_text.to_string(), + target_char_count: char_count, + displayed_char_count: 0, + displayed_byte_offset: 0, + fractional_chunks: 0.0, + last_update_time: now, + last_tick_time: now, + animation_start_time: now, + display_buffer: String::with_capacity(initial_text.len() + 4), + is_live, + timeline_index: None, + } + } + + pub fn restore(previous: &Self, new_text: &str, is_live: bool) -> Self { + let mut restored = Self::new(new_text, is_live); + let visible_prefix = &previous.target_text[..previous.displayed_byte_offset]; + let (common_chars, common_bytes) = common_prefix_len(visible_prefix, new_text); + + restored.displayed_char_count = common_chars; + restored.displayed_byte_offset = common_bytes; + restored.animation_start_time = previous.animation_start_time; + restored.timeline_index = previous.timeline_index; + restored + } + + pub fn update_target(&mut self, new_text: &str, is_live: bool) { + let prev_char_count = self.target_char_count; + let had_backlog = self.displayed_char_count < prev_char_count; + + self.target_text.clear(); + self.target_text.push_str(new_text); + self.target_char_count = new_text.chars().count(); + self.is_live = is_live; + + // Clamp char count if the new text is shorter than what was already displayed. + if self.displayed_char_count > self.target_char_count { + self.displayed_char_count = self.target_char_count; + } + + // Always recalculate byte offset: the new text may have different + // byte widths at already-displayed positions (e.g. markdown formatting + // changes between streaming updates). + self.displayed_byte_offset = self.target_text + .char_indices() + .nth(self.displayed_char_count) + .map_or(self.target_text.len(), |(i, _)| i); + + // Arrival burst: only when we had fully caught up and were waiting + // for more text. If backlog already exists, stay on the amortized cadence. + let added_chars = self.target_char_count.saturating_sub(prev_char_count); + if added_chars > 0 && !had_backlog { + self.advance_displayed(added_chars.min(ARRIVAL_BURST)); + } + + let now = Instant::now(); + self.last_update_time = now; + // If the animation had already caught up and was waiting for more text, + // restart the frame clock so idle time doesn't count as reveal time. + // If backlog already existed, keep the clock to preserve smooth cadence. + if !had_backlog { + self.last_tick_time = now; + } + // Reserve only the deficit (reserve(n) guarantees capacity >= len + n). + let needed = new_text.len() + 4; + if self.display_buffer.capacity() < needed { + self.display_buffer.reserve(needed - self.display_buffer.len()); + } + } + + pub fn advance_displayed(&mut self, chars_to_add: usize) { + if chars_to_add == 0 || self.displayed_char_count >= self.target_char_count { return; } + let remaining = &self.target_text[self.displayed_byte_offset..]; + let mut byte_advance = 0; + let mut actual_chars = 0; + for (byte_idx, _char) in remaining.char_indices() { + if actual_chars >= chars_to_add { byte_advance = byte_idx; break; } + actual_chars += 1; + } + if actual_chars <= chars_to_add && byte_advance == 0 && !remaining.is_empty() { + byte_advance = remaining.len(); + } + self.displayed_char_count = (self.displayed_char_count + actual_chars).min(self.target_char_count); + self.displayed_byte_offset = (self.displayed_byte_offset + byte_advance).min(self.target_text.len()); + } + + pub fn tick(&mut self) -> bool { + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.last_tick_time); + self.last_tick_time = now; + self.tick_with_elapsed(elapsed) + } + + pub fn tick_with_elapsed(&mut self, elapsed: Duration) -> bool { + if self.displayed_char_count >= self.target_char_count { return false; } + let remaining = self.target_char_count - self.displayed_char_count; + + // Finish snap: when the stream is done and only a few chars remain, show them all. + if !self.is_live && remaining <= FINISH_SNAP_THRESHOLD { + self.advance_displayed(remaining); + return true; + } + + // Moly-style amortization: reveal fixed-size chunks at a fixed cadence + // instead of accelerating as backlog grows. + self.fractional_chunks += elapsed.as_secs_f64() / REVEAL_INTERVAL.as_secs_f64(); + let advance_chunks = self.fractional_chunks.floor() as usize; + self.fractional_chunks -= advance_chunks as f64; + if advance_chunks > 0 { + self.advance_displayed(advance_chunks * REVEAL_CHUNK_SIZE); + return true; + } + false + } + + pub fn fill_display_buffer(&mut self) { + self.display_buffer.clear(); + self.display_buffer.push_str(&self.target_text[..self.displayed_byte_offset]); + self.display_buffer.push_str(" \u{25CF}"); + } + + pub fn needs_frame(&self) -> bool { + self.displayed_char_count < self.target_char_count + } + + /// Streaming is complete when the live field is absent and all text has been revealed. + pub fn is_complete(&self) -> bool { + !self.needs_frame() && !self.is_live + } + + pub fn timeout_after(&self) -> Duration { + if self.is_live { + LIVE_STREAM_STALL_TIMEOUT + } else { + FINISHED_STREAM_TIMEOUT + } + } + + pub fn is_timed_out(&self) -> bool { + self.last_update_time.elapsed() > self.timeout_after() + } +} + +fn common_prefix_len(lhs: &str, rhs: &str) -> (usize, usize) { + let mut chars = 0; + let mut bytes = 0; + let mut lhs_chars = lhs.chars(); + + for (byte_idx, rhs_char) in rhs.char_indices() { + let Some(lhs_char) = lhs_chars.next() else { + break; + }; + if lhs_char != rhs_char { + break; + } + chars += 1; + bytes = byte_idx + rhs_char.len_utf8(); + } + + (chars, bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_state(text: &str) -> StreamingAnimState { + StreamingAnimState::new(text, true) + } + + #[test] + fn test_advance_ascii() { + let mut s = make_state("Hello, world!"); + s.advance_displayed(5); + assert_eq!(s.displayed_char_count, 5); + assert_eq!(&s.target_text[..s.displayed_byte_offset], "Hello"); + } + + #[test] + fn test_advance_utf8_multibyte() { + let mut s = make_state("你好世界abcd"); + s.advance_displayed(2); + assert_eq!(s.displayed_char_count, 2); + assert_eq!(&s.target_text[..s.displayed_byte_offset], "你好"); + } + + #[test] + fn test_advance_clamps_at_end() { + let mut s = make_state("abc"); + s.advance_displayed(100); + assert_eq!(s.displayed_char_count, 3); + assert_eq!(s.displayed_byte_offset, 3); + } + + #[test] + fn test_update_target_extends() { + let mut s = make_state("Hello"); + s.advance_displayed(5); + s.update_target("Hello, world!", true); + assert_eq!(s.target_char_count, 13); + // Arrival burst reveals only the newly added chars, capped by ARRIVAL_BURST. + assert_eq!(s.displayed_char_count, 5 + ARRIVAL_BURST.min(8)); + } + + #[test] + fn test_update_target_uses_single_char_burst_when_waiting_for_new_text() { + let mut s = make_state("Hello"); + s.advance_displayed(5); + s.update_target("Hello, world!", true); + assert_eq!(s.displayed_char_count, 6); + } + + #[test] + fn test_update_target_does_not_burst_while_backlog_exists() { + let mut s = make_state("Hello"); + s.advance_displayed(2); + s.update_target("Hello!", true); + // When backlog already exists, keep the amortized cadence instead of + // applying a fresh burst on every incoming update. + assert_eq!(s.displayed_char_count, 2); + } + + #[test] + fn test_update_target_shrinks_safely() { + let mut s = make_state("Hello, world!"); + s.advance_displayed(10); + s.update_target("Hi", true); + assert_eq!(s.displayed_char_count, 2); + assert_eq!(s.displayed_byte_offset, 2); + s.fill_display_buffer(); + assert!(s.display_buffer.starts_with("Hi")); + } + + #[test] + fn test_update_target_recalculates_byte_offset_for_different_prefix() { + // Simulate: displayed 5 ASCII chars, then text replaced with CJK characters. + // Old byte offset (5) would be inside a multi-byte char in the new text. + let mut s = make_state("hello world"); + s.advance_displayed(5); + assert_eq!(s.displayed_byte_offset, 5); + + // New text has 5+ chars but first 5 chars are 3-byte CJK. + // Without the fix, displayed_byte_offset stays 5, crashing on slice. + s.update_target("你好世界测试数据", true); + assert_eq!(s.displayed_char_count, 5); + // 5 CJK chars × 3 bytes = 15 + assert_eq!(s.displayed_byte_offset, 15); + // Must not panic: + s.fill_display_buffer(); + assert!(s.display_buffer.starts_with("你好世界测")); + } + + #[test] + fn test_tick_advances() { + let mut s = make_state("Hello, world!"); + let changed = s.tick_with_elapsed(REVEAL_INTERVAL); + assert!(changed); + assert_eq!(s.displayed_char_count, REVEAL_CHUNK_SIZE); + } + + #[test] + fn test_tick_waits_for_full_chunk_interval() { + let mut s = make_state("Hello, world!"); + assert!(!s.tick_with_elapsed(REVEAL_INTERVAL / 2)); + assert_eq!(s.displayed_char_count, 0); + } + + #[test] + fn test_tick_large_gap_smooth() { + let mut s = make_state(&"a".repeat(1000)); + // Even after a large elapsed gap, keep a steady amortized pace. + assert!(s.tick_with_elapsed(Duration::from_secs(1))); + assert!(s.displayed_char_count >= 30); + assert!(s.displayed_char_count <= 40); + } + + #[test] + fn test_fill_display_buffer() { + let mut s = make_state("Hello"); + s.advance_displayed(3); + s.fill_display_buffer(); + assert!(s.display_buffer.starts_with("Hel")); + assert!(s.display_buffer.contains('\u{25CF}') || s.display_buffer.contains('●')); + } + + #[test] + fn test_is_complete_msc4357() { + let mut s = make_state("Hi"); + s.advance_displayed(2); + // is_live=true → not complete even though all text revealed + assert!(!s.is_complete()); + // Simulate final edit without live field + s.is_live = false; + assert!(s.is_complete()); + } + + #[test] + fn test_update_target_sets_live() { + let mut s = make_state("Hello"); + assert!(s.is_live); + s.update_target("Hello, world!", false); + assert!(!s.is_live); + } + + #[test] + fn test_restore_preserves_common_prefix() { + // Extension: keep what was already displayed + let mut prev = make_state("Hello, world!"); + prev.advance_displayed(5); + let restored = StreamingAnimState::restore(&prev, "Hello, world!!!", true); + assert_eq!(restored.displayed_char_count, 5); + assert_eq!(&restored.target_text[..restored.displayed_byte_offset], "Hello"); + + // Divergence: clamp to the common prefix + let mut prev2 = make_state("Hello, world!"); + prev2.advance_displayed(12); + let restored2 = StreamingAnimState::restore(&prev2, "Hello there", true); + assert_eq!(&restored2.target_text[..restored2.displayed_byte_offset], "Hello"); + } + + #[test] + fn test_timeout_split_by_live_state() { + // Live stream survives 31s idle (5min stall timeout) + let mut live = make_state("Hello"); + live.last_update_time = Instant::now() - Duration::from_secs(31); + assert!(!live.is_timed_out()); + + // Finished stream times out after 31s (30s cleanup timeout) + let mut finished = make_state("Hello"); + finished.is_live = false; + finished.last_update_time = Instant::now() - Duration::from_secs(31); + assert!(finished.is_timed_out()); + } + + #[test] + fn test_tick_zero_elapsed() { + let mut s = make_state("Hello"); + assert!(!s.tick_with_elapsed(Duration::ZERO)); + assert_eq!(s.displayed_char_count, 0); + } + + #[test] + fn test_update_target_preserves_tick_clock_when_backlog_already_exists() { + let mut s = make_state("Hello, world!"); + s.advance_displayed(3); + let before = Instant::now() - Duration::from_millis(120); + s.last_tick_time = before; + + s.update_target("Hello, world!!!", true); + + assert_eq!(s.last_tick_time, before); + } + + #[test] + fn test_update_target_resets_tick_clock_when_waiting_for_new_text() { + let mut s = make_state("Hello"); + s.advance_displayed(5); + let before = Instant::now() - Duration::from_secs(5); + s.last_tick_time = before; + + s.update_target("Hello!", true); + + assert!(s.last_tick_time > before); + } + + #[test] + fn test_finish_snap() { + let mut s = make_state(&"a".repeat(30)); + s.advance_displayed(20); + // 10 remaining but is_live=true → normal tick, no snap. + s.tick_with_elapsed(Duration::from_millis(16)); + assert!(s.displayed_char_count < 30); + + // Mark as finished → remaining <= FINISH_SNAP_THRESHOLD → snaps to end. + s.is_live = false; + assert!(s.tick_with_elapsed(Duration::from_millis(1))); + assert_eq!(s.displayed_char_count, 30); + } + +} diff --git a/src/home/welcome_screen.rs b/src/home/welcome_screen.rs index 5e08674dc..892c16ed6 100644 --- a/src/home/welcome_screen.rs +++ b/src/home/welcome_screen.rs @@ -1,4 +1,5 @@ use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* @@ -7,9 +8,9 @@ script_mod! { mod.widgets.WELCOME_TEXT_COLOR = #x4 - mod.widgets.WelcomeScreen = SolidView { + mod.widgets.WelcomeScreen = #(WelcomeScreen::register_widget(vm)) { width: Fill, height: Fill - align: Align{x: 0.0, y: 0.5} + align: Align{x: 0.5, y: 0.5} show_bg: true, draw_bg.color: (COLOR_PRIMARY) @@ -22,13 +23,15 @@ script_mod! { welcome_message := RoundedView { padding: 40. - width: Fill, height: Fit + width: Fill, height: Fill flow: Down, spacing: 20 + align: Align{x: 0.5, y: 0.5} draw_bg.color: (COLOR_PRIMARY) title := Label { - text: "Welcome to Robrix!", + text: "" + align: Align{x: 0.5, y: 0.5} draw_text +: { color: (mod.widgets.WELCOME_TEXT_COLOR), text_style: theme.font_bold { @@ -38,7 +41,7 @@ script_mod! { } // Using the HTML widget to taking advantage of embedding a link within text with proper vertical alignment - MessageHtml { + body := MessageHtml { padding: Inset{top: 12, left: 0.} font_size: 14. font_color: (mod.widgets.WELCOME_TEXT_COLOR) @@ -52,14 +55,51 @@ script_mod! { // color_hover: #0f0, // } } - body:"

Our Matrix client is under heavy development. Currently, you can access the rooms and spaces that you've joined in other clients.

-


-

But don't worry, we're constantly expanding the featureset of Robrix!

-


-

Look for the latest announcements in our Matrix channel:

-

#robrix:matrix.org

- " + body:"" } } } } + +#[derive(Script, ScriptHook, Widget)] +pub struct WelcomeScreen { + #[deref] view: View, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, +} + +impl Widget for WelcomeScreen { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.handle_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.draw_walk(cx, scope, walk) + } +} + +impl WelcomeScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "welcome_screen.title")); + self.view + .html(cx, ids!(body)) + .set_text(cx, tr_key(self.app_language, "welcome_screen.body_html")); + self.view.redraw(cx); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 000000000..2f19806e4 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,115 @@ +use std::{collections::HashMap, sync::OnceLock}; + +use serde::{Deserialize, Serialize}; + +/// App UI language preference stored in persisted app state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppLanguage { + #[serde(rename = "en", alias = "English")] + #[default] + English, + #[serde(rename = "zh-CN", alias = "ChineseSimplified")] + ChineseSimplified, +} + +impl AppLanguage { + pub const ALL: [Self; 2] = [ + Self::English, + Self::ChineseSimplified, + ]; + + pub fn code(self) -> &'static str { + match self { + Self::English => "en", + Self::ChineseSimplified => "zh-CN", + } + } + + pub fn from_dropdown_index(index: usize) -> Self { + Self::ALL + .get(index) + .copied() + .unwrap_or(Self::English) + } + + pub fn dropdown_index(self) -> usize { + Self::ALL + .iter() + .position(|lang| *lang == self) + .unwrap_or(0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum I18nKey { + AllSettingsTitle, + SettingsCategoryAccount, + SettingsCategoryPreferences, + SettingsCategoryLabs, + LanguageTitle, + ApplicationLanguageLabel, + LanguageReloadHint, + LanguageOptionEnglish, + LanguageOptionChineseSimplified, +} + +impl I18nKey { + fn as_str(self) -> &'static str { + match self { + I18nKey::AllSettingsTitle => "settings.all_settings_title", + I18nKey::SettingsCategoryAccount => "settings.category.account", + I18nKey::SettingsCategoryPreferences => "settings.category.preferences", + I18nKey::SettingsCategoryLabs => "settings.category.labs", + I18nKey::LanguageTitle => "settings.preferences.language.title", + I18nKey::ApplicationLanguageLabel => "settings.preferences.language.application_label", + I18nKey::LanguageReloadHint => "settings.preferences.language.reload_hint", + I18nKey::LanguageOptionEnglish => "language.option.english", + I18nKey::LanguageOptionChineseSimplified => "language.option.chinese_simplified", + } + } +} + +fn load_dictionary(language: AppLanguage) -> HashMap { + let json = match language { + AppLanguage::English => include_str!("../resources/i18n/en.json"), + AppLanguage::ChineseSimplified => include_str!("../resources/i18n/zh-CN.json"), + }; + serde_json::from_str(json).unwrap_or_default() +} + +fn dictionary(language: AppLanguage) -> &'static HashMap { + static EN_DICTIONARY: OnceLock> = OnceLock::new(); + static ZH_CN_DICTIONARY: OnceLock> = OnceLock::new(); + + match language { + AppLanguage::English => EN_DICTIONARY.get_or_init(|| load_dictionary(AppLanguage::English)), + AppLanguage::ChineseSimplified => ZH_CN_DICTIONARY.get_or_init(|| load_dictionary(AppLanguage::ChineseSimplified)), + } +} + +pub fn tr_key<'a>(language: AppLanguage, key: &'a str) -> &'a str { + dictionary(language) + .get(key) + .map(String::as_str) + .or_else(|| dictionary(AppLanguage::English).get(key).map(String::as_str)) + .unwrap_or(key) +} + +pub fn tr_fmt(language: AppLanguage, key: &str, vars: &[(&str, &str)]) -> String { + let mut output = tr_key(language, key).to_string(); + for (name, value) in vars { + output = output.replace(&format!("{{{name}}}"), value); + } + output +} + +pub fn tr(language: AppLanguage, key: I18nKey) -> &'static str { + tr_key(language, key.as_str()) +} + +pub fn language_dropdown_labels(language: AppLanguage) -> Vec { + vec![ + tr(language, I18nKey::LanguageOptionEnglish).to_string(), + tr(language, I18nKey::LanguageOptionChineseSimplified).to_string(), + ] +} diff --git a/src/lib.rs b/src/lib.rs index 346c0314b..e730e3b75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,8 @@ pub mod app; pub mod persistence; /// The settings screen and settings-related content/widgets. pub mod settings; +/// App-localized text and language preference definitions. +pub mod i18n; /// Login screen pub mod login; @@ -78,6 +80,8 @@ pub mod media_cache; pub mod verification; pub mod utils; +/// Multi-account management for supporting multiple Matrix accounts simultaneously. +pub mod account_manager; pub mod temp_storage; pub mod location; diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 3b3c322a1..3296db520 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest}; +use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, sliding_sync::{submit_async_request, AccountSwitchAction, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -69,19 +69,13 @@ script_mod! { } } - RoundedView { + View { margin: Inset{top: 40, bottom: 40} width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit align: Align{x: 0.5, y: 0.5} flow: Overlay, - show_bg: true, - draw_bg +: { - color: (COLOR_SECONDARY) - border_radius: 6.0 - } - View { width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit @@ -123,6 +117,19 @@ script_mod! { is_password: true, } + confirm_password_wrapper := View { + width: 275, height: Fit, + visible: false, + + confirm_password_input := RobrixTextInput { + width: 275, height: Fit + flow: Right, // do not wrap + padding: 10, + empty_text: "Confirm password" + is_password: true, + } + } + View { width: 275, height: Fit, flow: Down, @@ -147,7 +154,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - Label { + homeserver_hint_label := Label { width: Fit, height: Fit padding: 0 draw_text +: { @@ -171,54 +178,61 @@ script_mod! { text: "Login" } - LineH { - width: 275 - margin: Inset{bottom: -5} - draw_bg.color: #C8C8C8 - } + login_only_view := View { + width: Fit, height: Fit, + flow: Down, + align: Align{x: 0.5, y: 0.5} + spacing: 15.0 - Label { - width: Fit, height: Fit - padding: 0, - draw_text +: { - color: (COLOR_TEXT) - text_style: TITLE_TEXT {font_size: 11.0} + LineH { + width: 275 + margin: Inset{bottom: -5} + draw_bg.color: #C8C8C8 } - text: "Or, login with an SSO provider:" - } - sso_view := View { - width: 275, height: Fit, - margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide - flow: Flow.Right{wrap: true}, - apple_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/apple.png") + sso_prompt_label := Label { + width: Fit, height: Fit + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 11.0} } + text: "Or, login with an SSO provider:" } - facebook_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/facebook.png") + + sso_view := View { + width: 275, height: Fit, + margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide + flow: Flow.Right{wrap: true}, + apple_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/apple.png") + } } - } - github_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/github.png") + facebook_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/facebook.png") + } } - } - gitlab_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/gitlab.png") + github_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/github.png") + } } - } - google_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/google.png") + gitlab_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/gitlab.png") + } } - } - twitter_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/x.png") + google_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/google.png") + } + } + twitter_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/x.png") + } } } } @@ -233,7 +247,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - Label { + account_prompt_label := Label { width: Fit, height: Fit padding: Inset{left: 1, right: 1, top: 0, bottom: 0} draw_text +: { @@ -246,13 +260,23 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } - signup_button := RobrixIconButton { + mode_toggle_button := RobrixIconButton { width: Fit, height: Fit padding: Inset{left: 15, right: 15, top: 10, bottom: 10} margin: Inset{bottom: 5} align: Align{x: 0.5, y: 0.5} text: "Sign up here" } + + // Cancel button for add-account mode (hidden by default) + cancel_button := RobrixIconButton { + width: Fit, height: Fit + padding: Inset{left: 15, right: 15, top: 10, bottom: 10} + margin: Inset{top: 10, bottom: 5} + align: Align{x: 0.5, y: 0.5} + text: "Cancel" + visible: false + } } // The modal that pops up to display login status messages, @@ -270,71 +294,194 @@ script_mod! { } } -static MATRIX_SIGN_UP_URL: &str = "https://matrix.org/docs/chat_basics/matrix-for-im/#creating-a-matrix-account"; - #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { #[source] source: ScriptObjectRef, #[deref] view: View, + /// Whether the screen is showing the in-app sign-up flow. + #[rust] signup_mode: bool, /// Boolean to indicate if the SSO login process is still in flight #[rust] sso_pending: bool, /// The URL to redirect to after logging in with SSO. #[rust] sso_redirect_url: Option, + /// The most recent login failure message shown to the user. + #[rust] last_failure_message_shown: Option, + #[rust] app_language: AppLanguage, + /// Boolean to indicate if we're in "add account" mode (adding another Matrix account). + #[rust] adding_account: bool, +} + +impl LoginScreen { + fn sync_mode_texts(&mut self, cx: &mut Cx) { + self.view.label(cx, ids!(title)).set_text(cx, + if self.signup_mode { + tr_key(self.app_language, "login.title.create_account") + } else { + tr_key(self.app_language, "login.title.login_to_robrix") + } + ); + self.view.button(cx, ids!(login_button)).set_text(cx, + if self.signup_mode { + tr_key(self.app_language, "login.button.create_account") + } else { + tr_key(self.app_language, "login.button.login") + } + ); + self.view.label(cx, ids!(account_prompt_label)).set_text(cx, + if self.signup_mode { + tr_key(self.app_language, "login.account_prompt.already_have") + } else { + tr_key(self.app_language, "login.account_prompt.no_account") + } + ); + self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, + if self.signup_mode { + tr_key(self.app_language, "login.mode_toggle.back_to_login") + } else { + tr_key(self.app_language, "login.mode_toggle.sign_up_here") + } + ); + } + + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.text_input(cx, ids!(user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.user_id").to_string()); + self.view.text_input(cx, ids!(password_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.password").to_string()); + self.view.text_input(cx, ids!(confirm_password_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.confirm_password").to_string()); + self.view.text_input(cx, ids!(homeserver_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.homeserver").to_string()); + self.view.label(cx, ids!(homeserver_hint_label)) + .set_text(cx, tr_key(self.app_language, "login.label.homeserver_optional")); + self.view.label(cx, ids!(sso_prompt_label)) + .set_text(cx, tr_key(self.app_language, "login.sso.prompt")); + let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login_status_modal.title")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login_status_modal.button.cancel")); + self.sync_mode_texts(cx); + } + + fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { + self.signup_mode = signup_mode; + self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); + self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); + self.sync_mode_texts(cx); + + if !signup_mode { + self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); + } + + self.redraw(cx); + } } impl Widget for LoginScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.handle_event(cx, event, scope); - self.match_event(cx, event); + self.widget_match_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.draw_walk(cx, scope, walk) } } -impl MatchEvent for LoginScreen { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { +impl WidgetMatchEvent for LoginScreen { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { let login_button = self.view.button(cx, ids!(login_button)); - let signup_button = self.view.button(cx, ids!(signup_button)); + let mode_toggle_button = self.view.button(cx, ids!(mode_toggle_button)); + let cancel_button = self.view.button(cx, ids!(cancel_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_input)); let password_input = self.view.text_input(cx, ids!(password_input)); + let confirm_password_input = self.view.text_input(cx, ids!(confirm_password_input)); let homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); - if signup_button.clicked(actions) { - log!("Opening URL \"{}\"", MATRIX_SIGN_UP_URL); - let _ = robius_open::Uri::new(MATRIX_SIGN_UP_URL).open(); + // Handle cancel button for add-account mode + if cancel_button.clicked(actions) { + self.adding_account = false; + // Reset the UI back to normal login mode + self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); + cancel_button.set_visible(cx, false); + self.view.view(cx, ids!(sso_view)).set_visible(cx, true); + mode_toggle_button.set_visible(cx, true); + cx.action(LoginAction::CancelAddAccount); + self.redraw(cx); + } + + if mode_toggle_button.clicked(actions) { + self.set_signup_mode(cx, !self.signup_mode); } if login_button.clicked(actions) || user_id_input.returned(actions).is_some() || password_input.returned(actions).is_some() + || (self.signup_mode && confirm_password_input.returned(actions).is_some()) || homeserver_input.returned(actions).is_some() { - let user_id = user_id_input.text(); + let user_id = user_id_input.text().trim().to_owned(); let password = password_input.text(); - let homeserver = homeserver_input.text(); + let confirm_password = confirm_password_input.text(); + let homeserver = homeserver_input.text().trim().to_owned(); if user_id.is_empty() { - login_status_modal_inner.set_title(cx, "Missing User ID"); - login_status_modal_inner.set_status(cx, "Please enter a valid User ID."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.missing_user_id.title")); + login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.missing_user_id.body")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); } else if password.is_empty() { - login_status_modal_inner.set_title(cx, "Missing Password"); - login_status_modal_inner.set_status(cx, "Please enter a valid password."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.missing_password.title")); + login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.missing_password.body")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); + } else if self.signup_mode && password != confirm_password { + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.password_mismatch.title")); + login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.password_mismatch.body")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); } else { - login_status_modal_inner.set_title(cx, "Logging in..."); - login_status_modal_inner.set_status(cx, "Waiting for a login response..."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); - submit_async_request(MatrixRequest::Login(LoginRequest::LoginByPassword(LoginByPassword { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - }))); + self.last_failure_message_shown = None; + login_status_modal_inner.set_title(cx, if self.signup_mode { + tr_key(self.app_language, "login.status.creating_account.title") + } else { + tr_key(self.app_language, "login.status.logging_in.title") + }); + login_status_modal_inner.set_status( + cx, + if self.signup_mode { + tr_key(self.app_language, "login.status.creating_account.body") + } else { + tr_key(self.app_language, "login.status.logging_in.body") + }, + ); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.cancel")); + submit_async_request(MatrixRequest::Login(if self.signup_mode { + LoginRequest::Register(RegisterAccount { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + }) + } else { + LoginRequest::LoginByPassword(LoginByPassword { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + is_add_account: self.adding_account, + }) + })); } login_status_modal.open(cx); self.redraw(cx); @@ -357,24 +504,28 @@ impl MatchEvent for LoginScreen { // Handle login-related actions received from background async tasks. match action.downcast_ref() { Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { + self.last_failure_message_shown = None; user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); - login_status_modal_inner.set_title(cx, "Logging in via CLI..."); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.logging_in_cli.title")); login_status_modal_inner.set_status( cx, - &format!("Auto-logging in as user {user_id}...") + &tr_fmt(self.app_language, "login.status.auto_logging_in_as_user", &[ + ("user_id", user_id.as_str()), + ]) ); let login_status_modal_button = login_status_modal_inner.button_ref(cx); - login_status_modal_button.set_text(cx, "Cancel"); + login_status_modal_button.set_text(cx, tr_key(self.app_language, "login.status.cancel")); login_status_modal_button.set_enabled(cx, false); // Login cancel not yet supported login_status_modal.open(cx); } Some(LoginAction::Status { title, status }) => { + self.last_failure_message_shown = None; login_status_modal_inner.set_title(cx, title); login_status_modal_inner.set_status(cx, status); let login_status_modal_button = login_status_modal_inner.button_ref(cx); - login_status_modal_button.set_text(cx, "Cancel"); + login_status_modal_button.set_text(cx, tr_key(self.app_language, "login.status.cancel")); login_status_modal_button.set_enabled(cx, true); login_status_modal.open(cx); self.redraw(cx); @@ -382,17 +533,33 @@ impl MatchEvent for LoginScreen { Some(LoginAction::LoginSuccess) => { // The main `App` component handles showing the main screen // and hiding the login screen & login status modal. + self.last_failure_message_shown = None; + self.set_signup_mode(cx, false); + self.adding_account = false; user_id_input.set_text(cx, ""); password_input.set_text(cx, ""); + confirm_password_input.set_text(cx, ""); homeserver_input.set_text(cx, ""); + // Reset title and buttons in case we were in add-account mode + self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); + cancel_button.set_visible(cx, false); + mode_toggle_button.set_visible(cx, true); login_status_modal.close(cx); self.redraw(cx); } Some(LoginAction::LoginFailure(error)) => { - login_status_modal_inner.set_title(cx, "Login Failed."); + if self.last_failure_message_shown.as_deref() == Some(error.as_str()) { + continue; + } + self.last_failure_message_shown = Some(error.clone()); + login_status_modal_inner.set_title(cx, if self.signup_mode { + tr_key(self.app_language, "login.status.account_creation_failed") + } else { + tr_key(self.app_language, "login.status.login_failed") + }); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); - login_status_modal_button.set_text(cx, "Okay"); + login_status_modal_button.set_text(cx, tr_key(self.app_language, "login.status.okay")); login_status_modal_button.set_enabled(cx, true); login_status_modal.open(cx); self.redraw(cx); @@ -414,6 +581,45 @@ impl MatchEvent for LoginScreen { Some(LoginAction::SsoSetRedirectUrl(url)) => { self.sso_redirect_url = Some(url.to_string()); } + Some(LoginAction::ShowAddAccountScreen) => { + self.adding_account = true; + // Update UI to "add account" mode + self.view.label(cx, ids!(title)).set_text(cx, "Add Another Account"); + cancel_button.set_visible(cx, true); + // Hide signup button in add-account mode (user already has an account) + mode_toggle_button.set_visible(cx, false); + self.redraw(cx); + } + Some(LoginAction::AddAccountSuccess) => { + // Reset the login screen state + self.adding_account = false; + user_id_input.set_text(cx, ""); + password_input.set_text(cx, ""); + homeserver_input.set_text(cx, ""); + // Reset title and buttons + self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); + cancel_button.set_visible(cx, false); + mode_toggle_button.set_visible(cx, true); + login_status_modal.close(cx); + self.redraw(cx); + } + _ => { } + } + + // Handle account switch actions - close modal when switch completes or fails + match action.downcast_ref() { + Some(AccountSwitchAction::Switched(_)) => { + login_status_modal.close(cx); + self.redraw(cx); + } + Some(AccountSwitchAction::Failed(error)) => { + login_status_modal_inner.set_title(cx, "Account Switch Failed"); + login_status_modal_inner.set_status(cx, error); + let login_status_modal_button = login_status_modal_inner.button_ref(cx); + login_status_modal_button.set_text(cx, "Okay"); + login_status_modal_button.set_enabled(cx, true); + self.redraw(cx); + } _ => { } } } @@ -448,6 +654,9 @@ impl MatchEvent for LoginScreen { pub enum LoginAction { /// A positive response from the backend Matrix task to the login screen. LoginSuccess, + /// A positive response when adding an additional account (multi-account mode). + /// The login was successful but we should add this as a new account, not replace the existing one. + AddAccountSuccess, /// A negative response from the backend Matrix task to the login screen. LoginFailure(String), /// A login-related status message to display to the user. @@ -465,15 +674,20 @@ pub enum LoginAction { /// informing it that the SSO login process is either still in flight (`true`) or has finished (`false`). /// /// Note that an inner value of `false` does *not* imply that the login request has - /// successfully finished. + /// successfully finished. /// The login screen can use this to prevent the user from submitting - /// additional SSO login requests while a previous request is in flight. + /// additional SSO login requests while a previous request is in flight. SsoPending(bool), /// Set the SSO redirect URL in the LoginScreen. /// /// When an SSO-based login is pendng, pressing the cancel button will send /// an HTTP request to this SSO server URL to gracefully shut it down. SsoSetRedirectUrl(Url), + /// Request to show the login screen in "add account" mode. + /// This is used when the user wants to add another Matrix account. + ShowAddAccountScreen, + /// Request to cancel adding an account and return to the previous screen. + CancelAddAccount, #[default] None, } diff --git a/src/logout/logout_state_machine.rs b/src/logout/logout_state_machine.rs index 3ccb922ca..9d2c4bde4 100644 --- a/src/logout/logout_state_machine.rs +++ b/src/logout/logout_state_machine.rs @@ -90,7 +90,7 @@ use anyhow::{anyhow, Result}; use makepad_widgets::{Cx, log}; use crate::home::navigation_tab_bar::NavigationBarAction; -use crate::persistence::delete_latest_user_id; +use crate::persistence::{delete_latest_user_id, skip_app_state_restore_once}; use crate::sliding_sync::clear_app_state; use crate::{ home::main_desktop_ui::MainDesktopUiAction, @@ -317,37 +317,16 @@ impl LogoutStateMachine { match self.perform_server_logout().await { Ok(_) => { - self.point_of_no_return.store(true, Ordering::Release); - set_logout_point_of_no_return(true); - self.transition_to( - LogoutState::PointOfNoReturn, - "Point of no return reached".to_string(), - 50 - ).await?; - - // We delete latest_user_id after reaching LOGOUT_POINT_OF_NO_RETURN: - // 1. To prevent auto-login with invalid session on next start - // 2. While keeping session file intact for potential future login - if let Err(e) = delete_latest_user_id().await { - log!("Warning: Failed to delete latest user ID: {}", e); - } + self.enter_point_of_no_return("Point of no return reached").await?; } Err(e) => { // Check if it's an M_UNKNOWN_TOKEN error if matches!(&e, LogoutError::Recoverable(RecoverableError::ServerLogoutFailed(msg)) if msg.contains("M_UNKNOWN_TOKEN")) { log!("Token already invalidated, continuing with logout"); - self.point_of_no_return.store(true, Ordering::Release); - set_logout_point_of_no_return(true); - self.transition_to( - LogoutState::PointOfNoReturn, - "Token already invalidated".to_string(), - 50 - ).await?; - - // Same delete operation as in the success case above - if let Err(e) = delete_latest_user_id().await { - log!("Warning: Failed to delete latest user ID: {}", e); - } + self.enter_point_of_no_return("Token already invalidated").await?; + } else if should_continue_local_logout_without_server(&e) { + log!("Homeserver appears unavailable, continuing with local logout: {}", e); + self.enter_point_of_no_return("Homeserver unavailable, continuing with local logout").await?; } else { // Restart sync service since we haven't reached point of no return if let Some(sync_service) = get_sync_service() { @@ -452,6 +431,30 @@ impl LogoutStateMachine { Ok(()) } + /// Sets the global point-of-no-return flags, writes the skip-restore marker, + /// and deletes the saved user ID so the next app start won't auto-login. + async fn enter_point_of_no_return(&self, message: &str) -> Result<()> { + self.point_of_no_return.store(true, Ordering::Release); + set_logout_point_of_no_return(true); + self.transition_to( + LogoutState::PointOfNoReturn, + message.to_string(), + 50 + ).await?; + + if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { + if let Err(e) = skip_app_state_restore_once(&user_id).await { + log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); + } + } + + if let Err(e) = delete_latest_user_id().await { + log!("Warning: Failed to delete latest user ID: {}", e); + } + + Ok(()) + } + // Individual step implementations async fn perform_prechecks(&self) -> Result<(), LogoutError> { log!("perform_prechecks started"); @@ -553,6 +556,33 @@ impl LogoutStateMachine { } } +fn should_continue_local_logout_without_server(error: &LogoutError) -> bool { + match error { + LogoutError::Recoverable(RecoverableError::Timeout(_)) => true, + LogoutError::Recoverable(RecoverableError::ServerLogoutFailed(msg)) => { + let msg_lower = msg.to_ascii_lowercase(); + msg_lower.contains("timeout") + || msg_lower.contains("timed out") + || msg_lower.contains("service unavailable") + || msg_lower.contains("bad gateway") + || msg_lower.contains("gateway timeout") + || msg_lower.contains("too many requests") + || msg_lower.contains("error sending request") + || msg_lower.contains("connection") + || msg_lower.contains("connect") + || msg_lower.contains("network") + || msg_lower.contains("dns") + || msg_lower.contains("i/o") + || msg_lower.contains("tls") + || msg_lower.contains("status code: 429") + || msg_lower.contains("status code: 502") + || msg_lower.contains("status code: 503") + || msg_lower.contains("status code: 504") + } + _ => false, + } +} + /// Global atomic flag indicating if the logout process has reached the "point of no return" /// where aborting the logout operation is no longer safe. static LOGOUT_POINT_OF_NO_RETURN: AtomicBool = AtomicBool::new(false); diff --git a/src/persistence/app_state.rs b/src/persistence/app_state.rs index 6bc88714f..7201ad033 100644 --- a/src/persistence/app_state.rs +++ b/src/persistence/app_state.rs @@ -7,6 +7,7 @@ use crate::{app::AppState, app_data_dir, persistence::persistent_state_dir}; const LATEST_APP_STATE_FILE_NAME: &str = "latest_app_state.json"; +const SKIP_APP_STATE_RESTORE_ONCE_FILE_NAME: &str = "skip_app_state_restore_once"; const WINDOW_GEOM_STATE_FILE_NAME: &str = "window_geom_state.json"; @@ -38,6 +39,26 @@ pub fn save_app_state( Ok(()) } +/// Marks that the next login for this user should skip automatic app-state restore once. +pub async fn skip_app_state_restore_once(user_id: &UserId) -> anyhow::Result<()> { + let marker_path = persistent_state_dir(user_id).join(SKIP_APP_STATE_RESTORE_ONCE_FILE_NAME); + if let Some(parent) = marker_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(marker_path, b"1").await?; + Ok(()) +} + +/// Consumes the one-shot "skip automatic restore" marker for the given user, if present. +pub async fn take_skip_app_state_restore_once(user_id: &UserId) -> anyhow::Result { + let marker_path = persistent_state_dir(user_id).join(SKIP_APP_STATE_RESTORE_ONCE_FILE_NAME); + match tokio::fs::remove_file(marker_path).await { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + } +} + /// Save the current state of the given window's geometry to persistent storage. pub fn save_window_state(window_ref: WindowRef, cx: &Cx) -> anyhow::Result<()> { let inner_size = window_ref.get_inner_size(cx); diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index d99855b7c..8d3e81a51 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -1,8 +1,8 @@ //! Handles app persistence by saving and restoring client session data to/from the filesystem. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail}; -use makepad_widgets::{log, Cx}; +use makepad_widgets::{log, warning, Cx}; use matrix_sdk::{ authentication::matrix::MatrixSession, ruma::{OwnedUserId, UserId}, @@ -140,7 +140,7 @@ async fn save_latest_user_id(user_id: &UserId) -> anyhow::Result<()> { /// is retrieved from the filesystem. pub async fn restore_session( user_id: Option -) -> anyhow::Result<(Client, Option)> { +) -> anyhow::Result<(Client, Option, ClientSessionPersisted)> { let user_id = if let Some(user_id) = user_id { Some(user_id) } else { @@ -179,8 +179,8 @@ pub async fn restore_session( }); // Build the client with the previous settings from the session. let client = Client::builder() - .homeserver_url(client_session.homeserver) - .sqlite_store(client_session.db_path, Some(&client_session.passphrase)) + .homeserver_url(client_session.homeserver.clone()) + .sqlite_store(client_session.db_path.clone(), Some(&client_session.passphrase)) .with_threading_support(matrix_sdk::ThreadingSupport::Enabled { with_subscriptions: true, }) @@ -200,7 +200,7 @@ pub async fn restore_session( client.restore_session(user_session).await?; save_latest_user_id(&user_id).await?; - Ok((client, sync_token)) + Ok((client, sync_token, client_session)) } /// Persist a logged-in client session to the filesystem for later use. @@ -254,3 +254,73 @@ pub async fn delete_latest_user_id() -> anyhow::Result { Ok(false) } } + +async fn delete_path_if_exists(path: &Path) -> anyhow::Result { + let metadata = match tokio::fs::metadata(path).await { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(anyhow!("Failed to inspect path {}: {e}", path.display())), + }; + + if metadata.is_dir() { + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| anyhow!("Failed to remove directory {}: {e}", path.display()))?; + } else { + tokio::fs::remove_file(path) + .await + .map_err(|e| anyhow!("Failed to remove file {}: {e}", path.display()))?; + } + + Ok(true) +} + +/// Remove the persisted Matrix session file for the given user if it exists. +/// +/// Returns: +/// - Ok(true) if the session file was found and deleted +/// - Ok(false) if the session file didn't exist +/// - Err if deletion failed +pub async fn delete_session(user_id: &UserId) -> anyhow::Result { + let session_file = session_file_path(user_id); + + if session_file.exists() { + let persisted_db_path = match tokio::fs::read_to_string(&session_file).await { + Ok(serialized_session) => { + match serde_json::from_str::(&serialized_session) { + Ok(session) => Some(session.client_session.db_path), + Err(e) => { + warning!( + "Failed to parse session file {} before cleanup: {e}", + session_file.display() + ); + None + } + } + } + Err(e) => { + warning!( + "Failed to read session file {} before cleanup: {e}", + session_file.display() + ); + None + } + }; + + if let Some(db_path) = persisted_db_path { + if let Err(e) = delete_path_if_exists(&db_path).await { + warning!( + "Failed to remove persisted Matrix store {} for {user_id}: {e}", + db_path.display() + ); + } + } + + tokio::fs::remove_file(&session_file) + .await + .map_err(|e| anyhow::anyhow!("Failed to remove session file {session_file:?}: {e}")) + .map(|_| true) + } else { + Ok(false) + } +} diff --git a/src/room/reply_preview.rs b/src/room/reply_preview.rs index 5a53687bb..03ec07948 100644 --- a/src/room/reply_preview.rs +++ b/src/room/reply_preview.rs @@ -107,7 +107,7 @@ script_mod! { padding: 13, spacing: 0, margin: Inset{left: 5, right: 0}, - draw_bg.border_radius: 4.0 + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_CLOSE) icon_walk: Walk{width: 16, height: 16, margin: 0} } diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 93b8d4a9d..a56b8eae2 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -5,7 +5,7 @@ //! The widgets included in the RoomInputBar are: //! * a preview of the message the user is replying to. //! * the location preview (which allows you to send your current location to the room), -//! and a button to show the location preview. +//! and a location card to show the location preview. //! * If TSP is enabled, a checkbox to enable TSP signing for the outgoing message. //! * A MentionableTextInput, which allows the user to type a message //! and mention other users via the `@` key. @@ -19,8 +19,8 @@ use makepad_widgets::*; use matrix_sdk::room::reply::{EnforceThread, Reply}; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; -use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId}; -use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; +use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId, OwnedUserId}; +use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, i18n::AppLanguage, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -28,6 +28,29 @@ script_mod! { mod.widgets.ICO_LOCATION_PERSON = crate_resource("self://resources/icons/location-person.svg") + mod.widgets.ICO_MENU = crate_resource("self://resources/icons/menu.svg") + mod.widgets.ICO_THREADS = crate_resource("self://resources/icons/double_chat.svg") + + mod.widgets.RoomEmojiButton = mod.widgets.RobrixIconButton { + spacing: 0 + text: "" + margin: 0 + padding: Inset{left: 8, right: 8, top: 6, bottom: 6} + icon_walk: Walk{width: 0, height: 0} + draw_text +: { + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + text_style: MESSAGE_TEXT_STYLE { font_size: 15.0 } + } + draw_bg +: { + color: (COLOR_PRIMARY) + color_hover: #F4F7FC + color_down: #E8EEF8 + border_size: 1.0 + border_color: (COLOR_SECONDARY) + } + } mod.widgets.RoomInputBar = set_type_default() do #(RoomInputBar::register_widget(vm)) { @@ -74,60 +97,164 @@ script_mod! { input_bar := View { width: Fill, height: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.75}} - flow: Right - // Bottom-align everything to ensure that buttons always stick to the bottom - // even when the mentionable_text_input box is very tall. - align: Align{y: 1.0}, + flow: Down padding: 6, + spacing: 4 + + more_actions_popup := View { + visible: false + width: Fill + height: Fit + flow: Right{wrap: true} + spacing: 6 + align: Align{x: 0.0, y: 0.5} + + location_card_button := RobrixIconButton { + width: Fit + align: Align{x: 0.0, y: 0.5} + margin: Inset{top: 1, bottom: 1} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 8 + draw_icon +: { + svg: (mod.widgets.ICO_LOCATION_PERSON) + color: (COLOR_ACTIVE_PRIMARY_DARKER) + }, + draw_bg +: { + color: (COLOR_BG_PREVIEW) + color_hover: #E0E8F0 + color_down: #D0D8E8 + border_size: 1.0 + border_color: (COLOR_SECONDARY) + } + draw_text +: { + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + } + icon_walk: Walk{width: 20, height: 20} + text: "location", + } - location_button := RobrixIconButton { - margin: 4 - spacing: 0, - draw_icon +: { - svg: (mod.widgets.ICO_LOCATION_PERSON) - color: (COLOR_ACTIVE_PRIMARY_DARKER) - }, - draw_bg +: { - color: (COLOR_BG_PREVIEW) - color_hover: #E0E8F0 - color_down: #D0D8E8 + threads_card_button := RobrixIconButton { + width: Fit + align: Align{x: 0.0, y: 0.5} + margin: Inset{top: 1, bottom: 1} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 8 + draw_icon +: { + svg: (mod.widgets.ICO_THREADS) + color: (COLOR_ACTIVE_PRIMARY_DARKER) + }, + draw_bg +: { + color: (COLOR_BG_PREVIEW) + color_hover: #E0E8F0 + color_down: #D0D8E8 + border_size: 1.0 + border_color: (COLOR_SECONDARY) + } + draw_text +: { + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + } + icon_walk: Walk{width: 20, height: 20} + text: "threads", } - icon_walk: Walk{width: 23, height: 23, margin: Inset{bottom: -1}} - text: "", } - // A checkbox that enables TSP signing for the outgoing message. - // If TSP is not enabled, this will be an empty invisible view. - tsp_sign_checkbox := TspSignAnycastCheckbox { - margin: Inset{bottom: 9, left: 6, right: 0} + emoji_picker_popup := View { + visible: false + width: Fit + height: Fit + flow: Right{wrap: true} + align: Align{x: 0.0, y: 0.5} + margin: Inset{left: 5, top: 1, bottom: 1} + padding: Inset{left: 0, right: 0, top: 0, bottom: 0} + spacing: 6 + + emoji_smile_button := mod.widgets.RoomEmojiButton { text: "😀" } + emoji_joy_button := mod.widgets.RoomEmojiButton { text: "😂" } + emoji_thumbsup_button := mod.widgets.RoomEmojiButton { text: "👍" } + emoji_heart_button := mod.widgets.RoomEmojiButton { text: "❤️" } + emoji_fire_button := mod.widgets.RoomEmojiButton { text: "🔥" } + emoji_party_button := mod.widgets.RoomEmojiButton { text: "🎉" } + emoji_think_button := mod.widgets.RoomEmojiButton { text: "🤔" } + emoji_clap_button := mod.widgets.RoomEmojiButton { text: "👏" } } - mentionable_text_input := MentionableTextInput { + input_row := View { width: Fill, height: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.75}} - margin: Inset { - top: 3, // add some space between the top border of the text input and the top border of the room input bar - bottom: 5.75, // to line up the middle of the text input with the middle of the buttons - left: 3, right: 3 // to give a bit of breathing room between the text input and the buttons on the sides - }, - - persistent +: { - center +: { - text_input := RobrixTextInput { - empty_text: "Write a message (in Markdown) ..." + flow: Right + // Bottom-align everything to ensure that buttons always stick to the bottom + // even when the mentionable_text_input box is very tall. + align: Align{y: 1.0}, + + // A checkbox that enables TSP signing for the outgoing message. + // If TSP is not enabled, this will be an empty invisible view. + tsp_sign_checkbox := TspSignAnycastCheckbox { + margin: Inset{bottom: 9, left: 6, right: 0} + } + + emoji_picker_button := RobrixIconButton { + margin: Inset{left: 3, right: 1, top: 4, bottom: 4} + spacing: 0, + draw_icon +: { + svg: (ICON_ADD_REACTION) + color: (COLOR_ACTIVE_PRIMARY_DARKER) + }, + draw_bg +: { + color: (COLOR_BG_PREVIEW) + color_hover: #E0E8F0 + color_down: #D0D8E8 + } + icon_walk: Walk{width: 19, height: 19} + text: "", + } + + mentionable_text_input := MentionableTextInput { + width: Fill, + height: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.75}} + margin: Inset { + top: 3, // add some space between the top border of the text input and the top border of this row + bottom: 5.75, // to line up the middle of the text input with the middle of the buttons + left: 3, right: 3 // to give a bit of breathing room between the text input and the buttons on the sides + }, + + persistent +: { + center +: { + text_input := RobrixTextInput { + empty_text: "Write a message (in Markdown) ..." + } } } } - } - send_message_button := RobrixPositiveIconButton { - // Disabled by default; enabled when text is inputted - enabled: false, - spacing: 0, - text: "", - margin: 4 - draw_icon +: { svg: (ICON_SEND) } - icon_walk: Walk{width: 21, height: 21}, + send_message_button := RobrixPositiveIconButton { + visible: false, + // Disabled by default; enabled when text is inputted + enabled: false, + spacing: 0, + text: "", + margin: 4 + draw_icon +: { svg: (ICON_SEND) } + icon_walk: Walk{width: 21, height: 21}, + } + + more_actions_button := RobrixIconButton { + spacing: 0, + text: "", + margin: 4 + draw_icon +: { svg: (mod.widgets.ICO_MENU) } + draw_bg +: { + color: (COLOR_ACTIVE_PRIMARY) + color_hover: (COLOR_ACTIVE_PRIMARY_DARKER) + color_down: #0C5DAA + } + icon_walk: Walk{width: 19, height: 19}, + } } } @@ -169,6 +296,12 @@ pub struct RoomInputBar { #[rust] was_replying_preview_visible: bool, /// Info about the message event that the user is currently replying to, if any. #[rust] replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + /// The most recently selected explicit bot target for this room. + #[rust] active_target_user_id: Option, + /// Whether the location card is currently expanded. + #[rust] is_location_card_expanded: bool, + /// Whether the emoji picker popup is currently expanded. + #[rust] is_emoji_picker_expanded: bool, } impl Widget for RoomInputBar { @@ -212,6 +345,23 @@ impl Widget for RoomInputBar { } impl RoomInputBar { + fn resolve_target_user_id( + &mut self, + explicit_target_user_id: Option, + reply_target_user_id: Option, + fallback_target_user_id: Option, + ) -> Option { + if let Some(explicit_target_user_id) = explicit_target_user_id { + self.active_target_user_id = Some(explicit_target_user_id.clone()); + Some(explicit_target_user_id) + } else if let Some(reply_target_user_id) = reply_target_user_id { + self.active_target_user_id = Some(reply_target_user_id.clone()); + Some(reply_target_user_id) + } else { + self.active_target_user_id.clone().or(fallback_target_user_id) + } + } + fn handle_actions( &mut self, cx: &mut Cx, @@ -230,9 +380,60 @@ impl RoomInputBar { self.redraw(cx); } - // Handle the add location button being clicked. - if self.button(cx, ids!(location_button)).clicked(actions) { - log!("Add location button clicked; requesting current location..."); + // Handle the more actions button being clicked. + if self.button(cx, ids!(more_actions_button)).clicked(actions) { + self.is_location_card_expanded = !self.is_location_card_expanded; + self.view.view(cx, ids!(more_actions_popup)).set_visible(cx, self.is_location_card_expanded); + self.redraw(cx); + } + + // Handle the emoji picker button being clicked. + if self.button(cx, ids!(emoji_picker_button)).clicked(actions) { + self.is_emoji_picker_expanded = !self.is_emoji_picker_expanded; + self.view.view(cx, ids!(emoji_picker_popup)).set_visible(cx, self.is_emoji_picker_expanded); + self.redraw(cx); + } + + let picked_emoji = if self.button(cx, ids!(emoji_smile_button)).clicked(actions) { + Some("😀") + } else if self.button(cx, ids!(emoji_joy_button)).clicked(actions) { + Some("😂") + } else if self.button(cx, ids!(emoji_thumbsup_button)).clicked(actions) { + Some("👍") + } else if self.button(cx, ids!(emoji_heart_button)).clicked(actions) { + Some("❤️") + } else if self.button(cx, ids!(emoji_fire_button)).clicked(actions) { + Some("🔥") + } else if self.button(cx, ids!(emoji_party_button)).clicked(actions) { + Some("🎉") + } else if self.button(cx, ids!(emoji_think_button)).clicked(actions) { + Some("🤔") + } else if self.button(cx, ids!(emoji_clap_button)).clicked(actions) { + Some("👏") + } else { + None + }; + + if let Some(emoji) = picked_emoji { + let mut text = mentionable_text_input.text(); + text.push_str(emoji); + mentionable_text_input.set_text(cx, &text); + self.enable_send_message_button(cx, !text.trim().is_empty()); + submit_async_request(MatrixRequest::SendTypingNotice { + room_id: room_screen_props.timeline_kind.room_id().clone(), + typing: !text.is_empty(), + }); + self.is_emoji_picker_expanded = false; + self.view.view(cx, ids!(emoji_picker_popup)).set_visible(cx, false); + self.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)).set_key_focus(cx); + self.redraw(cx); + } + + // Handle the location card being clicked. + if self.button(cx, ids!(location_card_button)).clicked(actions) { + log!("Location card clicked; requesting current location..."); + self.is_location_card_expanded = false; + self.view.view(cx, ids!(more_actions_popup)).set_visible(cx, false); if let Err(_e) = init_location_subscriber(cx) { error!("Failed to initialize location subscriber"); enqueue_popup_notification( @@ -245,6 +446,14 @@ impl RoomInputBar { self.redraw(cx); } + if self.button(cx, ids!(threads_card_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + MessageAction::ShowThreadsPane, + ); + self.redraw(cx); + } + // Handle the send location button being clicked. if self.button(cx, ids!(location_preview.send_location_button)).clicked(actions) { let location_preview = self.location_preview(cx, ids!(location_preview)); @@ -255,6 +464,10 @@ impl RoomInputBar { LocationMessageEventContent::new(geo_uri.clone(), geo_uri) ) ); + let reply_target_user_id = self + .replying_to + .as_ref() + .map(|(event_tl_item, _emb)| event_tl_item.sender().to_owned()); let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| event_tl_item.event_id().map(|event_id| { let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { @@ -279,6 +492,11 @@ impl RoomInputBar { timeline_kind: room_screen_props.timeline_kind.clone(), message, replied_to, + target_user_id: self.resolve_target_user_id( + None, + reply_target_user_id, + room_screen_props.bound_bot_user_id.clone(), + ), #[cfg(feature = "tsp")] sign_with_tsp: self.is_tsp_signing_enabled(cx), }); @@ -295,6 +513,21 @@ impl RoomInputBar { { let entered_text = mentionable_text_input.text().trim().to_string(); if !entered_text.is_empty() { + if self.try_handle_bot_shortcut(cx, &entered_text, room_screen_props) { + self.clear_replying_to(cx); + mentionable_text_input.set_text(cx, ""); + submit_async_request(MatrixRequest::SendTypingNotice { + room_id: room_screen_props.timeline_kind.room_id().clone(), + typing: false, + }); + self.enable_send_message_button(cx, false); + self.redraw(cx); + return; + } + let reply_target_user_id = self + .replying_to + .as_ref() + .map(|(event_tl_item, _emb)| event_tl_item.sender().to_owned()); let message = mentionable_text_input.create_message_with_mentions(&entered_text); let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| event_tl_item.event_id().map(|event_id| { @@ -320,6 +553,11 @@ impl RoomInputBar { timeline_kind: room_screen_props.timeline_kind.clone(), message, replied_to, + target_user_id: self.resolve_target_user_id( + None, + reply_target_user_id, + room_screen_props.bound_bot_user_id.clone(), + ), #[cfg(feature = "tsp")] sign_with_tsp: self.is_tsp_signing_enabled(cx), }); @@ -398,6 +636,7 @@ impl RoomInputBar { populate_preview_of_timeline_item( cx, &replying_preview.html_or_plaintext(cx, ids!(reply_preview_content.reply_preview_body)), + AppLanguage::default(), replying_to.0.content(), replying_to.0.sender(), &replying_preview_username, @@ -414,7 +653,7 @@ impl RoomInputBar { // so that the user can immediately start typing their reply // without having to manually click on the message input box. if grab_key_focus { - self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)).set_key_focus(cx); + self.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)).set_key_focus(cx); } self.button(cx, ids!(cancel_reply_button)).reset_hover(cx); self.redraw(cx); @@ -495,7 +734,7 @@ impl RoomInputBar { } } - /// Sets the send_message_button to be enabled and green, or disabled and gray. + /// Sets the send_message_button to be shown/enabled and green, or hidden/disabled and gray. /// /// This should be called to update the button state when the message TextInput content changes. fn enable_send_message_button(&mut self, cx: &mut Cx, enable: bool) { @@ -506,12 +745,59 @@ impl RoomInputBar { (COLOR_FG_DISABLED, COLOR_BG_DISABLED) }; script_apply_eval!(cx, send_message_button, { + visible: #(enable), enabled: #(enable), draw_icon.color: #(fg_color), draw_bg.color: #(bg_color), }); } + fn try_handle_bot_shortcut( + &mut self, + cx: &mut Cx, + entered_text: &str, + room_screen_props: &RoomScreenProps, + ) -> bool { + if !(entered_text == "/bot" || entered_text.starts_with("/bot ")) { + return false; + } + + let popup_message = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { + Some(( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + )) + } else if entered_text != "/bot" { + Some(( + "Only `/bot` is supported right now. Use `/bot` and choose an action from the room panel.", + PopupKind::Info, + )) + } else if !room_screen_props.app_service_enabled { + Some(( + "Enable App Service in Settings before using /bot.", + PopupKind::Warning, + )) + } else if !room_screen_props.app_service_room_bound { + Some(( + "Bind BotFather to this room before using /bot.", + PopupKind::Warning, + )) + } else { + None + }; + + if let Some((message, kind)) = popup_message { + enqueue_popup_notification(message, kind, Some(4.0)); + } else { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + MessageAction::ToggleAppServiceActions, + ); + } + + true + } + /// Updates the visibility of select views based on the user's new power levels. /// /// This will show/hide the `input_bar` and the `can_not_send_message_notice` views. @@ -607,8 +893,9 @@ impl RoomInputBarRef { RoomInputBarState { was_replying_preview_visible: inner.was_replying_preview_visible, replying_to: inner.replying_to.clone(), + active_target_user_id: inner.active_target_user_id.clone(), editing_pane_state: inner.child_by_path(ids!(editing_pane)).as_editing_pane().save_state(), - text_input_state: inner.child_by_path(ids!(input_bar.mentionable_text_input.text_input)).as_text_input().save_state(), + text_input_state: inner.child_by_path(ids!(input_bar.input_row.mentionable_text_input.text_input)).as_text_input().save_state(), } } @@ -626,6 +913,7 @@ impl RoomInputBarRef { was_replying_preview_visible, text_input_state, replying_to, + active_target_user_id, editing_pane_state, } = saved_state; @@ -637,8 +925,16 @@ impl RoomInputBarRef { inner.update_user_power_levels(cx, user_power_levels); // 1. Restore the state of the TextInput within the MentionableTextInput. - inner.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) + inner.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)) .restore_state(cx, text_input_state); + let is_text_input_empty = inner.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)) + .text() + .is_empty(); + inner.enable_send_message_button(cx, !is_text_input_empty); + inner.is_location_card_expanded = false; + inner.view.view(cx, ids!(more_actions_popup)).set_visible(cx, false); + inner.is_emoji_picker_expanded = false; + inner.view.view(cx, ids!(emoji_picker_popup)).set_visible(cx, false); // 2. Restore the state of the replying-to preview. if let Some(replying_to) = replying_to { @@ -647,6 +943,7 @@ impl RoomInputBarRef { inner.clear_replying_to(cx); } inner.was_replying_preview_visible = was_replying_preview_visible; + inner.active_target_user_id = active_target_user_id; // 3. Restore the state of the editing pane. if let Some(editing_pane_state) = editing_pane_state { @@ -675,6 +972,8 @@ pub struct RoomInputBarState { text_input_state: TextInputState, /// The event that the user is currently replying to, if any. replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + /// The most recently selected explicit bot target for this room. + active_target_user_id: Option, /// The state of the `EditingPane`, if any message was being edited. editing_pane_state: Option, } diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index 8f37cca1c..cd003dac5 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -1,8 +1,11 @@ use std::cell::RefCell; use makepad_widgets::{text::selection::Cursor, *}; +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] +use rfd::FileDialog; +use matrix_sdk::ruma::OwnedUserId; -use crate::{app::ConfirmDeleteAction, avatar_cache::{self}, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, MatrixRequest, submit_async_request}, utils}; +use crate::{account_manager, app::{AppState, ConfirmDeleteAction}, avatar_cache::{self}, home::navigation_tab_bar::get_own_profile, i18n::{AppLanguage, tr_fmt, tr_key}, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::{user_profile::UserProfile, user_profile_cache}, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, AccountSwitchAction, MatrixRequest, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -14,11 +17,11 @@ script_mod! { width: Fill, height: Fit flow: Down - TitleLabel { + account_settings_title := TitleLabel { text: "Account Settings" } - SubsectionLabel { + avatar_section_label := SubsectionLabel { text: "Your Avatar:" } @@ -97,7 +100,7 @@ script_mod! { } } - SubsectionLabel { + display_name_section_label := SubsectionLabel { text: "Your Display Name:" } @@ -119,8 +122,7 @@ script_mod! { // their styles to RobrixNeutralIconButton / RobrixPositiveIconButton. cancel_display_name_button := RobrixNeutralIconButton { enabled: false, - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, draw_icon.svg: (ICON_FORBIDDEN) @@ -130,10 +132,10 @@ script_mod! { accept_display_name_button := RobrixPositiveIconButton { enabled: false, - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_CHECKMARK) icon_walk: Walk{width: 16, height: 16, margin: 0} text: "Save Name" @@ -147,7 +149,7 @@ script_mod! { } } - SubsectionLabel { + user_id_section_label := SubsectionLabel { text: "Your User ID:" } @@ -177,7 +179,128 @@ script_mod! { } } - SubsectionLabel { + multiple_accounts_section_label := SubsectionLabel { + text: "Multiple Accounts:" + } + + View { + width: Fill, height: Fit + flow: Down, + spacing: 8, + margin: Inset{left: 5, right: 5, bottom: 10} + + // Account entries will be shown here + // Active account (current) + active_account_view := RoundedView { + width: Fill, height: Fit + flow: Right, + align: Align{y: 0.5} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 10 + show_bg: true + draw_bg +: { + color: (COLOR_ACTIVE_PRIMARY) + border_radius: 4.0 + } + + View { + width: Fill, height: Fit + flow: Down, + spacing: 2 + + active_account_label := Label { + width: Fill, height: Fit + draw_text +: { + color: (COLOR_TEXT), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "@user:server" + } + + active_account_status_label := Label { + width: Fit, height: Fit + draw_text +: { + color: (COLOR_FG_ACCEPT_GREEN), + text_style: MESSAGE_TEXT_STYLE { font_size: 9 }, + } + text: "Active" + } + } + } + + // Other accounts section (populated dynamically) + other_accounts_label := Label { + width: Fill, height: Fit + margin: Inset{top: 5, left: 2} + visible: false + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 10 }, + } + text: "Other accounts:" + } + + // Container for other account entries (simplified: show one other account) + other_account_entry := RoundedView { + width: Fill, height: Fit + flow: Right, + align: Align{y: 0.5} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 10 + visible: false + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + border_size: 1.0 + border_color: #555 + } + + View { + width: Fill, height: Fit + flow: Down, + spacing: 2 + + other_account_label := Label { + width: Fill, height: Fit + draw_text +: { + color: (COLOR_TEXT), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "@other:server" + } + } + + switch_account_button := RobrixIconButton { + width: Fit, height: Fit + padding: Inset{top: 6, bottom: 6, left: 10, right: 10} + draw_icon.svg: (ICON_JUMP) + icon_walk: Walk{width: 14, height: 14} + text: "Switch" + } + } + + account_count_label := Label { + width: Fill, height: Fit + margin: Inset{top: 5, bottom: 5, left: 5} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 10 }, + } + text: "1 account logged in" + } + + add_account_button := RobrixIconButton { + width: Fit, + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} + margin: Inset{top: 5} + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16} + text: "Add Another Account" + } + } + + other_actions_section_label := SubsectionLabel { text: "Other actions:" } @@ -189,8 +312,7 @@ script_mod! { spacing: 10 manage_account_button := RobrixIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, - padding: Inset{left: 12, right: 15} + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} draw_icon.svg: (ICON_EXTERNAL_LINK) icon_walk: Walk{width: 16, height: 16} @@ -198,7 +320,6 @@ script_mod! { } logout_button := RobrixNegativeIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} draw_icon.svg: (ICON_LOGOUT) @@ -215,10 +336,19 @@ pub struct AccountSettings { #[deref] view: View, #[rust] own_profile: Option, + #[rust] app_language: AppLanguage, + /// List of other account user IDs (not the currently active one) + #[rust] other_accounts: Vec, } impl Widget for AccountSettings { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.match_event(cx, event); let copy_user_id_button = self.view.button(cx, ids!(copy_user_id_button)); @@ -228,7 +358,7 @@ impl Widget for AccountSettings { cx.widget_action( copy_user_id_button.widget_uid(), TooltipAction::HoverIn { - text: "Copy User ID".to_string(), + text: tr_key(self.app_language, "settings.account.tooltip.copy_user_id").to_string(), widget_rect: copy_user_id_button_area.rect(cx), options: CalloutTooltipOptions { position: TooltipPosition::Top, @@ -250,19 +380,45 @@ impl Widget for AccountSettings { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.draw_walk(cx, scope, walk) } } impl MatchEvent for AccountSettings { fn handle_signal(&mut self, cx: &mut Cx) { + // If we don't have a profile yet, try to get it if self.own_profile.is_none() { + user_profile_cache::process_user_profile_updates(cx); + if let Some(new_profile) = get_own_profile(cx) { + self.own_profile = Some(new_profile.clone()); + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_profile.user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, new_profile.username.as_deref().unwrap_or_default()); + self.populate_avatar_views(cx); + self.populate_account_list(cx); + self.view.redraw(cx); + } return; } + // Process avatar updates from the cache avatar_cache::process_avatar_updates(cx); + // Update avatar from cache if we have a profile if let Some(profile) = self.own_profile.as_mut() { - profile.avatar_state.update_from_cache(cx); + if profile.avatar_state.uri().is_some() { + let new_data = profile.avatar_state.update_from_cache(cx); + if new_data.is_some() { + self.populate_avatar_views(cx); + self.view.redraw(cx); + } + } } } @@ -277,7 +433,11 @@ impl MatchEvent for AccountSettings { // Handle LogoutAction::InProgress to update button state if let Some(LogoutAction::InProgress(is_in_progress)) = action.downcast_ref() { let logout_button = self.view.button(cx, ids!(logout_button)); - logout_button.set_text(cx, if *is_in_progress { "Logging out..." } else { "Log out" }); + logout_button.set_text(cx, if *is_in_progress { + tr_key(self.app_language, "settings.account.button.logging_out") + } else { + tr_key(self.app_language, "settings.account.button.log_out") + }); logout_button.set_enabled(cx, !*is_in_progress); logout_button.reset_hover(cx); continue; @@ -296,7 +456,11 @@ impl MatchEvent for AccountSettings { profile.avatar_state.update_from_cache(cx); self.populate_avatar_views(cx); enqueue_popup_notification( - format!("Successfully {} avatar.", if new_avatar_url.is_some() { "updated" } else { "deleted" }), + if new_avatar_url.is_some() { + tr_key(self.app_language, "settings.account.popup.avatar_updated") + } else { + tr_key(self.app_language, "settings.account.popup.avatar_deleted") + }, PopupKind::Success, Some(4.0), ); @@ -334,7 +498,11 @@ impl MatchEvent for AccountSettings { display_name_input.set_disabled(cx, false); Self::enable_display_name_buttons(cx, false, &accept_display_name_button, &cancel_display_name_button); enqueue_popup_notification( - format!("Successfully {} display name.", if new_name.is_some() { "updated" } else { "removed" }), + if new_name.is_some() { + tr_key(self.app_language, "settings.account.popup.display_name_updated") + } else { + tr_key(self.app_language, "settings.account.popup.display_name_removed") + }, PopupKind::Success, Some(4.0), ); @@ -373,32 +541,55 @@ impl MatchEvent for AccountSettings { } } + if self.view.button(cx, ids!(logout_button)).clicked(actions) { + cx.action(LogoutConfirmModalAction::Open); + return; + } + let Some(own_profile) = &self.own_profile else { return }; if upload_avatar_button.clicked(actions) { - // TODO: uncomment the below once avatar uploading is implemented - // Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); - // Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); - enqueue_popup_notification( - "Avatar uploading is not yet implemented.", - PopupKind::Warning, - Some(4.0), - ); + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + { + if let Some(avatar_path) = FileDialog::new() + .add_filter("Image", &["png", "jpg", "jpeg"]) + .pick_file() + { + submit_async_request(MatrixRequest::UploadAvatar { avatar_path }); + cx.action(AccountSettingsAction::AvatarUploadStarted); + enqueue_popup_notification( + tr_key(self.app_language, "settings.account.popup.uploading_avatar"), + PopupKind::Info, + Some(5.0), + ); + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + enqueue_popup_notification( + tr_key(self.app_language, "settings.account.popup.avatar_upload_not_implemented"), + PopupKind::Warning, + Some(4.0), + ); + } } if delete_avatar_button.clicked(actions) { + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + { // Don't immediately disable the buttons. Instead, we wait for the user // to confirm the action in the confirmation modal, // and then we disable the buttons in the AvatarDeleteStarted action handler. + let app_language = self.app_language; let content = ConfirmationModalContent { - title_text: "Delete Avatar".into(), - body_text: "Are you sure you want to delete your avatar?".into(), - accept_button_text: Some("Delete".into()), - on_accept_clicked: Some(Box::new(|cx| { + title_text: tr_key(app_language, "settings.account.modal.delete_avatar.title").into(), + body_text: tr_key(app_language, "settings.account.modal.delete_avatar.body").into(), + accept_button_text: Some(tr_key(app_language, "settings.account.modal.delete_avatar.accept").into()), + on_accept_clicked: Some(Box::new(move |cx| { submit_async_request(MatrixRequest::SetAvatar { avatar_url: None }); cx.action(AccountSettingsAction::AvatarDeleteStarted); enqueue_popup_notification( - "Deleting your avatar...", + tr_key(app_language, "settings.account.popup.deleting_avatar"), PopupKind::Info, Some(5.0), ); @@ -406,6 +597,15 @@ impl MatchEvent for AccountSettings { ..Default::default() }; cx.action(ConfirmDeleteAction::Show(RefCell::new(Some(content)))); + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + enqueue_popup_notification( + "Deleting avatar is not yet supported on this platform.", + PopupKind::Warning, + Some(4.0), + ); + } } // Enable the name change buttons if the user modified the display name to be different. @@ -436,7 +636,7 @@ impl MatchEvent for AccountSettings { display_name_input.set_is_read_only(cx, true); Self::enable_display_name_buttons(cx, false, &accept_display_name_button, &cancel_display_name_button); enqueue_popup_notification( - "Uploading new display name...", + tr_key(self.app_language, "settings.account.popup.uploading_display_name"), PopupKind::Info, Some(5.0), ); @@ -445,7 +645,7 @@ impl MatchEvent for AccountSettings { if self.view.button(cx, ids!(copy_user_id_button)).clicked(actions) { cx.copy_to_clipboard(own_profile.user_id.as_str()); enqueue_popup_notification( - "Copied your User ID to the clipboard.", + tr_key(self.app_language, "settings.account.popup.copied_user_id"), PopupKind::Success, Some(3.0), ); @@ -455,7 +655,7 @@ impl MatchEvent for AccountSettings { // TODO: support opening the user's account management page in a browser, // or perhaps in an in-app pane if that's what is needed for regular UN+PW login. enqueue_popup_notification( - "Account management is not yet implemented.", + tr_key(self.app_language, "settings.account.popup.account_management_not_implemented"), PopupKind::Warning, Some(4.0), ); @@ -464,10 +664,139 @@ impl MatchEvent for AccountSettings { if self.view.button(cx, ids!(logout_button)).clicked(actions) { cx.action(LogoutConfirmModalAction::Open); } + + // Handle "Switch Account" button click + if self.view.button(cx, ids!(switch_account_button)).clicked(actions) { + // Switch to the first other account + if let Some(other_id) = self.other_accounts.first().cloned() { + log!("Switching to account: {}", other_id); + submit_async_request(MatrixRequest::SwitchAccount { user_id: other_id }); + } + } + + // Handle "Add Account" button click + if self.view.button(cx, ids!(add_account_button)).clicked(actions) { + // Navigate to login screen in "add account" mode + cx.action(LoginAction::ShowAddAccountScreen); + } + + // Handle account switch result and new account added + for action in actions { + if let Some(AccountSwitchAction::Switched(new_user_id)) = action.downcast_ref() { + log!("Account switched to: {}, refreshing profile and account list", new_user_id); + // Refresh the profile with new account's data + if let Some(new_profile) = get_own_profile(cx) { + self.own_profile = Some(new_profile.clone()); + // Update the UI with new profile + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_profile.user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, new_profile.username.as_deref().unwrap_or_default()); + self.populate_avatar_views(cx); + } else { + // Profile not yet available, at least update the user_id label + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, ""); + // Clear the old avatar + self.own_profile = None; + } + // Refresh the account list to show new active account + self.populate_account_list(cx); + self.view.redraw(cx); + } + // Refresh account list when a new account is added + if let Some(LoginAction::AddAccountSuccess) = action.downcast_ref() { + log!("New account added, refreshing account list"); + self.populate_account_list(cx); + self.view.redraw(cx); + } + // Refresh profile and account list after login success + if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { + log!("Login success, refreshing profile and account list"); + if let Some(new_profile) = get_own_profile(cx) { + self.own_profile = Some(new_profile.clone()); + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_profile.user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, new_profile.username.as_deref().unwrap_or_default()); + self.populate_avatar_views(cx); + } + self.populate_account_list(cx); + self.view.redraw(cx); + } + } } } impl AccountSettings { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(account_settings_title)) + .set_text(cx, tr_key(self.app_language, "settings.account.title")); + self.view + .label(cx, ids!(avatar_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.your_avatar")); + self.view + .button(cx, ids!(upload_avatar_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.upload_avatar")); + self.view + .button(cx, ids!(delete_avatar_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.delete_avatar")); + self.view + .label(cx, ids!(display_name_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.your_display_name")); + self.view + .text_input(cx, ids!(display_name_input)) + .set_empty_text(cx, tr_key(self.app_language, "settings.account.display_name.placeholder").to_string()); + self.view + .button(cx, ids!(cancel_display_name_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.cancel")); + self.view + .button(cx, ids!(accept_display_name_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.save_name")); + self.view + .label(cx, ids!(user_id_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.your_user_id")); + if self.own_profile.is_none() { + self.view + .label(cx, ids!(user_id)) + .set_text(cx, tr_key(self.app_language, "settings.account.user_id.not_logged_in")); + } + self.view + .label(cx, ids!(multiple_accounts_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.multiple_accounts")); + self.view + .label(cx, ids!(active_account_status_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.active_status")); + self.view + .label(cx, ids!(other_accounts_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.other_accounts")); + self.view + .button(cx, ids!(switch_account_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.switch")); + self.view + .button(cx, ids!(add_account_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.add_another_account")); + self.view + .label(cx, ids!(other_actions_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.other_actions")); + self.view + .button(cx, ids!(manage_account_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.manage_account")); + self.view + .button(cx, ids!(logout_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.log_out")); + self.populate_account_list(cx); + self.view.redraw(cx); + } + /// Populate avatar-related views with the user's profile data. /// /// This does nothing if `self.own_profile` is `None`. @@ -522,6 +851,7 @@ impl AccountSettings { self.own_profile = Some(own_profile); self.populate_avatar_views(cx); + self.sync_app_language(cx); self.view.button(cx, ids!(upload_avatar_button)).reset_hover(cx); self.view.button(cx, ids!(delete_avatar_button)).reset_hover(cx); @@ -533,6 +863,54 @@ impl AccountSettings { self.view.redraw(cx); } + /// Populate the account list with logged-in accounts from the AccountManager. + fn populate_account_list(&mut self, cx: &mut Cx) { + let count = account_manager::account_count(); + let label_text = if count == 0 { + tr_key(self.app_language, "settings.account.account_count.none").to_string() + } else if count == 1 { + tr_key(self.app_language, "settings.account.account_count.one").to_string() + } else { + tr_fmt( + self.app_language, + "settings.account.account_count.many", + &[("count", &count.to_string())], + ) + }; + self.view.label(cx, ids!(account_count_label)).set_text(cx, &label_text); + + // Get the active account + let active_user_id = account_manager::get_active_user_id(); + + // Show/hide active account view based on whether there's an active account + let has_active = active_user_id.is_some(); + self.view.view(cx, ids!(active_account_view)).set_visible(cx, has_active); + + // Show the active account + if let Some(ref active_id) = active_user_id { + self.view.label(cx, ids!(active_account_label)) + .set_text(cx, active_id.as_str()); + } + + // Get other accounts (excluding active) + let all_accounts = account_manager::get_all_user_ids(); + self.other_accounts = all_accounts + .into_iter() + .filter(|id| Some(id) != active_user_id.as_ref()) + .collect(); + + // Show "Other accounts" label and entry only if there are other accounts + let has_other_accounts = !self.other_accounts.is_empty(); + self.view.label(cx, ids!(other_accounts_label)).set_visible(cx, has_other_accounts); + self.view.view(cx, ids!(other_account_entry)).set_visible(cx, has_other_accounts); + + // If there's at least one other account, show it + if let Some(other_id) = self.other_accounts.first() { + self.view.label(cx, ids!(other_account_label)) + .set_text(cx, other_id.as_str()); + } + } + /// Enable or disable the delete avatar button. fn enable_delete_avatar_button( cx: &mut Cx, @@ -642,6 +1020,11 @@ impl AccountSettingsRef { let Some(mut inner) = self.borrow_mut() else { return }; inner.populate(cx, own_profile); } + + pub fn set_app_language(&self, cx: &mut Cx, app_language: AppLanguage) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_app_language(cx, app_language); + } } /// Actions that are handled by the AccountSettings widget. diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs new file mode 100644 index 000000000..200116764 --- /dev/null +++ b/src/settings/bot_settings.rs @@ -0,0 +1,248 @@ +use makepad_widgets::*; + +use crate::{ + app::{AppState, BotSettingsState}, + i18n::{AppLanguage, tr_key}, + persistence, + shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::current_user_id, +}; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.BotSettingsInfoLabel = Label { + width: Fill + height: Fit + margin: Inset{left: 5, top: 2, bottom: 2} + draw_text +: { + color: (MESSAGE_TEXT_COLOR) + text_style: REGULAR_TEXT { font_size: 10.5 } + } + text: "" + } + + mod.widgets.BotSettings = #(BotSettings::register_widget(vm)) { + width: Fill + height: Fit + flow: Down + spacing: 10 + + app_service_title := TitleLabel { + text: "App Service" + } + + description := mod.widgets.BotSettingsInfoLabel { + margin: Inset{left: 5, right: 8, bottom: 4} + text: "Enable Matrix app service support here. Robrix stays a normal Matrix client: it binds BotFather to a room and sends the matching slash commands." + } + + toggle_row := View { + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + spacing: 12 + margin: Inset{left: 5, bottom: 2} + + enable_label := SubsectionLabel { + width: Fit + height: Fit + margin: 0 + text: "Enable App Service" + } + + toggle_button := RobrixNeutralIconButton { + width: Fit + height: Fit + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} + draw_icon.svg: (ICON_HIERARCHY) + icon_walk: Walk{width: 16, height: 16} + text: "Enable App Service" + } + } + + bot_details := View { + visible: false + width: Fill + height: Fit + flow: Down + + bot_user_id_label := SubsectionLabel { + text: "BotFather User ID:" + } + + bot_user_id_input := RobrixTextInput { + margin: Inset{top: 2, left: 5, right: 5, bottom: 8} + width: 280 + height: Fit + empty_text: "bot or @bot:server" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + spacing: 10 + + save_button := RobrixPositiveIconButton { + width: Fit + height: Fit + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} + margin: Inset{left: 5} + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16} + text: "Save" + } + } + } + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct BotSettings { + #[deref] + view: View, + #[rust] + app_language: AppLanguage, +} + +impl Widget for BotSettings { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } + 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 { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for BotSettings { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let toggle_button = self.view.button(cx, ids!(toggle_button)); + let bot_details = self.view.view(cx, ids!(bot_details)); + let bot_user_id_input = self.view.text_input(cx, ids!(bot_user_id_input)); + let save_button = self.view.button(cx, ids!(buttons.save_button)); + + let Some(app_state) = _scope.data.get_mut::() else { + return; + }; + + if toggle_button.clicked(actions) { + let enabled = !app_state.bot_settings.enabled; + app_state.bot_settings.enabled = enabled; + persist_bot_settings(app_state); + self.sync_ui(cx, &app_state.bot_settings); + bot_details.set_visible(cx, enabled); + self.view.redraw(cx); + } + + if save_button.clicked(actions) || bot_user_id_input.returned(actions).is_some() { + app_state.bot_settings.botfather_user_id = bot_user_id_input.text().trim().to_string(); + persist_bot_settings(app_state); + enqueue_popup_notification( + tr_key(self.app_language, "settings.labs.app_service.popup.saved"), + PopupKind::Success, + Some(3.0), + ); + self.sync_ui(cx, &app_state.bot_settings); + } + } +} + +impl BotSettings { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(app_service_title)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.title")); + self.view + .label(cx, ids!(description)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.description")); + self.view + .label(cx, ids!(enable_label)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.enable_label")); + self.view + .label(cx, ids!(bot_user_id_label)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.botfather_user_id")); + self.view + .text_input(cx, ids!(bot_user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "settings.labs.app_service.botfather_placeholder").to_string()); + self.view + .button(cx, ids!(buttons.save_button)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.button.save")); + self.view.redraw(cx); + } + + fn sync_ui(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { + self.view + .view(cx, ids!(bot_details)) + .set_visible(cx, bot_settings.enabled); + self.view + .text_input(cx, ids!(bot_user_id_input)) + .set_text(cx, &bot_settings.botfather_user_id); + + let toggle_text = if bot_settings.enabled { + tr_key(self.app_language, "settings.labs.app_service.button.disable") + } else { + tr_key(self.app_language, "settings.labs.app_service.button.enable") + }; + self.view + .button(cx, ids!(toggle_button)) + .set_text(cx, toggle_text); + self.view.button(cx, ids!(toggle_button)).reset_hover(cx); + self.view + .button(cx, ids!(buttons.save_button)) + .reset_hover(cx); + self.view.redraw(cx); + } + + /// Populates the bot settings UI from the current persisted app state. + pub fn populate(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { + self.sync_app_language(cx); + self.sync_ui(cx, bot_settings); + } +} + +impl BotSettingsRef { + /// See [`BotSettings::populate()`]. + pub fn populate(&self, cx: &mut Cx, bot_settings: &BotSettingsState) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.populate(cx, bot_settings); + } + + pub fn set_app_language(&self, cx: &mut Cx, app_language: AppLanguage) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.set_app_language(cx, app_language); + } +} + +fn persist_bot_settings(app_state: &AppState) { + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(app_state.clone(), user_id) { + error!("Failed to persist bot settings. Error: {e}"); + } + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 579bf0849..3155e1186 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -2,8 +2,10 @@ use makepad_widgets::ScriptVm; pub mod settings_screen; pub mod account_settings; +pub mod bot_settings; pub fn script_mod(vm: &mut ScriptVm) { account_settings::script_mod(vm); + bot_settings::script_mod(vm); settings_screen::script_mod(vm); } diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index a255b7034..5633bbc6f 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,12 +1,13 @@ use makepad_widgets::*; -use crate::{home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, profile::user_profile::UserProfile, settings::account_settings::AccountSettingsWidgetExt}; +use crate::{app::{AppState, BotSettingsState}, home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, i18n::{AppLanguage, I18nKey, language_dropdown_labels, tr}, persistence, profile::user_profile::UserProfile, settings::{account_settings::AccountSettingsWidgetExt, bot_settings::BotSettingsWidgetExt}, shared::{popup_list::{PopupKind, enqueue_popup_notification}, styles::{apply_neutral_button_style, apply_primary_button_style}}, sliding_sync::current_user_id}; script_mod! { use mod.prelude.widgets.* use mod.widgets.* + // The main, top-level settings screen widget. mod.widgets.SettingsScreen = #(SettingsScreen::register_widget(vm)) { width: Fill, height: Fill, @@ -48,23 +49,99 @@ script_mod! { // Make sure the dividing line is aligned with the close_button LineH { padding: 10, margin: Inset{top: 10, right: 2} } + settings_category_cards := View { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + align: Align{y: 0.5} + spacing: 10 + margin: Inset{left: 5, right: 5, bottom: 8} + + category_account_button := RobrixNeutralIconButton { + width: Fit, height: Fit, + padding: Inset{top: 9, bottom: 9, left: 14, right: 14} + spacing: 0, + icon_walk: Walk{width: 0, height: 0, margin: 0} + text: "Account" + } + + category_preferences_button := RobrixNeutralIconButton { + width: Fit, height: Fit, + padding: Inset{top: 9, bottom: 9, left: 14, right: 14} + spacing: 0, + icon_walk: Walk{width: 0, height: 0, margin: 0} + text: "Preferences" + } + + category_labs_button := RobrixNeutralIconButton { + width: Fit, height: Fit, + padding: Inset{top: 9, bottom: 9, left: 14, right: 14} + spacing: 0, + icon_walk: Walk{width: 0, height: 0, margin: 0} + text: "Labs" + } + } + ScrollXYView { width: Fill, height: Fill flow: Down - // The account settings section. - account_settings := AccountSettings {} + settings_sections := View { + width: Fill, height: Fit + flow: Down + + // The account settings section. + account_settings_section := View { + width: Fill, height: Fit + flow: Down + account_settings := AccountSettings {} + } + + preferences_settings_section := View { + visible: false + width: Fill, height: Fit + flow: Down + spacing: 8 + + preferences_language_title := TitleLabel { + text: "Language" + } + + preferences_application_language_label := SubsectionLabel { + text: "Application language" + } + + language_dropdown := DropDownFlat { + width: 165 + height: 40 + margin: Inset{left: 5, top: 2, bottom: 2} + labels: ["English", "Simplified Chinese"] + } + + preferences_language_hint_label := Label { + width: Fill + height: Fit + margin: Inset{left: 5, right: 8, top: 3, bottom: 4} + draw_text +: { + color: (MESSAGE_TEXT_COLOR) + text_style: REGULAR_TEXT { font_size: 10.5 } + } + text: "The app will reload after selecting another language" + } + } - LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + labs_settings_section := View { + visible: false + width: Fill, height: Fit + flow: Down - // The TSP wallet settings section. - tsp_settings_screen := TspSettingsScreen {} + bot_settings := BotSettings {} - LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } - // Add other settings sections here as needed. - // Don't forget to add a `show()` fn to those settings sections - // and call them in `SettingsScreen::show()`. + // The TSP wallet settings section. + tsp_settings_screen := TspSettingsScreen {} + } + } } } @@ -84,14 +161,32 @@ script_mod! { } +/// The top-level widget showing all app and user settings/preferences. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum SettingsCategory { + #[default] + Account, + Preferences, + Labs, +} + /// The top-level widget showing all app and user settings/preferences. #[derive(Script, ScriptHook, Widget)] pub struct SettingsScreen { #[deref] view: View, + + #[rust] selected_category: SettingsCategory, + #[rust] app_language: AppLanguage, } impl Widget for SettingsScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.handle_event(cx, event, scope); // Close the pane if: @@ -119,56 +214,176 @@ impl Widget for SettingsScreen { cx.action(NavigationBarAction::CloseSettings); } - #[cfg(feature = "tsp")] if let Event::Actions(actions) = event { - use crate::tsp::{ - create_did_modal::CreateDidModalAction, - create_wallet_modal::CreateWalletModalAction, - }; - - for action in actions { - // Handle the create wallet modal being opened or closed. - match action.downcast_ref() { - Some(CreateWalletModalAction::Open) => { - use crate::tsp::create_wallet_modal::CreateWalletModalWidgetExt; - self.view.create_wallet_modal(cx, ids!(create_wallet_modal_inner)).show(cx); - self.view.modal(cx, ids!(create_wallet_modal)).open(cx); + if self.view.drop_down(cx, ids!(language_dropdown)).changed(actions).is_some() { + let selected_language = AppLanguage::from_dropdown_index( + self.view.drop_down(cx, ids!(language_dropdown)).selected_item(), + ); + if self.app_language != selected_language { + self.set_app_language(cx, selected_language); + if let Some(app_state) = scope.data.get_mut::() { + if app_state.app_language != selected_language { + app_state.app_language = selected_language; + persist_app_state(app_state); + enqueue_popup_notification( + tr(selected_language, I18nKey::LanguageReloadHint), + PopupKind::Info, + Some(4.0), + ); + } } - Some(CreateWalletModalAction::Close) => { - self.view.modal(cx, ids!(create_wallet_modal)).close(cx); - } - None => { } } + } + + if self.view.button(cx, ids!(category_account_button)).clicked(actions) { + self.set_selected_category(cx, SettingsCategory::Account); + } + else if self.view.button(cx, ids!(category_preferences_button)).clicked(actions) { + self.set_selected_category(cx, SettingsCategory::Preferences); + } + else if self.view.button(cx, ids!(category_labs_button)).clicked(actions) { + self.set_selected_category(cx, SettingsCategory::Labs); + } + + #[cfg(feature = "tsp")] + { + use crate::tsp::{ + create_did_modal::CreateDidModalAction, + create_wallet_modal::CreateWalletModalAction, + }; - // Handle the create DID modal being opened or closed. - match action.downcast_ref() { - Some(CreateDidModalAction::Open) => { - use crate::tsp::create_did_modal::CreateDidModalWidgetExt; - self.view.create_did_modal(cx, ids!(create_did_modal_inner)).show(cx); - self.view.modal(cx, ids!(create_did_modal)).open(cx); + for action in actions { + // Handle the create wallet modal being opened or closed. + match action.downcast_ref() { + Some(CreateWalletModalAction::Open) => { + use crate::tsp::create_wallet_modal::CreateWalletModalWidgetExt; + self.view.create_wallet_modal(cx, ids!(create_wallet_modal_inner)).show(cx); + self.view.modal(cx, ids!(create_wallet_modal)).open(cx); + } + Some(CreateWalletModalAction::Close) => { + self.view.modal(cx, ids!(create_wallet_modal)).close(cx); + } + None => { } } - Some(CreateDidModalAction::Close) => { - self.view.modal(cx, ids!(create_did_modal)).close(cx); + + // Handle the create DID modal being opened or closed. + match action.downcast_ref() { + Some(CreateDidModalAction::Open) => { + use crate::tsp::create_did_modal::CreateDidModalWidgetExt; + self.view.create_did_modal(cx, ids!(create_did_modal_inner)).show(cx); + self.view.modal(cx, ids!(create_did_modal)).open(cx); + } + Some(CreateDidModalAction::Close) => { + self.view.modal(cx, ids!(create_did_modal)).close(cx); + } + None => { } } - None => { } } } } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.draw_walk(cx, scope, walk) } } impl SettingsScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(settings_header_title)) + .set_text(cx, tr(self.app_language, I18nKey::AllSettingsTitle)); + self.view + .button(cx, ids!(category_account_button)) + .set_text(cx, tr(self.app_language, I18nKey::SettingsCategoryAccount)); + self.view + .button(cx, ids!(category_preferences_button)) + .set_text(cx, tr(self.app_language, I18nKey::SettingsCategoryPreferences)); + self.view + .button(cx, ids!(category_labs_button)) + .set_text(cx, tr(self.app_language, I18nKey::SettingsCategoryLabs)); + self.view + .label(cx, ids!(preferences_language_title)) + .set_text(cx, tr(self.app_language, I18nKey::LanguageTitle)); + self.view + .label(cx, ids!(preferences_application_language_label)) + .set_text(cx, tr(self.app_language, I18nKey::ApplicationLanguageLabel)); + self.view + .label(cx, ids!(preferences_language_hint_label)) + .set_text(cx, tr(self.app_language, I18nKey::LanguageReloadHint)); + let language_dropdown = self.view.drop_down(cx, ids!(language_dropdown)); + language_dropdown.set_labels(cx, language_dropdown_labels(self.app_language)); + language_dropdown.set_selected_item(cx, self.app_language.dropdown_index()); + self.view + .account_settings(cx, ids!(account_settings)) + .set_app_language(cx, self.app_language); + self.view + .bot_settings(cx, ids!(bot_settings)) + .set_app_language(cx, self.app_language); + self.view.redraw(cx); + } + + fn set_selected_category(&mut self, cx: &mut Cx, category: SettingsCategory) { + self.selected_category = category; + self.sync_selected_category(cx); + } + + fn sync_selected_category(&mut self, cx: &mut Cx) { + let show_account = self.selected_category == SettingsCategory::Account; + let show_preferences = self.selected_category == SettingsCategory::Preferences; + let show_labs = self.selected_category == SettingsCategory::Labs; + + self.view.view(cx, ids!(account_settings_section)).set_visible(cx, show_account); + self.view.view(cx, ids!(preferences_settings_section)).set_visible(cx, show_preferences); + self.view.view(cx, ids!(labs_settings_section)).set_visible(cx, show_labs); + + let mut category_account_button = self.view.button(cx, ids!(category_account_button)); + let mut category_preferences_button = self.view.button(cx, ids!(category_preferences_button)); + let mut category_labs_button = self.view.button(cx, ids!(category_labs_button)); + + if show_account { + apply_primary_button_style(cx, &mut category_account_button); + } else { + apply_neutral_button_style(cx, &mut category_account_button); + } + if show_preferences { + apply_primary_button_style(cx, &mut category_preferences_button); + } else { + apply_neutral_button_style(cx, &mut category_preferences_button); + } + if show_labs { + apply_primary_button_style(cx, &mut category_labs_button); + } else { + apply_neutral_button_style(cx, &mut category_labs_button); + } + + category_account_button.reset_hover(cx); + category_preferences_button.reset_hover(cx); + category_labs_button.reset_hover(cx); + self.view.redraw(cx); + } + /// Fetches the current user's profile and uses it to populate the settings screen. - pub fn populate(&mut self, cx: &mut Cx, own_profile: Option) { + pub fn populate(&mut self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState, app_language: AppLanguage) { let Some(profile) = own_profile.or_else(|| get_own_profile(cx)) else { error!("Failed to get own profile for settings screen."); return; }; self.view.account_settings(cx, ids!(account_settings)).populate(cx, profile); + self.view.bot_settings(cx, ids!(bot_settings)).populate(cx, bot_settings); + self.set_app_language(cx, app_language); + self.set_selected_category(cx, SettingsCategory::Account); self.view.button(cx, ids!(close_button)).reset_hover(cx); cx.set_key_focus(self.view.area()); self.redraw(cx); @@ -177,8 +392,16 @@ impl SettingsScreen { impl SettingsScreenRef { /// See [`SettingsScreen::populate()`]. - pub fn populate(&self, cx: &mut Cx, own_profile: Option) { + pub fn populate(&self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState, app_language: AppLanguage) { let Some(mut inner) = self.borrow_mut() else { return; }; - inner.populate(cx, own_profile); + inner.populate(cx, own_profile, bot_settings, app_language); + } +} + +fn persist_app_state(app_state: &AppState) { + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(app_state.clone(), user_id) { + error!("Failed to persist app state after updating language setting. Error: {e}"); + } } } diff --git a/src/shared/collapsible_header.rs b/src/shared/collapsible_header.rs index e9ad7e337..5a15803ee 100644 --- a/src/shared/collapsible_header.rs +++ b/src/shared/collapsible_header.rs @@ -10,7 +10,7 @@ use makepad_widgets::*; use makepad_widgets::animator::Animate; -use crate::home::rooms_list::RoomsListScopeProps; +use crate::{app::AppState, home::rooms_list::RoomsListScopeProps, i18n::tr_key}; use super::expand_arrow::ExpandArrow; use super::unread_badge::UnreadBadgeWidgetRefExt as _; @@ -82,15 +82,15 @@ pub enum HeaderCategory { None, } impl HeaderCategory { - fn as_str(&self) -> &'static str { + fn i18n_key(&self) -> Option<&'static str> { match self { - HeaderCategory::Invites => "Invites", - HeaderCategory::Favorites => "Favorites", - HeaderCategory::RegularRooms => "Rooms", - HeaderCategory::DirectRooms => "People", - HeaderCategory::LowPriority => "Low Priority", - HeaderCategory::LeftRooms => "Left Rooms", - HeaderCategory::None => "", + HeaderCategory::Invites => Some("rooms_list.category.invites"), + HeaderCategory::Favorites => Some("rooms_list.category.favorites"), + HeaderCategory::RegularRooms => Some("rooms_list.category.rooms"), + HeaderCategory::DirectRooms => Some("rooms_list.category.people"), + HeaderCategory::LowPriority => Some("rooms_list.category.low_priority"), + HeaderCategory::LeftRooms => Some("rooms_list.category.left_rooms"), + HeaderCategory::None => None, } } } @@ -133,10 +133,18 @@ impl Widget for CollapsibleHeader { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // Set arrow and label state during draw to ensure child widgets are available. + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); if let Some(mut arrow) = self.view.child_by_path(ids!(collapse_icon)).borrow_mut::() { arrow.set_is_open_no_animate(self.is_expanded); } - self.view.child_by_path(ids!(label)).set_text(cx, self.category.as_str()); + self.view.child_by_path(ids!(label)).set_text( + cx, + self.category + .i18n_key() + .map_or("", |key| tr_key(app_language, key)), + ); self.view.child_by_path(ids!(unread_badge)) .as_unread_badge() .update_counts(false, self.num_unread_mentions, 0); diff --git a/src/shared/room_filter_input_bar.rs b/src/shared/room_filter_input_bar.rs index ccbee0601..d89a7fed8 100644 --- a/src/shared/room_filter_input_bar.rs +++ b/src/shared/room_filter_input_bar.rs @@ -5,6 +5,7 @@ //! reused consistently across both Desktop and Mobile layouts. use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* @@ -69,6 +70,7 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct RoomFilterInputBar { #[deref] view: View, + #[rust] app_language: AppLanguage, } /// Actions emitted by the `RoomFilterInputBar` based on user interaction with it. @@ -89,11 +91,23 @@ impl ActionDefaultRef for RoomFilterAction { impl Widget for RoomFilterInputBar { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } 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 { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.draw_walk(cx, scope, walk) } } @@ -131,3 +145,13 @@ impl WidgetMatchEvent for RoomFilterInputBar { } } } + +impl RoomFilterInputBar { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view + .text_input(cx, ids!(input)) + .set_empty_text(cx, tr_key(self.app_language, "room_filter_input.placeholder").to_string()); + self.view.redraw(cx); + } +} diff --git a/src/shared/styles.rs b/src/shared/styles.rs index feb778dff..a80fa55e5 100644 --- a/src/shared/styles.rs +++ b/src/shared/styles.rs @@ -7,7 +7,7 @@ script_mod! { mod.widgets.ICON_ADD = crate_resource("self://resources/icons/add.svg") mod.widgets.ICON_ADD_REACTION = crate_resource("self://resources/icons/add_reaction.svg") - mod.widgets.ICON_ADD_USER = crate_resource("self://resources/icons/add_user.svg") + mod.widgets.ICON_ADD_USER = crate_resource("self://resources/icons/add_user.svg") // TODO: FIX mod.widgets.ICON_ADD_WALLET = crate_resource("self://resources/icons/add_wallet.svg") mod.widgets.ICON_FORBIDDEN = crate_resource("self://resources/icons/forbidden.svg") mod.widgets.ICON_CHECKMARK = crate_resource("self://resources/icons/checkmark.svg") @@ -19,7 +19,7 @@ script_mod! { mod.widgets.ICON_COPY = crate_resource("self://resources/icons/copy.svg") mod.widgets.ICON_EDIT = crate_resource("self://resources/icons/edit.svg") mod.widgets.ICON_EXTERNAL_LINK = crate_resource("self://resources/icons/external_link.svg") - mod.widgets.ICON_IMPORT = crate_resource("self://resources/icons/import.svg") + mod.widgets.ICON_IMPORT = crate_resource("self://resources/icons/import.svg") // TODO: FIX mod.widgets.ICON_HIERARCHY = crate_resource("self://resources/icons/hierarchy.svg") mod.widgets.ICON_HOME = crate_resource("self://resources/icons/home.svg") mod.widgets.ICON_HTML_FILE = crate_resource("self://resources/icons/html_file.svg") @@ -187,10 +187,6 @@ script_mod! { mod.widgets.COLOR_IMAGE_VIEWER_META_BACKGROUND = #E8E8E8 - // Ensure all settings buttons have a consistent height - mod.widgets.SETTINGS_BUTTON_HEIGHT = 40 - - // A text input widget styled for Robrix. mod.widgets.RobrixTextInput = TextInput { width: Fill, height: Fit diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 1744639a8..039fd9587 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -6,14 +6,25 @@ use eyeball_im::VectorDiff; use futures_util::{future::join_all, pin_mut, StreamExt}; use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; +use mime::{IMAGE_JPEG, IMAGE_PNG}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ + config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, ListThreadsOptions, RelationsOptions, RoomMember}, ruma::{ + api::{Direction, client::{ + account::register::v3::Request as RegistrationRequest, + room::create_room::v3::{Request as CreateRoomRequest, RoomPreset}, + directory::get_public_rooms_filtered, + error::ErrorKind, + profile::{AvatarUrl, DisplayName, set_avatar_url}, + receipt::create_receipt::v3::ReceiptType, + uiaa::{AuthData, AuthType, Dummy}, + }}, directory::{Filter as PublicRoomsFilter, RoomTypeFilter}, events::{ relation::RelationType, room::{ - message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource - }, MessageLikeEventType, StateEventType + encryption::RoomEncryptionEventContent, message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource + }, + space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, + InitialStateEvent, MessageLikeEventType, StateEventType }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, RoomState, SessionChange, SuccessorRoom }; @@ -27,13 +38,14 @@ use tokio::{ sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, }; use url::Url; -use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path::{ Path, PathBuf }, sync::{Arc, LazyLock, Mutex}, time::Duration}; use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ - app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ - add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails - }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ + account_manager::{self, Account}, + app::{AppStateAction, RoomFilterRemoteSearchAction}, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ + add_room::{CreatableSpacesAction, CreateRoomAction, CreateRoomContext, KnockResultAction}, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, build_room_search_text, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails + }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state, take_skip_app_state_restore_once}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ @@ -84,9 +96,11 @@ impl std::fmt::Debug for Cli { impl From for Cli { fn from(login: LoginByPassword) -> Self { Self { - user_id: login.user_id, + user_id: login.user_id.trim().to_owned(), password: login.password, - homeserver: login.homeserver, + homeserver: login.homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), proxy: None, login_screen: false, verbose: false, @@ -94,6 +108,198 @@ impl From for Cli { } } +impl From for Cli { + fn from(registration: RegisterAccount) -> Self { + Self { + user_id: registration.user_id.trim().to_owned(), + password: registration.password, + homeserver: registration.homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), + proxy: None, + login_screen: false, + verbose: false, + } + } +} + +fn infer_homeserver_from_user_id(user_id: &str) -> Option { + let user_id: OwnedUserId = user_id.trim().try_into().ok()?; + Some(user_id.server_name().to_string()) +} + +async fn finalize_authenticated_client( + client: Client, + client_session: ClientSessionPersisted, + fallback_user_id: &str, + is_add_account: bool, +) -> Result<(Client, Option, bool, ClientSessionPersisted)> { + if client.matrix_auth().logged_in() { + let logged_in_user_id = client.user_id() + .map(ToString::to_string) + .unwrap_or_else(|| fallback_user_id.to_owned()); + log!("Logged in successfully."); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + if let Err(e) = persistence::save_session(&client, client_session.clone()).await { + let err_msg = format!("Failed to save session state to storage: {e}"); + error!("{err_msg}"); + enqueue_popup_notification(err_msg, PopupKind::Error, None); + } + Ok((client, None, is_add_account, client_session)) + } else { + let err_msg = format!( + "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + bail!(err_msg); + } +} + +fn registration_localpart(user_id: &str) -> Result { + let trimmed = user_id.trim(); + if trimmed.is_empty() { + bail!("Please enter a valid username or Matrix user ID."); + } + + if let Ok(full_user_id) = >::try_from(trimmed) { + return Ok(full_user_id.localpart().to_owned()); + } + + let localpart = trimmed.trim_start_matches('@'); + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { + bail!("Please enter a valid username or full Matrix user ID."); + } + + Ok(localpart.to_owned()) +} + +fn registration_request( + username: &str, + password: &str, + session: Option, +) -> RegistrationRequest { + let mut request = RegistrationRequest::new(); + request.username = Some(username.to_owned()); + request.password = Some(password.to_owned()); + request.initial_device_display_name = Some("robrix-un-pw".to_owned()); + request.refresh_token = true; + if let Some(session) = session { + let mut dummy = Dummy::new(); + dummy.session = Some(session); + request.auth = Some(AuthData::Dummy(dummy)); + } + request +} + +fn registration_uiaa_error_message(error: &matrix_sdk::Error) -> String { + if let matrix_sdk::Error::Http(http_error) = error { + match http_error.client_api_error_kind() { + Some(ErrorKind::UserInUse) => { + return "That user ID is already taken. Please choose another one.".to_owned(); + } + Some(ErrorKind::InvalidUsername) => { + return "That user ID is invalid. Use a username like `alice` or a full Matrix ID like `@alice:matrix.org`.".to_owned(); + } + Some(ErrorKind::WeakPassword) => { + return "That password is too weak. Please choose a stronger password.".to_owned(); + } + Some(ErrorKind::Forbidden { .. }) => { + return "This homeserver does not allow open registration.".to_owned(); + } + Some(ErrorKind::LimitExceeded { .. }) => { + return "The homeserver is rate limiting account creation right now. Please try again shortly.".to_owned(); + } + _ => {} + } + } + + format!("Could not create account: {error}") +} + +fn unsupported_registration_flow_message( + flows: &[matrix_sdk::ruma::api::client::uiaa::AuthFlow], +) -> String { + let supports_registration_token = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::RegistrationToken)) + }); + if supports_registration_token { + return "This homeserver requires a registration token. Robrix does not support token-based registration yet.".to_owned(); + } + + let supports_terms = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Terms)) + }); + if supports_terms { + return "This homeserver requires an interactive terms-of-service step. Robrix does not support that registration flow yet.".to_owned(); + } + + "This homeserver requires an unsupported registration flow. Please try another homeserver or register with a different client.".to_owned() +} + +async fn clear_persisted_session(user_id: Option<&UserId>) { + let Some(user_id) = user_id else { + return; + }; + + if let Err(e) = persistence::delete_session(user_id).await { + warning!("Failed to delete persisted session for {user_id}: {e}"); + } + + let latest_user_id = persistence::most_recent_user_id().await; + if latest_user_id.as_deref() == Some(user_id) { + if let Err(e) = persistence::delete_latest_user_id().await { + warning!("Failed to delete latest user id for {user_id}: {e}"); + } + } +} + +enum SessionResetAction { + Reauthenticate { message: String }, +} + +async fn reset_runtime_state_for_relogin() { + let sync_service = { SYNC_SERVICE.lock().unwrap().take() }; + if let Some(sync_service) = sync_service { + sync_service.stop().await; + } + + CLIENT.lock().unwrap().take(); + DEFAULT_SSO_CLIENT.lock().unwrap().take(); + IGNORED_USERS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + + let on_clear_appstate = Arc::new(Notify::new()); + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); + + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { + warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); + } +} + +fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { + matches!( + error.client_api_error_kind(), + Some(ErrorKind::UnknownToken { .. } | ErrorKind::MissingToken) + ) +} + +fn is_invalid_batch_token_timeline_error(error: &matrix_sdk_ui::timeline::Error) -> bool { + let error_text = error.to_string().to_ascii_lowercase(); + error_text.contains("invalid batch token") + || error_text.contains("must start with 's' or 't'") +} + +fn is_thread_unknown_parent_timeline_error(error: &matrix_sdk_ui::timeline::Error) -> bool { + let error_text = error.to_string().to_ascii_lowercase(); + error_text.contains("unknown parent event") +} + /// Build a new client. async fn build_client( @@ -116,7 +322,10 @@ async fn build_client( .collect() }; + let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); let homeserver_url = cli.homeserver.as_deref() + .filter(|homeserver| !homeserver.trim().is_empty()) + .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); // .unwrap_or("https://matrix.org/"); @@ -167,17 +376,20 @@ async fn build_client( /// /// This function is used by the login screen to log in to the Matrix server. /// -/// Upon success, this function returns the logged-in client and an optional sync token. +/// Upon success, this function returns the logged-in client, an optional sync token, +/// a boolean indicating if this is an add-account operation (multi-account mode), +/// and the client session for storing in the account manager. async fn login( cli: &Cli, login_request: LoginRequest, -) -> Result<(Client, Option)> { +) -> Result<(Client, Option, bool, ClientSessionPersisted)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { - let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { - &Cli::from(login_by_password) + let (cli, is_add_account) = if let LoginRequest::LoginByPassword(login_by_password) = login_request { + let is_add_account = login_by_password.is_add_account; + (&Cli::from(login_by_password), is_add_account) } else { - cli + (cli, false) }; let (client, client_session) = build_client(cli, app_data_dir()).await?; Cx::post_action(LoginAction::Status { @@ -196,25 +408,83 @@ async fn login( let status = format!("Logged in as {}.\n → Loading rooms...", cli.user_id); // enqueue_popup_notification(status.clone()); enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - if let Err(e) = persistence::save_session(&client, client_session).await { + if let Err(e) = persistence::save_session(&client, client_session.clone()).await { let err_msg = format!("Failed to save session state to storage: {e}"); error!("{err_msg}"); enqueue_popup_notification(err_msg, PopupKind::Error, None); } - Ok((client, None)) } else { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } + finalize_authenticated_client(client, client_session, &cli.user_id, is_add_account).await + } + + LoginRequest::Register(registration) => { + let cli = Cli::from(RegisterAccount { + user_id: registration.user_id.clone(), + password: registration.password.clone(), + homeserver: registration.homeserver.clone(), + }); + let localpart = registration_localpart(®istration.user_id)?; + let (client, client_session) = build_client(&cli, app_data_dir()).await?; + Cx::post_action(LoginAction::Status { + title: "Creating account".into(), + status: format!("Creating account {localpart}..."), + }); + + let auth = client.matrix_auth(); + let initial_request = registration_request(&localpart, ®istration.password, None); + let register_result = match auth.register(initial_request).await { + Ok(response) => Ok(response), + Err(error) => { + if let Some(uiaa_info) = error.as_uiaa_response() { + let supports_dummy = uiaa_info.flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Dummy)) + }); + if supports_dummy { + Cx::post_action(LoginAction::Status { + title: "Completing sign up".into(), + status: "Confirming registration with the homeserver...".into(), + }); + auth.register(registration_request( + &localpart, + ®istration.password, + uiaa_info.session.clone(), + )) + .await + } else { + bail!(unsupported_registration_flow_message(&uiaa_info.flows)); + } + } else { + bail!(registration_uiaa_error_message(&error)); + } + } + }?; + + if !client.matrix_auth().logged_in() { + let err_msg = format!( + "Account {} was created, but the homeserver did not return a login session. Please log in manually.", + register_result.user_id, + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + bail!(err_msg); + } + + finalize_authenticated_client(client, client_session, register_result.user_id.as_str(), false) + .await } - LoginRequest::LoginBySSOSuccess(client, client_session) => { - if let Err(e) = persistence::save_session(&client, client_session).await { + LoginRequest::LoginBySSOSuccess(client, client_session, is_add_account) => { + if let Err(e) = persistence::save_session(&client, client_session.clone()).await { error!("Failed to save session state to storage: {e:?}"); } - Ok((client, None)) + Ok((client, None, is_add_account, client_session)) } LoginRequest::HomeserverLoginTypesQuery(_) => { bail!("LoginRequest::HomeserverLoginTypesQuery not handled earlier"); @@ -313,6 +583,17 @@ pub enum AccountDataAction { DisplayNameChangeFailed(String), } +/// Actions emitted in response to account switching. +#[derive(Debug, Clone)] +pub enum AccountSwitchAction { + /// Account switch is starting - UI should show loading state. + Starting(OwnedUserId), + /// Successfully switched to a different account. + Switched(OwnedUserId), + /// Failed to switch accounts. + Failed(String), +} + /// Actions emitted in response to a [`MatrixRequest::OpenOrCreateDirectMessage`]. #[derive(Debug)] pub enum DirectMessageRoomAction { @@ -337,6 +618,30 @@ pub enum DirectMessageRoomAction { }, } +#[derive(Clone, Debug)] +pub struct FetchedRoomThread { + pub thread_root_event_id: OwnedEventId, + pub timestamp: MilliSecondsSinceUnixEpoch, + pub title: String, + pub reply_count: u32, + pub latest_reply_preview: Option, +} + +#[derive(Clone, Debug)] +pub enum RoomThreadsAction { + Loaded { + room_id: OwnedRoomId, + from: Option, + threads: Vec, + prev_batch_token: Option, + }, + Failed { + room_id: OwnedRoomId, + from: Option, + error: String, + }, +} + /// Either a main room timeline or a thread-focused timeline. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum TimelineKind { @@ -379,6 +684,10 @@ impl std::fmt::Display for TimelineKind { pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), + /// Request to switch to a different logged-in account. + SwitchAccount { + user_id: OwnedUserId, + }, /// Request to logout. Logout { is_desktop: bool, @@ -408,6 +717,11 @@ pub enum MatrixRequest { thread_root_event_id: OwnedEventId, timeline_item_index: usize, }, + /// Request to fetch a page of thread roots for the given room. + ListRoomThreads { + room_id: OwnedRoomId, + from: Option, + }, /// Request to fetch profile information for all members of a room. /// /// This can be *very* slow depending on the number of members in the room. @@ -434,6 +748,12 @@ pub enum MatrixRequest { room_id: OwnedRoomId, user_id: OwnedUserId, }, + /// Request to bind or unbind the configured botfather for the given room. + SetRoomBotBinding { + room_id: OwnedRoomId, + bound: bool, + bot_user_id: OwnedUserId, + }, /// Request to join the given room. JoinRoom { room_id: OwnedRoomId, @@ -463,6 +783,12 @@ pub enum MatrixRequest { room_or_alias_id: OwnedRoomOrAliasId, via: Vec, }, + /// Request to search server-side directory for users, rooms, or spaces. + SearchDirectory { + query: String, + kind: RemoteDirectorySearchKind, + limit: u64, + }, /// Request to fetch the full details (the room preview) of a tombstoned room. GetSuccessorRoomDetails { tombstoned_room_id: OwnedRoomId, @@ -478,6 +804,14 @@ pub enum MatrixRequest { user_profile: UserProfile, allow_create: bool, }, + /// Request to create a new room, optionally underneath a selected parent space. + CreateRoom { + room_name: String, + parent_space_id: Option, + context: CreateRoomContext, + }, + /// Request the list of joined spaces where the current user may create child rooms. + GetCreatableSpaces, /// Request to fetch profile information for the given user ID. GetUserProfile { user_id: OwnedUserId, @@ -534,6 +868,11 @@ pub enum MatrixRequest { /// which is only needed because it isn't present in the `RoomMember` object. room_id: OwnedRoomId, }, + /// Request to upload and set the avatar of the current user's account. + UploadAvatar { + /// The path to a local PNG or JPEG image file. + avatar_path: PathBuf, + }, /// Request to set or remove the avatar of the current user's account. SetAvatar { /// * If `Some`, the avatar will be set to the given MXC URI. @@ -570,6 +909,7 @@ pub enum MatrixRequest { timeline_kind: TimelineKind, message: RoomMessageEventContent, replied_to: Option, + target_user_id: Option, #[cfg(feature = "tsp")] sign_with_tsp: bool, }, @@ -670,6 +1010,95 @@ pub enum MatrixRequest { }, } +fn add_octos_target_user_id( + mut content: serde_json::Value, + target_user_id: &UserId, +) -> serde_json::Value { + if let Some(content_obj) = content.as_object_mut() { + content_obj.insert( + "org.octos.target_user_id".to_string(), + serde_json::Value::String(target_user_id.to_string()), + ); + } + content +} + +async fn ensure_target_user_joined_room( + room: &Room, + target_user_id: &UserId, +) -> Result<()> { + let already_present = room + .get_member_no_sync(target_user_id) + .await + .ok() + .flatten() + .is_some(); + if already_present { + return Ok(()); + } + + room.invite_user_by_id(target_user_id).await?; + + for _attempt in 0..20 { + let joined = room + .get_member_no_sync(target_user_id) + .await + .ok() + .flatten() + .is_some(); + if joined { + return Ok(()); + } + + tokio::time::sleep(Duration::from_millis(250)).await; + } + + Ok(()) +} + +#[cfg(test)] +mod matrix_request_tests { + use super::*; + + #[test] + fn should_add_octos_target_user_id_to_message_content() { + let target_user_id = OwnedUserId::try_from("@bot_weather:example.com").unwrap(); + let content = serde_json::json!({ + "msgtype": "m.text", + "body": "hello", + }); + + let content = add_octos_target_user_id(content, target_user_id.as_ref()); + + assert_eq!( + content + .get("org.octos.target_user_id") + .and_then(|value| value.as_str()), + Some("@bot_weather:example.com") + ); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RemoteDirectorySearchKind { + People, + Rooms, + Spaces, +} + +#[derive(Clone, Debug)] +pub enum RemoteDirectorySearchResult { + User(UserProfile), + Room { + room_name_id: RoomNameId, + avatar_uri: Option, + }, + Space { + space_name_id: RoomNameId, + avatar_uri: Option, + }, +} + /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { @@ -681,7 +1110,8 @@ pub fn submit_async_request(req: MatrixRequest) { /// Details of a login request that get submitted within [`MatrixRequest::Login`]. pub enum LoginRequest{ LoginByPassword(LoginByPassword), - LoginBySSOSuccess(Client, ClientSessionPersisted), + Register(RegisterAccount), + LoginBySSOSuccess(Client, ClientSessionPersisted, bool), LoginByCli, HomeserverLoginTypesQuery(String), @@ -691,6 +1121,16 @@ pub struct LoginByPassword { pub user_id: String, pub password: String, pub homeserver: Option, + /// Whether this login is for adding another account (multi-account mode). + pub is_add_account: bool, +} + +/// Information needed to register a new account on a Matrix homeserver. +#[derive(Clone)] +pub struct RegisterAccount { + pub user_id: String, + pub password: String, + pub homeserver: Option, } @@ -711,11 +1151,84 @@ async fn matrix_worker_task( while let Some(request) = request_receiver.recv().await { match request { MatrixRequest::Login(login_request) => { - if let Err(e) = login_sender.send(login_request).await { - error!("Error sending login request to login_sender: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to login worker task." - ))); + // Check if this is an add-account login (when already logged in) + let is_add_account = match &login_request { + LoginRequest::LoginByPassword(lpw) => lpw.is_add_account, + LoginRequest::LoginBySSOSuccess(_, _, is_add) => *is_add, + _ => false, + }; + + if is_add_account { + // Handle add-account login directly in the worker task + log!("Processing add-account login directly in worker task"); + let cli = Cli::default(); + match login(&cli, login_request).await { + Ok((client, _sync_token, _is_add, session)) => { + let user_id = client.user_id() + .expect("BUG: client.user_id() returned None after login!"); + + // Add to account manager + let account = Account { + client: client.clone(), + user_id: user_id.to_owned(), + session, + display_name: None, + avatar_url: None, + }; + let is_new = account_manager::add_account(account); + log!("Add-account login successful for {}. New account: {}", user_id, is_new); + + // Post success action + Cx::post_action(LoginAction::AddAccountSuccess); + enqueue_popup_notification( + format!("Added account: {}", user_id), + PopupKind::Success, + Some(3.0), + ); + } + Err(e) => { + error!("Add-account login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + } + } + } else { + // Forward to login_sender for initial login flow + if let Err(e) = login_sender.send(login_request).await { + error!("Error sending login request to login_sender: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(String::from( + "BUG: failed to send login request to login worker task." + ))); + } + } + } + + MatrixRequest::SwitchAccount { user_id } => { + // Check if the account exists in AccountManager + if account_manager::get_client_for_user(&user_id).is_some() { + // Set the target account for switch + set_account_switch_target(user_id.clone()); + + // Notify UI that switch is starting (app.rs handles the popup notification) + Cx::post_action(AccountSwitchAction::Starting(user_id.clone())); + + // Stop the sync service - this will cause the main loop to restart + if let Some(sync_service) = get_sync_service() { + sync_service.stop().await; + } + + // The main loop will detect the account switch target and restart with the new account + // We return Ok(()) to signal the worker should end gracefully + return Ok(()); + } else { + error!("Account {} not found in AccountManager", user_id); + Cx::post_action(AccountSwitchAction::Failed( + format!("Account {} not found", user_id) + )); + enqueue_popup_notification( + format!("Account not found: {}", user_id), + PopupKind::Error, + Some(3.0), + ); } } @@ -740,38 +1253,95 @@ async fn matrix_worker_task( log!("Skipping pagination request for unknown {timeline_kind}"); continue; }; + let client = get_client(); // Spawn a new async task that will make the actual pagination request. let _paginate_task = Handle::current().spawn(async move { log!("Starting {direction} pagination request for {timeline_kind}..."); - sender.send(TimelineUpdate::PaginationRunning(direction)).unwrap(); + if sender.send(TimelineUpdate::PaginationRunning(direction)).is_err() { + warning!("Skipping {direction} pagination request for {timeline_kind}: timeline receiver was dropped before start."); + return; + } SignalToUI::set_ui_signal(); - let res = if direction == PaginationDirection::Forwards { + let mut res = if direction == PaginationDirection::Forwards { timeline.paginate_forwards(num_events).await } else { timeline.paginate_backwards(num_events).await }; + if direction == PaginationDirection::Backwards + && res + .as_ref() + .err() + .is_some_and(is_invalid_batch_token_timeline_error) + { + warning!( + "Detected an invalid cached batch token for {timeline_kind}; clearing the room event cache and retrying once." + ); + let room_id = timeline_kind.room_id().clone(); + if let Some(room) = client.and_then(|client| client.get_room(&room_id)) { + match room.event_cache().await { + Ok((room_event_cache, _drop_handles)) => { + match room_event_cache.clear().await { + Ok(()) => { + res = timeline.paginate_backwards(num_events).await; + } + Err(clear_error) => { + warning!( + "Failed to clear event cache for room {room_id} after invalid batch token: {clear_error}" + ); + } + } + } + Err(event_cache_error) => { + warning!( + "Failed to access room event cache for room {room_id} after invalid batch token: {event_cache_error}" + ); + } + } + } + } + match res { Ok(fully_paginated) => { log!("Completed {direction} pagination request for {timeline_kind}, hit {} of timeline? {}", if direction == PaginationDirection::Forwards { "end" } else { "start" }, if fully_paginated { "yes" } else { "no" }, ); - sender.send(TimelineUpdate::PaginationIdle { + if sender.send(TimelineUpdate::PaginationIdle { fully_paginated, direction, - }).unwrap(); - SignalToUI::set_ui_signal(); + }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping completed {direction} pagination update for {timeline_kind}: timeline receiver was dropped."); + } } Err(error) => { + if direction == PaginationDirection::Backwards + && matches!(timeline_kind, TimelineKind::Thread { .. }) + && is_thread_unknown_parent_timeline_error(&error) + { + warning!( + "Treating unknown parent event as end-of-thread for {timeline_kind}." + ); + sender.send(TimelineUpdate::PaginationIdle { + fully_paginated: true, + direction, + }).unwrap(); + SignalToUI::set_ui_signal(); + return; + } error!("Error sending {direction} pagination request for {timeline_kind}: {error:?}"); - sender.send(TimelineUpdate::PaginationError { + if sender.send(TimelineUpdate::PaginationError { error, direction, - }).unwrap(); - SignalToUI::set_ui_signal(); + }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping failed {direction} pagination update for {timeline_kind}: timeline receiver was dropped."); + } } } }); @@ -791,11 +1361,14 @@ async fn matrix_worker_task( Ok(_) => log!("Successfully edited message {timeline_event_item_id:?} in {timeline_kind}."), Err(ref e) => error!("Error editing message {timeline_event_item_id:?} in {timeline_kind}: {e:?}"), } - sender.send(TimelineUpdate::MessageEdited { + if sender.send(TimelineUpdate::MessageEdited { timeline_event_item_id, result, - }).unwrap(); - SignalToUI::set_ui_signal(); + }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping message edited update for {timeline_kind}: timeline receiver was dropped."); + } }); } @@ -855,6 +1428,37 @@ async fn matrix_worker_task( }); } + MatrixRequest::ListRoomThreads { room_id, from } => { + let Some(room) = get_client().and_then(|client| client.get_room(&room_id)) else { + Cx::post_action(RoomThreadsAction::Failed { + room_id, + from, + error: String::from("Room not found."), + }); + continue; + }; + + let _list_threads_task = Handle::current().spawn(async move { + match fetch_room_threads_page(&room, from.clone()).await { + Ok((threads, prev_batch_token)) => { + Cx::post_action(RoomThreadsAction::Loaded { + room_id, + from, + threads, + prev_batch_token, + }); + } + Err(error) => { + Cx::post_action(RoomThreadsAction::Failed { + room_id, + from, + error: error.to_string(), + }); + } + } + }); + } + MatrixRequest::SyncRoomMemberList { timeline_kind } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for sync members list request"); @@ -865,8 +1469,11 @@ async fn matrix_worker_task( log!("Sending sync room members request for {timeline_kind}..."); timeline.fetch_members().await; log!("Completed sync room members request for {timeline_kind}."); - sender.send(TimelineUpdate::RoomMembersSynced).unwrap(); - SignalToUI::set_ui_signal(); + if sender.send(TimelineUpdate::RoomMembersSynced).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping room members synced update for {timeline_kind}: timeline receiver was dropped."); + } }); } @@ -1002,6 +1609,68 @@ async fn matrix_worker_task( }); } + MatrixRequest::SetRoomBotBinding { + room_id, + bound, + bot_user_id, + } => { + let Some(client) = get_client() else { continue }; + let _bot_binding_task = Handle::current().spawn(async move { + let Some(room) = client.get_room(&room_id) else { + let error_message = + format!("Room {room_id} was not found for the bot binding request."); + error!("{error_message}"); + enqueue_popup_notification(error_message, PopupKind::Error, None); + return; + }; + + let membership_result = if bound { + room.invite_user_by_id(&bot_user_id).await + } else { + room.kick_user(&bot_user_id, Some("Robrix app service unbind")).await + }; + + match membership_result { + Ok(()) => { + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: None, + }); + } + Err(error) => { + let membership_exists = + room.get_member_no_sync(&bot_user_id).await.ok().flatten().is_some(); + let should_mark_bound = if bound { membership_exists } else { false }; + + if should_mark_bound != bound { + error!( + "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", + if bound { "invite" } else { "remove" } + ); + enqueue_popup_notification( + format!( + "Failed to {} BotFather {bot_user_id}: {error}", + if bound { "invite" } else { "remove" } + ), + PopupKind::Error, + None, + ); + return; + } + + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: Some(error.to_string()), + }); + } + } + }); + } + MatrixRequest::JoinRoom { room_id } => { let Some(client) = get_client() else { continue }; let _join_room_task = Handle::current().spawn(async move { @@ -1069,18 +1738,23 @@ async fn matrix_worker_task( let _get_members_task = Handle::current().spawn(async move { let send_update = |members: Vec, source: &str| { log!("{} {} members for {timeline_kind}", source, members.len()); - sender.send(TimelineUpdate::RoomMembersListFetched { members }).unwrap(); - SignalToUI::set_ui_signal(); + if sender.send(TimelineUpdate::RoomMembersListFetched { members }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping room members list update for {timeline_kind}: timeline receiver was dropped."); + } }; let room = timeline.room(); if local_only { - if let Ok(members) = room.members_no_sync(memberships).await { - send_update(members, "Got"); + match room.members_no_sync(memberships).await { + Ok(members) => send_update(members, "Got"), + Err(e) => error!("Failed to get room members (local_only) for {timeline_kind}: {e:?}"), } } else { - if let Ok(members) = room.members(memberships).await { - send_update(members, "Successfully fetched"); + match room.members(memberships).await { + Ok(members) => send_update(members, "Successfully fetched"), + Err(e) => error!("Failed to fetch room members for {timeline_kind}: {e:?}"), } } }); @@ -1094,6 +1768,122 @@ async fn matrix_worker_task( }); } + MatrixRequest::SearchDirectory { query, kind, limit } => { + let Some(client) = get_client() else { continue }; + let _search_task = Handle::current().spawn(async move { + let query = query.trim().to_owned(); + let action_kind = kind.clone(); + log!("Remote directory search request: kind={kind:?}, query=\"{query}\", limit={limit}"); + if query.is_empty() { + Cx::post_action(RoomFilterRemoteSearchAction::Results { + query, + kind: action_kind, + results: Vec::new(), + }); + return; + } + + let result = match &kind { + RemoteDirectorySearchKind::People => { + let mut users = Vec::new(); + let mut seen_user_ids = HashSet::new(); + + if let Ok(user_id) = UserId::parse(&query).map(|u| u.to_owned()) { + if let Ok(response) = client.account().fetch_user_profile_of(&user_id).await { + if seen_user_ids.insert(user_id.clone()) { + users.push(RemoteDirectorySearchResult::User(UserProfile { + username: response.get_static::().ok().flatten(), + user_id, + avatar_state: response.get_static::() + .ok() + .map_or(AvatarState::Unknown, AvatarState::Known), + })); + } + } + } + + match client.search_users(&query, limit).await { + Ok(response) => { + for user in response.results.into_iter() { + if seen_user_ids.insert(user.user_id.clone()) { + users.push(RemoteDirectorySearchResult::User(UserProfile { + username: user.display_name, + user_id: user.user_id, + avatar_state: AvatarState::Known(user.avatar_url), + })); + } + if users.len() >= limit as usize { + break; + } + } + Ok(users) + } + Err(_e) if !users.is_empty() => Ok(users), + Err(e) => Err(e.to_string()), + } + } + RemoteDirectorySearchKind::Rooms | RemoteDirectorySearchKind::Spaces => { + let mut filter = PublicRoomsFilter::new(); + filter.generic_search_term = Some(query.clone()); + filter.room_types = match &kind { + RemoteDirectorySearchKind::Rooms => vec![RoomTypeFilter::Default], + RemoteDirectorySearchKind::Spaces => vec![RoomTypeFilter::Space], + RemoteDirectorySearchKind::People => Vec::new(), + }; + let mut request = get_public_rooms_filtered::v3::Request::new(); + request.filter = filter; + client.public_rooms_filtered(request).await + .map(|response| { + response.chunk.into_iter() + .take(limit as usize) + .map(|room| { + let display_name = room.name + .or_else(|| room.canonical_alias.as_ref().map(ToString::to_string)) + .unwrap_or_else(|| room.room_id.to_string()); + let room_name_id = RoomNameId::new( + RoomDisplayName::Named(display_name), + room.room_id.clone(), + ); + match &kind { + RemoteDirectorySearchKind::Spaces => { + RemoteDirectorySearchResult::Space { + space_name_id: room_name_id, + avatar_uri: room.avatar_url, + } + } + _ => { + RemoteDirectorySearchResult::Room { + room_name_id, + avatar_uri: room.avatar_url, + } + } + } + }) + .collect::>() + }) + .map_err(|e| e.to_string()) + } + }; + + match result { + Ok(results) => { + Cx::post_action(RoomFilterRemoteSearchAction::Results { + query, + kind: action_kind, + results, + }); + } + Err(error) => { + Cx::post_action(RoomFilterRemoteSearchAction::Failed { + query, + kind: action_kind, + error, + }); + } + } + }); + } + MatrixRequest::GetSuccessorRoomDetails { tombstoned_room_id } => { let Some(client) = get_client() else { continue }; let (sender, successor_room) = { @@ -1150,6 +1940,74 @@ async fn matrix_worker_task( }); } + MatrixRequest::CreateRoom { room_name, parent_space_id, context } => { + let Some(client) = get_client() else { continue }; + let _create_room_task = Handle::current().spawn(async move { + let mut request = CreateRoomRequest::new(); + request.name = Some(room_name.clone()); + request.preset = Some(RoomPreset::PrivateChat); + request.initial_state.push( + InitialStateEvent::with_empty_state_key( + RoomEncryptionEventContent::with_recommended_defaults(), + ).to_raw_any() + ); + + log!("Creating new room \"{room_name}\"..."); + match client.create_room(request).await { + Ok(room) => { + let mut space_link_error = None; + if let Some(space_id) = parent_space_id.as_ref() + && let Err(error) = attach_room_to_space(&client, &room, space_id).await + { + error!("Created room {} but failed to add it to space {space_id}: {error}", room.room_id()); + space_link_error = Some(error.to_string()); + } + + let room_name_id = RoomNameId::from_room(&room).await; + Cx::post_action(CreateRoomAction::Created { + room_name_id, + parent_space_id, + space_link_error, + context, + }); + } + Err(error) => { + error!("Failed to create room \"{room_name}\": {error}"); + Cx::post_action(CreateRoomAction::Failed { room_name, error, context }); + } + } + }); + } + + MatrixRequest::GetCreatableSpaces => { + let Some(client) = get_client() else { continue }; + let _creatable_spaces_task = Handle::current().spawn(async move { + let Some(user_id) = client.user_id().map(ToOwned::to_owned) else { + Cx::post_action(CreatableSpacesAction::Loaded { spaces: Vec::new() }); + return; + }; + + let mut spaces = Vec::new(); + for room in client.joined_rooms() { + if room.room_type() != Some(ruma::room::RoomType::Space) { + continue; + } + + let Ok(power_levels) = room.power_levels().await else { + continue; + }; + if !power_levels.user_can_send_state(&user_id, StateEventType::SpaceChild) { + continue; + } + + spaces.push(RoomNameId::from_room(&room).await); + } + + spaces.sort_by_cached_key(|space| space.to_string().to_lowercase()); + Cx::post_action(CreatableSpacesAction::Loaded { spaces }); + }); + } + MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { @@ -1286,6 +2144,55 @@ async fn matrix_worker_task( }); } + MatrixRequest::UploadAvatar { avatar_path } => { + let Some(client) = get_client() else { continue }; + let _upload_avatar_task = Handle::current().spawn(async move { + let data = match std::fs::read(&avatar_path) { + Ok(data) => data, + Err(e) => { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + format!("Failed to read selected avatar file {:?}: {e}", avatar_path) + )); + return; + } + }; + + let content_type = match imghdr::from_bytes(&data) { + Some(imghdr::Type::Png) => IMAGE_PNG, + Some(imghdr::Type::Jpeg) => IMAGE_JPEG, + _ => { + let ext = avatar_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some("png") => IMAGE_PNG, + Some("jpg") | Some("jpeg") => IMAGE_JPEG, + _ => { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + "Unsupported avatar format. Please choose a PNG or JPEG image.".to_string() + )); + return; + } + } + } + }; + + log!("Uploading avatar from file: {:?}", avatar_path); + match client.account().upload_avatar(&content_type, data).await { + Ok(new_avatar_uri) => { + log!("Successfully uploaded avatar."); + Cx::post_action(AccountDataAction::AvatarChanged(Some(new_avatar_uri))); + } + Err(e) => { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + format!("Failed to upload avatar: {e}") + )); + } + } + }); + } + MatrixRequest::SetAvatar { avatar_url } => { let Some(client) = get_client() else { continue }; let _set_avatar_task = Handle::current().spawn(async move { @@ -1298,6 +2205,30 @@ async fn matrix_worker_task( Cx::post_action(AccountDataAction::AvatarChanged(avatar_url)); } Err(e) => { + if is_removing && e.client_api_error_kind() == Some(&ErrorKind::Unrecognized) { + log!("Avatar delete endpoint not recognized by homeserver, retrying fallback request..."); + let Some(user_id) = client.user_id() else { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + "Failed to remove avatar: not authenticated.".to_string() + )); + return; + }; + #[allow(deprecated)] + let fallback_result = client.send( + set_avatar_url::v3::Request::new(user_id.to_owned(), None) + ).await; + match fallback_result { + Ok(_) => { + log!("Successfully removed avatar via fallback endpoint."); + Cx::post_action(AccountDataAction::AvatarChanged(None)); + } + Err(fallback_err) => { + let err_msg = format!("Failed to remove avatar: {fallback_err}"); + Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); + } + } + return; + } let err_msg = format!("Failed to {} avatar: {e}", if is_removing { "remove" } else { "set" }); Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); } @@ -1452,15 +2383,17 @@ async fn matrix_worker_task( let _typing_notices_task = Handle::current().spawn(async move { while let Ok(user_ids) = typing_notice_receiver.recv().await { // log!("Received typing notifications for room {room_id}: {user_ids:?}"); - let users = join_all(user_ids.into_iter().map(|user_id| { - let tl = main_timeline.clone(); - async move { - tl.room().get_member_no_sync(&user_id).await - .ok().flatten() - .and_then(|m| m.display_name().map(|d| d.to_owned())) - .unwrap_or_else(|| user_id.to_string()) - } - })).await; + let mut users = Vec::with_capacity(user_ids.len()); + for user_id in user_ids { + let display_name = main_timeline.room() + .get_member_no_sync(&user_id) + .await + .ok() + .flatten() + .and_then(|m| m.display_name().map(|d| d.to_owned())) + .unwrap_or_else(|| user_id.to_string()); + users.push(display_name); + } if let Err(e) = timeline_update_sender.send(TimelineUpdate::TypingUsers { users }) { error!("Error: timeline update sender couldn't send the list of typing users: {e:?}"); } @@ -1596,6 +2529,7 @@ async fn matrix_worker_task( timeline_kind, message, replied_to, + target_user_id, #[cfg(feature = "tsp")] sign_with_tsp, } => { @@ -1669,11 +2603,84 @@ async fn matrix_worker_task( return; } }; - match timeline.send(reply_content.into()).await { - Ok(_send_handle) => log!("Sent reply message to {timeline_kind}."), + + if let Some(target_user_id) = target_user_id.as_ref() { + if let Err(_e) = ensure_target_user_joined_room( + timeline.room(), + target_user_id.as_ref(), + ) + .await + { + error!("Failed to ensure targeted bot {target_user_id} joined {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to invite {target_user_id} into this room: {_e}"), + PopupKind::Error, + None, + ); + return; + } + + let raw_content = match serde_json::to_value(&reply_content) { + Ok(content) => add_octos_target_user_id(content, target_user_id.as_ref()), + Err(_e) => { + error!("Failed to serialize reply content for {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to send reply: {_e}"), + PopupKind::Error, + None, + ); + return; + } + }; + match timeline.room().send_raw("m.room.message", raw_content).await { + Ok(_response) => log!("Sent targeted reply message to {timeline_kind}."), + Err(_e) => { + error!("Failed to send targeted reply message to {timeline_kind}: {_e:?}"); + enqueue_popup_notification(format!("Failed to send reply: {_e}"), PopupKind::Error, None); + } + } + } else { + match timeline.send(reply_content.into()).await { + Ok(_send_handle) => log!("Sent reply message to {timeline_kind}."), + Err(_e) => { + error!("Failed to send reply message to {timeline_kind}: {_e:?}"); + enqueue_popup_notification(format!("Failed to send reply: {_e}"), PopupKind::Error, None); + } + } + } + } else if let Some(target_user_id) = target_user_id.as_ref() { + if let Err(_e) = ensure_target_user_joined_room( + timeline.room(), + target_user_id.as_ref(), + ) + .await + { + error!("Failed to ensure targeted bot {target_user_id} joined {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to invite {target_user_id} into this room: {_e}"), + PopupKind::Error, + None, + ); + return; + } + + let raw_content = match serde_json::to_value(&message) { + Ok(content) => add_octos_target_user_id(content, target_user_id.as_ref()), + Err(_e) => { + error!("Failed to serialize message content for {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to send message: {_e}"), + PopupKind::Error, + None, + ); + return; + } + }; + match timeline.room().send_raw("m.room.message", raw_content).await { + Ok(_response) => log!("Sent targeted message to {timeline_kind}."), Err(_e) => { - error!("Failed to send reply message to {timeline_kind}: {_e:?}"); - enqueue_popup_notification(format!("Failed to send reply: {_e}"), PopupKind::Error, None); + error!("Failed to send targeted message to {timeline_kind}: {_e:?}"); + enqueue_popup_notification(format!("Failed to send message: {_e}"), PopupKind::Error, None); } } } else { @@ -1936,6 +2943,39 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } +async fn attach_room_to_space(client: &Client, child_room: &Room, space_id: &OwnedRoomId) -> Result<()> { + let user_id = client.user_id().ok_or_else(|| anyhow!("Current user ID not found"))?; + let space_room = client.get_room(space_id) + .ok_or_else(|| anyhow!("Selected space {space_id} was not found"))?; + let child_power_levels = child_room.power_levels().await?; + + let child_route = room_route_with_fallback(child_room).await; + space_room + .send_state_event_for_key(child_room.room_id(), SpaceChildEventContent::new(child_route)) + .await?; + + if child_power_levels.user_can_send_state(user_id, StateEventType::SpaceParent) { + let mut parent_content = SpaceParentEventContent::new(room_route_with_fallback(&space_room).await); + parent_content.canonical = true; + child_room + .send_state_event_for_key(space_room.room_id(), parent_content) + .await?; + } + + Ok(()) +} + +async fn room_route_with_fallback(room: &Room) -> Vec { + match room.route().await { + Ok(route) if !route.is_empty() => route, + Ok(_) | Err(_) => room.room_id() + .server_name() + .map(ToOwned::to_owned) + .into_iter() + .collect(), + } +} + /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -2129,6 +3169,35 @@ pub fn current_user_id() -> Option { /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); +/// Flag to indicate an account switch is in progress. +/// Contains the user_id to switch to, if any. +static ACCOUNT_SWITCH_TARGET: Mutex> = Mutex::new(None); + +/// Check if an account switch is pending (non-consuming peek). +fn is_account_switch_pending() -> bool { + ACCOUNT_SWITCH_TARGET.lock().ok().map(|g| g.is_some()).unwrap_or(false) +} + +/// Take the account switch target, consuming it. Only call when ready to perform the switch. +fn take_account_switch_target() -> Option { + ACCOUNT_SWITCH_TARGET.lock().ok()?.take() +} + +/// Set the target account to switch to. +fn set_account_switch_target(user_id: OwnedUserId) { + if let Ok(mut guard) = ACCOUNT_SWITCH_TARGET.lock() { + *guard = Some(user_id); + } +} + +/// Clear the account switch target without taking it. +#[allow(dead_code)] +fn clear_account_switch_target() { + if let Ok(mut guard) = ACCOUNT_SWITCH_TARGET.lock() { + *guard = None; + } +} + /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { @@ -2298,11 +3367,17 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); - match persistence::restore_session(specified_username).await { - Ok(session) => Some(session), + match persistence::restore_session(specified_username.clone()).await { + Ok((client, sync_token, session)) => Some((client, sync_token, true, session)), Err(e) => { let status_err = "Could not restore previous user session.\n\nPlease login again."; log!("{status_err} Error: {e:?}"); + clear_persisted_session( + specified_username + .as_deref() + .or(most_recent_user_id.as_deref()), + ) + .await; Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { @@ -2312,7 +3387,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { homeserver: cli.homeserver.clone(), }); match login(cli, LoginRequest::LoginByCli).await { - Ok(new_login) => Some(new_login), + Ok((client, sync_token, _is_add_account, session)) => Some((client, sync_token, false, session)), Err(e) => { error!("CLI-based login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure( @@ -2338,197 +3413,419 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // which causes the loop to wait for the user to submit a new manual login request. let mut initial_client_opt = new_login_opt; - let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token) = match initial_client_opt.take() { - Some(login) => login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); + loop { + let (client, sync_service, logged_in_user_id) = 'login_loop: loop { + let (client, _sync_token, validate_session, session) = match initial_client_opt.take() { + Some(login) => login, + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token, _is_add_account, session)) => break (client, sync_token, false, session), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); + } } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err, + }); + return; } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err, - }); - return; } } } + }; + + if validate_session { + match client.whoami().await { + Ok(_) => {} + Err(e) if is_invalid_token_http_error(&e) => { + clear_persisted_session(client.user_id()).await; + let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; + Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.to_string(), + }); + continue 'login_loop; + } + Err(e) => { + warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); + } + } } - }; - // Deallocate the default SSO client after a successful login. - if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { - let _ = client_opt.take(); - } + // Deallocate the default SSO client after a successful login. + if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { + let _ = client_opt.take(); + } - let logged_in_user_id: OwnedUserId = client.user_id() - .expect("BUG: Client::user_id() returned None after successful login!") - .to_owned(); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + let logged_in_user_id: OwnedUserId = client.user_id() + .expect("BUG: Client::user_id() returned None after successful login!") + .to_owned(); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + + // Add the account to the AccountManager + let account = account_manager::Account { + client: client.clone(), + user_id: logged_in_user_id.clone(), + session, + display_name: None, + avatar_url: None, + }; + let is_new = account_manager::add_account(account); + log!("Added account {} to AccountManager. New account: {}", logged_in_user_id, is_new); - // Store this active client in our global Client state so that other tasks can access it. - if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); - } + // Store this active client in our global Client state so that other tasks can access it. + if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + } - // Listen for changes to our verification status and incoming verification requests. - add_verification_event_handlers_and_sync_client(client.clone()); + // Listen for changes to our verification status and incoming verification requests. + add_verification_event_handlers_and_sync_client(client.clone()); - // Listen for updates to the ignored user list. - handle_ignore_user_list_subscriber(client.clone()); + // Listen for updates to the ignored user list. + handle_ignore_user_list_subscriber(client.clone()); - // Listen for session changes, e.g., when the access token becomes invalid. - handle_session_changes(client.clone()); + Cx::post_action(LoginAction::Status { + title: "Connecting".into(), + status: "Setting up sync service...".into(), + }); + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + let err_msg = if is_invalid_token_error(&e) { + "Your login token is no longer valid.\n\nPlease log in again.".to_string() + } else { + format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") + }; + if is_invalid_token_error(&e) { + clear_persisted_session(client.user_id()).await; + } + Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); + // Clear the stored client so the next login attempt doesn't trigger the + // "unexpectedly replaced an existing client" warning. + let _ = CLIENT.lock().unwrap().take(); + continue 'login_loop; + } + }; - Cx::post_action(LoginAction::Status { - title: "Connecting".into(), - status: "Setting up sync service...".into(), - }); - let sync_service = match SyncService::builder(client.clone()) - .with_offline_mode() - .build() - .await - { - Ok(ss) => ss, - Err(e) => { - error!("Failed to create SyncService: {e:?}"); - let err_msg = if is_invalid_token_error(&e) { - "Your login token is no longer valid.\n\nPlease log in again.".to_string() - } else { - format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") - }; - Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); - // Clear the stored client so the next login attempt doesn't trigger the - // "unexpectedly replaced an existing client" warning. - let _ = CLIENT.lock().unwrap().take(); - continue 'login_loop; - } + break 'login_loop (client, sync_service, logged_in_user_id); }; - break 'login_loop (client, sync_service, logged_in_user_id); - }; - - // Signal login success now that SyncService::build() has already succeeded (inside - // 'login_loop), which is the only step that can fail with an invalid/expired token. - // Doing this before sync_service.start() lets the UI transition to the home screen - // without waiting for the sync loop to begin. - Cx::post_action(LoginAction::LoginSuccess); + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + // Listen for session changes, e.g., when the access token becomes invalid. + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); - // Attempt to load the previously-saved app state. - handle_load_app_state(logged_in_user_id.to_owned()); - handle_sync_indicator_subscriber(&sync_service); - handle_sync_service_state_subscriber(sync_service.state()); - sync_service.start().await; + // Signal login success now that SyncService::build() has already succeeded (inside + // 'login_loop), which is the only step that can fail with an invalid/expired token. + // Doing this before sync_service.start() lets the UI transition to the home screen + // without waiting for the sync loop to begin. + Cx::post_action(LoginAction::LoginSuccess); - let room_list_service = sync_service.room_list_service(); + // Attempt to load the previously-saved app state. + handle_load_app_state(logged_in_user_id.to_owned()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; - if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); - } + let room_list_service = sync_service.room_list_service(); - let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); - let mut space_service_task = rt.spawn(space_service_loop(client)); + if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + } - // Now, this task becomes an infinite loop that monitors the state of the - // three core matrix-related background tasks that we just spawned above. - #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - loop { - tokio::select! { - result = &mut matrix_worker_task_handle => { - match result { - Ok(Ok(())) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended due to logout"); - } else { - error!("BUG: matrix worker task ended unexpectedly!"); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client)); + + // Now, this task becomes an infinite loop that monitors the + // matrix/background tasks for the currently-authenticated session. + #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. + let reauth_message: Option = loop { + tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + break Some(message); + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; } } - Ok(Err(e)) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended with error due to logout: {e:?}"); - } else { - error!("Error: matrix worker task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Rooms list update error: {e}"), - PopupKind::Error, - None, - ); + } + result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + // Check if this is due to logout or account switch + if is_logout_in_progress() { + log!("matrix worker task ended due to logout"); + } else if is_account_switch_pending() { + log!("matrix worker task ended due to account switch"); + } else { + error!("BUG: matrix worker task ended unexpectedly!"); + } + } + Ok(Err(e)) => { + // Check if this is due to logout or account switch + if is_logout_in_progress() { + log!("matrix worker task ended with error due to logout: {e:?}"); + } else if is_account_switch_pending() { + log!("matrix worker task ended with error due to account switch: {e:?}"); + } else { + error!("Error: matrix worker task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Rooms list update error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); } - }, - Err(e) => { - error!("BUG: failed to join matrix worker task: {e:?}"); } + break None; } - break; - } - result = &mut room_list_service_task => { - match result { - Ok(Ok(())) => { - error!("BUG: room list service loop task ended unexpectedly!"); + result = &mut room_list_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + if is_logout_in_progress() || is_account_switch_pending() { + log!("room list service loop task ended due to logout/account switch"); + } else { + error!("BUG: room list service loop task ended unexpectedly!"); + } + } + Ok(Err(e)) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: room list service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Room list service error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join room list service loop task: {e:?}"); + } } - Ok(Err(e)) => { - error!("Error: room list service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Room list service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join room list service loop task: {e:?}"); + break None; + } + result = &mut space_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + if is_logout_in_progress() || is_account_switch_pending() { + log!("space service loop task ended due to logout/account switch"); + } else { + error!("BUG: space service loop task ended unexpectedly!"); + } + } + Ok(Err(e)) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: space service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Space service error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join space service loop task: {e:?}"); + } } + break None; } - break; } - result = &mut space_service_task => { - match result { - Ok(Ok(())) => { - error!("BUG: space service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: space service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Space service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join space service loop task: {e:?}"); + }; + + // Check if we need to restart for an account switch (loop to handle consecutive switches) + while let Some(switch_user_id) = take_account_switch_target() { + // Clear all backend state + CLIENT.lock().unwrap().take(); + SYNC_SERVICE.lock().unwrap().take(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + IGNORED_USERS.lock().unwrap().clear(); + + // Clear the rooms list UI + enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); + + // Post action to clear UI state + Cx::post_action(AccountSwitchAction::Starting(switch_user_id.clone())); + + // Update active account + account_manager::set_active_account(&switch_user_id); + // Recreate worker task and service loops + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + REQUEST_SENDER.lock().unwrap().replace(sender); + // Restore session for the switched account + match persistence::restore_session(Some(switch_user_id.clone())).await { + Ok((client, _sync_token, _session)) => { + // Store the client + CLIENT.lock().unwrap().replace(client.clone()); + + // Set up the new client + add_verification_event_handlers_and_sync_client(client.clone()); + handle_ignore_user_list_subscriber(client.clone()); + + // Create new sync service + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + Cx::post_action(AccountSwitchAction::Failed(format!("Failed to create sync service: {e}"))); + return; + } + }; + + // Load app state for the new user + handle_load_app_state(switch_user_id.clone()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; + let room_list_service = sync_service.room_list_service(); + + SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)); + + let (login_sender, _login_receiver) = tokio::sync::mpsc::channel(1); + + // Set up session change handler for the switched account + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); + + let mut matrix_worker_task_handle = rt.spawn(matrix_worker_task(receiver, login_sender)); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client.clone())); + + // Notify UI that switch is complete (app.rs handles the popup notification) + Cx::post_action(AccountSwitchAction::Switched(switch_user_id.clone())); + + // Re-enter the main monitoring loop + loop { + tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + error!("Session reset during account switch: {}", message); + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + Cx::post_action(AccountSwitchAction::Failed(message)); + break; + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; + } + } + } + result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("BUG: matrix worker task ended unexpectedly!"); + } + } + Ok(Err(e)) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: matrix worker task ended:\n\t{e:?}"); + } + } + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); + } + } + break; + } + result = &mut room_list_service_task => { + session_change_handler_task.abort(); + if let Err(e) = result { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Room list service task error: {e:?}"); + } + } + break; + } + result = &mut space_service_task => { + session_change_handler_task.abort(); + if let Err(e) = result { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Space service task error: {e:?}"); + } + } + break; + } + } } + // After inner loop breaks, outer while loop will check for another pending account switch + } + Err(e) => { + error!("Failed to restore session for account switch: {e:?}"); + Cx::post_action(AccountSwitchAction::Failed(format!("Failed to restore session: {e}"))); + enqueue_popup_notification( + format!("Account switch failed: {e}"), + PopupKind::Error, + None, + ); + // Don't loop back - a failed switch shouldn't keep trying + break; } - break; } } + + // Only run reauth cleanup if we got a reauth message (not account switch or logout) + if let Some(reauth_msg) = reauth_message { + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + + reset_runtime_state_for_relogin().await; + Cx::post_action(LoginAction::LoginFailure(reauth_msg.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: reauth_msg, + }); + } } } @@ -3030,6 +4327,11 @@ async fn add_new_room( }; rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { room_name_id: room_name_id.clone(), + search_text: build_room_search_text( + &room_name_id, + &new_room.room.canonical_alias(), + &new_room.room.alt_aliases(), + ), inviter_info, room_avatar, canonical_alias: new_room.room.canonical_alias(), @@ -3114,6 +4416,11 @@ async fn add_new_room( is_marked_unread: new_room.is_marked_unread, room_avatar, room_name_id: room_name_id.clone(), + search_text: build_room_search_text( + &room_name_id, + &new_room.room.canonical_alias(), + &new_room.room.alt_aliases(), + ), canonical_alias: new_room.room.canonical_alias(), alt_aliases: new_room.room.alt_aliases(), has_been_paginated: false, @@ -3191,6 +4498,17 @@ fn handle_ignore_user_list_subscriber(client: Client) { /// If loading fails, it shows a popup notification with the error message. fn handle_load_app_state(user_id: OwnedUserId) { Handle::current().spawn(async move { + match take_skip_app_state_restore_once(&user_id).await { + Ok(true) => { + log!("Skipping automatic app state restore once for {user_id} after explicit logout."); + return; + } + Ok(false) => {} + Err(e) => { + warning!("Failed to check skip-restore marker for {user_id}: {e}"); + } + } + match load_app_state(&user_id).await { Ok(app_state) => { if !app_state.saved_dock_state_home.open_rooms.is_empty() @@ -3235,7 +4553,10 @@ fn is_invalid_token_error(e: &sync_service::Error) -> bool { /// When the homeserver rejects the access token with a 401 `M_UNKNOWN_TOKEN` error /// (e.g., the token was revoked or expired), this emits a [`LoginAction::LoginFailure`] /// so the user is prompted to log in again. -fn handle_session_changes(client: Client) { +fn handle_session_changes( + client: Client, + session_reset_sender: UnboundedSender, +) -> JoinHandle<()> { let mut receiver = client.subscribe_to_session_changes(); Handle::current().spawn(async move { loop { @@ -3248,6 +4569,11 @@ fn handle_session_changes(client: Client) { }; error!("Session token is no longer valid (soft_logout: {soft_logout}). Prompting re-login."); Cx::post_action(LoginAction::LoginFailure(msg.to_string())); + clear_persisted_session(client.user_id()).await; + let _ = session_reset_sender.send(SessionResetAction::Reauthenticate { + message: msg.to_string(), + }); + break; } Ok(SessionChange::TokensRefreshed) => {} Err(broadcast::error::RecvError::Lagged(n)) => { @@ -3258,7 +4584,7 @@ fn handle_session_changes(client: Client) { } } } - }); + }) } fn handle_sync_service_state_subscriber(mut subscriber: Subscriber) { @@ -3529,6 +4855,87 @@ async fn text_preview_of_latest_thread_reply( } } +async fn sender_display_name_for_timeline_event( + room: &Room, + event: &matrix_sdk::deserialized_responses::TimelineEvent, +) -> Option<(OwnedUserId, String)> { + let raw = event.raw(); + let sender_id = raw.get_field::("sender").ok().flatten()?; + let sender_room_member = match room.get_member_no_sync(&sender_id).await { + Ok(Some(rm)) => Some(rm), + _ => None, + }; + let sender_name = sender_room_member.as_ref() + .and_then(|rm| rm.display_name()) + .unwrap_or(sender_id.as_str()) + .to_string(); + Some((sender_id, sender_name)) +} + +fn fallback_preview_for_timeline_event( + event: &matrix_sdk::deserialized_responses::TimelineEvent, + sender_name: &str, + as_html: bool, +) -> String { + text_preview_of_raw_timeline_event(event.raw(), sender_name) + .unwrap_or_else(|| { + let event_type = event.raw().get_field::("type").ok().flatten(); + TextPreview::from(( + event_type.unwrap_or_else(|| "unknown event type".to_string()), + BeforeText::UsernameWithColon, + )) + }) + .format_with(sender_name, as_html) +} + +async fn fetch_room_threads_page( + room: &Room, + from: Option, +) -> Result<(Vec, Option), matrix_sdk::Error> { + let response = room.list_threads(ListThreadsOptions { + from: from.clone(), + limit: Some(uint!(20)), + ..Default::default() + }).await?; + + let mut threads = Vec::new(); + for event in response.chunk { + let Some(thread_root_event_id) = event.event_id() else { continue }; + let timestamp = event.timestamp().unwrap_or_else(MilliSecondsSinceUnixEpoch::now); + let sender_name = sender_display_name_for_timeline_event(room, &event).await + .map(|(_, sender_name)| sender_name) + .unwrap_or_else(|| String::from("Unknown user")); + let title = utils::replace_linebreaks_separators( + &fallback_preview_for_timeline_event(&event, &sender_name, false), + true, + ).into_owned(); + let title = if title.trim().is_empty() { + String::from("(No message preview)") + } else { + title + }; + + let reply_count = event.thread_summary.summary() + .map(|summary| summary.num_replies) + .unwrap_or(0); + let latest_reply_preview = if let Some(latest_event) = event.bundled_latest_thread_event.as_ref() { + text_preview_of_latest_thread_reply(room, latest_event).await + } else { + None + }; + + threads.push(FetchedRoomThread { + thread_root_event_id, + timestamp, + title, + reply_count, + latest_reply_preview, + }); + } + + Ok((threads, response.prev_batch_token)) +} + /// Returns the timestamp and an HTML-formatted text preview of the given `latest_event`. /// @@ -4062,7 +5469,7 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { + if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session, false)).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( "BUG: failed to send login request to matrix worker thread." diff --git a/src/space_service_sync.rs b/src/space_service_sync.rs index c02bbc8a1..a77d0633c 100644 --- a/src/space_service_sync.rs +++ b/src/space_service_sync.rs @@ -10,7 +10,7 @@ use matrix_sdk::{Client, RoomState, media::MediaRequestParameters}; use matrix_sdk_ui::spaces::{SpaceRoom, SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState}; use ruma::{OwnedMxcUri, OwnedRoomId, events::room::MediaSource, room::RoomType}; use tokio::{runtime::Handle, sync::mpsc::{UnboundedReceiver, UnboundedSender}, task::JoinHandle}; -use crate::{home::{rooms_list::{RoomsListUpdate, enqueue_rooms_list_update}, spaces_bar::{JoinedSpaceInfo, SpacesListUpdate, enqueue_spaces_list_update}}, room::FetchedRoomAvatar, utils::{self, RoomNameId}}; +use crate::{home::{rooms_list::{RoomsListUpdate, enqueue_rooms_list_update}, spaces_bar::{JoinedSpaceInfo, SpacesListUpdate, build_space_search_text, enqueue_spaces_list_update}}, room::FetchedRoomAvatar, utils::{self, RoomNameId}}; /// Whether to enable verbose logging of all spaces service diff updates. const LOG_SPACE_SERVICE_DIFFS: bool = cfg!(feature = "log_space_service_diffs"); @@ -350,6 +350,14 @@ async fn add_new_space(space: &SpaceRoom, client: &Client) { matrix_sdk::RoomDisplayName::Named(space.display_name.clone()), space.room_id.clone(), ), + search_text: build_space_search_text( + &RoomNameId::new( + matrix_sdk::RoomDisplayName::Named(space.display_name.clone()), + space.room_id.clone(), + ), + &space.canonical_alias, + &space.topic, + ), canonical_alias: space.canonical_alias.clone(), topic: space.topic.clone(), space_avatar, diff --git a/src/tsp/create_did_modal.rs b/src/tsp/create_did_modal.rs index 361d62023..f51e8bccb 100644 --- a/src/tsp/create_did_modal.rs +++ b/src/tsp/create_did_modal.rs @@ -86,17 +86,14 @@ script_mod! { width: Fit, height: Fit, did_web := RadioButtonFlat { text: "Web" - draw_text +: { color: (COLOR_TEXT) } animator: { active: { default: on } } } did_webvh := RadioButtonFlat { text: "WebVH" - draw_text +: { color: (COLOR_TEXT) } animator: { disabled: { default: on } } } did_peer := RadioButtonFlat { text: "Peer", - draw_text +: { color: (COLOR_TEXT) } animator: { disabled: { default: on } } } } @@ -108,7 +105,7 @@ script_mod! { server_input := RobrixTextInput { width: Fill, height: Fit, flow: Right, // do not wrap - padding: Inset { left: 10, right: 10, top: 5, bottom: 5 } + padding: Inset{top: 3, bottom: 3} empty_text: "p.teaspoon.world", draw_text +: { text_style: REGULAR_TEXT {font_size: 10.0} @@ -150,7 +147,7 @@ script_mod! { did_server_input := RobrixTextInput { width: Fill, height: Fit, flow: Right, // do not wrap - padding: Inset { left: 10, right: 10, top: 5, bottom: 5 } + padding: Inset{top: 3, bottom: 3} empty_text: "did.teaspoon.world", draw_text +: { text_style: REGULAR_TEXT {font_size: 10.0} diff --git a/src/tsp/create_wallet_modal.rs b/src/tsp/create_wallet_modal.rs index 1e1709b30..79c477597 100644 --- a/src/tsp/create_wallet_modal.rs +++ b/src/tsp/create_wallet_modal.rs @@ -101,7 +101,7 @@ script_mod! { wallet_file_name_input := RobrixTextInput { width: Fill, height: Fit, flow: Right, // do not wrap - padding: Inset { left: 10, right: 10, top: 5, bottom: 5 } + padding: Inset{top: 3, bottom: 3} empty_text: "my_wallet_file", draw_text +: { text_style: REGULAR_TEXT {font_size: 10.0} diff --git a/src/tsp/sign_anycast_checkbox.rs b/src/tsp/sign_anycast_checkbox.rs index 971a4854d..8634c05ee 100644 --- a/src/tsp/sign_anycast_checkbox.rs +++ b/src/tsp/sign_anycast_checkbox.rs @@ -13,10 +13,5 @@ script_mod! { mod.widgets.TspSignAnycastCheckbox = CheckBoxFlat { text: "TSP", active: false, - draw_text +: { - color: COLOR_TEXT, - text_style: theme.font_regular {font_size: 11}, - mark_color_active: COLOR_TEXT, - } } } diff --git a/src/tsp/tsp_settings_screen.rs b/src/tsp/tsp_settings_screen.rs index 879ae3ded..adc9130b1 100644 --- a/src/tsp/tsp_settings_screen.rs +++ b/src/tsp/tsp_settings_screen.rs @@ -1,7 +1,7 @@ use makepad_widgets::*; -use crate::{shared::{popup_list::{enqueue_popup_notification, PopupKind}, styles::*}, tsp::{create_did_modal::CreateDidModalAction, create_wallet_modal::CreateWalletModalAction, submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest, TspWalletAction, TspWalletEntry, TspWalletMetadata}}; +use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, shared::{popup_list::{enqueue_popup_notification, PopupKind}, styles::*}, tsp::{create_did_modal::CreateDidModalAction, create_wallet_modal::CreateWalletModalAction, submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest, TspWalletAction, TspWalletEntry, TspWalletMetadata}}; script_mod! { link tsp_enabled @@ -9,19 +9,18 @@ script_mod! { use mod.prelude.widgets.* use mod.widgets.* - mod.widgets.REPUBLISH_IDENTITY_BUTTON_TEXT = "Republish Current Identity to DID Server" // The view containing all TSP-related settings. mod.widgets.TspSettingsScreen = #(TspSettingsScreen::register_widget(vm)) { width: Fill, height: Fit flow: Down - TitleLabel { - text: "TSP Wallet Settings" + title := TitleLabel { + text: "" } - SubsectionLabel { - text: "Your active identity:" + section_active_identity := SubsectionLabel { + text: "" } View { @@ -40,7 +39,7 @@ script_mod! { current_identity_label := Label { width: Fill, height: Fit flow: Flow.Right{wrap: true}, - margin: Inset{top: 8} + margin: Inset{top: 10} draw_text +: { text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } @@ -48,23 +47,23 @@ script_mod! { } republish_identity_button := RobrixIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{top: 8, bottom: 10, left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_UPLOAD) icon_walk: Walk{width: 16, height: 16} - text: mod.widgets.REPUBLISH_IDENTITY_BUTTON_TEXT + text: "" } - SubsectionLabel { - text: "Your Wallets:" + section_wallets := SubsectionLabel { + text: "" } no_wallets_label := View { width: Fill, height: Fit - Label { + no_wallets_text := Label { width: Fill, height: Fit margin: Inset{top: 10, bottom: 8, left: 13, right: 10}, flow: Flow.Right{wrap: true}, @@ -72,7 +71,7 @@ script_mod! { color: (COLOR_TEXT_WARNING_NOT_FOUND), text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } - text: "No wallets found. Create or import a wallet." + text: "" } } @@ -108,36 +107,36 @@ script_mod! { spacing: 10 create_did_button := RobrixPositiveIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_ADD_USER) - icon_walk: Walk{width: 19, height: Fit, margin: 0} - text: "Create New Identity (DID)" + icon_walk: Walk{width: 21, height: Fit, margin: 0} + text: "" } create_wallet_button := RobrixPositiveIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_ADD_WALLET) icon_walk: Walk{width: 21, height: Fit, margin: 0} - text: "Create New Wallet" + text: "" } import_wallet_button := RobrixIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} - text: "Import Existing Wallet" - draw_icon +: { - svg: (ICON_IMPORT) - color: (COLOR_PRIMARY) - } - icon_walk: Walk{width: 16, height: 16} + text: "" + // TODO: fix this icon, or pick a different SVG + // draw_icon +: { + // svg: (ICON_IMPORT) + // color: (COLOR_PRIMARY) + // } + // icon_walk: Walk{width: 16, height: 16} + icon_walk: Walk{width: 0, height: 0} } } } @@ -158,18 +157,18 @@ impl WalletState { self.active_wallet.is_some() as usize + self.other_wallets.len() } - fn get(&self, index: usize) -> Option<(&TspWalletMetadata, WalletStatusAndDefault)> { + fn get(&self, index: usize, app_language: AppLanguage) -> Option<(&TspWalletMetadata, WalletStatusAndDefault)> { if let Some(active) = self.active_wallet.as_ref() { if index == 0 { - Some((active, WalletStatusAndDefault::new(WalletStatus::Opened, true))) + Some((active, WalletStatusAndDefault::new(WalletStatus::Opened, true, app_language))) } else { self.other_wallets.get(index - 1).map(|(m, s)| - (m, WalletStatusAndDefault::new(*s, false)) + (m, WalletStatusAndDefault::new(*s, false, app_language)) ) } } else { self.other_wallets.get(index).map(|(m, s)| - (m, WalletStatusAndDefault::new(*s, false)) + (m, WalletStatusAndDefault::new(*s, false, app_language)) ) } } @@ -185,10 +184,11 @@ pub enum WalletStatus { pub struct WalletStatusAndDefault { pub status: WalletStatus, pub is_default: bool, + pub app_language: AppLanguage, } impl WalletStatusAndDefault { - pub fn new(status: WalletStatus, is_default: bool) -> Self { - Self { status, is_default } + pub fn new(status: WalletStatus, is_default: bool, app_language: AppLanguage) -> Self { + Self { status, is_default, app_language } } } @@ -208,15 +208,29 @@ pub struct TspSettingsScreen { /// to avoid having to re-fetch them from the shared TSP state every time, /// as that requires locking the mutex and can be expensive. #[rust] wallets: Option, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, } impl Widget for TspSettingsScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.match_event(cx, event); self.view.handle_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } if self.wallets.is_none() { // If we don't have any wallets, load them from the TSP state. self.refresh_wallets(); @@ -228,7 +242,7 @@ impl Widget for TspSettingsScreen { self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { Some(current_did) => (current_did.to_string(), COLOR_FG_ACCEPT_GREEN, true), - None => ("No default identity has been set.".to_string(), COLOR_TEXT_WARNING_NOT_FOUND, false), + None => (tr_key(self.app_language, "tsp.settings.identity.none_set").to_string(), COLOR_TEXT_WARNING_NOT_FOUND, false), }; let mut current_identity_label = self.view.label(cx, ids!(current_identity_label)); script_apply_eval!(cx, current_identity_label, { @@ -253,7 +267,7 @@ impl Widget for TspSettingsScreen { return DrawStep::done(); }; - for (metadata, mut status_and_default) in (0..wallets.len()).filter_map(|i| wallets.get(i)) { + for (metadata, mut status_and_default) in (0..wallets.len()).filter_map(|i| wallets.get(i, self.app_language)) { let item_live_id = LiveId::from_str(metadata.url.as_url_unencoded()); let item = list.item(cx, item_live_id, id!(wallet_entry)).unwrap(); // Pass the wallet metadata in through Scope via props, @@ -297,7 +311,7 @@ impl MatchEvent for TspSettingsScreen { continue; } enqueue_popup_notification( - format!("Removed wallet \"{}\".", metadata.wallet_name), + tr_fmt(self.app_language, "tsp.settings.popup.wallet.removed", &[("wallet_name", metadata.wallet_name.as_str())]), PopupKind::Success, Some(4.0), ); @@ -305,8 +319,7 @@ impl MatchEvent for TspSettingsScreen { // If the removed wallet was the default wallet, notify the user. // The user should then select another wallet as the default. enqueue_popup_notification( - "The default wallet was removed.\n\n\ - TSP features will not work properly until you set a default wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.default_removed_warning"), PopupKind::Warning, None, ); @@ -332,7 +345,7 @@ impl MatchEvent for TspSettingsScreen { } Some(TspWalletAction::DefaultWalletChanged(Err(_))) => { enqueue_popup_notification( - "Failed to set default wallet, could not find or open selected wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.set_default_failed"), PopupKind::Error, None, ); @@ -352,7 +365,7 @@ impl MatchEvent for TspSettingsScreen { } Some(TspWalletAction::WalletOpened(Err(e))) => { enqueue_popup_notification( - format!("Failed to open wallet: {e}"), + tr_fmt(self.app_language, "tsp.settings.popup.wallet.open_failed", &[("error", &e.to_string())]), PopupKind::Error, None, ); @@ -378,19 +391,19 @@ impl MatchEvent for TspSettingsScreen { // restore the republish button to its original state. script_apply_eval!(cx, republish_identity_button, { enabled: true, - text: mod.widgets.REPUBLISH_IDENTITY_BUTTON_TEXT, + text: #(tr_key(self.app_language, "tsp.settings.button.republish_identity")), }); match result { Ok(did) => { enqueue_popup_notification( - format!("Successfully republished identity \"{}\" to the DID server.", did), + tr_fmt(self.app_language, "tsp.settings.popup.identity.republish_success", &[("did", did.as_str())]), PopupKind::Success, Some(5.0), ); } Err(e) => { enqueue_popup_notification( - format!("Failed to republish identity to the DID server: {e}"), + tr_fmt(self.app_language, "tsp.settings.popup.identity.republish_failed", &[("error", &e.to_string())]), PopupKind::Error, None, ); @@ -412,13 +425,13 @@ impl MatchEvent for TspSettingsScreen { if let Some(did) = self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { cx.copy_to_clipboard(did); enqueue_popup_notification( - "Copied your default TSP identity to the clipboard.", + tr_key(self.app_language, "tsp.settings.popup.identity.copied"), PopupKind::Success, Some(3.0), ); } else { enqueue_popup_notification( - "No default TSP identity has been set.", + tr_key(self.app_language, "tsp.settings.popup.identity.none_set"), PopupKind::Warning, Some(4.0), ); @@ -433,13 +446,13 @@ impl MatchEvent for TspSettingsScreen { if let Some(our_did) = self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { script_apply_eval!(cx, republish_identity_button, { enabled: false, - text: "Republishing DID now...", + text: #(tr_key(self.app_language, "tsp.settings.button.republishing_now")), }); submit_tsp_request(TspRequest::RepublishDid { did: our_did.to_string() }); } else { enqueue_popup_notification( - "You must set a default TSP identity to be republished.", + tr_key(self.app_language, "tsp.settings.popup.identity.must_set_default"), PopupKind::Error, Some(5.0), ); @@ -460,7 +473,7 @@ impl MatchEvent for TspSettingsScreen { if self.view.button(cx, ids!(import_wallet_button)).clicked(actions) { // TODO: support importing an existing wallet. enqueue_popup_notification( - "Importing an existing wallet is not yet implemented.", + tr_key(self.app_language, "tsp.settings.popup.wallet.import_not_implemented"), PopupKind::Warning, Some(4.0), ); @@ -469,6 +482,36 @@ impl MatchEvent for TspSettingsScreen { } impl TspSettingsScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.title")); + self.view + .label(cx, ids!(section_active_identity)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.section.active_identity")); + self.view + .button(cx, ids!(republish_identity_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.republish_identity")); + self.view + .label(cx, ids!(section_wallets)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.section.wallets")); + self.view + .label(cx, ids!(no_wallets_text)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.wallet.none")); + self.view + .button(cx, ids!(create_did_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.create_identity")); + self.view + .button(cx, ids!(create_wallet_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.create_wallet")); + self.view + .button(cx, ids!(import_wallet_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.import_wallet")); + self.view.redraw(cx); + } + /// Re-fetches the TSP state and populates this widget's list of wallets. fn refresh_wallets(&mut self) { let tsp_state = tsp_state_ref().lock().unwrap(); @@ -496,7 +539,7 @@ impl TspSettingsScreen { fn has_default_wallet(&self) -> bool { let Some(wallets) = self.wallets.as_ref() else { enqueue_popup_notification( - "No TSP wallets found.\n\nPlease create or import a wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.none_found"), PopupKind::Warning, Some(5.0), ); @@ -504,7 +547,7 @@ impl TspSettingsScreen { }; if wallets.active_wallet.is_none() { enqueue_popup_notification( - "No default TSP wallet is set.\n\nPlease select or create a default wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.no_default"), PopupKind::Warning, Some(5.0), ); diff --git a/src/tsp/wallet_entry/mod.rs b/src/tsp/wallet_entry/mod.rs index 68bb2c4c0..6832eada4 100644 --- a/src/tsp/wallet_entry/mod.rs +++ b/src/tsp/wallet_entry/mod.rs @@ -5,6 +5,7 @@ use makepad_widgets::*; use crate::{ app::ConfirmDeleteAction, + i18n::{AppLanguage, tr_fmt, tr_key}, shared::{confirmation_modal::ConfirmationModalContent, popup_list::{enqueue_popup_notification, PopupKind}}, tsp::{submit_tsp_request, tsp_settings_screen::{WalletStatus, WalletStatusAndDefault}, TspRequest, TspWalletMetadata} }; @@ -18,13 +19,11 @@ script_mod! { mod.widgets.WalletEntry = #(WalletEntry::register_widget(vm)) { width: Fill, height: Fit flow: Down - align: Align { y: 0.5 } View { width: Fill, height: Fit flow: Flow.Right{wrap: true}, padding: 10 - align: Align { y: 0.5 } wallet_name := Label { width: Fit, height: Fit @@ -34,7 +33,7 @@ script_mod! { color: (MESSAGE_TEXT_COLOR), text_style: theme.font_bold { font_size: 12 }, } - text: "[Wallet Name]" + text: "" } wallet_path := Label { @@ -45,24 +44,22 @@ script_mod! { color: (MESSAGE_TEXT_COLOR), text_style: theme.font_regular { font_size: 11 }, } - text: "[Wallet Path/URL]" + text: "" } is_default_label_view := View { visible: false, width: Fit, height: Fit margin: Inset{left: 20} - align: Align { y: 0.5 } - Label { + is_default_label := Label { + margin: Inset{top: 2.9} width: Fit, height: Fit - margin: Inset{top: 3} - align: Align { y: 0.5 } flow: Right, draw_text +: { color: (COLOR_FG_ACCEPT_GREEN), text_style: theme.font_bold { font_size: 11 }, } - text: "✅ Default" + text: "" } } @@ -70,45 +67,40 @@ script_mod! { visible: false, width: Fit, height: Fit margin: Inset{left: 20} - align: Align { y: 0.5 } - Label { + not_found_label := Label { margin: Inset{top: 2.9} width: Fit, height: Fit flow: Right, - align: Align { y: 0.5 } draw_text +: { color: (COLOR_FG_DANGER_RED), text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } - text: "Wallet not found!" + text: "" } } set_default_wallet_button := RobrixIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 20} draw_icon.svg: (ICON_CHECKMARK) icon_walk: Walk{width: 16, height: 16} - text: "Set As Default" + text: "" } remove_wallet_button := RobrixNegativeIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 20} draw_icon.svg: (ICON_CLOSE) icon_walk: Walk{ width: 16, height: 16 } - text: "Remove From List" + text: "" } delete_wallet_button := RobrixNegativeIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 20} draw_icon.svg: (ICON_TRASH) icon_walk: Walk{ width: 16, height: 16 } - text: "Delete Wallet" + text: "" } } @@ -124,6 +116,7 @@ pub struct WalletEntry { #[deref] view: View, #[rust] metadata: Option, + #[rust] app_language: AppLanguage, } impl Widget for WalletEntry { @@ -139,13 +132,11 @@ impl Widget for WalletEntry { if self.view.button(cx, ids!(remove_wallet_button)).clicked(actions) { let metadata_clone = metadata.clone(); let content = ConfirmationModalContent { - title_text: "Remove Wallet".into(), - body_text: format!( - "Are you sure you want to remove the wallet \"{}\" \ - from the list?\n\nThis won't delete the actual wallet file.", - metadata.wallet_name - ).into(), - accept_button_text: Some("Remove".into()), + title_text: tr_key(self.app_language, "tsp.wallet_entry.modal.remove.title").into(), + body_text: tr_fmt(self.app_language, "tsp.wallet_entry.modal.remove.body", &[ + ("wallet_name", metadata.wallet_name.as_str()), + ]).into(), + accept_button_text: Some(tr_key(self.app_language, "tsp.wallet_entry.modal.remove.accept").into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_tsp_request(TspRequest::RemoveWallet(metadata_clone)); })), @@ -157,7 +148,7 @@ impl Widget for WalletEntry { if self.view.button(cx, ids!(delete_wallet_button)).clicked(actions) { // TODO: Implement the delete wallet feature. enqueue_popup_notification( - "Delete wallet feature is not yet implemented.", + tr_key(self.app_language, "tsp.wallet_entry.popup.delete_not_implemented"), PopupKind::Warning, None, ); @@ -173,6 +164,7 @@ impl Widget for WalletEntry { if self.metadata.as_ref().is_none_or(|m| m != metadata) { self.metadata = Some(metadata.clone()); } + self.app_language = sd.app_language; self.label(cx, ids!(wallet_name)).set_text( cx, @@ -182,6 +174,26 @@ impl Widget for WalletEntry { cx, metadata.url.as_url_unencoded() ); + self.label(cx, ids!(is_default_label_view.is_default_label)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.default_label"), + ); + self.label(cx, ids!(not_found_label_view.not_found_label)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.not_found"), + ); + self.button(cx, ids!(set_default_wallet_button)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.button.set_default"), + ); + self.button(cx, ids!(remove_wallet_button)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.button.remove"), + ); + self.button(cx, ids!(delete_wallet_button)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.button.delete"), + ); // There is a weird makepad bug where if we re-style one instance of the // `set_default_wallet_button` in one WalletEntry, all other instances of that button // get their styling messed up in weird ways. diff --git a/src/tsp_dummy/mod.rs b/src/tsp_dummy/mod.rs index c9451f506..e8c37e8aa 100644 --- a/src/tsp_dummy/mod.rs +++ b/src/tsp_dummy/mod.rs @@ -17,22 +17,23 @@ //! will be replaced with these dummy widgets when the `tsp` feature is not enabled. use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* use mod.widgets.* - mod.widgets.TspSettingsScreen = View { + mod.widgets.TspSettingsScreen = #(TspSettingsScreen::register_widget(vm)) { width: Fill, height: Fit flow: Down align: Align{x: 0} - TitleLabel { - text: "TSP Wallet Settings" + title := TitleLabel { + text: "" } - Label { + message := Label { width: Fill, height: Fit flow: Flow.Right{wrap: true}, align: Align{x: 0} @@ -41,7 +42,7 @@ script_mod! { color: (MESSAGE_TEXT_COLOR), text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } - text: "TSP features are not included in this build.\nTo use TSP, build Robrix with the 'tsp' feature enabled." + text: "" } } @@ -70,3 +71,46 @@ script_mod! { visible: false } } + +#[derive(Script, ScriptHook, Widget)] +pub struct TspSettingsScreen { + #[deref] view: View, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, +} + +impl Widget for TspSettingsScreen { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.handle_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.draw_walk(cx, scope, walk) + } +} + +impl TspSettingsScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.title")); + self.view + .label(cx, ids!(message)) + .set_text(cx, tr_key(self.app_language, "tsp_dummy.message.disabled")); + self.view.redraw(cx); + } +}