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)
+ }
+
+