diff --git a/.cargo/config.toml b/.cargo/config.toml index 5402c0999..307b72d51 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,6 +3,15 @@ [target.'cfg(all())'] rustflags = ["--cfg", "ruma_identifiers_storage=\"Arc\""] +## LiveKit/libwebrtc requires -ObjC linker flag on macOS +[target.'cfg(target_os = "macos")'] +rustflags = ["-C", "link-args=-Wl,-ObjC"] + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=-crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=-crt-static"] ## Override the bundle/package ID that Makepad uses by default with Robrix's ID. [env] MAKEPAD_BUNDLE_IDENTIFIER = { value = "rs.robius.robrix", force = true } diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..d8d683286 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build *)" + ] + } +} diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index ee14edd5f..a707e7e64 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -125,6 +125,9 @@ jobs: with: key: macos-${{ matrix.arch }}-build-${{ hashFiles('Cargo.lock') }} + - name: Install LLVM + run: brew install llvm + - name: Build run: | cargo build --profile fast diff --git a/Cargo.lock b/Cargo.lock index a58c9c6c8..be07cd427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9244,4 +9244,4 @@ dependencies = [ "serde", "syn 2.0.106", "winnow 1.0.1", -] +] \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index f5dfc6019..b8b69fa39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,9 +66,18 @@ matrix-sdk-ui = { git = "https://github.com/Project-Robius-China/matrix-rust-sdk ruma = { git = "https://github.com/ruma/ruma", rev = "a0acf4187a7c7557d145db54bcb23b01f6295ce7", features = [ "compat-optional", "compat-unset-avatar", + "unstable-msc3401", ] } rand = "0.8.5" rangemap = "1.5.0" +## Direct rustls dep used only to install the process-level CryptoProvider +## at startup. matrix-sdk and livekit both pull in rustls 0.23 but neither +## installs a default provider; without an explicit install_default call, +## any rustls-using crate that asks for the default (e.g. LiveKit's +## webrtc-sys TLS handshake) panics with +## "Could not automatically determine the process-level CryptoProvider". +## aws-lc-rs is already in our dep tree via webrtc-sys / quinn-TSP. +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] } sanitize-filename = "0.6" serde = "1.0" serde_json = "1.0" @@ -87,7 +96,7 @@ url = "2.5.0" ## crypto provider that matrix-sdk/reqwest already use so the config type matches ## and we avoid a "no process-level CryptoProvider" panic. See ## src/proxy_config.rs::webpki_rustls_config. -rustls = { version = "0.23.37", default-features = false, features = ["ring", "std", "tls12"] } +## rustls = { version = "0.23.37", default-features = false, features = ["ring", "std", "tls12"] } webpki-roots = "1.0" ## aws-lc-sys has no pregenerated bindings for aarch64-unknown-linux-ohos, so the @@ -103,6 +112,18 @@ aws-lc-rs = { version = "1", features = ["bindgen"] } rfd = "0.15" cargo-packager-updater = "0.2" semver = "1" +## nokhwa camera capture - only for desktop platforms (core-video-sys doesn't work on iOS) +nokhwa = { version = "0.10", features = ["input-native"] } + +## LiveKit WebRTC SDK - only for desktop platforms (macOS, Linux) +## Windows is excluded due to MSVC runtime library mismatch with webrtc-sys +## rustls-tls-native-roots is required for WSS connections +[target.'cfg(all(not(any(target_os = "android", target_os = "ios", target_os = "windows"))))'.dependencies] +livekit = { version = "0.7", features = ["rustls-tls-native-roots"] } +## rodio for ringtone playback in 1:1 voice calls (incoming ring + outgoing dial tone). +## Gated to the same desktop platforms as livekit; mobile/Windows use a stub. +## "vorbis" enables OGG decoding for the bundled ringtone assets. +rodio = { version = "0.20", default-features = false, features = ["vorbis"] } ## 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/makepad b/makepad new file mode 160000 index 000000000..2cff94d01 --- /dev/null +++ b/makepad @@ -0,0 +1 @@ +Subproject commit 2cff94d01b842643699c0ee4a23c4d9462c43f8d diff --git a/resources/icons/microphone.svg b/resources/icons/microphone.svg new file mode 100644 index 000000000..4f2957aa5 --- /dev/null +++ b/resources/icons/microphone.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/icons/microphone_off.svg b/resources/icons/microphone_off.svg new file mode 100644 index 000000000..ee4a9f4ca --- /dev/null +++ b/resources/icons/microphone_off.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/icons/phone.svg b/resources/icons/phone.svg new file mode 100644 index 000000000..1ba60be90 --- /dev/null +++ b/resources/icons/phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/video.svg b/resources/icons/video.svg new file mode 100644 index 000000000..5ac9a5a63 --- /dev/null +++ b/resources/icons/video.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/sounds/ring_in.ogg b/resources/sounds/ring_in.ogg new file mode 100644 index 000000000..e69de29bb diff --git a/resources/sounds/ring_out.ogg b/resources/sounds/ring_out.ogg new file mode 100644 index 000000000..e69de29bb diff --git a/src/app.rs b/src/app.rs index c0fc867f8..e40d0d9e3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use std::{ time::Duration, }; use makepad_widgets::*; +use makepad_widgets::makepad_platform::permission::Permission; use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId, events::room::message::RoomMessageEventContent}}; use serde::{Deserialize, Serialize}; use url::Url; @@ -26,7 +27,8 @@ use crate::{ }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, register::RegisterAction, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalAction, ConfirmationModalContent, ConfirmationModalWidgetRefExt}, file_upload_modal::{FilePreviewerAction, FileUploadModalWidgetRefExt}, forward_modal::{ForwardMessageModalAction, ForwardMessageModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification, enqueue_notification, NotificationItem, NotificationAction, NotifActionStyle}, room_filter_input_bar::FilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, RoomSettingsFetchedAction, RoomAvatarUploadedAction, TimelineKind, AccountSwitchAction, current_user_id, get_client, submit_async_request, get_timeline_update_sender, end_account_switch_guard}, updater::{UpdateCheckOutcome, check_for_updates, load_skipped_update_version, save_skipped_update_version, update_release_page_url}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, - }, settings::app_preferences::{AppPreferences, AppPreferencesAction, UiZoom, effective_is_desktop} + }, settings::app_preferences::{AppPreferences, AppPreferencesAction, UiZoom, effective_is_desktop}, + voip::{VoipGlobalState, VoipAction, PipVoipOverlayWidgetRefExt}, }; use crate::shared::room_filter_search_results::{RoomFilterResultAction, RoomFilterResultTarget}; use crate::shared::room_filter_search_results::RoomFilterSearchResultsListWidgetRefExt; @@ -190,6 +192,18 @@ script_mod! { bot_binding_modal_inner := BotBindingModal {} } } + // Full-screen modal that surfaces incoming 1:1 + // voice calls. Driven by `OneOnOneUiAction` — + // opens on `ShowIncomingModal`, closes on + // `HideIncomingModal` / Accept / Decline / + // ring timeout. + incoming_call_modal := Modal { + content +: { + width: Fill, height: Fill + align: Align{x: 0.5, y: 0.4} + incoming_call_modal_inner := IncomingCallModal {} + } + } room_filter_modal := Modal { content +: { room_filter_modal_inner := RoundedShadowView { @@ -457,6 +471,9 @@ script_mod! { } } + // PiP overlay for VoIP calls (shown when switching away from active call) + pip_voip_overlay := PipVoipOverlay {} + PopupList {} // Tooltips must be shown in front of all other UI elements, @@ -784,6 +801,15 @@ impl MatchEvent for App { self.skipped_update_version = load_skipped_update_version(); self.start_auto_update_check(cx); + // Install the process-level rustls CryptoProvider before any + // TLS connection is attempted. matrix-sdk + livekit both pull + // in rustls 0.23 without selecting a provider; the first + // rustls user that hits a `get_default()` call (LiveKit's + // webrtc-sys handshake) panics if we skip this. Calling + // install_default twice returns Err — we ignore it because + // multiple modules may try (e.g. TSP init also installs). + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + log!("App::Startup: starting matrix sdk loop"); let _tokio_rt_handle = crate::sliding_sync::start_matrix_tokio().unwrap(); @@ -791,6 +817,13 @@ impl MatchEvent for App { log!("App::Startup: initializing TSP (Trust Spanning Protocol) module."); crate::tsp::tsp_init(_tokio_rt_handle).unwrap(); } + + // Initialize VoIP global state and pre-warm camera permission + video input + // enumeration at launch. Doing this here (instead of when the VoIP lobby opens) + // hides the slow AVFoundation device enumeration — especially with DAL plugins + // like the OBS Studio virtual camera — behind the home-screen load rather than + // making the user wait at "Waiting for camera permission..." in the lobby. + VoipGlobalState::initialize(cx); } fn handle_signal(&mut self, cx: &mut Cx) { @@ -806,10 +839,23 @@ impl MatchEvent for App { let keywords = std::mem::take(&mut self.pending_room_filter_keywords); self.update_room_filter_modal_results(cx, &keywords); } - } - - fn handle_audio_devices(&mut self, cx: &mut Cx, devices: &AudioDevicesEvent) { - cx.use_audio_outputs(&devices.default_output()); + // 1:1 voice call ring timer. Lives on `VoipGlobalState` because + // the call FSM survives screen navigation; firing it transitions + // the FSM to `Ended { Missed }`. + let ring_timer_fired = if cx.has_global::() { + let state = cx.get_global::(); + state.ring_timer.is_timer(event).is_some() + } else { false }; + if ring_timer_fired { + if cx.has_global::() { + let state = cx.get_global::(); + state.ring_timer = Timer::default(); + } + VoipGlobalState::apply_call_event( + cx, + crate::voip::oneonone::OneOnOneEvent::RingTimeout, + ); + } } fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { @@ -1567,6 +1613,42 @@ impl MatchEvent for App { _ => {} } + // Handle VoIP PiP overlay actions + match action.downcast_ref() { + Some(VoipAction::ShowPip { room_id }) => { + log!("App: VoipAction::ShowPip received for room {}", room_id); + self.ui.pip_voip_overlay(cx, ids!(pip_voip_overlay)).show(cx, room_id.clone()); + continue; + } + Some(VoipAction::HidePip) => { + log!("App: VoipAction::HidePip received"); + self.ui.pip_voip_overlay(cx, ids!(pip_voip_overlay)).hide(cx); + continue; + } + Some(VoipAction::ReturnToVoipTab { room_id }) => { + log!("App: VoipAction::ReturnToVoipTab received for room {}", room_id); + // Hide the PiP overlay + self.ui.pip_voip_overlay(cx, ids!(pip_voip_overlay)).hide(cx); + // Navigate back to the VoIP tab by emitting a RoomsListAction::Selected + // We need to look up the room name from RoomsList + if let Some(room_name_id) = cx.get_global::().get_room_name(room_id) { + cx.widget_action( + self.ui.widget_uid(), + RoomsListAction::Selected(SelectedRoom::Voip { room_name_id, voice_only: false }), + ); + } + self.ui.redraw(cx); + continue; + } + Some(VoipAction::PipHangup { room_id }) => { + log!("App: VoipAction::PipHangup received for room {}", room_id); + // Hide the PiP overlay - the VoipScreen will handle the actual hangup + self.ui.pip_voip_overlay(cx, ids!(pip_voip_overlay)).hide(cx); + // The action will continue to propagate to VoipScreen + } + _ => {} + } + // 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() { @@ -1651,6 +1733,10 @@ impl MatchEvent for App { } } } + + // Restore VoIP token state to global state for caching + VoipGlobalState::restore_token_state(cx, self.app_state.voip_tokens.clone()); + cx.action(MainDesktopUiAction::LoadDockFromAppState); continue; } @@ -2313,6 +2399,8 @@ impl AppMain for App { crate::join_leave_room_modal::script_mod(vm); crate::verification_modal::script_mod(vm); crate::profile::script_mod(vm); + crate::voip::voip_screen::script_mod(vm); + crate::voip::pip_overlay::script_mod(vm); crate::home::script_mod(vm); crate::login::script_mod(vm); crate::register::script_mod(vm); @@ -2331,6 +2419,18 @@ impl AppMain for App { } } + // Update VoipGlobalState from camera-related platform events at the App + // level so the state is populated even before a VoipScreen widget exists. + match event { + Event::PermissionResult(result) if result.permission == Permission::Camera => { + VoipGlobalState::handle_permission_result(cx, result.status); + } + Event::VideoInputs(ev) => { + VoipGlobalState::handle_video_inputs(cx, ev); + } + _ => {} + } + self.handle_ui_zoom_shortcuts(cx, event); // Forward events to the MatchEvent trait implementation. @@ -2837,6 +2937,12 @@ impl App { .set_displayed_space(cx, space_name_id); id!(space_lobby_view) } + SelectedRoom::Voip { room_name_id, .. } => { + // VoIP uses RoomScreen with VoIP as main content (no timeline) + let room_screen = self.ui.room_screen(cx, ids!(room_screen_0)); + room_screen.set_voip_visible(cx, true, Some(room_name_id.room_id().clone())); + id!(room_view_0) + } }; // Set the generic StackNavigation header title. This header is only @@ -2895,6 +3001,12 @@ pub struct AppState { pub adding_account: bool, /// Local configuration and UI state for bot-assisted room binding. pub bot_settings: BotSettingsState, + /// The room ID for VoIP calls, set when navigating to VoIP screen from a call notification. + #[serde(skip)] + pub voip_room_id: Option, + /// Cached VoIP tokens (OpenID and LiveKit JWT) for faster reconnection. + #[serde(default)] + pub voip_tokens: crate::voip::VoipTokenState, /// Global source of truth for agent identities, keyed by agent MXID. /// /// Persisted per Matrix account. Old saved states that predate this field @@ -3522,6 +3634,17 @@ pub enum SelectedRoom { Space { space_name_id: RoomNameId, }, + Voip { + room_name_id: RoomNameId, + /// When true, open VoipScreen in voice-only mode: no camera + /// preview in the lobby, microphone-only publish, avatar tiles + /// instead of video tiles. Driven by the voice-call button. + /// `#[serde(default)]` keeps backward compat with persisted + /// app state from before this field existed (defaults to false + /// = the original group/video call behavior). + #[serde(default)] + voice_only: bool, + }, } impl SelectedRoom { @@ -3531,6 +3654,8 @@ impl SelectedRoom { SelectedRoom::InvitedRoom { room_name_id } => room_name_id.room_id(), SelectedRoom::Space { space_name_id } => space_name_id.room_id(), SelectedRoom::Thread { room_name_id, .. } => room_name_id.room_id(), + SelectedRoom::Voip { room_name_id, .. } => room_name_id.room_id(), + } } @@ -3540,6 +3665,26 @@ impl SelectedRoom { SelectedRoom::InvitedRoom { room_name_id } => room_name_id, SelectedRoom::Space { space_name_id } => space_name_id, SelectedRoom::Thread { room_name_id, .. } => room_name_id, + SelectedRoom::Voip { room_name_id, .. } => room_name_id, + } + } + + /// Returns the `TimelineKind` for this room, if applicable. + /// Returns `None` for invited rooms, spaces, and VoIP rooms which don't have timelines. + pub fn timeline_kind(&self) -> Option { + match self { + SelectedRoom::JoinedRoom { room_name_id } => { + Some(TimelineKind::MainRoom { room_id: room_name_id.room_id().clone() }) + } + SelectedRoom::Thread { room_name_id, thread_root_event_id } => { + Some(TimelineKind::Thread { + room_id: room_name_id.room_id().clone(), + thread_root_event_id: thread_root_event_id.clone(), + }) + } + SelectedRoom::InvitedRoom { .. } => None, + SelectedRoom::Space { .. } => None, + SelectedRoom::Voip { .. } => None, } } @@ -3570,6 +3715,12 @@ impl SelectedRoom { &format!("{}##{}", room_name_id.room_id(), thread_root_event_id) ) } + SelectedRoom::Voip { room_name_id, .. } => { + // VoIP tabs get a distinct ID to differentiate from normal room tabs + LiveId::from_str( + &format!("{}##voip", room_name_id.room_id()) + ) + } other => LiveId::from_str(other.room_id().as_str()), } } @@ -3581,26 +3732,7 @@ impl SelectedRoom { SelectedRoom::InvitedRoom { room_name_id } => room_name_id.to_string(), SelectedRoom::Space { space_name_id } => format!("[Space] {space_name_id}"), SelectedRoom::Thread { room_name_id, .. } => format!("[Thread] {room_name_id}"), - } - } - - /// Returns the `TimelineKind` for this selected room. - /// - /// Returns `None` for `InvitedRoom` and `Space` variants, as they don't have timelines. - pub fn timeline_kind(&self) -> Option { - match self { - SelectedRoom::JoinedRoom { room_name_id } => { - Some(TimelineKind::MainRoom { - room_id: room_name_id.room_id().clone(), - }) - } - SelectedRoom::Thread { room_name_id, thread_root_event_id } => { - Some(TimelineKind::Thread { - room_id: room_name_id.room_id().clone(), - thread_root_event_id: thread_root_event_id.clone(), - }) - } - SelectedRoom::InvitedRoom { .. } | SelectedRoom::Space { .. } => None, + SelectedRoom::Voip { room_name_id, .. } => format!("[VoIP] {room_name_id}"), } } } @@ -3664,6 +3796,7 @@ impl SavedDockState { impl PartialEq for SelectedRoom { fn eq(&self, other: &Self) -> bool { match (self, other) { + // Threads are equal if room_id and thread_root_event_id match ( SelectedRoom::Thread { room_name_id: lhs_room_name_id, @@ -3677,7 +3810,19 @@ impl PartialEq for SelectedRoom { lhs_room_name_id.room_id() == rhs_room_name_id.room_id() && lhs_thread_root_event_id == rhs_thread_root_event_id } + // Thread is never equal to non-Thread (SelectedRoom::Thread { .. }, _) | (_, SelectedRoom::Thread { .. }) => false, + // VoIP rooms are equal only to other VoIP rooms with same room_id. + // `voice_only` is intentionally ignored: clicking voice in a + // room that already has a video call tab open should focus + // that tab rather than opening a duplicate. + ( + SelectedRoom::Voip { room_name_id: lhs, .. }, + SelectedRoom::Voip { room_name_id: rhs, .. }, + ) => lhs.room_id() == rhs.room_id(), + // VoIP is never equal to non-VoIP (even if same room_id) + (SelectedRoom::Voip { .. }, _) | (_, SelectedRoom::Voip { .. }) => false, + // All other variants compare by room_id only _ => self.room_id() == other.room_id(), } } diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index 94b6df9ac..3c8dd5a72 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -645,6 +645,7 @@ impl HomeScreen { | SelectedTab::Home => id!(home_page), SelectedTab::Settings => id!(settings_page), SelectedTab::AddRoom => id!(add_room_page), + SelectedTab::VoIP => id!(voip_page), SelectedTab::Directory => id!(directory_page), }, ) diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 1d669036d..085a656a8 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -10,6 +10,7 @@ use crate::{ persistence, sliding_sync::{AccountSwitchAction, current_user_id, get_client}, utils::RoomNameId, + voip::{VoipAction, VoipGlobalState, VoipScreenWidgetRefExt}, }; use super::{invite_screen::InviteScreenWidgetRefExt, room_screen::RoomScreenWidgetRefExt, rooms_list::RoomsListAction}; @@ -70,6 +71,7 @@ script_mod! { room_screen := mod.widgets.RoomScreen {} invite_screen := mod.widgets.InviteScreen {} space_lobby_screen := mod.widgets.SpaceLobbyScreen {} + voip_screen := mod.widgets.VoipScreen {} } } } @@ -189,6 +191,16 @@ impl MainDesktopUI { space_name_id, ); } + SelectedRoom::Voip { room_name_id, voice_only } => { + // VoIP tabs use VoipScreen directly, not RoomScreen. + // `voice_only` switches the screen into voice-only mode + // (avatar instead of camera preview, no video publish). + widget.as_voip_screen().initialize( + cx, + room_name_id.room_id().clone(), + *voice_only, + ); + } } } @@ -218,6 +230,7 @@ impl MainDesktopUI { let kind = match &room { SelectedRoom::JoinedRoom { .. } | SelectedRoom::Thread { .. } => id!(room_screen), + SelectedRoom::Voip { .. } => id!(voip_screen), // VoIP uses standalone VoipScreen SelectedRoom::InvitedRoom { .. } => id!(invite_screen), SelectedRoom::Space { .. } => id!(space_lobby_screen), }; @@ -253,8 +266,10 @@ impl MainDesktopUI { /// Closes a tab in the dock and focuses on the latest open room. fn close_tab(&mut self, cx: &mut Cx, tab_id: LiveId) { + log!("close_tab called for tab_id: {:?}", tab_id); let dock = self.view.dock(cx, ids!(dock)); if let Some(room_being_closed) = self.open_rooms.get(&tab_id) { + log!("Found room to close: {:?}", room_being_closed); self.room_order.retain(|sr| sr != room_being_closed); if self.open_rooms.len() > 1 { @@ -273,15 +288,20 @@ impl MainDesktopUI { } } else { // If there is no room to focus, notify app to reset the selected room in the app state + log!("No more rooms to focus, selecting home_tab"); cx.action(AppStateAction::FocusNone); dock.select_tab(cx, id!(home_tab)); self.most_recently_selected_room = None; } + } else { + log!("Room not found in open_rooms for tab_id: {:?}", tab_id); } + log!("Calling dock.close_tab for tab_id: {:?}", tab_id); dock.close_tab(cx, tab_id); self.tab_to_close = None; self.open_rooms.remove(&tab_id); + log!("Tab closed, open_rooms now has {} tabs", self.open_rooms.len()); } /// Closes every open tab belonging to the given room, including thread tabs. @@ -502,6 +522,25 @@ impl WidgetMatchEvent for MainDesktopUI { match widget_action.cast() { // Whenever a tab (except for the home_tab) is pressed, notify the app state. DockAction::TabWasPressed(tab_id) => { + // Check if we're switching FROM a VoIP tab with active call -> show PiP + // Or if we're switching TO a VoIP tab -> hide PiP + let prev_room = self.most_recently_selected_room.clone(); + let new_room = self.open_rooms.get(&tab_id).cloned(); + + // Detect switch TO VoIP tab -> hide PiP + if let Some(SelectedRoom::Voip { .. }) = &new_room { + log!("MainDesktopUI: Switching TO VoIP tab, hiding PiP"); + cx.action(VoipAction::HidePip); + } + // Detect switch FROM VoIP tab with active call -> show PiP + else if let Some(SelectedRoom::Voip { room_name_id, .. }) = &prev_room { + let room_id = room_name_id.room_id(); + if VoipGlobalState::is_call_active(cx, room_id) { + log!("MainDesktopUI: Switching FROM VoIP tab with active call, showing PiP"); + cx.action(VoipAction::ShowPip { room_id: room_id.clone() }); + } + } + if tab_id == id!(home_tab) { cx.action(AppStateAction::FocusNone); self.most_recently_selected_room = None; @@ -518,6 +557,15 @@ impl WidgetMatchEvent for MainDesktopUI { should_save_dock_action = true; } DockAction::TabCloseWasPressed(tab_id) => { + // If closing a VoIP tab, call hangup on the VoipScreen first and hide PiP + if let Some(SelectedRoom::Voip { .. }) = self.open_rooms.get(&tab_id) { + log!("MainDesktopUI: Closing VoIP tab via dock X button, calling hangup and hiding PiP"); + let dock = self.view.dock(cx, ids!(dock)); + let widget = dock.item(tab_id); + widget.as_voip_screen().hangup(cx); + // Hide PiP when VoIP tab is closed + cx.action(VoipAction::HidePip); + } self.tab_to_close = Some(tab_id); self.close_tab(cx, tab_id); self.redraw(cx); @@ -559,6 +607,7 @@ impl WidgetMatchEvent for MainDesktopUI { // Note that this cannot be performed within draw_walk() as the draw flow prevents from // performing actions that would trigger a redraw, and the Dock internally performs (and expects) // a redraw to be happening in order to draw the tab content. + log!("MainDesktopUI: RoomsListAction::Selected received for {:?}", selected_room); self.focus_or_create_tab(cx, selected_room.clone()); } RoomsListAction::InviteAccepted { room_name_id } => { @@ -568,6 +617,27 @@ impl WidgetMatchEvent for MainDesktopUI { RoomsListAction::None => { } } + // Handle VoIP actions + if let Some(VoipAction::Close(room_id)) = action.downcast_ref() { + log!("MainDesktopUI: VoipAction::Close received for room {:?}", room_id); + // Find the VoIP tab for this room and close it + let voip_tab_id = LiveId::from_str(&format!("{}##voip", room_id)); + log!("Looking for voip_tab_id: {:?}, open_rooms has {} tabs", voip_tab_id, self.open_rooms.len()); + if self.open_rooms.contains_key(&voip_tab_id) { + log!("Found VoIP tab, closing it"); + self.tab_to_close = Some(voip_tab_id); + self.close_tab(cx, voip_tab_id); + self.redraw(cx); + should_save_dock_action = true; + } else { + log!("VoIP tab NOT found in open_rooms! Trying to close anyway..."); + // Try closing the tab anyway via the dock + let dock = self.view.dock(cx, ids!(dock)); + dock.close_tab(cx, voip_tab_id); + self.redraw(cx); + } + } + // Handle our own actions related to dock updates that we have previously emitted. match action.downcast_ref() { Some(MainDesktopUiAction::LoadDockFromAppState) => { diff --git a/src/home/main_mobile_ui.rs b/src/home/main_mobile_ui.rs index f06118447..c1c33dfab 100644 --- a/src/home/main_mobile_ui.rs +++ b/src/home/main_mobile_ui.rs @@ -116,6 +116,14 @@ impl Widget for MainMobileUI { .room_screen(cx, ids!(room_screen)) .set_displayed_room(cx, room_name_id, Some(thread_root_event_id.clone())); } + Some(SelectedRoom::Voip { room_name_id, .. }) => { + show_welcome = false; + show_room = true; // VoIP uses RoomScreen with VoIP as main content + show_invite = false; + show_space_lobby = false; + let room_screen = self.view.room_screen(cx, ids!(room_screen)); + room_screen.set_voip_visible(cx, true, Some(room_name_id.room_id().clone())); + } None => { show_welcome = true; show_room = false; diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index abe8783b4..8aa0f8124 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -845,6 +845,7 @@ pub enum SelectedTab { Home, AddRoom, Settings, + VoIP, /// The public room directory browser screen. /// Entered from the sidebar header's compass button; no dedicated tab in the /// navigation bar (so no button to keep selected here). @@ -896,11 +897,13 @@ pub enum NavigationBarAction { CloseSettings, /// Go the space screen for the given space. GoToSpace { space_name_id: RoomNameId }, + // /// Go to the VoIP call screen. + // GoToVoip, // TODO: add GoToAlertsInbox, once we add that button/screen /// The given tab was selected as the active top-level view. - /// This is needed to ensure that the proper tab is marked as selected. + /// This is needed to ensure that the proper tab is marked as selected. TabSelected(SelectedTab), /// Toggle whether the SpacesBar is shown, i.e., show/hide it. /// This is only applicable in the Mobile view mode, because the SpacesBar diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index dde61431b..1c4b0d5cf 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -53,6 +53,7 @@ use crate::shared::video_message_player::VideoMessagePlayerWidgetRefExt; use crate::event_preview::{summarize_audio_message, summarize_video_message}; use crate::shared::animated_image::{AnimatedImageRef, AnimatedImageWidgetRefExt}; use crate::settings::app_preferences::effective_is_desktop; +use crate::voip::voip_screen::VoipScreenWidgetExt; use rangemap::RangeSet; @@ -2924,44 +2925,9 @@ script_mod! { } text: "" } - - avatar_row := mod.widgets.AvatarRow {} } - } - } - - // The summary row shown for a collapsed group of adjacent small state events. - mod.widgets.SmallStateEventsSummary = View { - width: Fill, - height: Fit, - flow: Right, - margin: Inset{ top: 4.0, bottom: 4.0} - padding: Inset{ left: 50.0, top: 1.0, bottom: 1.0, right: 10.0 } - spacing: 7.0 - cursor: MouseCursor.Default - - summary_label := Label { - width: Fit, - height: Fit - flow: Right - margin: Inset{top: 1.5} - draw_text +: { - text_style: SMALL_STATE_TEXT_STYLE {} - color: (SMALL_STATE_TEXT_COLOR) - } - text: "" - } - - spacer := View { - width: Fill - height: Fit - } - state_group_toggle_button := mod.widgets.SmallStateGroupToggleButton { - width: Fit - height: Fit - margin: Inset{ left: 2.0, top: 1.0 } - text: "" + avatar_row := mod.widgets.AvatarRow {} } } @@ -3093,14 +3059,14 @@ script_mod! { // Floating circular button that opens the `ThreadsSlidingPane`. // Mirrors `SearchMessagesButton`'s layout (Fill/Fill overlay aligned - // top-right) but reserves 96px on the right so it sits to the left of the - // search (48px) and info (rightmost) buttons. + // top-right) but reserves 192px on the right so it sits to the left of the + // voice-call (144px), video-call (96px), search (48px) and info buttons. mod.widgets.ThreadsButton = #(ThreadsButton::register_widget(vm)) { width: Fill, height: Fill, flow: Overlay, align: Align{x: 1.0, y: 0.0}, - padding: Inset{right: 96}, + padding: Inset{right: 192}, visible: true, View { @@ -3178,6 +3144,90 @@ script_mod! { } } + // Floating circular call button — voice only. Sits at right: 144, between + // threads (192px) and video-call (96px). + mod.widgets.VoiceCallButton = #(VoiceCallButton::register_widget(vm)) { + width: Fill, + height: Fill, + flow: Overlay, + align: Align{x: 1.0, y: 0.0}, + padding: Inset{right: 144}, + visible: true, + + View { + width: 65, height: 65, + align: Align{x: 0.5, y: 0.0}, + flow: Overlay, + + inner_button := RobrixIconButton { + spacing: 0, + width: 40, height: 40, + align: Align{x: 0.5, y: 0.5}, + margin: Inset{top: 8}, + + draw_icon +: { + svg: (ICON_PHONE), + color: #555, + } + icon_walk: Walk{width: 18, height: 18} + + draw_bg +: { + background_color: #edededce, + background_color_hover: #d0d0d0ce, + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size); + let c = self.rect_size * 0.5; + sdf.circle(c.x, c.x, c.x); + sdf.fill_keep(mix(self.background_color, self.background_color_hover, self.hover)); + return sdf.result + } + } + } + } + } + + // Floating circular call button — video/group call. Sits at right: 96, + // between voice-call (144px) and search (48px). + mod.widgets.VideoCallButton = #(VideoCallButton::register_widget(vm)) { + width: Fill, + height: Fill, + flow: Overlay, + align: Align{x: 1.0, y: 0.0}, + padding: Inset{right: 96}, + visible: true, + + View { + width: 65, height: 65, + align: Align{x: 0.5, y: 0.0}, + flow: Overlay, + + inner_button := RobrixIconButton { + spacing: 0, + width: 40, height: 40, + align: Align{x: 0.5, y: 0.5}, + margin: Inset{top: 8}, + + draw_icon +: { + svg: (ICON_VIDEO), + color: #555, + } + icon_walk: Walk{width: 18, height: 18} + + draw_bg +: { + background_color: #edededce, + background_color_hover: #d0d0d0ce, + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size); + let c = self.rect_size * 0.5; + sdf.circle(c.x, c.x, c.x); + sdf.fill_keep(mix(self.background_color, self.background_color_hover, self.hover)); + return sdf.result + } + } + } + } + } + mod.widgets.ThreadsSlidingPane = #(ThreadsSlidingPane::register_widget(vm)) { visible: false, flow: Overlay, @@ -4608,6 +4658,7 @@ script_mod! { CondensedStickerMessage := mod.widgets.CondensedStickerMessage {} SmallStateEvent := mod.widgets.SmallStateEvent {} SmallStateEventsSummary := mod.widgets.SmallStateEventsSummary {} + RtcNotificationEvent := mod.widgets.RtcNotificationEvent {} Empty := mod.widgets.Empty {} EncryptionNotice := mod.widgets.EncryptionNotice {} DateDivider := mod.widgets.DateDivider {} @@ -4623,8 +4674,8 @@ script_mod! { // Clicking it opens the `room_info_sliding_pane` (desktop only). info_button := mod.widgets.InfoButton { } - // Floating threads button at the top-right, sitting left of the search - // and info buttons. Clicking it opens the `threads_sliding_pane`. + // Floating threads button at the top-right, sitting left of the voice/video + // call, search, and info buttons. Clicking it opens the `threads_sliding_pane`. threads_button := mod.widgets.ThreadsButton { } // Floating search button at the top-right (mirrors jump-to-bottom). @@ -4634,6 +4685,14 @@ script_mod! { // so it composites OVER the timeline's `new_batch` cards. Defining it // inside this Timeline overlay let those cards z-fight through it. search_messages_button := mod.widgets.SearchMessagesButton { } + + // Floating voice-call button (phone icon), sitting between threads and + // video-call. Desktop only. + voice_call_button := mod.widgets.VoiceCallButton { } + + // Floating video-call button (camera icon), sitting between voice-call + // and search. Desktop only. + video_call_button := mod.widgets.VideoCallButton { } } mod.widgets.TranslationLangPopupButton = RobrixIconButton { @@ -4818,6 +4877,51 @@ script_mod! { // as `room_info_sliding_pane`, which has no z-order glitch. search_messages_pane := mod.widgets.SearchMessagesSlidingPane { } + // Active call banner - shown when there's an ongoing call in the room + active_call_banner := View { + width: Fill + height: Fit + visible: false + padding: Inset{top: 8, bottom: 8, left: 16, right: 16} + align: Align{x: 0.5, y: 0.0} + show_bg: true + draw_bg +: { + color: #7b1fa2 + } + + { + width: Fit + height: Fit + flow: Right + spacing: 12 + align: Align{y: 0.5} + + { + icon_path: dep("crate://self/resources/icons/video.svg") + draw_icon: { color: #fff } + icon_size: vec2(20.0, 20.0) + } + +