12 Commits

Author SHA1 Message Date
5eab7c3e4f Process events list client side 2025-11-25 09:48:49 +01:00
a7bfd713c3 Ready to implement room widget 2025-11-24 17:59:12 +01:00
4be661d999 Fix appearance of unread conversations 2025-11-24 17:55:26 +01:00
1f4e374e66 Display rooms list 2025-11-24 17:50:31 +01:00
cce9b3de5d Hide menu by default on desktop 2025-11-24 16:36:36 +01:00
820b095be0 Display the list of spaces 2025-11-24 16:05:01 +01:00
0a37688116 Can react to event 2025-11-24 13:40:14 +01:00
4d72644a31 Can edit message 2025-11-24 13:18:23 +01:00
0a395b0d26 Can redact message 2025-11-24 13:06:31 +01:00
639cc6c737 Can send text message 2025-11-24 12:54:59 +01:00
bf119a34fb Can get room messages 2025-11-24 12:36:59 +01:00
7562a7fc61 Get latest message for a room 2025-11-24 11:20:20 +01:00
21 changed files with 832 additions and 55 deletions

View File

@@ -0,0 +1,204 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_room_controller::RoomIdInPath;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::Room;
use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind};
use matrix_sdk::room::MessagesOptions;
use matrix_sdk::room::edit::EditedContent;
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
use matrix_sdk::ruma::events::relation::Annotation;
use matrix_sdk::ruma::events::room::message::{
RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
};
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
#[derive(Serialize)]
pub struct APIEvent {
id: OwnedEventId,
time: MilliSecondsSinceUnixEpoch,
sender: OwnedUserId,
data: Box<RawValue>,
}
impl APIEvent {
pub async fn from_evt(msg: TimelineEvent, room_id: &RoomId) -> anyhow::Result<Self> {
let (event, raw) = match &msg.kind {
TimelineEventKind::Decrypted(d) => (d.event.deserialize()?, d.event.json()),
TimelineEventKind::UnableToDecrypt { event, .. }
| TimelineEventKind::PlainText { event } => (
event.deserialize()?.into_full_event(room_id.to_owned()),
event.json(),
),
};
Ok(Self {
id: event.event_id().to_owned(),
time: event.origin_server_ts(),
sender: event.sender().to_owned(),
data: raw.to_owned(),
})
}
}
#[derive(Serialize)]
pub struct APIEventsList {
pub start: String,
pub end: Option<String>,
pub events: Vec<APIEvent>,
}
/// Get messages for a given room
pub(super) async fn get_events(
room: &Room,
limit: u32,
from: Option<&str>,
) -> anyhow::Result<APIEventsList> {
let mut msg_opts = MessagesOptions::backward();
msg_opts.from = from.map(str::to_string);
msg_opts.limit = UInt::from(limit);
let messages = room.messages(msg_opts).await?;
Ok(APIEventsList {
start: messages.start,
end: messages.end,
events: stream::iter(messages.chunk)
.then(async |msg| APIEvent::from_evt(msg, room.room_id()).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?,
})
}
#[derive(Deserialize)]
pub struct GetRoomEventsQuery {
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
from: Option<String>,
}
/// Get the events for a room
pub async fn get_for_room(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
query: web::Query<GetRoomEventsQuery>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(HttpResponse::Ok()
.json(get_events(&room, query.limit.unwrap_or(500), query.from.as_deref()).await?))
}
#[derive(Deserialize)]
struct SendTextMessageRequest {
content: String,
}
pub async fn send_text_message(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
room.send(RoomMessageEventContent::text_plain(req.content))
.await?;
Ok(HttpResponse::Accepted().finish())
}
#[derive(serde::Deserialize)]
pub struct EventIdInPath {
pub(crate) event_id: OwnedEventId,
}
pub async fn set_text_content(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let edit_event = match room
.make_edit_event(
&event_path.event_id,
EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain(
req.content,
)),
)
.await
{
Ok(msg) => msg,
Err(e) => {
log::error!(
"Failed to created edit message event {}: {e}",
event_path.event_id
);
return Ok(HttpResponse::InternalServerError()
.json(format!("Failed to create edit message event! {e}")));
}
};
Ok(match room.send(edit_event).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to edit event message {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to edit event! {e}"))
}
})
}
#[derive(Deserialize)]
struct EventReactionBody {
key: String,
}
pub async fn react_to_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let body = client.auth.decode_json_body::<EventReactionBody>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let annotation = Annotation::new(event_path.event_id.to_owned(), body.key.to_owned());
room.send(ReactionEventContent::from(annotation)).await?;
Ok(HttpResponse::Accepted().finish())
}
pub async fn redact_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(match room.redact(&event_path.event_id, None, None).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to redact event {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to redact event! {e}"))
}
})
}

View File

@@ -1,4 +1,5 @@
use crate::controllers::HttpResult; use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_event_controller::{APIEvent, get_events};
use crate::controllers::matrix::matrix_media_controller; use crate::controllers::matrix::matrix_media_controller;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor; use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpRequest, HttpResponse, web};
@@ -15,10 +16,13 @@ pub struct APIRoomInfo {
avatar: Option<OwnedMxcUri>, avatar: Option<OwnedMxcUri>,
is_space: bool, is_space: bool,
parents: Vec<OwnedRoomId>, parents: Vec<OwnedRoomId>,
number_unread_messages: u64,
latest_event: Option<APIEvent>,
} }
impl APIRoomInfo { impl APIRoomInfo {
async fn from_room(r: &Room) -> anyhow::Result<Self> { async fn from_room(r: &Room) -> anyhow::Result<Self> {
// Get parent spaces
let parent_spaces = r let parent_spaces = r
.parent_spaces() .parent_spaces()
.await? .await?
@@ -47,6 +51,8 @@ impl APIRoomInfo {
avatar: r.avatar_url(), avatar: r.avatar_url(),
is_space: r.is_space(), is_space: r.is_space(),
parents: parent_spaces, parents: parent_spaces,
number_unread_messages: r.unread_notification_counts().notification_count,
latest_event: get_events(r, 1, None).await?.events.into_iter().next(),
}) })
} }
} }
@@ -77,7 +83,7 @@ pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct RoomIdInPath { pub struct RoomIdInPath {
id: OwnedRoomId, pub(crate) room_id: OwnedRoomId,
} }
/// Get the list of joined rooms of the user /// Get the list of joined rooms of the user
@@ -85,7 +91,7 @@ pub async fn single_room_info(
client: MatrixClientExtractor, client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>, path: web::Path<RoomIdInPath>,
) -> HttpResult { ) -> HttpResult {
Ok(match client.client.client.get_room(&path.id) { Ok(match client.client.client.get_room(&path.room_id) {
None => HttpResponse::NotFound().json("Room not found"), None => HttpResponse::NotFound().json("Room not found"),
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r).await?), Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r).await?),
}) })
@@ -97,7 +103,7 @@ pub async fn room_avatar(
client: MatrixClientExtractor, client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>, path: web::Path<RoomIdInPath>,
) -> HttpResult { ) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.id) else { let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found")); return Ok(HttpResponse::NotFound().json("Room not found"));
}; };

View File

@@ -1,3 +1,4 @@
pub mod matrix_event_controller;
pub mod matrix_media_controller; pub mod matrix_media_controller;
pub mod matrix_profile_controller; pub mod matrix_profile_controller;
pub mod matrix_room_controller; pub mod matrix_room_controller;

View File

@@ -10,7 +10,8 @@ use matrixgw_backend::app_config::AppConfig;
use matrixgw_backend::broadcast_messages::BroadcastMessage; use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants; use matrixgw_backend::constants;
use matrixgw_backend::controllers::matrix::{ use matrixgw_backend::controllers::matrix::{
matrix_media_controller, matrix_profile_controller, matrix_room_controller, matrix_event_controller, matrix_media_controller, matrix_profile_controller,
matrix_room_controller,
}; };
use matrixgw_backend::controllers::{ use matrixgw_backend::controllers::{
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller, auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
@@ -147,11 +148,11 @@ async fn main() -> std::io::Result<()> {
web::get().to(matrix_room_controller::get_joined_spaces), web::get().to(matrix_room_controller::get_joined_spaces),
) )
.route( .route(
"/api/matrix/room/{id}", "/api/matrix/room/{room_id}",
web::get().to(matrix_room_controller::single_room_info), web::get().to(matrix_room_controller::single_room_info),
) )
.route( .route(
"/api/matrix/room/{id}/avatar", "/api/matrix/room/{room_id}/avatar",
web::get().to(matrix_room_controller::room_avatar), web::get().to(matrix_room_controller::room_avatar),
) )
// Matrix profile controller // Matrix profile controller
@@ -163,6 +164,27 @@ async fn main() -> std::io::Result<()> {
"/api/matrix/profile/get_multiple", "/api/matrix/profile/get_multiple",
web::post().to(matrix_profile_controller::get_multiple), web::post().to(matrix_profile_controller::get_multiple),
) )
// Matrix events controller
.route(
"/api/matrix/room/{room_id}/events",
web::get().to(matrix_event_controller::get_for_room),
)
.route(
"/api/matrix/room/{room_id}/send_text_message",
web::post().to(matrix_event_controller::send_text_message),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/set_text_content",
web::post().to(matrix_event_controller::set_text_content),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/react",
web::post().to(matrix_event_controller::react_to_event),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}",
web::delete().to(matrix_event_controller::redact_event),
)
// Matrix media controller // Matrix media controller
.route( .route(
"/api/matrix/media/{mxc}", "/api/matrix/media/{mxc}",

View File

@@ -0,0 +1,70 @@
import { APIClient } from "../ApiClient";
import type { Room } from "./MatrixApiRoom";
export interface MatrixRoomMessage {
type: "m.room.message";
content: {
body: string;
msgtype: "m.text" | "m.image" | string;
"m.relates_to"?: {
event_id: string;
rel_type: "m.replace" | string;
};
file?: {
url: string;
};
};
}
export interface MatrixReaction {
type: "m.reaction";
content: {
"m.relates_to": {
event_id: string;
key: string;
};
};
}
export interface MatrixRoomRedaction {
type: "m.room.redaction";
redacts: string;
}
export type MatrixEventData =
| MatrixRoomMessage
| MatrixReaction
| MatrixRoomRedaction
| { type: "other" };
export interface MatrixEvent {
id: string;
time: number;
sender: string;
data: MatrixEventData;
}
export interface MatrixEventsList {
start: string;
end?: string;
events: MatrixEvent[];
}
export class MatrixApiEvent {
/**
* Get Matrix room events
*/
static async GetRoomEvents(
room: Room,
from?: string
): Promise<MatrixEventsList> {
return (
await APIClient.exec({
method: "GET",
uri:
`/matrix/room/${encodeURIComponent(room.id)}/events` +
(from ? `?from=${from}` : ""),
})
).data;
}
}

View File

@@ -0,0 +1,12 @@
import { APIClient } from "../ApiClient";
export class MatrixApiMedia {
/**
* Get media URL
*/
static MediaURL(url: string, thumbnail: boolean): string {
return `${APIClient.ActualBackendURL()}/matrix/media/${encodeURIComponent(
url
)}?thumbnail=${thumbnail}`;
}
}

View File

@@ -0,0 +1,26 @@
import { APIClient } from "../ApiClient";
export interface UserProfile {
user_id: string;
display_name?: string;
avatar?: string;
}
export type UsersMap = Map<string, UserProfile>;
export class MatrixApiProfile {
/**
* Get multiple profiles information
*/
static async GetMultiple(ids: string[]): Promise<UsersMap> {
const list: UserProfile[] = (
await APIClient.exec({
method: "POST",
uri: "/matrix/profile/get_multiple",
jsonData: ids,
})
).data;
return new Map(list.map((e) => [e.user_id, e]));
}
}

View File

@@ -0,0 +1,55 @@
import { APIClient } from "../ApiClient";
import type { UserInfo } from "../AuthApi";
import type { MatrixEvent } from "./MatrixApiEvent";
import type { UsersMap } from "./MatrixApiProfile";
export interface Room {
id: string;
name?: string;
members: string[];
avatar?: string;
is_space?: boolean;
parents: string[];
number_unread_messages: number;
latest_event?: MatrixEvent;
}
/**
* Find main member of room
*/
export function mainRoomMember(user: UserInfo, r: Room): string | undefined {
if (r.members.length <= 1) return r.members[0];
if (r.members.length < 2)
return r.members[0] == user.matrix_user_id ? r.members[1] : r.members[0];
return undefined;
}
/**
* Find room name
*/
export function roomName(user: UserInfo, r: Room, users: UsersMap): string {
if (r.name) return r.name;
const name = r.members
.filter((m) => m !== user.matrix_user_id)
.map((m) => users.get(m)?.display_name ?? m)
.join(",");
return name === "" ? "Empty room" : name;
}
export class MatrixApiRoom {
/**
* Get the list of joined rooms
*/
static async ListJoined(): Promise<Room[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/matrix/room/joined",
})
).data;
}
}

View File

@@ -7,3 +7,12 @@ body,
#root { #root {
height: 100%; height: 100%;
} }
#root {
display: flex;
flex-direction: column;
}
#root > div {
flex: 1;
}

View File

@@ -1,6 +1,5 @@
import { MatrixSyncApi } from "../api/MatrixSyncApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage"; import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MainMessageWidget } from "../widgets/messages/MainMessagesWidget";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage"; import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
export function HomeRoute(): React.ReactElement { export function HomeRoute(): React.ReactElement {
@@ -8,15 +7,5 @@ export function HomeRoute(): React.ReactElement {
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />; if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
return ( return <MainMessageWidget />;
<p>
Todo home route{" "}
<AsyncWidget
loadKey={1}
errMsg="Failed to start sync thread!"
load={MatrixSyncApi.Start}
build={() => <>sync started</>}
/>
</p>
);
} }

View File

@@ -0,0 +1,101 @@
import type {
MatrixEvent,
MatrixEventsList,
} from "../api/matrix/MatrixApiEvent";
import type { Room } from "../api/matrix/MatrixApiRoom";
export interface MessageReaction {
event_id: string;
account: string;
key: string;
}
export interface Message {
event_id: string;
sent: number;
modified: boolean;
reactions: MessageReaction[];
content: string;
image?: string;
}
export class RoomEventsManager {
readonly room: Room;
private events: MatrixEvent[];
messages: Message[];
endToken?: string;
constructor(room: Room, initialMessages: MatrixEventsList) {
this.room = room;
this.events = [];
this.messages = [];
this.processNewEvents(initialMessages);
}
/**
* Process events given by the API
*/
processNewEvents(evts: MatrixEventsList) {
this.endToken = evts.end;
this.events = [...this.events, ...evts.events];
this.rebuildMessagesList();
}
private rebuildMessagesList() {
// Sorts events list to process oldest events first
this.events.sort((a, b) => a.time - b.time);
// First, process redactions to skip redacted events
let redacted = new Set(
this.events
.map((e) =>
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
)
.filter((e) => e !== undefined)
);
for (const evt of this.events) {
if (redacted.has(evt.id)) continue;
const data = evt.data;
// Message
if (data.type === "m.room.message") {
// Check if this message replaces another one
if (data.content["m.relates_to"]) {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"]?.event_id
);
if (!message) continue;
message.modified = true;
message.content = data.content.body;
continue;
}
this.messages.push({
event_id: evt.id,
modified: false,
reactions: [],
sent: evt.time,
image: data.content.file?.url,
content: data.content.body,
});
}
// Reaction
if (data.type === "m.reaction") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"].event_id
);
if (!message) continue;
message.reactions.push({
account: evt.sender,
event_id: evt.id,
key: data.content["m.relates_to"].key,
});
}
}
}
}

View File

@@ -1,7 +1,6 @@
import { Button } from "@mui/material"; import { Button } from "@mui/material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import Toolbar from "@mui/material/Toolbar";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react"; import * as React from "react";
import { Outlet, useNavigate } from "react-router"; import { Outlet, useNavigate } from "react-router";
@@ -105,20 +104,18 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
signOut, signOut,
}} }}
> >
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<Box <Box
ref={layoutRef} ref={layoutRef}
sx={{ sx={{
position: "relative", position: "relative",
display: "flex", display: "flex",
overflow: "hidden", overflow: "hidden",
height: "100%",
width: "100%",
}} }}
> >
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<DashboardSidebar <DashboardSidebar
expanded={isNavigationExpanded} expanded={isNavigationExpanded}
setExpanded={setIsNavigationExpanded} setExpanded={setIsNavigationExpanded}
@@ -132,7 +129,6 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
minWidth: 0, minWidth: 0,
}} }}
> >
<Toolbar sx={{ displayPrint: "none" }} />
<Box <Box
component="main" component="main"
sx={{ sx={{

View File

@@ -81,7 +81,11 @@ export default function DashboardHeader({
); );
return ( return (
<AppBar color="inherit" position="absolute" sx={{ displayPrint: "none" }}> <AppBar
color="inherit"
position="static"
sx={{ displayPrint: "none", overflow: "hidden" }}
>
<Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}> <Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}>
<Stack <Stack
direction="row" direction="row"

View File

@@ -3,7 +3,6 @@ import Icon from "@mdi/react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer"; import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List"; import List from "@mui/material/List";
import Toolbar from "@mui/material/Toolbar";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type {} from "@mui/material/themeCssVarsAugmentation"; import type {} from "@mui/material/themeCssVarsAugmentation";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
@@ -28,7 +27,6 @@ export interface DashboardSidebarProps {
export default function DashboardSidebar({ export default function DashboardSidebar({
expanded = true, expanded = true,
setExpanded, setExpanded,
disableCollapsibleSidebar = false,
container, container,
}: DashboardSidebarProps) { }: DashboardSidebarProps) {
const theme = useTheme(); const theme = useTheme();
@@ -53,8 +51,6 @@ export default function DashboardSidebar({
return () => {}; return () => {};
}, [expanded, theme.transitions.duration.enteringScreen]); }, [expanded, theme.transitions.duration.enteringScreen]);
const mini = !disableCollapsibleSidebar && !expanded;
const handleSetSidebarExpanded = React.useCallback( const handleSetSidebarExpanded = React.useCallback(
(newExpanded: boolean) => () => { (newExpanded: boolean) => () => {
setExpanded(newExpanded); setExpanded(newExpanded);
@@ -66,15 +62,13 @@ export default function DashboardSidebar({
if (!isOverSmViewport) { if (!isOverSmViewport) {
setExpanded(false); setExpanded(false);
} }
}, [mini, setExpanded, isOverSmViewport]); }, [expanded, setExpanded, isOverSmViewport]);
const hasDrawerTransitions = const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
const getDrawerContent = React.useCallback( const getDrawerContent = React.useCallback(
(viewport: "phone" | "tablet" | "desktop") => ( (viewport: "phone" | "tablet" | "desktop") => (
<React.Fragment> <React.Fragment>
<Toolbar />
<Box <Box
component="nav" component="nav"
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`} aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
@@ -84,9 +78,10 @@ export default function DashboardSidebar({
flexDirection: "column", flexDirection: "column",
justifyContent: "space-between", justifyContent: "space-between",
overflow: "auto", overflow: "auto",
scrollbarGutter: mini ? "stable" : "auto", scrollbarGutter: !expanded ? "stable" : "auto",
overflowX: "hidden", overflowX: "hidden",
pt: !mini ? 0 : 2, pt: expanded ? 0 : 2,
paddingTop: 0,
...(hasDrawerTransitions ...(hasDrawerTransitions
? getDrawerSxTransitionMixin(isFullyExpanded, "padding") ? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
: {}), : {}),
@@ -95,9 +90,9 @@ export default function DashboardSidebar({
<List <List
dense dense
sx={{ sx={{
padding: mini ? 0 : 0.5, padding: !expanded ? 0 : 0.5,
mb: 4, mb: 4,
width: mini ? MINI_DRAWER_WIDTH : "auto", width: !expanded ? MINI_DRAWER_WIDTH : "auto",
}} }}
> >
<DashboardSidebarPageItem <DashboardSidebarPageItem
@@ -105,30 +100,34 @@ export default function DashboardSidebar({
title="Messages" title="Messages"
icon={<Icon path={mdiForum} size={"1.5em"} />} icon={<Icon path={mdiForum} size={"1.5em"} />}
href="/" href="/"
mini={viewport === "desktop"}
/> />
<DashboardSidebarDividerItem /> <DashboardSidebarDividerItem />
<DashboardSidebarPageItem <DashboardSidebarPageItem
title="Matrix link" title="Matrix link"
icon={<Icon path={mdiLinkLock} size={"1.5em"} />} icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
href="/matrix_link" href="/matrix_link"
mini={viewport === "desktop"}
/> />
<DashboardSidebarPageItem <DashboardSidebarPageItem
title="API tokens" title="API tokens"
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />} icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
href="/tokens" href="/tokens"
mini={viewport === "desktop"}
/> />
<DashboardSidebarPageItem <DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected} disabled={!user.info.matrix_account_connected}
title="WS Debug" title="WS Debug"
icon={<Icon path={mdiBug} size={"1.5em"} />} icon={<Icon path={mdiBug} size={"1.5em"} />}
href="/wsdebug" href="/wsdebug"
mini={viewport === "desktop"}
/> />
</List> </List>
</Box> </Box>
</React.Fragment> </React.Fragment>
), ),
[ [
mini, expanded,
hasDrawerTransitions, hasDrawerTransitions,
isFullyExpanded, isFullyExpanded,
user.info.matrix_account_connected, user.info.matrix_account_connected,
@@ -136,8 +135,14 @@ export default function DashboardSidebar({
); );
const getDrawerSharedSx = React.useCallback( const getDrawerSharedSx = React.useCallback(
(isTemporary: boolean) => { (isTemporary: boolean, desktop?: boolean) => {
const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH; const drawerWidth = desktop
? expanded
? MINI_DRAWER_WIDTH
: 0
: !expanded
? MINI_DRAWER_WIDTH
: DRAWER_WIDTH;
return { return {
displayPrint: "none", displayPrint: "none",
@@ -154,17 +159,16 @@ export default function DashboardSidebar({
}, },
}; };
}, },
[expanded, mini] [expanded, !expanded]
); );
const sidebarContextValue = React.useMemo(() => { const sidebarContextValue = React.useMemo(() => {
return { return {
onPageItemClick: handlePageItemClick, onPageItemClick: handlePageItemClick,
mini,
fullyExpanded: isFullyExpanded, fullyExpanded: isFullyExpanded,
hasDrawerTransitions, hasDrawerTransitions,
}; };
}, [handlePageItemClick, mini, isFullyExpanded, hasDrawerTransitions]); }, [handlePageItemClick, !expanded, isFullyExpanded, hasDrawerTransitions]);
return ( return (
<DashboardSidebarContext.Provider value={sidebarContextValue}> <DashboardSidebarContext.Provider value={sidebarContextValue}>
@@ -179,7 +183,7 @@ export default function DashboardSidebar({
sx={{ sx={{
display: { display: {
xs: "block", xs: "block",
sm: disableCollapsibleSidebar ? "block" : "none", sm: "none",
md: "none", md: "none",
}, },
...getDrawerSharedSx(true), ...getDrawerSharedSx(true),
@@ -192,7 +196,7 @@ export default function DashboardSidebar({
sx={{ sx={{
display: { display: {
xs: "none", xs: "none",
sm: disableCollapsibleSidebar ? "none" : "block", sm: "block",
md: "none", md: "none",
}, },
...getDrawerSharedSx(false), ...getDrawerSharedSx(false),
@@ -204,7 +208,7 @@ export default function DashboardSidebar({
variant="permanent" variant="permanent"
sx={{ sx={{
display: { xs: "none", md: "block" }, display: { xs: "none", md: "block" },
...getDrawerSharedSx(false), ...getDrawerSharedSx(false, true),
}} }}
> >
{getDrawerContent("desktop")} {getDrawerContent("desktop")}

View File

@@ -2,7 +2,6 @@ import * as React from "react";
const DashboardSidebarContext = React.createContext<{ const DashboardSidebarContext = React.createContext<{
onPageItemClick: () => void; onPageItemClick: () => void;
mini: boolean;
fullyExpanded: boolean; fullyExpanded: boolean;
hasDrawerTransitions: boolean; hasDrawerTransitions: boolean;
} | null>(null); } | null>(null);

View File

@@ -17,6 +17,7 @@ export interface DashboardSidebarPageItemProps {
href: string; href: string;
action?: React.ReactNode; action?: React.ReactNode;
disabled?: boolean; disabled?: boolean;
mini?: boolean;
} }
export default function DashboardSidebarPageItem({ export default function DashboardSidebarPageItem({
@@ -25,6 +26,7 @@ export default function DashboardSidebarPageItem({
href, href,
action, action,
disabled = false, disabled = false,
mini = false,
}: DashboardSidebarPageItemProps) { }: DashboardSidebarPageItemProps) {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -32,11 +34,7 @@ export default function DashboardSidebarPageItem({
if (!sidebarContext) { if (!sidebarContext) {
throw new Error("Sidebar context was used without a provider."); throw new Error("Sidebar context was used without a provider.");
} }
const { const { onPageItemClick, fullyExpanded = true } = sidebarContext;
onPageItemClick,
mini = false,
fullyExpanded = true,
} = sidebarContext;
const hasExternalHref = href const hasExternalHref = href
? href.startsWith("http://") || href.startsWith("https://") ? href.startsWith("http://") || href.startsWith("https://")

View File

@@ -0,0 +1,85 @@
import { Divider } from "@mui/material";
import React from "react";
import {
MatrixApiProfile,
type UsersMap,
} from "../../api/matrix/MatrixApiProfile";
import { MatrixApiRoom, type Room } from "../../api/matrix/MatrixApiRoom";
import { MatrixSyncApi } from "../../api/MatrixSyncApi";
import { AsyncWidget } from "../AsyncWidget";
import { RoomSelector } from "./RoomSelector";
import { RoomWidget } from "./RoomWidget";
import { SpaceSelector } from "./SpaceSelector";
export function MainMessageWidget(): React.ReactElement {
const [rooms, setRooms] = React.useState<Room[] | undefined>();
const [users, setUsers] = React.useState<UsersMap | undefined>();
const load = async () => {
await MatrixSyncApi.Start();
const rooms = await MatrixApiRoom.ListJoined();
setRooms(rooms);
// Get the list of users in rooms
const users = rooms.reduce((prev, r) => {
r.members.forEach((m) => prev.add(m));
return prev;
}, new Set<string>());
setUsers(await MatrixApiProfile.GetMultiple([...users]));
};
return (
<AsyncWidget
loadKey={1}
load={load}
ready={!!rooms && !!users}
errMsg="Failed to initialize messaging component!"
build={() => <_MainMessageWidget rooms={rooms!} users={users!} />}
/>
);
}
function _MainMessageWidget(p: {
rooms: Room[];
users: UsersMap;
}): React.ReactElement {
const [space, setSpace] = React.useState<string | undefined>();
const [room, setRoom] = React.useState<Room | undefined>();
const spaceRooms = React.useMemo(() => {
return p.rooms
.filter((r) => !r.is_space && (!space || r.parents.includes(space)))
.sort(
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
);
}, [space, p.rooms]);
return (
<div style={{ display: "flex", height: "100%" }}>
<SpaceSelector {...p} selectedSpace={space} onChange={setSpace} />
<Divider orientation="vertical" />
<RoomSelector
{...p}
rooms={spaceRooms}
currRoom={room}
onChange={setRoom}
/>
<Divider orientation="vertical" />
{room === undefined && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flex: 1,
}}
>
No room selected.
</div>
)}
{room && <RoomWidget {...p} room={room} />}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Avatar } from "@mui/material";
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import {
mainRoomMember,
roomName,
type Room,
} from "../../api/matrix/MatrixApiRoom";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
export function RoomIcon(p: {
room: Room;
users: UsersMap;
}): React.ReactElement {
const user = useUserInfo();
let url = p.room.avatar;
if (!url) {
const member = mainRoomMember(user.info, p.room);
if (member) url = p.users.get(member)?.avatar;
}
const name = roomName(user.info, p.room, p.users);
return (
<Avatar
variant={p.room.is_space ? "square" : undefined}
src={url ? MatrixApiMedia.MediaURL(url, true) : undefined}
>
{name.slice(0, 1)}
</Avatar>
);
}

View File

@@ -0,0 +1,80 @@
import {
Chip,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import { roomName, type Room } from "../../api/matrix/MatrixApiRoom";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { RoomIcon } from "./RoomIcon";
const ROOM_SELECTOR_WIDTH = "300px";
export function RoomSelector(p: {
users: UsersMap;
rooms: Room[];
currRoom?: Room;
onChange: (r: Room) => void;
}): React.ReactElement {
const user = useUserInfo();
if (p.rooms.length === 0)
return (
<div
style={{
width: ROOM_SELECTOR_WIDTH,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
No room to display.
</div>
);
return (
<List
style={{
width: ROOM_SELECTOR_WIDTH,
}}
>
{p.rooms.map((r) => (
<ListItem
key={r.id}
secondaryAction={
r.number_unread_messages === 0 ? undefined : (
<Chip color="error" label={r.number_unread_messages} />
)
}
disablePadding
>
<ListItemButton
role={undefined}
onClick={() => p.onChange(r)}
dense
selected={p.currRoom?.id === r.id}
>
<ListItemIcon>
<RoomIcon room={r} {...p} />
</ListItemIcon>
<ListItemText
primary={
<span
style={{
fontWeight:
r.number_unread_messages > 0 ? "bold" : undefined,
}}
>
{roomName(user.info, r, p.users)}
</span>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
);
}

View File

@@ -0,0 +1,30 @@
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { RoomEventsManager } from "../../utils/RoomEventsManager";
import { AsyncWidget } from "../AsyncWidget";
export function RoomWidget(p: {
room: Room;
users: UsersMap;
}): React.ReactElement {
const [roomMgr, setRoomMgr] = React.useState<undefined | RoomEventsManager>();
const load = async () => {
setRoomMgr(undefined);
const messages = await MatrixApiEvent.GetRoomEvents(p.room);
const mgr = new RoomEventsManager(p.room, messages);
setRoomMgr(mgr);
};
return (
<AsyncWidget
loadKey={p.room.id}
ready={!!roomMgr}
load={load}
errMsg="Failed to load room!"
build={() => <>room</>}
/>
);
}

View File

@@ -0,0 +1,53 @@
import HomeIcon from "@mui/icons-material/Home";
import { Button } from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { RoomIcon } from "./RoomIcon";
export function SpaceSelector(p: {
rooms: Room[];
users: UsersMap;
selectedSpace?: string;
onChange: (space?: string) => void;
}): React.ReactElement {
const spaces = React.useMemo(
() => p.rooms.filter((r) => r.is_space),
[p.rooms]
);
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<SpaceButton
icon={<HomeIcon />}
onClick={() => p.onChange()}
selected={p.selectedSpace === undefined}
/>
{spaces.map((s) => (
<SpaceButton
key={s.id}
icon={<RoomIcon room={s} {...p} />}
onClick={() => p.onChange(s.id)}
selected={p.selectedSpace === s.id}
/>
))}
</div>
);
}
function SpaceButton(p: {
selected?: boolean;
icon: React.ReactElement;
onClick: () => void;
}): React.ReactElement {
return (
<Button
variant={p.selected ? "contained" : "text"}
style={{ margin: "2px 5px", padding: "25px 10px", fontSize: "200%" }}
onClick={p.onClick}
>
{p.icon}
</Button>
);
}