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