From 1da348b014b4ee30d6a909853c899ef20cb6bb82 Mon Sep 17 00:00:00 2001 From: Gintautas Kazlauskas Date: Wed, 21 Oct 2026 03:33:46 +0300 Subject: [PATCH 1/2] feat: explore public spaces, and a Discord-style space menu A compass in the spaces rail opens a directory of public spaces: search, read another server's directory, peek at a space's rooms, and join the space or individual rooms. join_room takes `via` servers so rooms found on other homeservers can be joined. Joins get a 5-minute timeout of their own (the SDK's 30s cut off federated joins the server went on to finish), and error messages now include their underlying cause instead of a bare "error sending request". Right-click or long-press a space for mark as read, invite, mute, notification level, hide muted rooms, browse rooms, leave (in red, with an option to take rooms only that space holds), and copy space id. Mute and notification level apply per room, since Matrix has no space-level setting; room summaries now carry each room's notify mode. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands.rs | 96 ++++- src-tauri/src/dto.rs | 34 ++ src-tauri/src/error.rs | 22 +- src-tauri/src/lib.rs | 4 + src-tauri/src/matrix/auth.rs | 14 +- src-tauri/src/matrix/directory.rs | 177 ++++++++++ src-tauri/src/matrix/mod.rs | 1 + src-tauri/src/matrix/rooms.rs | 106 +++++- src/App.tsx | 4 + src/components/ContextMenu.tsx | 375 ++++++++++++++++++++ src/components/CreateRoom.tsx | 2 +- src/components/ExploreSpaces.tsx | 567 ++++++++++++++++++++++++++++++ src/components/RoomList.tsx | 7 +- src/components/SpaceDialogs.tsx | 239 +++++++++++++ src/components/SpacesRail.tsx | 201 ++++++++++- src/lib/bindings/DirectoryPage.ts | 12 + src/lib/bindings/NotifyMode.ts | 6 + src/lib/bindings/PublicRoom.ts | 19 + src/lib/bindings/RoomSummary.ts | 8 +- src/lib/bindings/index.ts | 3 + src/lib/ipc.ts | 33 +- src/lib/notify.test.ts | 1 + src/lib/settings.ts | 3 + src/lib/types.ts | 2 + src/store/index.ts | 79 ++++- src/store/spaces.test.ts | 86 +++++ 26 files changed, 2055 insertions(+), 46 deletions(-) create mode 100644 src-tauri/src/matrix/directory.rs create mode 100644 src/components/ContextMenu.tsx create mode 100644 src/components/ExploreSpaces.tsx create mode 100644 src/components/SpaceDialogs.tsx create mode 100644 src/lib/bindings/DirectoryPage.ts create mode 100644 src/lib/bindings/NotifyMode.ts create mode 100644 src/lib/bindings/PublicRoom.ts create mode 100644 src/store/spaces.test.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 78546af..071a910 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,11 +8,11 @@ use tauri::{AppHandle, Manager, State}; use crate::{ dto::{ - HomeserverInfo, RegisterAuth, RegisterEmailToken, RegisterStep, RoomMemberDto, RoomsSnapshot, + HomeserverInfo, NotifyMode, RegisterAuth, RegisterEmailToken, RegisterStep, RoomMemberDto, RoomsSnapshot, SendOptions, SessionInfo, SpaceSummary, StickerOptions, TimelineItemDto, }, error::{Error, Result}, - matrix::{auth, core::AppState, media, packs, presence, profile, rooms, timeline}, + matrix::{auth, core::AppState, directory, media, packs, presence, profile, rooms, timeline}, rtc, verification, }; @@ -157,9 +157,36 @@ pub async fn create_room( rooms::create(&*state.core().await?, room).await } +/// Join by alias or ID. `via` names servers to try, for a room ID ours +/// doesn't know yet — one found in another server's directory, say. #[tauri::command] -pub async fn join_room(state: State<'_, AppState>, alias_or_id: String) -> Result { - rooms::join(&*state.core().await?, &alias_or_id).await +pub async fn join_room( + state: State<'_, AppState>, + alias_or_id: String, + via: Option>, +) -> Result { + rooms::join(&*state.core().await?, &alias_or_id, &via.unwrap_or_default()).await +} + +/// One page of public spaces from a server's directory — ours when `server` is +/// omitted. +#[tauri::command] +pub async fn get_public_spaces( + state: State<'_, AppState>, + server: Option, + search: Option, + since: Option, +) -> Result { + directory::public_spaces(&*state.core().await?, server, search, since).await +} + +/// The rooms inside a space, readable before joining it. +#[tauri::command] +pub async fn get_space_rooms( + state: State<'_, AppState>, + space_id: String, +) -> Result> { + directory::space_rooms(&*state.core().await?, &parse_room_id(&space_id)?).await } /// Leave a room. Doesn't forget it — see `rooms::leave`. @@ -224,6 +251,67 @@ pub async fn set_room_muted( Ok(()) } +/// Set the notification level on several rooms at once — every room in a +/// space, say. `None` returns them to the default. +#[tauri::command] +pub async fn set_rooms_notify_mode( + app: AppHandle, + state: State<'_, AppState>, + room_ids: Vec, + mode: Option, +) -> Result<()> { + let core = state.core().await?; + each_room(&app, &core, &room_ids, |core, room_id| async move { + match mode { + Some(mode) => rooms::set_notify_mode(core, &room_id, mode).await, + // No mode: back to the default, which is what unmuting means. + None => rooms::set_muted(core, &room_id, false).await, + } + }) + .await +} + +/// Mark several rooms read at once — a whole space, say — without opening any. +#[tauri::command] +pub async fn mark_rooms_read( + app: AppHandle, + state: State<'_, AppState>, + room_ids: Vec, +) -> Result<()> { + let core = state.core().await?; + each_room(&app, &core, &room_ids, |core, room_id| async move { + rooms::mark_read(core, &room_id).await + }) + .await +} + +/// Run `action` on each room in turn, then refresh it in the sidebar. +/// +/// One at a time: push rules are a single account-wide ruleset the SDK caches, +/// and concurrent writes to it race. A room that fails doesn't stop the rest; +/// the first error is reported once they've all had their turn. +async fn each_room<'a, F, Fut>( + app: &AppHandle, + core: &'a crate::matrix::MatrixCore, + room_ids: &[String], + action: F, +) -> Result<()> +where + F: Fn(&'a crate::matrix::MatrixCore, OwnedRoomId) -> Fut, + Fut: std::future::Future>, +{ + let mut first_error = None; + for raw in room_ids { + let room_id = parse_room_id(raw)?; + if let Err(e) = action(core, room_id.clone()).await { + tracing::warn!("{room_id}: {e}"); + first_error.get_or_insert(e); + } + rooms::emit_room_refresh(app, core, &room_id).await; + } + first_error.map_or(Ok(()), Err) +} + #[tauri::command] pub async fn set_room_marked_unread( state: State<'_, AppState>, diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs index c5c189f..5516c25 100644 --- a/src-tauri/src/dto.rs +++ b/src-tauri/src/dto.rs @@ -176,6 +176,9 @@ pub struct RoomSummary { /// room type we don't draw. Spaces have their own flag and their own rail. pub is_utility: bool, pub is_muted: bool, + /// The level set for this room, if one has been. `None` is the account's + /// default for this kind of room. + pub notify_mode: Option, pub membership: RoomMembership, pub notification_count: u64, pub highlight_count: u64, @@ -215,6 +218,37 @@ pub struct LatestEvent { pub timestamp: u64, } +/// How much a room notifies — Matrix's per-room push rule, by its effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub enum NotifyMode { + All, + Mentions, + Mute, +} + +impl From for NotifyMode { + fn from(mode: matrix_sdk::notification_settings::RoomNotificationMode) -> Self { + use matrix_sdk::notification_settings::RoomNotificationMode as M; + match mode { + M::AllMessages => Self::All, + M::MentionsAndKeywordsOnly => Self::Mentions, + M::Mute => Self::Mute, + } + } +} + +impl From for matrix_sdk::notification_settings::RoomNotificationMode { + fn from(mode: NotifyMode) -> Self { + match mode { + NotifyMode::All => Self::AllMessages, + NotifyMode::Mentions => Self::MentionsAndKeywordsOnly, + NotifyMode::Mute => Self::Mute, + } + } +} + #[derive(Debug, Clone, Serialize, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 76b1334..815bccd 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -113,8 +113,28 @@ impl From for Error { impl Serialize for Error { fn serialize(&self, s: S) -> Result { - UwuError { kind: self.kind(), message: self.to_string() }.serialize(s) + UwuError { kind: self.kind(), message: message(self) }.serialize(s) } } +/// The error's own message, plus whatever caused it that it doesn't already say. +/// +/// Transport errors keep the useful part in their sources: reqwest's top line +/// is "error sending request for url (…)", and only further down does it say +/// "operation timed out" or "connection refused". +fn message(error: &Error) -> String { + let mut message = error.to_string(); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + let text = cause.to_string(); + // `#[error("{0}")]` wrappers repeat their inner error word for word. + if !text.is_empty() && !message.contains(&text) { + message.push_str(": "); + message.push_str(&text); + } + source = cause.source(); + } + message +} + pub type Result = std::result::Result; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5e83a8..bb7a3dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,6 +62,8 @@ pub fn run() { commands::get_members, commands::create_room, commands::join_room, + commands::get_public_spaces, + commands::get_space_rooms, commands::leave_room, commands::update_room, commands::get_room_permissions, @@ -69,6 +71,8 @@ pub fn run() { commands::set_typing, commands::set_room_muted, commands::set_room_marked_unread, + commands::set_rooms_notify_mode, + commands::mark_rooms_read, // image packs commands::get_image_packs, commands::get_all_image_packs, diff --git a/src-tauri/src/matrix/auth.rs b/src-tauri/src/matrix/auth.rs index 1625cf9..04d0b54 100644 --- a/src-tauri/src/matrix/auth.rs +++ b/src-tauri/src/matrix/auth.rs @@ -10,7 +10,7 @@ use matrix_sdk::{ authentication::matrix::MatrixSession, encryption::{BackupDownloadStrategy, EncryptionSettings}, ruma::{ - ClientSecret, OwnedClientSecret, OwnedSessionId, RoomId, UInt, + ClientSecret, OwnedClientSecret, OwnedSessionId, UInt, api::client::{ account::{register, request_registration_token_via_email}, session::get_login_types::v3::LoginType, @@ -670,18 +670,6 @@ fn friendly_login_error(error: &matrix_sdk::Error) -> String { } } -/// Resolve a room alias or ID to a room ID. -pub async fn resolve_room( - client: &Client, - alias_or_id: &str, -) -> Result { - if let Ok(room_id) = RoomId::parse(alias_or_id) { - return Ok(room_id); - } - let alias = matrix_sdk::ruma::RoomAliasId::parse(alias_or_id)?; - Ok(client.resolve_room_alias(&alias).await?.room_id) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/matrix/directory.rs b/src-tauri/src/matrix/directory.rs new file mode 100644 index 0000000..9ba510a --- /dev/null +++ b/src-tauri/src/matrix/directory.rs @@ -0,0 +1,177 @@ +//! Finding rooms you aren't in yet: the public directory, and a look inside a +//! space before joining it. + +use matrix_sdk::ruma::{ + OwnedServerName, RoomId, ServerName, UInt, + api::client::{directory::get_public_rooms_filtered, space::get_hierarchy}, + directory::{Filter, PublicRoomsChunk, RoomTypeFilter}, + room::{JoinRuleKind, RoomSummary as HierarchySummary, RoomType}, +}; +use ts_rs::TS; + +use crate::{error::Result, matrix::MatrixCore}; + +/// How many directory entries to ask for at once. +const DIRECTORY_PAGE_SIZE: u32 = 30; + +/// How many rooms of a space to show before joining it. The hierarchy pages +/// too, but a preview that needs a "load more" has stopped being a preview. +const PREVIEW_LIMIT: u32 = 50; + +/// A room or space as seen from outside: what the directory and a space's +/// hierarchy can tell you without joining. +#[derive(Debug, Clone, serde::Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct PublicRoom { + pub id: String, + pub name: Option, + pub topic: Option, + pub alias: Option, + pub avatar_url: Option, + pub members: u64, + pub is_space: bool, + /// `public` | `knock` | `invite` | `restricted` | … — the spec's spelling. + /// Only `public` can be walked into; the rest are shown so a preview + /// doesn't silently hide half a space. + pub join_rule: String, + /// Servers to try when joining. The directory's own server for a directory + /// entry, the space's `via` for a child — a room on someone else's + /// homeserver can't be joined by bare ID from ours without them. + pub via: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryPage { + pub rooms: Vec, + /// Pass back as `since` for the next page; `None` means that was the last. + pub next_batch: Option, + /// The server's guess. Some servers don't send one. + pub total_estimate: Option, +} + +/// Public spaces listed in a server's directory. +/// +/// `server` is whose directory to read — ours when `None`. Asking another +/// server goes through ours over federation, which some servers refuse; the +/// error is passed through as-is because it's usually the explanation. +pub async fn public_spaces( + core: &MatrixCore, + server: Option, + search: Option, + since: Option, +) -> Result { + let server = server + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .map(ServerName::parse) + .transpose()?; + + let mut filter = Filter::new(); + filter.generic_search_term = + search.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + filter.room_types = vec![RoomTypeFilter::Space]; + + let mut request = get_public_rooms_filtered::v3::Request::new(); + request.server = server.clone(); + request.limit = Some(UInt::from(DIRECTORY_PAGE_SIZE)); + request.since = since; + request.filter = filter; + + let response = core.client.public_rooms_filtered(request).await?; + + // An entry from our own directory needs no `via`; one from elsewhere needs + // the server we found it on. + let via: Vec = server.iter().map(|s| s.to_string()).collect(); + + Ok(DirectoryPage { + rooms: response + .chunk + .into_iter() + // The filter is a request, not a guarantee: older servers ignore + // `room_types` and hand back everything. + .filter(|chunk| chunk.room_type == Some(RoomType::Space)) + .map(|chunk| from_directory(chunk, &via)) + .collect(), + next_batch: response.next_batch, + total_estimate: response.total_room_count_estimate.map(u64::from), + }) +} + +/// The rooms directly inside a space, for deciding whether to join it. +/// +/// Works on spaces we aren't in as long as they're public or peekable: our +/// server fetches the hierarchy over federation, using `via` to find it. +pub async fn space_rooms(core: &MatrixCore, space_id: &RoomId) -> Result> { + let mut request = get_hierarchy::v1::Request::new(space_id.to_owned()); + request.max_depth = Some(UInt::from(1u32)); + request.limit = Some(UInt::from(PREVIEW_LIMIT)); + + let response = core.client.send(request).await?; + + // The children's `via` live on the root's `m.space.child` events, not on + // the children themselves. + let mut via_for = std::collections::HashMap::new(); + for chunk in &response.rooms { + if chunk.summary.room_id != space_id { + continue; + } + for raw in &chunk.children_state { + if let Ok(event) = raw.deserialize() { + let via: Vec = event.content.via.iter().map(|s| s.to_string()).collect(); + via_for.insert(event.state_key, via); + } + } + } + + Ok(response + .rooms + .into_iter() + // The first entry is the space itself. + .filter(|chunk| chunk.summary.room_id != space_id) + .map(|chunk| { + let via = via_for.get(&chunk.summary.room_id).cloned().unwrap_or_default(); + from_hierarchy(chunk.summary, via) + }) + .collect()) +} + +fn from_directory(chunk: PublicRoomsChunk, via: &[String]) -> PublicRoom { + PublicRoom { + id: chunk.room_id.to_string(), + name: chunk.name.filter(|n| !n.trim().is_empty()), + topic: chunk.topic.filter(|t| !t.trim().is_empty()), + alias: chunk.canonical_alias.map(|a| a.to_string()), + avatar_url: chunk.avatar_url.map(|u| u.to_string()), + members: u64::from(chunk.num_joined_members), + is_space: chunk.room_type == Some(RoomType::Space), + join_rule: join_rule_str(&chunk.join_rule), + via: via.to_vec(), + } +} + +fn from_hierarchy(summary: HierarchySummary, via: Vec) -> PublicRoom { + PublicRoom { + id: summary.room_id.to_string(), + name: summary.name.filter(|n| !n.trim().is_empty()), + topic: summary.topic.filter(|t| !t.trim().is_empty()), + alias: summary.canonical_alias.map(|a| a.to_string()), + avatar_url: summary.avatar_url.map(|u| u.to_string()), + members: u64::from(summary.num_joined_members), + is_space: summary.room_type == Some(RoomType::Space), + join_rule: join_rule_str(&summary.join_rule.kind()), + via, + } +} + +fn join_rule_str(kind: &JoinRuleKind) -> String { + kind.as_str().to_owned() +} + +/// Parse `via` servers, dropping any that don't parse rather than failing the +/// join: they're hints, and the rest may well be enough. +pub fn parse_via(via: &[String]) -> Vec { + via.iter().filter_map(|s| ServerName::parse(s).ok()).collect() +} diff --git a/src-tauri/src/matrix/mod.rs b/src-tauri/src/matrix/mod.rs index 25eb0fd..e8da516 100644 --- a/src-tauri/src/matrix/mod.rs +++ b/src-tauri/src/matrix/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod core; +pub mod directory; pub mod media; pub mod packs; pub mod presence; diff --git a/src-tauri/src/matrix/rooms.rs b/src-tauri/src/matrix/rooms.rs index 1fb1ca4..766b479 100644 --- a/src-tauri/src/matrix/rooms.rs +++ b/src-tauri/src/matrix/rooms.rs @@ -17,7 +17,7 @@ use ts_rs::TS; use crate::{ dto::{ - Diff, LatestEvent, RoomMemberDto, RoomMembership, RoomSummary, RoomsUpdate, SpaceSummary, + Diff, LatestEvent, NotifyMode, RoomMemberDto, RoomMembership, RoomSummary, RoomsUpdate, SpaceSummary, Verification, }, error::{Error, Result}, @@ -163,10 +163,8 @@ pub async fn summarise(room: &Room) -> Result { // summary — a room that's wrongly grouped is better than a missing room. let is_direct = room.is_direct().await.unwrap_or(false); - let muted = matches!( - room.cached_user_defined_notification_mode(), - Some(matrix_sdk::notification_settings::RoomNotificationMode::Mute) - ); + let notify_mode = room.cached_user_defined_notification_mode().map(NotifyMode::from); + let muted = notify_mode == Some(NotifyMode::Mute); let parent_spaces = collect_parent_spaces(room).await; let room_type = room_type_str(room); @@ -187,6 +185,7 @@ pub async fn summarise(room: &Room) -> Result { is_space: room.is_space(), is_utility: is_utility_room_type(room_type), is_muted: muted, + notify_mode, membership: membership(room.state()), notification_count, highlight_count, @@ -345,6 +344,7 @@ async fn summarise_or_placeholder(room: &Room) -> RoomSummary { is_space: false, is_utility: false, is_muted: false, + notify_mode: None, membership: membership(room.state()), notification_count: 0, highlight_count: 0, @@ -718,9 +718,42 @@ pub async fn permissions(core: &MatrixCore, room_id: &RoomId) -> Result Result { - let room_id = super::auth::resolve_room(&core.client, alias_or_id).await?; - let room = core.client.join_room_by_id(&room_id).await?; +/// How long a join may take before we give up on hearing back. +/// +/// Joining a room on another homeserver is the slowest call in the API: ours +/// has to fetch the room's whole state over federation before it answers, and +/// for a big room on matrix.org that's routinely minutes. The SDK's default of +/// 30 seconds turned those joins into an "error sending request" while the +/// server carried on and joined anyway. +const JOIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// Join by alias or ID. +/// +/// `via` matters for a room ID on another homeserver: ours has never heard of +/// it and needs to be told whom to ask. An alias carries its own answer, which +/// the server resolves during the join. +pub async fn join(core: &MatrixCore, alias_or_id: &str, via: &[String]) -> Result { + use matrix_sdk::{config::RequestConfig, ruma::api::client::membership::join_room_by_id_or_alias}; + + let target = matrix_sdk::ruma::RoomOrAliasId::parse(alias_or_id.trim())?; + let servers = super::directory::parse_via(via); + + // The slow part, sent by hand so it can have a long timeout of its own + // rather than raising it for every request. No retries: the server keeps + // working on a join we stopped waiting for, and a retry just queues up + // behind it. + let mut request = join_room_by_id_or_alias::v3::Request::new(target); + request.via = servers; + let response = core + .client + .send(request) + .with_request_config(RequestConfig::new().timeout(JOIN_TIMEOUT).disable_retry()) + .await?; + + // Then the SDK's own join, which is instant now that we're in, and does the + // local bookkeeping the raw request skips — marking the room joined so it + // can be opened before the next sync says so. + let room = core.client.join_room_by_id_or_alias((&*response.room_id).into(), &[]).await?; Ok(room.room_id().to_string()) } @@ -798,7 +831,34 @@ pub async fn set_muted(core: &MatrixCore, room_id: &RoomId, muted: bool) -> Resu settings.unmute_room(room_id, encrypted, one_to_one).await }; result.map_err(|e| Error::Other(e.to_string()))?; + refresh_cached_mode(&settings, &room).await; + Ok(()) +} + +/// Set how much a room notifies: everything, mentions only, or nothing. +/// +/// "Nothing" is `set_muted`, so that turning it back off takes the careful +/// unmute path rather than a bare rule write. +pub async fn set_notify_mode(core: &MatrixCore, room_id: &RoomId, mode: NotifyMode) -> Result<()> { + if mode == NotifyMode::Mute { + return set_muted(core, room_id, true).await; + } + + let settings = core.client.notification_settings().await; + let room = core.room(&room_id.to_owned())?; + settings + .set_room_notification_mode(room_id, mode.into()) + .await + .map_err(|e| Error::Other(e.to_string()))?; + refresh_cached_mode(&settings, &room).await; + Ok(()) +} +async fn refresh_cached_mode( + settings: &matrix_sdk::notification_settings::NotificationSettings, + room: &Room, +) { + let room_id = room.room_id(); // `summarise` reads the mode from a per-room cache that the SDK only // refills when a sync brings the new `m.push_rules` back, so a summary // taken now would still report the old state. The rules read here are the @@ -812,7 +872,37 @@ pub async fn set_muted(core: &MatrixCore, room_id: &RoomId, muted: bool) -> Resu Some(mode) => room.update_cached_user_defined_notification_mode(mode), None => room.clear_user_defined_notification_mode(), } +} + +/// Mark a room read without opening it. +/// +/// The timeline's own `mark_as_read` needs the room open, which "mark the +/// whole space read" can't ask for. A receipt on the latest event the room +/// list already knows about says the same thing, and clearing the manual +/// unread flag covers rooms marked unread by hand. +pub async fn mark_read(core: &MatrixCore, room_id: &RoomId) -> Result<()> { + use matrix_sdk::ruma::{ + OwnedEventId, + api::client::receipt::create_receipt::v3::ReceiptType, + events::receipt::ReceiptThread, + }; + use matrix_sdk_base::latest_event::LatestEventValue; + + let room = core.room(&room_id.to_owned())?; + + if let LatestEventValue::Remote(event) = room.latest_event() { + let json: serde_json::Value = event.raw().deserialize_as_unchecked()?; + if let Some(event_id) = json + .get("event_id") + .and_then(|id| id.as_str()) + .and_then(|id| OwnedEventId::try_from(id).ok()) + { + room.send_single_receipt(ReceiptType::Read, ReceiptThread::Unthreaded, event_id) + .await?; + } + } + room.set_unread_flag(false).await?; Ok(()) } diff --git a/src/App.tsx b/src/App.tsx index 0c2be31..75749a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,8 @@ import { useEdgeSwipeBack, useIsMobile, useKeyboardSafeArea } from "./lib/viewpo import { selectActiveRoom, useStore } from "./store"; import { ChatPane, EmptyPane } from "./components/ChatPane"; import { CreateRoom } from "./components/CreateRoom"; +import { ExploreSpaces } from "./components/ExploreSpaces"; +import { SpaceDialogs } from "./components/SpaceDialogs"; import { DropZone } from "./components/DropZone"; import { Lightbox } from "./components/Lightbox"; import { LoginScreen } from "./components/LoginScreen"; @@ -163,6 +165,8 @@ function Shell() { + + diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx new file mode 100644 index 0000000..c66af32 --- /dev/null +++ b/src/components/ContextMenu.tsx @@ -0,0 +1,375 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { Icon } from "./ui"; +import { corner, space, textSize } from "../lib/scale"; + +export type MenuEntry = + | { + kind?: "item"; + label: string; + /** A second line under the label — the current value, say. */ + hint?: string; + icon?: string; + /** Drawn in red: the item takes something away. */ + danger?: boolean; + disabled?: boolean; + onSelect: () => void; + } + | { kind: "check"; label: string; checked: boolean; onToggle: (next: boolean) => void } + | { kind: "radio"; label: string; hint?: string; checked: boolean; onSelect: () => void } + | { kind: "submenu"; label: string; hint?: string; items: MenuEntry[] } + | { kind: "separator" }; + +const MENU_WIDTH = 220; +const EDGE = 8; + +/** + * A small menu at the pointer, for actions that shouldn't sit one stray click + * away — modelled on Discord's server menu: sections split by hairlines, + * checkboxes that stay open when ticked, and submenus that fly out to the side. + * + * Closes on a click anywhere else, Escape, a resize, or picking an item. + */ +export function ContextMenu({ + at, + items, + onClose, +}: { + at: { x: number; y: number }; + items: MenuEntry[]; + onClose: () => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("resize", onClose); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onClose); + }; + }, [onClose]); + + return createPortal( +
{ + e.stopPropagation(); + onClose(); + }} + onContextMenu={(e) => { + e.preventDefault(); + onClose(); + }} + style={{ position: "fixed", inset: 0, zIndex: 190 }} + > + +
, + document.body, + ); +} + +/** + * One column of entries. Measures itself once mounted and moves back on + * screen if it would spill off the right or bottom edge; a submenu that can't + * fit on the right of its parent opens on the left instead. + */ +function Panel({ + at, + items, + onClose, + flipX, +}: { + at: { x: number; y: number }; + items: MenuEntry[]; + onClose: () => void; + /** For a submenu: where to go instead if `at` doesn't fit — the parent's left edge. */ + flipX?: number; +}) { + const ref = useRef(null); + const [pos, setPos] = useState(at); + const [open, setOpen] = useState<{ index: number; at: { x: number; y: number }; flipX: number } | null>( + null, + ); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const { width, height } = el.getBoundingClientRect(); + let x = at.x; + if (x + width > window.innerWidth - EDGE) { + x = flipX !== undefined ? flipX - width : window.innerWidth - width - EDGE; + } + const y = Math.min(at.y, window.innerHeight - height - EDGE); + setPos({ x: Math.max(EDGE, x), y: Math.max(EDGE, y) }); + }, [at.x, at.y, flipX]); + + function openSubmenu(index: number, row: HTMLElement) { + const r = row.getBoundingClientRect(); + setOpen({ index, at: { x: r.right + 4, y: r.top - 5 }, flipX: r.left - 4 }); + } + + const sub = open && items[open.index]; + + return ( + <> +
e.stopPropagation()} + style={{ + position: "fixed", + left: pos.x, + top: pos.y, + width: MENU_WIDTH, + maxHeight: `calc(100vh - ${EDGE * 2}px)`, + overflowY: "auto", + padding: space(6), + borderRadius: corner(14), + background: "var(--surface-card-raised)", + border: "1px solid var(--border-default)", + boxShadow: "var(--shadow-pop)", + }} + > + {items.map((entry, index) => { + if (entry.kind === "separator") { + return ( +
+ ); + } + + if (entry.kind === "submenu") { + return ( + } + ariaHasPopup + onHover={(row) => openSubmenu(index, row)} + onClick={(row) => + open?.index === index ? setOpen(null) : openSubmenu(index, row) + } + /> + ); + } + + // Anything else closes a submenu that the pointer has moved away from. + const leave = () => setOpen(null); + + if (entry.kind === "check") { + return ( + } + onHover={leave} + // Stays open: ticking a box is a setting, not a departure. + onClick={() => entry.onToggle(!entry.checked)} + /> + ); + } + + if (entry.kind === "radio") { + return ( + } + onHover={leave} + onClick={() => { + onClose(); + entry.onSelect(); + }} + /> + ); + } + + return ( + + ) + } + onHover={leave} + onClick={() => { + onClose(); + entry.onSelect(); + }} + /> + ); + })} +
+ + {sub && sub.kind === "submenu" && ( + + )} + + ); +} + +function Row({ + label, + hint, + leading, + trailing, + danger, + disabled, + active, + role = "menuitem", + checked, + ariaHasPopup, + onHover, + onClick, +}: { + label: string; + hint?: string; + leading?: React.ReactNode; + trailing?: React.ReactNode; + danger?: boolean; + disabled?: boolean; + active?: boolean; + role?: string; + checked?: boolean; + ariaHasPopup?: boolean; + onHover: (row: HTMLElement) => void; + onClick: (row: HTMLElement) => void; +}) { + const [hover, setHover] = useState(false); + const lit = (hover || active) && !disabled; + const colour = danger ? "var(--status-danger)" : "var(--text-primary)"; + + return ( + + ); +} + +function Checkbox({ on }: { on: boolean }) { + return ( + + {on && } + + ); +} + +function Radio({ on }: { on: boolean }) { + return ( + + {on && ( + + )} + + ); +} diff --git a/src/components/CreateRoom.tsx b/src/components/CreateRoom.tsx index 00bb742..6fbe263 100644 --- a/src/components/CreateRoom.tsx +++ b/src/components/CreateRoom.tsx @@ -206,7 +206,7 @@ function Dialog() { * the room exists, and `open_timeline` on a room the room list doesn't know * yet raises. Giving up quietly is fine — the room list will still fill in. */ -async function waitForRoom(roomId: string, timeoutMs = 5000): Promise { +export async function waitForRoom(roomId: string, timeoutMs = 5000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (useStore.getState().rooms.some((r) => r.id === roomId)) return; diff --git a/src/components/ExploreSpaces.tsx b/src/components/ExploreSpaces.tsx new file mode 100644 index 0000000..a9a455b --- /dev/null +++ b/src/components/ExploreSpaces.tsx @@ -0,0 +1,567 @@ +import { useEffect, useRef, useState } from "react"; + +import type { PublicRoom } from "../lib/bindings"; +import * as ipc from "../lib/ipc"; +import { useStore } from "../store"; +import { waitForRoom } from "./CreateRoom"; +import { Avatar, Button, HoverRow, Icon, Modal, RaveLabel, Spinner, Tag } from "./ui"; +import { corner, headingSize, space, textSize } from "../lib/scale"; + +/** How long typing has to pause before the directory is asked again. */ +const SEARCH_DEBOUNCE_MS = 350; + +/** + * Browsing public spaces: a server's directory, searchable, with a look at the + * rooms inside each space before you commit to joining it. + * + * Mounted from the shell; opened from the compass in the spaces rail. + */ +export function ExploreSpaces() { + const open = useStore((s) => s.showExplore); + if (!open) return null; + return ; +} + +function Dialog() { + const close = useStore((s) => s.closeExplore); + const ownServer = useStore((s) => s.session?.userId.split(":").slice(1).join(":") ?? ""); + + const [search, setSearch] = useState(""); + const [server, setServer] = useState(""); + // What the list is actually showing, so typing doesn't refetch per keystroke. + const [query, setQuery] = useState({ search: "", server: "" }); + + const [spaces, setSpaces] = useState([]); + const [nextBatch, setNextBatch] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(null); + + // Every fetch takes a ticket; a response whose ticket is stale belongs to a + // query the user has already moved on from, and is dropped. + const ticket = useRef(0); + + useEffect(() => { + const next = { search: search.trim(), server: server.trim() }; + const t = setTimeout( + // Same values, same object: a new one would refetch what's on screen. + () => + setQuery((cur) => + cur.search === next.search && cur.server === next.server ? cur : next, + ), + SEARCH_DEBOUNCE_MS, + ); + return () => clearTimeout(t); + }, [search, server]); + + async function load(since?: string) { + const mine = ++ticket.current; + setLoading(true); + setError(null); + try { + const page = await ipc.getPublicSpaces({ + server: query.server || undefined, + search: query.search || undefined, + since, + }); + if (mine !== ticket.current) return; + setSpaces((prev) => (since ? [...prev, ...page.rooms] : page.rooms)); + setNextBatch(page.nextBatch); + } catch (e) { + if (mine !== ticket.current) return; + if (!since) setSpaces([]); + setNextBatch(null); + setError(ipc.asUwuError(e).message); + } finally { + if (mine === ticket.current) setLoading(false); + } + } + + useEffect(() => { + setExpanded(null); + void load(); + // `load` reads `query` and nothing else that changes. + }, [query]); + + return ( + +
+
+ search + +
+
+ server + +
+
+ + {/* A floor under the results, so the dialog doesn't shrink and jump + under the pointer while a search narrows them down. */} +
+ {error && ( +
+ {query.server + ? `couldn't read ${query.server}'s directory — ${error}` + : `couldn't load the directory — ${error}`} +
+ )} + + {!error && !loading && spaces.length === 0 && ( +
+ {query.search + ? `no public spaces match "${query.search}"` + : `${query.server || "your server"} doesn't list any public spaces. try another server~`} +
+ )} + +
+ {spaces.map((sp) => ( + setExpanded((cur) => (cur === sp.id ? null : sp.id))} + /> + ))} +
+ + {loading && ( +
+ +
+ )} + + {!loading && nextBatch && ( +
+ +
+ )} +
+
+ ); +} + +/** One space from the directory, which opens to show the rooms inside it. */ +function SpaceRow({ + space: sp, + expanded, + onToggle, +}: { + space: PublicRoom; + expanded: boolean; + onToggle: () => void; +}) { + const joined = useStore((s) => s.spaces.some((x) => x.id === sp.id)); + const close = useStore((s) => s.closeExplore); + const setActiveSpace = useStore((s) => s.setActiveSpace); + const refreshSpaces = useStore((s) => s.refreshSpaces); + const showBanner = useStore((s) => s.showBanner); + const [busy, setBusy] = useState(false); + + const name = displayName(sp); + + async function join() { + if (busy) return; + setBusy(true); + try { + await ipc.joinRoom(sp.id, sp.via); + await waitForSpace(sp.id, refreshSpaces); + setActiveSpace(sp.id); + close(); + } catch (e) { + showBanner("error", ipc.asUwuError(e).message); + } finally { + setBusy(false); + } + } + + function open() { + setActiveSpace(sp.id); + close(); + } + + return ( +
+ + +
+
+ + {name} + + +
+ + {sp.topic && } +
+
e.stopPropagation()} style={{ flex: "none", paddingTop: space(2) }}> + {joined ? ( + + ) : sp.joinRule === "public" ? ( + <> + + {busy && } + + ) : ( + + )} +
+
+ + {expanded && ( +
+ +
+ )} +
+ ); +} + +/** + * The rooms inside a space, each joinable on its own. + * + * Also what "browse rooms" in a space's context menu shows. `onOpen` runs when + * the user opens a room or space they're in, so the dialog around it can close. + */ +export function SpaceRooms({ + spaceId, + via, + onOpen, +}: { + spaceId: string; + /** Servers to join through when a child doesn't name its own. */ + via: string[]; + onOpen: () => void; +}) { + const [rooms, setRooms] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + ipc + .getSpaceRooms(spaceId) + .then((r) => live && setRooms(r)) + .catch((e) => live && setError(ipc.asUwuError(e).message)); + return () => { + live = false; + }; + }, [spaceId]); + + return ( +
+ inside + {error ? ( +
+ couldn't look inside — {error} +
+ ) : rooms === null ? ( + + ) : rooms.length === 0 ? ( +
+ nothing public in here yet +
+ ) : ( +
+ {rooms.map((room) => ( + + ))} +
+ )} +
+ ); +} + +function ChildRow({ + room, + fallbackVia, + onOpen, +}: { + room: PublicRoom; + fallbackVia: string[]; + onOpen: () => void; +}) { + const joined = useStore((s) => + room.isSpace + ? s.spaces.some((x) => x.id === room.id) + : s.rooms.some((r) => r.id === room.id && r.membership === "joined"), + ); + const selectRoom = useStore((s) => s.selectRoom); + const setActiveSpace = useStore((s) => s.setActiveSpace); + const refreshSpaces = useStore((s) => s.refreshSpaces); + const showBanner = useStore((s) => s.showBanner); + const [busy, setBusy] = useState(false); + + const name = displayName(room); + + function open() { + if (room.isSpace) setActiveSpace(room.id); + else void selectRoom(room.id); + onOpen(); + } + + async function join() { + if (busy) return; + setBusy(true); + try { + await ipc.joinRoom(room.id, room.via.length ? room.via : fallbackVia); + // Stay in the dialog: picking several rooms out of a space is the + // common case. The row flips to "open" once sync has caught up. + if (room.isSpace) await waitForSpace(room.id, refreshSpaces); + else await waitForRoom(room.id); + } catch (e) { + showBanner("error", ipc.asUwuError(e).message); + } finally { + setBusy(false); + } + } + + return ( +
+ +
+
+ {room.isSpace && } + + {name} + +
+
+ {memberCount(room.members)} +
+
+ {joined ? ( + + ) : room.joinRule === "public" ? ( + + ) : ( + + )} +
+ ); +} + +function Meta({ room }: { room: PublicRoom }) { + return ( +
+ {memberCount(room.members)} + {room.alias && ( + + {room.alias} + + )} +
+ ); +} + +function Topic({ text, lines }: { text: string; lines: number }) { + return ( +
+ {text} +
+ ); +} + +/** + * Joining a space on another server makes ours fetch its whole state first, + * which can take minutes. Without a word, a spinner that long reads as stuck. + */ +function SlowJoinNote() { + return ( +
+ big spaces can take a minute~ +
+ ); +} + +/** Why there's no join button: the room exists but isn't open to walk into. */ +function JoinRuleTag({ rule }: { rule: string }) { + const label = + rule === "knock" || rule === "knock_restricted" + ? "ask to join" + : rule === "restricted" + ? "members only" + : "invite only"; + return ( + + {label} + + ); +} + +function TextInput({ + value, + onChange, + placeholder, + icon, + autoFocus, +}: { + value: string; + onChange: (next: string) => void; + placeholder?: string; + icon?: string; + autoFocus?: boolean; +}) { + return ( +
+ {icon && } + onChange(e.target.value)} + style={{ + flex: 1, + minWidth: 0, + padding: space("10px 0"), + background: "transparent", + border: "none", + outline: "none", + color: "var(--text-primary)", + fontSize: textSize(14), + }} + /> +
+ ); +} + +function displayName(room: PublicRoom): string { + return room.name ?? room.alias ?? room.id; +} + +function memberCount(n: number): string { + return n === 1 ? "1 member" : `${n.toLocaleString()} members`; +} + +/** + * Wait for a just-joined space to show up in the rail, for a few seconds at + * most. + * + * Spaces aren't pushed like rooms are — the list is re-read on a slow poll — + * so this asks for it directly until the space is there. + */ +async function waitForSpace( + spaceId: string, + refreshSpaces: () => Promise, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await refreshSpaces(); + if (useStore.getState().spaces.some((s) => s.id === spaceId)) return; + await new Promise((resolve) => setTimeout(resolve, 300)); + } +} diff --git a/src/components/RoomList.tsx b/src/components/RoomList.tsx index 9048589..e26e498 100644 --- a/src/components/RoomList.tsx +++ b/src/components/RoomList.tsx @@ -50,6 +50,9 @@ export function RoomList({ ); const unreadTotal = useStore(selectUnreadTotal); + const hideMuted = useStore( + (s) => !!activeSpaceId && s.settings.hideMutedInSpaces.includes(activeSpaceId), + ); // The chips are hidden until asked for — they're a rarely-used control and // they crowd the top of the list. @@ -61,9 +64,9 @@ export function RoomList({ const groups = useMemo( () => groupRooms( - filterRooms(allRooms, { activeSpaceId, filter, search, spaces }), + filterRooms(allRooms, { activeSpaceId, filter, search, spaces, hideMuted }), ), - [allRooms, activeSpaceId, filter, search, spaces], + [allRooms, activeSpaceId, filter, search, spaces, hideMuted], ); const heading = diff --git a/src/components/SpaceDialogs.tsx b/src/components/SpaceDialogs.tsx new file mode 100644 index 0000000..18343c5 --- /dev/null +++ b/src/components/SpaceDialogs.tsx @@ -0,0 +1,239 @@ +import { useMemo, useState } from "react"; + +import * as ipc from "../lib/ipc"; +import { roomsOnlyInSpace, useStore } from "../store"; +import { SpaceRooms } from "./ExploreSpaces"; +import { Button, Modal, RaveLabel, Spinner, Toggle } from "./ui"; +import { corner, headingSize, space, textSize } from "../lib/scale"; + +/** + * The dialogs a space's context menu opens: leave, invite, and browse its rooms. + * + * Mounted from the shell; the menu is on each space in the rail — right-click, + * or press and hold on a phone. + */ +export function SpaceDialogs() { + const dialog = useStore((s) => s.spaceDialog); + const sp = useStore((s) => s.spaces.find((x) => x.id === dialog?.spaceId)); + // Gone from under us — left elsewhere, or the rail refreshed it away. + if (!dialog || !sp) return null; + + switch (dialog.kind) { + case "leave": + return ; + case "invite": + return ; + case "browse": + return ; + } +} + +/** + * Leaving a space, and optionally the rooms you joined through it. + * + * A space is only a folder of rooms, so leaving it alone takes nothing away — + * the rooms stay in "everything". Taking them too is a choice, off by default, + * and only ever covers rooms no other space of yours holds. + */ +function LeaveSpace({ spaceId }: { spaceId: string }) { + const close = useStore((s) => s.closeSpaceDialog); + const sp = useStore((s) => s.spaces.find((x) => x.id === spaceId)); + const rooms = useStore((s) => s.rooms); + const spaces = useStore((s) => s.spaces); + + const [withRooms, setWithRooms] = useState(false); + const [leaving, setLeaving] = useState(false); + + const ownRooms = useMemo( + () => roomsOnlyInSpace(rooms, spaces, spaceId), + [rooms, spaces, spaceId], + ); + + if (!sp) return null; + + async function leave() { + if (leaving || !sp) return; + setLeaving(true); + const { activeRoomId, activeSpaceId, selectRoom, setActiveSpace, refreshSpaces, showBanner } = + useStore.getState(); + const roomIds = withRooms ? ownRooms.map((r) => r.id) : []; + + try { + await ipc.leaveRoom(spaceId); + } catch (e) { + showBanner("error", ipc.asUwuError(e).message); + setLeaving(false); + return; + } + + // The space is gone either way now; a room that won't let go is reported, + // not a reason to undo anything. + const results = await Promise.allSettled(roomIds.map((id) => ipc.leaveRoom(id))); + const stuck = results.filter((r) => r.status === "rejected").length; + + if (activeSpaceId === spaceId) setActiveSpace(null); + if (activeRoomId && roomIds.includes(activeRoomId)) void selectRoom(null); + void refreshSpaces(); + close(); + + if (stuck > 0) { + showBanner( + "error", + `left ${sp.name}, but ${stuck === 1 ? "one room" : `${stuck} rooms`} wouldn't let go — try leaving ${stuck === 1 ? "it" : "them"} from room settings`, + ); + } + } + + return ( + +
+ it drops out of your rail. rooms you're in stay in "everything" unless you take + them with you — and if the space is private, you'd need an invite to get back. +
+ + {ownRooms.length > 0 && ( +
+
+
+ leave its rooms too +
+
+ {ownRooms.length === 1 + ? `the 1 room you're in here: ${ownRooms[0].name}` + : `the ${ownRooms.length} rooms you're in here: ${listNames(ownRooms.map((r) => r.name))}`} + . rooms that are also in another of your spaces stay. +
+
+
+ +
+
+ )} + +
+ + +
+
+ ); +} + +/** "a, b, c and 4 more" — enough to recognise, short enough to read. */ +function listNames(names: string[], shown = 3): string { + if (names.length <= shown) return names.join(", "); + return `${names.slice(0, shown).join(", ")} and ${names.length - shown} more`; +} + +/** + * Invite someone to a space by their Matrix ID. + * + * Only the space: the rooms inside it are theirs to pick once they're in, the + * same as anyone who finds the space in the directory. + */ +function InviteToSpace({ spaceId, name }: { spaceId: string; name: string }) { + const close = useStore((s) => s.closeSpaceDialog); + const showBanner = useStore((s) => s.showBanner); + const [userId, setUserId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const trimmed = userId.trim(); + // `@name:server` — loose on purpose; the server has the final say. + const plausible = /^@[^:\s]+:\S+$/.test(trimmed); + + async function invite() { + if (!plausible || busy) return; + setBusy(true); + setError(null); + try { + await ipc.inviteUser(spaceId, trimmed); + close(); + showBanner("info", `invited ${trimmed} to ${name}`); + } catch (e) { + setError(ipc.asUwuError(e).message); + setBusy(false); + } + } + + return ( + + who + setUserId(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void invite(); + }} + style={{ + width: "100%", + padding: space("10px 13px"), + borderRadius: corner(14), + background: "var(--surface-inset)", + border: "1px solid var(--border-subtle)", + outline: "none", + color: "var(--text-primary)", + fontSize: textSize(14), + }} + /> +
+ {error ?? "they get an invite to the space, and pick which rooms to join once they're in."} +
+
+ +
+
+ ); +} + +/** + * Every room in a space, joined or not — Discord's "show all channels". + * + * The room list only shows rooms you're in; this is where the rest are. + */ +function BrowseRooms({ spaceId, name }: { spaceId: string; name: string }) { + const close = useStore((s) => s.closeSpaceDialog); + return ( + +
+ everything in the space, including rooms you haven't joined yet. +
+ +
+ ); +} diff --git a/src/components/SpacesRail.tsx b/src/components/SpacesRail.tsx index 3b07610..a614b88 100644 --- a/src/components/SpacesRail.tsx +++ b/src/components/SpacesRail.tsx @@ -1,8 +1,14 @@ +import { useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; +import type { NotifyMode } from "../lib/bindings"; import { accentFor, initialsFor } from "../lib/display"; +import { haptic } from "../lib/haptics"; +import * as ipc from "../lib/ipc"; import { mediaUrl } from "../lib/ipc"; -import { useStore } from "../store"; +import { useIsMobile, useLongPress } from "../lib/viewport"; +import { roomsInSpace, useStore } from "../store"; +import { ContextMenu, type MenuEntry } from "./ContextMenu"; import { AvatarButton } from "./ProfileCard"; import { dragRegion, Icon, Logo, PresenceDot } from "./ui"; import { corner, space, textSize } from "../lib/scale"; @@ -19,13 +25,21 @@ export function SpacesRail({ asDrawer, onClose, }: { asDrawer?: boolean; onClose?: () => void } = {}) { - const { spaces, activeSpaceId, session, setActiveSpace, openSettings } = useStore( + const { + spaces, + activeSpaceId, + session, + setActiveSpace, + openSettings, + openExplore, + } = useStore( useShallow((s) => ({ spaces: s.spaces, activeSpaceId: s.activeSpaceId, session: s.session, setActiveSpace: s.setActiveSpace, openSettings: s.openSettings, + openExplore: s.openExplore, })), ); @@ -106,8 +120,31 @@ export function SpacesRail({ active={activeSpaceId === space.id} accent={accentFor(space.id)} onClick={() => chooseSpace(space.id)} + spaceId={space.id} /> ))} + +
void; + /** The space this tile stands for, which gives it a context menu. */ + spaceId?: string; }) { const src = mediaUrl(mxc, { width: 46, height: 46 }); + const isMobile = useIsMobile(); + const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null); + const tile = useRef(null); + // A long press ends in a touchend, which the browser follows with a click — + // and a click on a tile selects the space and shuts the drawer the menu + // just opened in. Swallow that one click. + const pressed = useRef(false); + + const longPress = useLongPress( + isMobile && spaceId + ? () => { + haptic("medium"); + pressed.current = true; + const r = tile.current?.getBoundingClientRect(); + if (r) setMenuAt({ x: r.right + 8, y: r.top }); + } + : undefined, + ); return (
); } + +const NOTIFY_LABELS: Record = { + all: "all messages", + mentions: "only @mentions", + mute: "nothing", +}; + +/** + * A space's context menu, after Discord's server menu. + * + * Matrix keeps notification settings per room, not per space, so the space-wide + * entries apply to every room in it you've joined, and read their current + * state back from those rooms: a level shows when they all agree. A room you + * join later starts on your default, as it would anywhere. + */ +function SpaceMenu({ + spaceId, + at, + onClose, +}: { + spaceId: string; + at: { x: number; y: number }; + onClose: () => void; +}) { + const rooms = useStore((s) => s.rooms); + const spaces = useStore((s) => s.spaces); + const hideMuted = useStore((s) => s.settings.hideMutedInSpaces.includes(spaceId)); + const { updateSettings, openSpaceDialog, showBanner } = useStore( + useShallow((s) => ({ + updateSettings: s.updateSettings, + openSpaceDialog: s.openSpaceDialog, + showBanner: s.showBanner, + })), + ); + + const joined = roomsInSpace(rooms, spaces, spaceId); + const ids = joined.map((r) => r.id); + const anyUnread = joined.some((r) => r.hasUnread); + const allMuted = joined.length > 0 && joined.every((r) => r.isMuted); + // No explicit level is the account default — "all messages" for group rooms. + const modes = new Set(joined.map((r) => r.notifyMode ?? "all")); + const mode = modes.size === 1 ? [...modes][0] : null; + + function run(work: Promise) { + work.catch((e) => showBanner("error", ipc.asUwuError(e).message)); + } + + const items: MenuEntry[] = [ + { + label: "mark as read", + disabled: !anyUnread, + onSelect: () => run(ipc.markRoomsRead(joined.filter((r) => r.hasUnread).map((r) => r.id))), + }, + { kind: "separator" }, + { label: "invite to space", onSelect: () => openSpaceDialog("invite", spaceId) }, + { kind: "separator" }, + { + kind: "check", + label: "mute space", + checked: allMuted, + onToggle: (next) => run(ipc.setRoomsNotifyMode(ids, next ? "mute" : null)), + }, + { + kind: "submenu", + label: "notification settings", + hint: mode ? NOTIFY_LABELS[mode] : joined.length ? "mixed" : undefined, + items: (["all", "mentions", "mute"] as const).map((m) => ({ + kind: "radio" as const, + label: NOTIFY_LABELS[m], + checked: mode === m, + onSelect: () => run(ipc.setRoomsNotifyMode(ids, m)), + })), + }, + { + kind: "check", + label: "hide muted rooms", + checked: hideMuted, + onToggle: (next) => { + const current = useStore.getState().settings.hideMutedInSpaces; + updateSettings({ + hideMutedInSpaces: next + ? [...current, spaceId] + : current.filter((id) => id !== spaceId), + }); + }, + }, + { label: "browse rooms", onSelect: () => openSpaceDialog("browse", spaceId) }, + { kind: "separator" }, + { + label: "leave space", + danger: true, + onSelect: () => openSpaceDialog("leave", spaceId), + }, + { kind: "separator" }, + { + label: "copy space id", + icon: "identification-card", + onSelect: () => + run( + navigator.clipboard + .writeText(spaceId) + .then(() => showBanner("info", "space id copied")), + ), + }, + ]; + + return ; +} diff --git a/src/lib/bindings/DirectoryPage.ts b/src/lib/bindings/DirectoryPage.ts new file mode 100644 index 0000000..707c251 --- /dev/null +++ b/src/lib/bindings/DirectoryPage.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PublicRoom } from "./PublicRoom"; + +export type DirectoryPage = { rooms: Array, +/** + * Pass back as `since` for the next page; `None` means that was the last. + */ +nextBatch: string | null, +/** + * The server's guess. Some servers don't send one. + */ +totalEstimate: number | null, }; diff --git a/src/lib/bindings/NotifyMode.ts b/src/lib/bindings/NotifyMode.ts new file mode 100644 index 0000000..1bff6c4 --- /dev/null +++ b/src/lib/bindings/NotifyMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How much a room notifies — Matrix's per-room push rule, by its effect. + */ +export type NotifyMode = "all" | "mentions" | "mute"; diff --git a/src/lib/bindings/PublicRoom.ts b/src/lib/bindings/PublicRoom.ts new file mode 100644 index 0000000..4c2557f --- /dev/null +++ b/src/lib/bindings/PublicRoom.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A room or space as seen from outside: what the directory and a space's + * hierarchy can tell you without joining. + */ +export type PublicRoom = { id: string, name: string | null, topic: string | null, alias: string | null, avatarUrl: string | null, members: number, isSpace: boolean, +/** + * `public` | `knock` | `invite` | `restricted` | … — the spec's spelling. + * Only `public` can be walked into; the rest are shown so a preview + * doesn't silently hide half a space. + */ +joinRule: string, +/** + * Servers to try when joining. The directory's own server for a directory + * entry, the space's `via` for a child — a room on someone else's + * homeserver can't be joined by bare ID from ours without them. + */ +via: Array, }; diff --git a/src/lib/bindings/RoomSummary.ts b/src/lib/bindings/RoomSummary.ts index 33af5c5..44c8b2a 100644 --- a/src/lib/bindings/RoomSummary.ts +++ b/src/lib/bindings/RoomSummary.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { LatestEvent } from "./LatestEvent"; +import type { NotifyMode } from "./NotifyMode"; import type { RoomMembership } from "./RoomMembership"; export type RoomSummary = { id: string, name: string, topic: string | null, canonicalAlias: string | null, avatarUrl: string | null, isDirect: boolean, @@ -13,7 +14,12 @@ dmUserId: string | null, isEncrypted: boolean, isSpace: boolean, * A room that isn't a conversation — a pack room, or anything else with a * room type we don't draw. Spaces have their own flag and their own rail. */ -isUtility: boolean, isMuted: boolean, membership: RoomMembership, notificationCount: number, highlightCount: number, hasUnread: boolean, isMarkedUnread: boolean, memberCount: number, latest: LatestEvent | null, +isUtility: boolean, isMuted: boolean, +/** + * The level set for this room, if one has been. `None` is the account's + * default for this kind of room. + */ +notifyMode: NotifyMode | null, membership: RoomMembership, notificationCount: number, highlightCount: number, hasUnread: boolean, isMarkedUnread: boolean, memberCount: number, latest: LatestEvent | null, /** * Server-side recency stamp, used to order the list. */ diff --git a/src/lib/bindings/index.ts b/src/lib/bindings/index.ts index cb8c2aa..ae8b9d1 100644 --- a/src/lib/bindings/index.ts +++ b/src/lib/bindings/index.ts @@ -6,6 +6,7 @@ export type * from "./CallParticipants"; export type * from "./Content"; export type * from "./DeviceInfo"; export type * from "./Diff"; +export type * from "./DirectoryPage"; export type * from "./EmoteRef"; export type * from "./ErrorKind"; export type * from "./EventItem"; @@ -16,6 +17,7 @@ export type * from "./LatestRelease"; export type * from "./MediaInfo"; export type * from "./NewRoom"; export type * from "./NewRoomResult"; +export type * from "./NotifyMode"; export type * from "./OwnPresence"; export type * from "./PackEdit"; export type * from "./PackImage"; @@ -27,6 +29,7 @@ export type * from "./Presence"; export type * from "./PresenceUpdate"; export type * from "./Profile"; export type * from "./ProfileUpdate"; +export type * from "./PublicRoom"; export type * from "./Reaction"; export type * from "./RecentPhotos"; export type * from "./RecoveryState"; diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index b471a0c..52c978a 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -12,18 +12,21 @@ import type { CallCredentials, CallParticipants, DeviceInfo, + DirectoryPage, Profile, ProfileUpdate, HomeserverInfo, ImagePack, NewRoom, NewRoomResult, + NotifyMode, OwnPresence, PackEdit, PackRoom, PackTarget, Presence, PresenceUpdate, + PublicRoom, RecentPhotos, RecoveryStatus, RegisterAuth, @@ -136,8 +139,24 @@ export const getMembers = (roomId: string) => export const createRoom = (room: NewRoom) => invoke("create_room", { room }); -export const joinRoom = (aliasOrId: string) => - invoke("join_room", { aliasOrId }); +/** + * Join by alias or ID. `via` names servers to ask — needed for a room ID found + * on another server, which ours has no other way to locate. + */ +export const joinRoom = (aliasOrId: string, via: string[] = []) => + invoke("join_room", { aliasOrId, via }); + +/** One page of public spaces from `server`'s directory, ours when omitted. */ +export const getPublicSpaces = (opts: { server?: string; search?: string; since?: string }) => + invoke("get_public_spaces", { + server: opts.server ?? null, + search: opts.search ?? null, + since: opts.since ?? null, + }); + +/** The rooms directly inside a space, readable without joining it. */ +export const getSpaceRooms = (spaceId: string) => + invoke("get_space_rooms", { spaceId }); /** * Leave a room. It stops being listed, but nothing local is deleted — this @@ -166,6 +185,16 @@ export const setTyping = (roomId: string, typing: boolean) => export const setRoomMuted = (roomId: string, muted: boolean) => invoke("set_room_muted", { roomId, muted }); +/** + * Set the notification level on several rooms — a whole space's worth. `null` + * returns them to the default, which is how a mute is undone. + */ +export const setRoomsNotifyMode = (roomIds: string[], mode: NotifyMode | null) => + invoke("set_rooms_notify_mode", { roomIds, mode }); + +/** Mark several rooms read without opening them. */ +export const markRoomsRead = (roomIds: string[]) => invoke("mark_rooms_read", { roomIds }); + export const setRoomMarkedUnread = (roomId: string, unread: boolean) => invoke("set_room_marked_unread", { roomId, unread }); diff --git a/src/lib/notify.test.ts b/src/lib/notify.test.ts index d1eedb7..8b6381a 100644 --- a/src/lib/notify.test.ts +++ b/src/lib/notify.test.ts @@ -59,6 +59,7 @@ function room(patch: Partial & { id: string }): RoomSummary { isSpace: false, isUtility: false, isMuted: false, + notifyMode: null, membership: "joined", notificationCount: 0, highlightCount: 0, diff --git a/src/lib/settings.ts b/src/lib/settings.ts index e1ba180..99fb772 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -78,6 +78,8 @@ export interface Settings { * traffic for something nobody else can see. */ recentReactions: string[]; + /** Spaces whose room list leaves muted rooms out — Discord's "hide muted channels". */ + hideMutedInSpaces: string[]; } /** @@ -117,6 +119,7 @@ export const DEFAULTS: Settings = { // A first-run row that isn't empty, in the house voice. These are replaced by // real usage as soon as the user reacts to anything. recentReactions: ["💜", "😹", "🥺", "✨", "👀", "🔥"], + hideMutedInSpaces: [], }; /** The range the message text size slider covers, in px. */ diff --git a/src/lib/types.ts b/src/lib/types.ts index c726793..90cf2ae 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -77,6 +77,8 @@ export interface RoomSummary { /** A room that holds data rather than a conversation — an image pack, say. */ isUtility: boolean; isMuted: boolean; + /** The level set for this room, if one has been; null is the account default. */ + notifyMode: "all" | "mentions" | "mute" | null; membership: "joined" | "invited" | "left" | "knocked" | "banned"; notificationCount: number; highlightCount: number; diff --git a/src/store/index.ts b/src/store/index.ts index b989f26..78f9c7c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -50,6 +50,8 @@ interface Draft { const EMPTY_DRAFT: Draft = { body: "", replyTo: null, editing: null }; +export type SpaceDialogKind = "leave" | "invite" | "browse"; + interface State { // session session: SessionInfo | null; @@ -112,6 +114,9 @@ interface State { /** The picture being looked at full-size, if any. */ lightbox: { mxc: string; name: string } | null; showCreateRoom: boolean; + showExplore: boolean; + /** The dialog open for a space from its context menu, if any. */ + spaceDialog: { kind: SpaceDialogKind; spaceId: string } | null; /** * Custom emote and sticker packs usable in the room that's open. @@ -167,6 +172,10 @@ interface Actions { closeLightbox(): void; openCreateRoom(): void; closeCreateRoom(): void; + openExplore(): void; + closeExplore(): void; + openSpaceDialog(kind: SpaceDialogKind, spaceId: string): void; + closeSpaceDialog(): void; updateSettings(patch: Partial): void; /** Move a reaction to the front of the recents row. */ noteReactionUsed(key: string): void; @@ -220,6 +229,8 @@ const initial: State = { profileCard: null, lightbox: null, showCreateRoom: false, + showExplore: false, + spaceDialog: null, packs: [], settings: prefs.load(), verificationRequest: null, @@ -368,6 +379,10 @@ export const useStore = create((set, get) => ({ openCreateRoom: () => set({ showCreateRoom: true }), closeCreateRoom: () => set({ showCreateRoom: false }), + openExplore: () => set({ showExplore: true }), + closeExplore: () => set({ showExplore: false }), + openSpaceDialog: (kind, spaceId) => set({ spaceDialog: { kind, spaceId } }), + closeSpaceDialog: () => set({ spaceDialog: null }), updateSettings: (patch) => set((s) => { @@ -564,17 +579,16 @@ export function filterRooms( filter, search, spaces, - }: Pick, + hideMuted = false, + }: Pick & { + /** Leave muted rooms out — only applies inside a space, as in Discord. */ + hideMuted?: boolean; + }, ): RoomSummary[] { const needle = search.trim().toLowerCase(); - // Space membership is recorded twice in Matrix and neither half is reliable - // alone: the space lists children via `m.space.child`, and a room *may* point - // back with `m.space.parent` — but most rooms never set the parent event. So - // the children list is the primary source and `parentSpaces` is a fallback. - const children = activeSpaceId - ? new Set(spaces.find((s) => s.id === activeSpaceId)?.children ?? []) - : null; + // See `isInSpace` for why membership is read both ways round. + const space = activeSpaceId ? spaces.find((s) => s.id === activeSpaceId) : undefined; const visible = rooms.filter((room) => { if (room.isSpace) return false; @@ -584,9 +598,8 @@ export function filterRooms( if (room.membership === "left" || room.membership === "banned") return false; if (activeSpaceId) { - const inSpace = - children?.has(room.id) || room.parentSpaces.includes(activeSpaceId); - if (!inSpace) return false; + if (!isInSpace(room, space ?? { id: activeSpaceId, children: [] })) return false; + if (hideMuted && room.isMuted) return false; } switch (filter) { @@ -618,6 +631,50 @@ export function filterRooms( }); } +/** + * Whether a room belongs to a space. + * + * Space membership is recorded twice in Matrix and neither half is reliable + * alone: the space lists children via `m.space.child`, and a room *may* point + * back with `m.space.parent` — but most rooms never set the parent event. So + * the children list is the primary source and `parentSpaces` is a fallback. + */ +export function isInSpace( + room: RoomSummary, + space: Pick, +): boolean { + return space.children.includes(room.id) || room.parentSpaces.includes(space.id); +} + +/** The rooms you've joined in a space — what a space-wide action applies to. */ +export function roomsInSpace( + rooms: RoomSummary[], + spaces: SpaceSummary[], + spaceId: string, +): RoomSummary[] { + const space = spaces.find((s) => s.id === spaceId); + if (!space) return []; + return rooms.filter((r) => !r.isSpace && r.membership === "joined" && isInSpace(r, space)); +} + +/** + * The joined rooms that are in `spaceId` and in no other space you're in — + * the ones it's safe to offer to leave along with it. + * + * A room shared with another space stays: leaving one space shouldn't empty a + * different one. Membership is read both ways round, as in `filterRooms`. + */ +export function roomsOnlyInSpace( + rooms: RoomSummary[], + spaces: SpaceSummary[], + spaceId: string, +): RoomSummary[] { + const others = spaces.filter((s) => s.id !== spaceId); + return roomsInSpace(rooms, spaces, spaceId).filter( + (room) => !others.some((other) => isInSpace(room, other)), + ); +} + export interface RoomGroup { title: string; rooms: RoomSummary[]; diff --git a/src/store/spaces.test.ts b/src/store/spaces.test.ts new file mode 100644 index 0000000..6a14391 --- /dev/null +++ b/src/store/spaces.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import type { RoomSummary, SpaceSummary } from "../lib/bindings"; +import { filterRooms, roomsInSpace, roomsOnlyInSpace } from "./index"; + +function room(id: string, over: Partial = {}): RoomSummary { + return { + id, + name: id, + membership: "joined", + isSpace: false, + isUtility: false, + isMuted: false, + parentSpaces: [], + recency: 0, + latest: null, + ...over, + } as RoomSummary; +} + +function space(id: string, children: string[]): SpaceSummary { + return { id, name: id, avatarUrl: null, children, notificationCount: 0, highlightCount: 0 }; +} + +/** + * Leaving a space can take its rooms along, so which rooms count matters: a + * room shared with another space must survive, or leaving one space quietly + * empties a different one. + */ +describe("roomsOnlyInSpace", () => { + const spaces = [space("!a", ["!solo", "!shared"]), space("!b", ["!shared"])]; + + it("takes rooms only this space holds, and keeps shared ones", () => { + const rooms = [room("!solo"), room("!shared"), room("!loose")]; + expect(roomsOnlyInSpace(rooms, spaces, "!a").map((r) => r.id)).toEqual(["!solo"]); + }); + + it("counts a room that only names the space as its parent", () => { + const rooms = [room("!child", { parentSpaces: ["!a"] })]; + expect(roomsOnlyInSpace(rooms, spaces, "!a").map((r) => r.id)).toEqual(["!child"]); + }); + + it("treats a parent pointer to another space as shared", () => { + const rooms = [room("!solo", { parentSpaces: ["!b"] })]; + expect(roomsOnlyInSpace(rooms, spaces, "!a")).toEqual([]); + }); + + it("skips invites, rooms already left, and sub-spaces", () => { + const rooms = [ + room("!solo", { membership: "invited" }), + room("!shared", { membership: "left" }), + room("!sub", { isSpace: true, parentSpaces: ["!a"] }), + ]; + expect(roomsOnlyInSpace(rooms, spaces, "!a")).toEqual([]); + }); +}); + +/** What the space menu's mute, notify and mark-read entries act on. */ +describe("roomsInSpace", () => { + const spaces = [space("!a", ["!child"])]; + + it("includes rooms listed by the space and rooms that point back at it", () => { + const rooms = [room("!child"), room("!pointer", { parentSpaces: ["!a"] }), room("!loose")]; + expect(roomsInSpace(rooms, spaces, "!a").map((r) => r.id)).toEqual(["!child", "!pointer"]); + }); + + it("is empty for a space we don't know", () => { + expect(roomsInSpace([room("!child")], spaces, "!gone")).toEqual([]); + }); +}); + +describe("filterRooms with hideMuted", () => { + const spaces = [space("!a", ["!loud", "!quiet"])]; + const rooms = [room("!loud"), room("!quiet", { isMuted: true }), room("!elsewhere", { isMuted: true })]; + const base = { filter: "all" as const, search: "", spaces, hideMuted: true }; + + it("drops muted rooms inside the space", () => { + const ids = filterRooms(rooms, { ...base, activeSpaceId: "!a" }).map((r) => r.id); + expect(ids).toEqual(["!loud"]); + }); + + it("leaves \"everything\" alone, like Discord's per-server setting", () => { + const ids = filterRooms(rooms, { ...base, activeSpaceId: null }).map((r) => r.id).sort(); + expect(ids).toEqual(["!elsewhere", "!loud", "!quiet"]); + }); +}); From 23d4fea6e0d648e9b879db496371cc312fffa9bd Mon Sep 17 00:00:00 2001 From: Gintautas Kazlauskas Date: Wed, 21 Oct 2026 22:19:51 +0300 Subject: [PATCH 2/2] feat: Discord-style call tiling with clickable tiles Tiles keep a 16:9 shape in a best-fit grid instead of stretching to fill rows. Clicking a tile focuses it, with everyone else in a filmstrip; clicking it again goes back to the grid. Screen shares are their own tiles and take the stage until someone picks otherwise. Co-Authored-By: Claude Opus 5.5 --- src/components/CallStage.tsx | 338 +++++++++++++++++++++++++++-------- src/styles/app.css | 17 ++ 2 files changed, 283 insertions(+), 72 deletions(-) diff --git a/src/components/CallStage.tsx b/src/components/CallStage.tsx index d9e5888..62a3c2e 100644 --- a/src/components/CallStage.tsx +++ b/src/components/CallStage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { Track } from "livekit-client"; @@ -9,11 +9,15 @@ import { useStore } from "../store"; import { useCallState } from "./CallBar"; import { Avatar, Icon, Spinner } from "./ui"; import { corner, space, textSize } from "../lib/scale"; +import { useIsMobile } from "../lib/viewport"; /** * The video stage: a grid of participant tiles above the timeline, in the shape * people already know from Discord and Meet. * + * Clicking a tile focuses it — it takes the stage and everything else becomes a + * filmstrip — and clicking the focused tile goes back to the grid. + * * It deliberately doesn't take the whole window by default — chat stays * readable underneath, which is the point of having calls inside a chat client. * Fullscreen is a toggle for when the call *is* the thing you're doing. @@ -22,6 +26,10 @@ export function CallStage({ roomId }: { roomId: string }) { const state = useCallState(); const showBanner = useStore((s) => s.showBanner); const [fullscreen, setFullscreen] = useState(false); + const mobile = useIsMobile(); + // `undefined` is "not chosen yet", which lets a screen share take the stage + // on its own; `null` is an explicit choice of the grid. + const [picked, setPicked] = useState(undefined); // Escape is what everyone reaches for to get out of a fullscreen video. useEffect(() => { @@ -40,9 +48,16 @@ export function CallStage({ roomId }: { roomId: string }) { const connecting = state.status === "connecting"; const reconnecting = state.status === "reconnecting"; - // A shared screen is the thing everyone is looking at, so it gets the stage - // and the people become a filmstrip. - const shared = state.participants.find((p) => p.screenTrack); + const tiles = tilesOf(state.participants); + // A shared screen is the thing everyone is looking at, so until someone + // picks otherwise it gets the stage. A pick that has left the call (they + // hung up, or stopped sharing) falls back the same way. + const firstScreen = tiles.find((t) => t.screen)?.key ?? null; + const focusKey = + picked === undefined || (picked !== null && !tiles.some((t) => t.key === picked)) + ? firstScreen + : picked; + const focused = tiles.find((t) => t.key === focusKey); async function run(action: () => Promise) { try { @@ -87,53 +102,18 @@ export function CallStage({ roomId }: { roomId: string }) { connecting to the call…
- ) : shared ? ( -
-
- -
-
- {state.participants.map((p) => ( -
- -
- ))} -
-
+ ) : focused ? ( + t !== focused)} + // Inline on a desktop the stage is wide and short, so the strip goes + // down the side; fullscreen or on a phone there's height to spare. + strip={fullscreen || mobile ? "bottom" : "side"} + large={fullscreen} + onPick={setPicked} + /> ) : ( -
- {state.participants.map((p) => ( -
- -
- ))} -
+ )}
@@ -197,25 +177,199 @@ export function CallStage({ roomId }: { roomId: string }) { return fullscreen ? createPortal(stage, document.body) : stage; } +/** One thing on the stage: someone's camera, or a screen they're sharing. */ +interface TileView { + key: string; + participant: CallParticipantView; + track: Track | null; + screen: boolean; +} + +/** + * A screen share is its own tile, the way Discord shows a stream next to the + * person streaming it, rather than replacing their face. Shares come first — + * they're what people joined to look at. + */ +function tilesOf(participants: CallParticipantView[]): TileView[] { + const screens = participants + .filter((p) => p.screenTrack) + .map((p) => ({ key: `${p.identity}:screen`, participant: p, track: p.screenTrack, screen: true })); + const cameras = participants.map((p) => ({ + key: `${p.identity}:camera`, + participant: p, + track: p.cameraTrack, + screen: false, + })); + return [...screens, ...cameras]; +} + +const ASPECT = 16 / 9; +const GAP = 10; + +/** + * The column count that makes the biggest 16:9 tiles fit the box. + * + * Stretching tiles to fill rows instead crops faces into slivers in a wide, + * short stage; keeping the shape and letterboxing the grid is what Discord + * and Meet do. + */ +function fit(count: number, width: number, height: number) { + let best = { columns: 1, width: 0, height: 0 }; + for (let columns = 1; columns <= count; columns++) { + const rows = Math.ceil(count / columns); + const w = Math.min( + (width - GAP * (columns - 1)) / columns, + ((height - GAP * (rows - 1)) / rows) * ASPECT, + ); + if (w > best.width) best = { columns, width: Math.floor(w), height: Math.floor(w / ASPECT) }; + } + return best; +} + +function useSize(element: HTMLElement | null) { + const [size, setSize] = useState({ width: 0, height: 0 }); + useLayoutEffect(() => { + if (!element) return; + // Measure now: the observer's first report waits for a frame, which would + // paint one empty grid first. + const rect = element.getBoundingClientRect(); + setSize({ width: rect.width, height: rect.height }); + const observer = new ResizeObserver(([entry]) => { + setSize({ width: entry.contentRect.width, height: entry.contentRect.height }); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + return size; +} + +function Grid({ tiles, onPick }: { tiles: TileView[]; onPick: (key: string) => void }) { + const [box, setBox] = useState(null); + const size = useSize(box); + const { columns, width, height } = fit(tiles.length, size.width, size.height); + + return ( +
+ {width > 0 && ( +
+ {tiles.map((t) => ( +
+ onPick(t.key)} /> +
+ ))} +
+ )} +
+ ); +} + +function Focus({ + focused, + others, + strip, + large, + onPick, +}: { + focused: TileView; + others: TileView[]; + strip: "side" | "bottom"; + large: boolean; + onPick: (key: string | null) => void; +}) { + const side = strip === "side"; + const thumbHeight = large ? 110 : 64; + + return ( +
+
+ onPick(null)} /> +
+ {others.length > 0 && ( +
+ {others.map((t) => ( +
+ onPick(t.key)} /> +
+ ))} +
+ )} +
+ ); +} + /** - * One participant. + * One participant's camera, or their shared screen. * * The video element is fed by attaching the LiveKit track directly — frames * can't travel through React state, so the track object comes down and the * element is wired to it here. */ function Tile({ - participant, - track, - label, - contain, + tile, + focused, + small, + onClick, }: { - participant: CallParticipantView; - track: Track | null; - label?: string; - /** Screen shares are letterboxed; faces are cropped to fill. */ - contain?: boolean; + tile: TileView; + /** On the stage: shown whole rather than cropped, and a click unfocuses. */ + focused?: boolean; + /** A filmstrip thumbnail, too small for a full-size avatar. */ + small?: boolean; + onClick: () => void; }) { + const { participant, track, screen } = tile; const video = useRef(null); const name = localpart(participant.userId); @@ -230,15 +384,26 @@ function Tile({ return (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} style={{ position: "relative", width: "100%", height: "100%", - borderRadius: corner(16), + borderRadius: corner(small ? 12 : 16), overflow: "hidden", background: "var(--ink-900)", border: "1px solid var(--border-subtle)", - outline: participant.isSpeaking + outline: participant.isSpeaking && !screen ? "2px solid var(--accent-primary)" : "2px solid transparent", outlineOffset: -1, @@ -259,20 +424,49 @@ function Tile({ style={{ width: "100%", height: "100%", - objectFit: contain ? "contain" : "cover", - transform: participant.isLocal && !label ? "scaleX(-1)" : undefined, + // Screens are letterboxed, and so is a focused face — it's on the + // stage to be seen, not cropped. Grid faces are cropped to fill. + objectFit: screen || focused ? "contain" : "cover", + transform: participant.isLocal && !screen ? "scaleX(-1)" : undefined, }} /> ) : ( - + )} -
+ + + +
{participant.isLocal ? "you" : name} - {label ? ` · ${label}` : ""} + {screen ? " · screen" : ""} - {participant.isMuted && !label && ( + {participant.isMuted && !screen && (