diff --git a/.cargo/config.toml b/.cargo/config.toml index 1e4c80007..10deaca5b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,3 +3,12 @@ [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"] \ No newline at end of file diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 7aee76ee6..559708920 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -123,9 +123,16 @@ jobs: with: key: macos-${{ matrix.arch }}-build-${{ hashFiles('Cargo.lock') }} + - name: Install LLVM + run: brew install llvm + - name: Build env: RUSTFLAGS: "-D warnings" + # Use Homebrew LLVM for C++ compilation to avoid cxx contiguous_range + # static_assert issue with Apple's libc++ + CXX: ${{ matrix.arch == 'arm64' && '/opt/homebrew/opt/llvm/bin/clang++' || '/usr/local/opt/llvm/bin/clang++' }} + CC: ${{ matrix.arch == 'arm64' && '/opt/homebrew/opt/llvm/bin/clang' || '/usr/local/opt/llvm/bin/clang' }} run: | cargo build --profile fast diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c0dd613dc..9aa87d0dc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,6 +50,10 @@ jobs: runs-on: macos-14 ## avoids having to install Linux deps steps: - uses: actions/checkout@v4 + - name: Select Xcode version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.1.0' - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy diff --git a/Cargo.toml b/Cargo.toml index 564970744..9e951c631 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ matrix-sdk-ui = { git = "https://github.com/project-robius/matrix-rust-sdk", bra 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" @@ -85,6 +86,14 @@ url = "2.5.0" 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"] } ## 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/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/src/app.rs b/src/app.rs index f4123db8e..e0f81ca59 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId, events::room::message::RoomMessageEventContent}}; use serde::{Deserialize, Serialize}; +use makepad_widgets::makepad_platform::permission::Permission; use crate::{ avatar_cache::{self, AvatarCacheEntry, clear_avatar_cache}, home::{ add_room::{CreateRoomModalAction, CreateRoomModalWidgetRefExt}, @@ -18,7 +19,8 @@ use crate::{ }, 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}, file_upload_modal::{FilePreviewerAction, FileUploadModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::FilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, AccountSwitchAction, current_user_id, submit_async_request, get_timeline_update_sender}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, - } + }, + voip::{VoipGlobalState, VoipAction, PipVoipOverlayWidgetRefExt}, }; script_mod! { @@ -311,6 +313,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, @@ -630,19 +635,9 @@ impl MatchEvent for App { log!("App::Startup: initializing TSP (Trust Spanning Protocol) module."); crate::tsp::tsp_init(_tokio_rt_handle).unwrap(); } - } - 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); - } + // Initialize VoIP global state (camera permissions, video inputs) + VoipGlobalState::initialize(cx); } fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { @@ -1007,6 +1002,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 }), + ); + } + 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() { @@ -1044,6 +1075,10 @@ impl MatchEvent for App { self.app_state.logged_in = logged_in_actual; // Initialize the global translation config so RoomInputBar can access it. crate::room::translation::set_global_config(&self.app_state.translation); + + // 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; } @@ -1458,6 +1493,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::logout::script_mod(vm); @@ -1472,6 +1509,8 @@ impl AppMain for App { error!("Failed to save window state. Error: {e}"); } if let Some(user_id) = current_user_id() { + // Get the latest VoIP token state from global state before saving + self.app_state.voip_tokens = VoipGlobalState::get_token_state(cx); let app_state = self.app_state.clone(); if let Err(e) = persistence::save_app_state(app_state, user_id) { error!("Failed to save app state. Error: {e}"); @@ -1498,6 +1537,17 @@ impl AppMain for App { } } + // Handle VoIP-related events at app level (before VoipScreen is shown) + 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); + } + _ => {} + } + // Forward events to the MatchEvent trait implementation. self.match_event(cx, event); let scope = &mut Scope::with_data(&mut self.app_state); @@ -1961,6 +2011,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 header title for the view being pushed. @@ -2025,6 +2081,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, /// Translation API configuration. #[serde(default)] pub translation: crate::room::translation::TranslationConfig, @@ -2316,6 +2378,9 @@ pub enum SelectedRoom { Space { space_name_id: RoomNameId, }, + Voip { + room_name_id: RoomNameId, + }, } impl SelectedRoom { @@ -2325,6 +2390,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(), + } } @@ -2334,6 +2401,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, } } @@ -2364,6 +2451,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()), } } @@ -2375,26 +2468,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}"), } } } @@ -2402,6 +2476,7 @@ impl SelectedRoom { 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, @@ -2415,7 +2490,16 @@ 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 + ( + 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 bb7cb03bb..c3d7d6b69 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -525,6 +525,7 @@ impl HomeScreen { | SelectedTab::Home => id!(home_page), SelectedTab::Settings => id!(settings_page), SelectedTab::AddRoom => id!(add_room_page), + SelectedTab::VoIP => id!(voip_page), }, ) } diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index be32c291d..354e2f944 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}, logout::logout_confirm_modal::LogoutAction, sliding_sync::AccountSwitchAction, 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, voip::{VoipAction, VoipGlobalState, VoipScreenWidgetRefExt}}; use super::{invite_screen::InviteScreenWidgetRefExt, room_screen::RoomScreenWidgetRefExt, rooms_list::RoomsListAction}; script_mod! { @@ -62,6 +62,7 @@ script_mod! { room_screen := mod.widgets.RoomScreen {} invite_screen := mod.widgets.InviteScreen {} space_lobby_screen := mod.widgets.SpaceLobbyScreen {} + voip_screen := mod.widgets.VoipScreen {} } } } @@ -176,6 +177,10 @@ impl MainDesktopUI { space_name_id, ); } + SelectedRoom::Voip { room_name_id } => { + // VoIP tabs use VoipScreen directly, not RoomScreen + widget.as_voip_screen().initialize(cx, room_name_id.room_id().clone()); + } } } @@ -205,6 +210,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), }; @@ -240,8 +246,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 { @@ -260,15 +268,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 all tabs @@ -460,6 +473,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; @@ -476,6 +508,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); @@ -518,6 +559,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 } => { @@ -527,6 +569,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..3ecc6842d 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 4e9c096e4..6c0d8d8c3 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -479,6 +479,15 @@ impl Widget for NavigationTabBar { SelectedTab::Home => self.view.radio_button(cx, ids!(home_button)).select(cx, scope), SelectedTab::AddRoom => self.view.radio_button(cx, ids!(add_room_button)).select(cx, scope), SelectedTab::Settings => self.view.radio_button(cx, ids!(settings_button)).select(cx, scope), + SelectedTab::VoIP => { + // VoIP doesn't have a dedicated button in the tab bar, + // so just deselect all buttons + for rb in radio_button_set.iter() { + if let Some(mut rb_inner) = rb.borrow_mut() { + rb_inner.animator_play(cx, ids!(active.off)); + } + } + } SelectedTab::Space { .. } => { for rb in radio_button_set.iter() { if let Some(mut rb_inner) = rb.borrow_mut() { @@ -506,6 +515,7 @@ pub enum SelectedTab { Home, AddRoom, Settings, + VoIP, // AlertsInbox, Space { space_name_id: RoomNameId }, } @@ -551,11 +561,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 7e6ee4f38..a190af8e4 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -41,6 +41,7 @@ use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; use crate::room::room_input_bar::RoomInputBarWidgetExt; use crate::shared::mentionable_text_input::MentionableTextInputAction; +use crate::voip::voip_screen::VoipScreenWidgetExt; use rangemap::RangeSet; @@ -858,6 +859,85 @@ script_mod! { } + // The view used for RTC notification events (call invites, call notifications). + // Displays a call notification with a "Join Call" button. + mod.widgets.RtcNotificationEvent = View { + width: Fill, + height: Fit, + flow: Right, + margin: Inset{ top: 8.0, bottom: 8.0 } + padding: Inset{ top: 8.0, bottom: 8.0, left: 10.0, right: 10.0 } + spacing: 0.0 + cursor: MouseCursor.Default + + show_bg: true + draw_bg +: { + color: #f0e6ff + } + + body := View { + width: Fill, + height: Fit + flow: Right, + padding: Inset{ left: 7.0, top: 2.0, bottom: 2.0 } + spacing: 10.0 + align: Align{y: 0.5} + + left_container := View { + align: Align{x: 0.5, y: 0} + width: 70.0, + height: Fit + + timestamp := Timestamp { + margin: Inset{top: 3} + } + } + + avatar := Avatar { + width: 24., + height: 24., + margin: 0 + + text_view +: { + text +: { + draw_text +: { + text_style: TITLE_TEXT { font_size: 9.0 } + } + } + } + } + + content := Label { + width: Fit, + height: Fit + margin: Inset{top: 2.5} + draw_text +: { + text_style: SMALL_STATE_TEXT_STYLE {}, + color: #333 + } + text: "" + } + + View { width: Fill, height: 1 } + + join_call_button := RobrixPositiveIconButton { + padding: 6.0 + draw_bg +: { + border_size: 0.75 + color: #7b1fa2 + } + draw_text +: { + color: #fff + text_style: SMALL_STATE_TEXT_STYLE {} + } + text: "Join Call" + } + + avatar_row := mod.widgets.AvatarRow {} + } + } + + // The view used for each day divider in a room's timeline. // The date text is centered between two horizontal lines. mod.widgets.DateDivider = View { @@ -1874,6 +1954,7 @@ script_mod! { ImageMessage := mod.widgets.ImageMessage {} CondensedImageMessage := mod.widgets.CondensedImageMessage {} SmallStateEvent := mod.widgets.SmallStateEvent {} + RtcNotificationEvent := mod.widgets.RtcNotificationEvent {} Empty := mod.widgets.Empty {} DateDivider := mod.widgets.DateDivider {} ReadMarker := mod.widgets.ReadMarker {} @@ -2004,6 +2085,76 @@ script_mod! { threads_sliding_pane := mod.widgets.ThreadsSlidingPane { } room_info_sliding_pane := mod.widgets.RoomInfoSlidingPane { } + // 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) + } + +