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/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 && ( 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"]); + }); +}); diff --git a/src/styles/app.css b/src/styles/app.css index f33a0de..5607ad9 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -129,6 +129,23 @@ button { outline: none; } +/* A call tile says it can be clicked only when the pointer is over it, so a + grid of faces doesn't carry a grid of icons. */ +.uwu-call-tile { + cursor: pointer; +} +.uwu-call-tile .uwu-call-tile-hint { + opacity: 0; + transition: opacity var(--dur-fast) ease; +} +.uwu-call-tile:hover .uwu-call-tile-hint, +.uwu-call-tile:focus-visible .uwu-call-tile-hint { + opacity: 1; +} +.uwu-call-tile:focus-visible { + outline: 2px solid var(--accent-primary) !important; +} + .uwu-scroll { overflow-y: auto; overscroll-behavior: contain;