Compare commits
28 Commits
564e606ac7
...
migrate-to
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eab7c3e4f | |||
| a7bfd713c3 | |||
| 4be661d999 | |||
| 1f4e374e66 | |||
| cce9b3de5d | |||
| 820b095be0 | |||
| 0a37688116 | |||
| 4d72644a31 | |||
| 0a395b0d26 | |||
| 639cc6c737 | |||
| bf119a34fb | |||
| 7562a7fc61 | |||
| d23190f9d2 | |||
| 35b53fee5c | |||
| 934e6a4cc1 | |||
| b744265242 | |||
| e8ce97eea0 | |||
| ecbe4885c1 | |||
| 1385afc974 | |||
| 8d2cea5f82 | |||
| 751e3b8654 | |||
| 24f06a78a9 | |||
| 6b70842b61 | |||
| 7203671b18 | |||
| 055ab3759c | |||
| 3ecfc6b470 | |||
| a1b22699e9 | |||
| 0d8905d842 |
37
matrixgw_backend/Cargo.lock
generated
37
matrixgw_backend/Cargo.lock
generated
@@ -241,6 +241,20 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-ws"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3a1fb4f9f2794b0aadaf2ba5f14a6f034c7e86957b458c506a8cb75953f2d99"
|
||||||
|
dependencies = [
|
||||||
|
"actix-codec",
|
||||||
|
"actix-http",
|
||||||
|
"actix-web",
|
||||||
|
"bytestring",
|
||||||
|
"futures-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -763,6 +777,17 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
|
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfb"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"fnv",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -2343,6 +2368,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "infer"
|
||||||
|
version = "0.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
|
||||||
|
dependencies = [
|
||||||
|
"cfb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3026,6 +3060,7 @@ dependencies = [
|
|||||||
"actix-remote-ip",
|
"actix-remote-ip",
|
||||||
"actix-session",
|
"actix-session",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"actix-ws",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base16ct 0.3.0",
|
"base16ct 0.3.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3033,6 +3068,7 @@ dependencies = [
|
|||||||
"env_logger",
|
"env_logger",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"infer",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jwt-simple",
|
"jwt-simple",
|
||||||
"lazy-regex",
|
"lazy-regex",
|
||||||
@@ -3049,7 +3085,6 @@ dependencies = [
|
|||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ actix-cors = "0.7.1"
|
|||||||
light-openid = "1.0.4"
|
light-openid = "1.0.4"
|
||||||
bytes = "1.10.1"
|
bytes = "1.10.1"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
urlencoding = "2.1.3"
|
|
||||||
base16ct = { version = "0.3.0", features = ["alloc"] }
|
base16ct = { version = "0.3.0", features = ["alloc"] }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
|
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
|
||||||
@@ -28,8 +27,10 @@ ipnet = { version = "2.11.0", features = ["serde"] }
|
|||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
mailchecker = "6.0.19"
|
mailchecker = "6.0.19"
|
||||||
matrix-sdk = "0.14.0"
|
matrix-sdk = { version = "0.14.0" }
|
||||||
url = "2.5.7"
|
url = "2.5.7"
|
||||||
ractor = "0.15.9"
|
ractor = "0.15.9"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
lazy-regex = "3.4.2"
|
lazy-regex = "3.4.2"
|
||||||
|
actix-ws = "0.3.0"
|
||||||
|
infer = "0.19.0"
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
use crate::matrix_connection::sync_thread::MatrixSyncTaskID;
|
use crate::matrix_connection::sync_thread::MatrixSyncTaskID;
|
||||||
use crate::users::{APIToken, UserEmail};
|
use crate::users::{APIToken, UserEmail};
|
||||||
use matrix_sdk::Room;
|
use matrix_sdk::Room;
|
||||||
|
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
|
||||||
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
|
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
|
||||||
|
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
|
||||||
use matrix_sdk::sync::SyncResponse;
|
use matrix_sdk::sync::SyncResponse;
|
||||||
|
|
||||||
pub type BroadcastSender = tokio::sync::broadcast::Sender<BroadcastMessage>;
|
pub type BroadcastSender = tokio::sync::broadcast::Sender<BroadcastMessage>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BxRoomEvent<E> {
|
||||||
|
pub user: UserEmail,
|
||||||
|
pub data: Box<E>,
|
||||||
|
pub room: Room,
|
||||||
|
}
|
||||||
|
|
||||||
/// Broadcast messages
|
/// Broadcast messages
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BroadcastMessage {
|
pub enum BroadcastMessage {
|
||||||
@@ -18,11 +27,11 @@ pub enum BroadcastMessage {
|
|||||||
/// Matrix sync thread has been interrupted
|
/// Matrix sync thread has been interrupted
|
||||||
SyncThreadStopped(MatrixSyncTaskID),
|
SyncThreadStopped(MatrixSyncTaskID),
|
||||||
/// New room message
|
/// New room message
|
||||||
RoomMessageEvent {
|
RoomMessageEvent(BxRoomEvent<OriginalSyncRoomMessageEvent>),
|
||||||
user: UserEmail,
|
/// New reaction message
|
||||||
event: Box<OriginalSyncRoomMessageEvent>,
|
ReactionEvent(BxRoomEvent<OriginalSyncReactionEvent>),
|
||||||
room: Room,
|
/// New room redaction
|
||||||
},
|
RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>),
|
||||||
/// Raw Matrix sync response
|
/// Raw Matrix sync response
|
||||||
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
|
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Auth header
|
/// Auth header
|
||||||
pub const API_AUTH_HEADER: &str = "x-client-auth";
|
pub const API_AUTH_HEADER: &str = "x-client-auth";
|
||||||
|
|
||||||
@@ -16,3 +18,11 @@ pub mod sessions {
|
|||||||
/// Authenticated ID
|
/// Authenticated ID
|
||||||
pub const USER_ID: &str = "uid";
|
pub const USER_ID: &str = "uid";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How often heartbeat pings are sent.
|
||||||
|
///
|
||||||
|
/// Should be half (or less) of the acceptable client timeout.
|
||||||
|
pub const WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How long before lack of client response causes a timeout.
|
||||||
|
pub const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|||||||
@@ -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}"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
use crate::utils::crypt_utils::sha512;
|
||||||
|
use actix_web::dev::Payload;
|
||||||
|
use actix_web::http::header;
|
||||||
|
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
|
||||||
|
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
|
||||||
|
use matrix_sdk::ruma::events::room::MediaSource;
|
||||||
|
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct MediaQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
thumbnail: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve a media file
|
||||||
|
pub async fn serve_media(req: HttpRequest, media: OwnedMxcUri) -> HttpResult {
|
||||||
|
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
|
||||||
|
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
|
||||||
|
|
||||||
|
let media = client
|
||||||
|
.client
|
||||||
|
.client
|
||||||
|
.media()
|
||||||
|
.get_media_content(
|
||||||
|
&MediaRequestParameters {
|
||||||
|
source: MediaSource::Plain(media),
|
||||||
|
format: match query.thumbnail {
|
||||||
|
true => MediaFormat::Thumbnail(MediaThumbnailSettings::new(
|
||||||
|
UInt::new(100).unwrap(),
|
||||||
|
UInt::new(100).unwrap(),
|
||||||
|
)),
|
||||||
|
false => MediaFormat::File,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let digest = sha512(&media);
|
||||||
|
|
||||||
|
let mime_type = infer::get(&media).map(|x| x.mime_type());
|
||||||
|
|
||||||
|
// Check if the browser already knows the etag
|
||||||
|
if let Some(c) = req.headers().get(header::IF_NONE_MATCH)
|
||||||
|
&& c.to_str().unwrap_or("") == digest
|
||||||
|
{
|
||||||
|
return Ok(HttpResponse::NotModified().finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.content_type(mime_type.unwrap_or("application/octet-stream"))
|
||||||
|
.insert_header(("etag", digest))
|
||||||
|
.insert_header(("cache-control", "max-age=360000"))
|
||||||
|
.body(media))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct MediaMXCInPath {
|
||||||
|
mxc: OwnedMxcUri,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save media resource handler
|
||||||
|
pub async fn serve_media_res(req: HttpRequest, media: web::Path<MediaMXCInPath>) -> HttpResult {
|
||||||
|
serve_media(req, media.into_inner().mxc).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
use futures_util::{StreamExt, stream};
|
||||||
|
use matrix_sdk::ruma::api::client::profile::{AvatarUrl, DisplayName, get_profile};
|
||||||
|
use matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UserIDInPath {
|
||||||
|
user_id: OwnedUserId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ProfileResponse {
|
||||||
|
user_id: OwnedUserId,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar: Option<OwnedMxcUri>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileResponse {
|
||||||
|
pub fn from(user_id: OwnedUserId, r: get_profile::v3::Response) -> anyhow::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
user_id,
|
||||||
|
display_name: r.get_static::<DisplayName>()?,
|
||||||
|
avatar: r.get_static::<AvatarUrl>()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user profile
|
||||||
|
pub async fn get_profile(
|
||||||
|
client: MatrixClientExtractor,
|
||||||
|
path: web::Path<UserIDInPath>,
|
||||||
|
) -> HttpResult {
|
||||||
|
let profile = client
|
||||||
|
.client
|
||||||
|
.client
|
||||||
|
.account()
|
||||||
|
.fetch_user_profile_of(&path.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(ProfileResponse::from(path.user_id.clone(), profile)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get multiple users profiles
|
||||||
|
pub async fn get_multiple(client: MatrixClientExtractor) -> HttpResult {
|
||||||
|
let users = client.auth.decode_json_body::<Vec<OwnedUserId>>()?;
|
||||||
|
|
||||||
|
let list = stream::iter(users)
|
||||||
|
.then(async |user_id| {
|
||||||
|
client
|
||||||
|
.client
|
||||||
|
.client
|
||||||
|
.account()
|
||||||
|
.fetch_user_profile_of(&user_id)
|
||||||
|
.await
|
||||||
|
.map(|r| ProfileResponse::from(user_id, r))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(list))
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::controllers::matrix::matrix_event_controller::{APIEvent, get_events};
|
||||||
|
use crate::controllers::matrix::matrix_media_controller;
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
use futures_util::{StreamExt, stream};
|
||||||
|
use matrix_sdk::room::ParentSpace;
|
||||||
|
use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomId, OwnedUserId};
|
||||||
|
use matrix_sdk::{Room, RoomMemberships};
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct APIRoomInfo {
|
||||||
|
id: OwnedRoomId,
|
||||||
|
name: Option<String>,
|
||||||
|
members: Vec<OwnedUserId>,
|
||||||
|
avatar: Option<OwnedMxcUri>,
|
||||||
|
is_space: bool,
|
||||||
|
parents: Vec<OwnedRoomId>,
|
||||||
|
number_unread_messages: u64,
|
||||||
|
latest_event: Option<APIEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl APIRoomInfo {
|
||||||
|
async fn from_room(r: &Room) -> anyhow::Result<Self> {
|
||||||
|
// Get parent spaces
|
||||||
|
let parent_spaces = r
|
||||||
|
.parent_spaces()
|
||||||
|
.await?
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| match d {
|
||||||
|
ParentSpace::Reciprocal(r) | ParentSpace::WithPowerlevel(r) => {
|
||||||
|
Some(r.room_id().to_owned())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: r.room_id().to_owned(),
|
||||||
|
name: r.name(),
|
||||||
|
members: r
|
||||||
|
.members(RoomMemberships::ACTIVE)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.user_id().to_owned())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
avatar: r.avatar_url(),
|
||||||
|
is_space: r.is_space(),
|
||||||
|
parents: parent_spaces,
|
||||||
|
number_unread_messages: r.unread_notification_counts().notification_count,
|
||||||
|
latest_event: get_events(r, 1, None).await?.events.into_iter().next(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the list of joined rooms of the user
|
||||||
|
pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
|
||||||
|
let list = stream::iter(client.client.client.joined_rooms())
|
||||||
|
.then(async |room| APIRoomInfo::from_room(&room).await)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get joined spaces rooms of user
|
||||||
|
pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult {
|
||||||
|
let list = stream::iter(client.client.client.joined_space_rooms())
|
||||||
|
.then(async |room| APIRoomInfo::from_room(&room).await)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct RoomIdInPath {
|
||||||
|
pub(crate) room_id: OwnedRoomId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the list of joined rooms of the user
|
||||||
|
pub async fn single_room_info(
|
||||||
|
client: MatrixClientExtractor,
|
||||||
|
path: web::Path<RoomIdInPath>,
|
||||||
|
) -> HttpResult {
|
||||||
|
Ok(match client.client.client.get_room(&path.room_id) {
|
||||||
|
None => HttpResponse::NotFound().json("Room not found"),
|
||||||
|
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r).await?),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get room avatar
|
||||||
|
pub async fn room_avatar(
|
||||||
|
req: HttpRequest,
|
||||||
|
client: MatrixClientExtractor,
|
||||||
|
path: web::Path<RoomIdInPath>,
|
||||||
|
) -> HttpResult {
|
||||||
|
let Some(room) = client.client.client.get_room(&path.room_id) else {
|
||||||
|
return Ok(HttpResponse::NotFound().json("Room not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(uri) = room.avatar_url() else {
|
||||||
|
return Ok(HttpResponse::NotFound().json("Room has no avatar"));
|
||||||
|
};
|
||||||
|
|
||||||
|
matrix_media_controller::serve_media(req, uri).await
|
||||||
|
}
|
||||||
4
matrixgw_backend/src/controllers/matrix/mod.rs
Normal file
4
matrixgw_backend/src/controllers/matrix/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod matrix_event_controller;
|
||||||
|
pub mod matrix_media_controller;
|
||||||
|
pub mod matrix_profile_controller;
|
||||||
|
pub mod matrix_room_controller;
|
||||||
@@ -3,10 +3,12 @@ use actix_web::{HttpResponse, ResponseError};
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
pub mod auth_controller;
|
pub mod auth_controller;
|
||||||
|
pub mod matrix;
|
||||||
pub mod matrix_link_controller;
|
pub mod matrix_link_controller;
|
||||||
pub mod matrix_sync_thread_controller;
|
pub mod matrix_sync_thread_controller;
|
||||||
pub mod server_controller;
|
pub mod server_controller;
|
||||||
pub mod tokens_controller;
|
pub mod tokens_controller;
|
||||||
|
pub mod ws_controller;
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub enum HttpFailure {
|
pub enum HttpFailure {
|
||||||
@@ -18,6 +20,10 @@ pub enum HttpFailure {
|
|||||||
OpenID(Box<dyn Error>),
|
OpenID(Box<dyn Error>),
|
||||||
#[error("an unspecified internal error occurred: {0}")]
|
#[error("an unspecified internal error occurred: {0}")]
|
||||||
InternalError(#[from] anyhow::Error),
|
InternalError(#[from] anyhow::Error),
|
||||||
|
#[error("Actix web error: {0}")]
|
||||||
|
ActixError(#[from] actix_web::Error),
|
||||||
|
#[error("Matrix error: {0}")]
|
||||||
|
MatrixError(#[from] matrix_sdk::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResponseError for HttpFailure {
|
impl ResponseError for HttpFailure {
|
||||||
|
|||||||
252
matrixgw_backend/src/controllers/ws_controller.rs
Normal file
252
matrixgw_backend/src/controllers/ws_controller.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
use crate::broadcast_messages::BroadcastMessage;
|
||||||
|
use crate::constants;
|
||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
use crate::matrix_connection::matrix_client::MatrixClient;
|
||||||
|
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
|
||||||
|
use crate::users::UserEmail;
|
||||||
|
use actix_web::dev::Payload;
|
||||||
|
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
|
||||||
|
use actix_ws::Message;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
|
||||||
|
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
|
||||||
|
use matrix_sdk::ruma::events::room::redaction::RoomRedactionEventContent;
|
||||||
|
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId};
|
||||||
|
use ractor::ActorRef;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tokio::sync::broadcast::Receiver;
|
||||||
|
use tokio::time::interval;
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct WsRoomEvent<E> {
|
||||||
|
pub room_id: OwnedRoomId,
|
||||||
|
pub event_id: OwnedEventId,
|
||||||
|
pub sender: OwnedUserId,
|
||||||
|
pub origin_server_ts: MilliSecondsSinceUnixEpoch,
|
||||||
|
pub data: Box<E>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages sent to the client
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum WsMessage {
|
||||||
|
/// Room message event
|
||||||
|
RoomMessageEvent(WsRoomEvent<RoomMessageEventContent>),
|
||||||
|
|
||||||
|
/// Room reaction event
|
||||||
|
RoomReactionEvent(WsRoomEvent<ReactionEventContent>),
|
||||||
|
|
||||||
|
/// Room reaction event
|
||||||
|
RoomRedactionEvent(WsRoomEvent<RoomRedactionEventContent>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WsMessage {
|
||||||
|
pub fn from_bx_message(msg: &BroadcastMessage, user: &UserEmail) -> Option<Self> {
|
||||||
|
match msg {
|
||||||
|
BroadcastMessage::RoomMessageEvent(evt) if &evt.user == user => {
|
||||||
|
Some(Self::RoomMessageEvent(WsRoomEvent {
|
||||||
|
room_id: evt.room.room_id().to_owned(),
|
||||||
|
event_id: evt.data.event_id.clone(),
|
||||||
|
sender: evt.data.sender.clone(),
|
||||||
|
origin_server_ts: evt.data.origin_server_ts,
|
||||||
|
data: Box::new(evt.data.content.clone()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
BroadcastMessage::ReactionEvent(evt) if &evt.user == user => {
|
||||||
|
Some(Self::RoomReactionEvent(WsRoomEvent {
|
||||||
|
room_id: evt.room.room_id().to_owned(),
|
||||||
|
event_id: evt.data.event_id.clone(),
|
||||||
|
sender: evt.data.sender.clone(),
|
||||||
|
origin_server_ts: evt.data.origin_server_ts,
|
||||||
|
data: Box::new(evt.data.content.clone()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
BroadcastMessage::RoomRedactionEvent(evt) if &evt.user == user => {
|
||||||
|
Some(Self::RoomRedactionEvent(WsRoomEvent {
|
||||||
|
room_id: evt.room.room_id().to_owned(),
|
||||||
|
event_id: evt.data.event_id.clone(),
|
||||||
|
sender: evt.data.sender.clone(),
|
||||||
|
origin_server_ts: evt.data.origin_server_ts,
|
||||||
|
data: Box::new(evt.data.content.clone()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main WS route
|
||||||
|
pub async fn ws(
|
||||||
|
req: HttpRequest,
|
||||||
|
stream: web::Payload,
|
||||||
|
tx: web::Data<broadcast::Sender<BroadcastMessage>>,
|
||||||
|
manager: web::Data<ActorRef<MatrixManagerMsg>>,
|
||||||
|
) -> HttpResult {
|
||||||
|
// Forcefully ignore request payload by manually extracting authentication information
|
||||||
|
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
|
||||||
|
|
||||||
|
// Check if Matrix link has been established first
|
||||||
|
if !client.client.is_client_connected() {
|
||||||
|
return Ok(HttpResponse::ExpectationFailed().json("Matrix link not established yet!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure sync thread is started
|
||||||
|
ractor::cast!(
|
||||||
|
manager,
|
||||||
|
MatrixManagerMsg::StartSyncThread(client.auth.user.email.clone())
|
||||||
|
)
|
||||||
|
.expect("Failed to start sync thread prior to running WebSocket!");
|
||||||
|
|
||||||
|
let rx = tx.subscribe();
|
||||||
|
|
||||||
|
let (res, session, msg_stream) = actix_ws::handle(&req, stream)?;
|
||||||
|
|
||||||
|
// spawn websocket handler (and don't await it) so that the response is returned immediately
|
||||||
|
actix_web::rt::spawn(ws_handler(
|
||||||
|
session,
|
||||||
|
msg_stream,
|
||||||
|
client.auth,
|
||||||
|
client.client,
|
||||||
|
rx,
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ws_handler(
|
||||||
|
mut session: actix_ws::Session,
|
||||||
|
mut msg_stream: actix_ws::MessageStream,
|
||||||
|
auth: AuthExtractor,
|
||||||
|
client: MatrixClient,
|
||||||
|
mut rx: Receiver<BroadcastMessage>,
|
||||||
|
) {
|
||||||
|
log::info!(
|
||||||
|
"WS connected for user {:?} / auth method={}",
|
||||||
|
client.email,
|
||||||
|
auth.method.light_str()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut last_heartbeat = Instant::now();
|
||||||
|
let mut interval = interval(constants::WS_HEARTBEAT_INTERVAL);
|
||||||
|
|
||||||
|
let reason = loop {
|
||||||
|
// waits for either `msg_stream` to receive a message from the client, the broadcast channel
|
||||||
|
// to send a message, or the heartbeat interval timer to tick, yielding the value of
|
||||||
|
// whichever one is ready first
|
||||||
|
tokio::select! {
|
||||||
|
ws_msg = rx.recv() => {
|
||||||
|
let msg = match ws_msg {
|
||||||
|
Ok(msg) => msg,
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break None,
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match (&msg, WsMessage::from_bx_message(&msg, &auth.user.email)) {
|
||||||
|
(BroadcastMessage::APITokenDeleted(t), _) => {
|
||||||
|
match &auth.method{
|
||||||
|
AuthenticatedMethod::Token(tok) if tok.id == t.id => {
|
||||||
|
log::info!(
|
||||||
|
"closing WS session of user {:?} as associated token was deleted {:?}",
|
||||||
|
client.email,
|
||||||
|
t.base.name
|
||||||
|
);
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
_=>{}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
(BroadcastMessage::UserDisconnectedFromMatrix(mail), _) if mail == &auth.user.email => {
|
||||||
|
log::info!(
|
||||||
|
"closing WS session of user {mail:?} as user was disconnected from Matrix"
|
||||||
|
);
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
|
||||||
|
(_, Some(message)) => {
|
||||||
|
// Send the message to the websocket
|
||||||
|
if let Ok(msg) = serde_json::to_string(&message)
|
||||||
|
&& let Err(e) = session.text(msg).await {
|
||||||
|
log::error!("Failed to send SyncEvent: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeat interval ticked
|
||||||
|
_tick = interval.tick() => {
|
||||||
|
// if no heartbeat ping/pong received recently, close the connection
|
||||||
|
if Instant::now().duration_since(last_heartbeat) > constants::WS_CLIENT_TIMEOUT {
|
||||||
|
log::info!(
|
||||||
|
"client has not sent heartbeat in over {:?}; disconnecting",constants::WS_CLIENT_TIMEOUT
|
||||||
|
);
|
||||||
|
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// send heartbeat ping
|
||||||
|
let _ = session.ping(b"").await;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Websocket messages
|
||||||
|
msg = msg_stream.next() => {
|
||||||
|
let msg = match msg {
|
||||||
|
// received message from WebSocket client
|
||||||
|
Some(Ok(msg)) => msg,
|
||||||
|
|
||||||
|
// client WebSocket stream error
|
||||||
|
Some(Err(err)) => {
|
||||||
|
log::error!("{err}");
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// client WebSocket stream ended
|
||||||
|
None => break None
|
||||||
|
};
|
||||||
|
|
||||||
|
log::debug!("msg: {msg:?}");
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Message::Text(s) => {
|
||||||
|
log::info!("Text message from WS: {s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::Binary(_) => {
|
||||||
|
// drop client's binary messages
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::Close(reason) => {
|
||||||
|
break reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::Ping(bytes) => {
|
||||||
|
last_heartbeat = Instant::now();
|
||||||
|
let _ = session.pong(&bytes).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::Pong(_) => {
|
||||||
|
last_heartbeat = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::Continuation(_) => {
|
||||||
|
log::warn!("no support for continuation frames");
|
||||||
|
}
|
||||||
|
|
||||||
|
// no-op; ignore
|
||||||
|
Message::Nop => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// attempt to close connection gracefully
|
||||||
|
let _ = session.close(reason).await;
|
||||||
|
|
||||||
|
log::info!("WS disconnected for user {:?}", client.email);
|
||||||
|
}
|
||||||
@@ -28,6 +28,16 @@ pub enum AuthenticatedMethod {
|
|||||||
Token(APIToken),
|
Token(APIToken),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AuthenticatedMethod {
|
||||||
|
pub fn light_str(&self) -> String {
|
||||||
|
match self {
|
||||||
|
AuthenticatedMethod::Cookie => "Cookie".to_string(),
|
||||||
|
AuthenticatedMethod::Dev => "DevAuthentication".to_string(),
|
||||||
|
AuthenticatedMethod::Token(t) => format!("Token({:?} - {})", t.id, t.base.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AuthExtractor {
|
pub struct AuthExtractor {
|
||||||
pub user: User,
|
pub user: User,
|
||||||
pub method: AuthenticatedMethod,
|
pub method: AuthenticatedMethod,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ impl MatrixClientExtractor {
|
|||||||
pub async fn to_extended_user_info(&self) -> anyhow::Result<ExtendedUserInfo> {
|
pub async fn to_extended_user_info(&self) -> anyhow::Result<ExtendedUserInfo> {
|
||||||
Ok(ExtendedUserInfo {
|
Ok(ExtendedUserInfo {
|
||||||
user: self.auth.user.clone(),
|
user: self.auth.user.clone(),
|
||||||
|
matrix_account_connected: self.client.is_client_connected(),
|
||||||
matrix_user_id: self.client.user_id().map(|id| id.to_string()),
|
matrix_user_id: self.client.user_id().map(|id| id.to_string()),
|
||||||
matrix_device_id: self.client.device_id().map(|id| id.to_string()),
|
matrix_device_id: self.client.device_id().map(|id| id.to_string()),
|
||||||
matrix_recovery_state: self.client.recovery_state(),
|
matrix_recovery_state: self.client.recovery_state(),
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ use actix_web::{App, HttpServer, web};
|
|||||||
use matrixgw_backend::app_config::AppConfig;
|
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::{
|
||||||
|
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,
|
||||||
tokens_controller,
|
tokens_controller, ws_controller,
|
||||||
};
|
};
|
||||||
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
|
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
|
||||||
use matrixgw_backend::users::User;
|
use matrixgw_backend::users::User;
|
||||||
@@ -133,6 +137,59 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/matrix_sync/status",
|
"/api/matrix_sync/status",
|
||||||
web::get().to(matrix_sync_thread_controller::status),
|
web::get().to(matrix_sync_thread_controller::status),
|
||||||
)
|
)
|
||||||
|
.service(web::resource("/api/ws").route(web::get().to(ws_controller::ws)))
|
||||||
|
// Matrix room controller
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/joined",
|
||||||
|
web::get().to(matrix_room_controller::joined_rooms),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/joined_spaces",
|
||||||
|
web::get().to(matrix_room_controller::get_joined_spaces),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/{room_id}",
|
||||||
|
web::get().to(matrix_room_controller::single_room_info),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/{room_id}/avatar",
|
||||||
|
web::get().to(matrix_room_controller::room_avatar),
|
||||||
|
)
|
||||||
|
// Matrix profile controller
|
||||||
|
.route(
|
||||||
|
"/api/matrix/profile/{user_id}",
|
||||||
|
web::get().to(matrix_profile_controller::get_profile),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/profile/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
|
||||||
|
.route(
|
||||||
|
"/api/matrix/media/{mxc}",
|
||||||
|
web::get().to(matrix_media_controller::serve_media_res),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.workers(4)
|
.workers(4)
|
||||||
.bind(&AppConfig::get().listen_address)?
|
.bind(&AppConfig::get().listen_address)?
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ pub struct FinishMatrixAuth {
|
|||||||
pub struct MatrixClient {
|
pub struct MatrixClient {
|
||||||
manager: ActorRef<MatrixManagerMsg>,
|
manager: ActorRef<MatrixManagerMsg>,
|
||||||
pub email: UserEmail,
|
pub email: UserEmail,
|
||||||
client: Client,
|
pub client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MatrixClient {
|
impl MatrixClient {
|
||||||
@@ -121,7 +121,7 @@ impl MatrixClient {
|
|||||||
.map_err(MatrixClientError::ReadDbPassphrase)?;
|
.map_err(MatrixClientError::ReadDbPassphrase)?;
|
||||||
|
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.server_name_or_homeserver_url(&AppConfig::get().matrix_homeserver)
|
.homeserver_url(&AppConfig::get().matrix_homeserver)
|
||||||
// Automatically refresh tokens if needed
|
// Automatically refresh tokens if needed
|
||||||
.handle_refresh_tokens()
|
.handle_refresh_tokens()
|
||||||
.sqlite_store(&db_path, Some(&passphrase))
|
.sqlite_store(&db_path, Some(&passphrase))
|
||||||
@@ -167,6 +167,9 @@ impl MatrixClient {
|
|||||||
.encryption()
|
.encryption()
|
||||||
.wait_for_e2ee_initialization_tasks()
|
.wait_for_e2ee_initialization_tasks()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Save stored session once
|
||||||
|
client.save_stored_session().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically save session when token gets refreshed
|
// Automatically save session when token gets refreshed
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
//!
|
//!
|
||||||
//! This file contains the logic performed by the threads that synchronize with Matrix account.
|
//! This file contains the logic performed by the threads that synchronize with Matrix account.
|
||||||
|
|
||||||
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
|
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender, BxRoomEvent};
|
||||||
use crate::matrix_connection::matrix_client::MatrixClient;
|
use crate::matrix_connection::matrix_client::MatrixClient;
|
||||||
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
|
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use matrix_sdk::Room;
|
use matrix_sdk::Room;
|
||||||
|
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
|
||||||
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
|
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
|
||||||
|
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
|
||||||
use ractor::ActorRef;
|
use ractor::ActorRef;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -48,19 +50,51 @@ async fn sync_thread_task(
|
|||||||
|
|
||||||
let mut sync_stream = client.sync_stream().await;
|
let mut sync_stream = client.sync_stream().await;
|
||||||
|
|
||||||
|
let mut handlers = vec![];
|
||||||
|
|
||||||
let tx_msg_handle = tx.clone();
|
let tx_msg_handle = tx.clone();
|
||||||
let user = client.email.clone();
|
let user_msg_handle = client.email.clone();
|
||||||
let room_message_handle = client.add_event_handler(
|
handlers.push(client.add_event_handler(
|
||||||
async move |event: OriginalSyncRoomMessageEvent, room: Room| {
|
async move |event: OriginalSyncRoomMessageEvent, room: Room| {
|
||||||
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent {
|
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent(BxRoomEvent {
|
||||||
user: user.clone(),
|
user: user_msg_handle.clone(),
|
||||||
event: Box::new(event),
|
data: Box::new(event),
|
||||||
room,
|
room,
|
||||||
}) {
|
})) {
|
||||||
log::warn!("Failed to forward room event! {e}");
|
log::warn!("Failed to forward room message event! {e}");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
));
|
||||||
|
|
||||||
|
let tx_reac_handle = tx.clone();
|
||||||
|
let user_reac_handle = client.email.clone();
|
||||||
|
handlers.push(client.add_event_handler(
|
||||||
|
async move |event: OriginalSyncReactionEvent, room: Room| {
|
||||||
|
if let Err(e) = tx_reac_handle.send(BroadcastMessage::ReactionEvent(BxRoomEvent {
|
||||||
|
user: user_reac_handle.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
})) {
|
||||||
|
log::warn!("Failed to forward reaction event! {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let tx_redac_handle = tx.clone();
|
||||||
|
let user_redac_handle = client.email.clone();
|
||||||
|
handlers.push(client.add_event_handler(
|
||||||
|
async move |event: OriginalSyncRoomRedactionEvent, room: Room| {
|
||||||
|
if let Err(e) =
|
||||||
|
tx_redac_handle.send(BroadcastMessage::RoomRedactionEvent(BxRoomEvent {
|
||||||
|
user: user_redac_handle.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
log::warn!("Failed to forward reaction event! {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -103,7 +137,9 @@ async fn sync_thread_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.remove_event_handler(room_message_handle);
|
for h in handlers {
|
||||||
|
client.remove_event_handler(h);
|
||||||
|
}
|
||||||
|
|
||||||
// Notify manager about termination, so this thread can be removed from the list
|
// Notify manager about termination, so this thread can be removed from the list
|
||||||
log::info!("Sync thread {id:?} terminated!");
|
log::info!("Sync thread {id:?} terminated!");
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ impl APIToken {
|
|||||||
pub struct ExtendedUserInfo {
|
pub struct ExtendedUserInfo {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub user: User,
|
pub user: User,
|
||||||
|
pub matrix_account_connected: bool,
|
||||||
pub matrix_user_id: Option<String>,
|
pub matrix_user_id: Option<String>,
|
||||||
pub matrix_device_id: Option<String>,
|
pub matrix_device_id: Option<String>,
|
||||||
pub matrix_recovery_state: EncryptionRecoveryState,
|
pub matrix_recovery_state: EncryptionRecoveryState,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256, Sha512};
|
||||||
|
|
||||||
/// Compute SHA256sum of a given string
|
/// Compute SHA256sum of a given string
|
||||||
pub fn sha256str(input: &str) -> String {
|
pub fn sha256str(input: &str) -> String {
|
||||||
hex::encode(Sha256::digest(input.as_bytes()))
|
hex::encode(Sha256::digest(input.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute SHA256sum of a given byte array
|
||||||
|
pub fn sha512(input: &[u8]) -> String {
|
||||||
|
hex::encode(Sha512::digest(input))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,73 +1,2 @@
|
|||||||
# React + TypeScript + Vite
|
# MatrixGW frontend
|
||||||
|
Built using React + TypeScript + Vite
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
13
matrixgw_frontend/package-lock.json
generated
13
matrixgw_frontend/package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-json-view-lite": "^2.5.0",
|
||||||
"react-router": "^7.9.5"
|
"react-router": "^7.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3674,6 +3675,18 @@
|
|||||||
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-json-view-lite": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-json-view-lite": "^2.5.0",
|
||||||
"react-router": "^7.9.5"
|
"react-router": "^7.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { HomeRoute } from "./routes/HomeRoute";
|
|||||||
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
|
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
|
||||||
import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
|
import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
|
||||||
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
||||||
|
import { WSDebugRoute } from "./routes/WSDebugRoute";
|
||||||
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
|
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
|
||||||
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
|
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export function App(): React.ReactElement {
|
|||||||
<Route path="matrix_link" element={<MatrixLinkRoute />} />
|
<Route path="matrix_link" element={<MatrixLinkRoute />} />
|
||||||
<Route path="matrix_auth_cb" element={<MatrixAuthCallback />} />
|
<Route path="matrix_auth_cb" element={<MatrixAuthCallback />} />
|
||||||
<Route path="tokens" element={<APITokensRoute />} />
|
<Route path="tokens" element={<APITokensRoute />} />
|
||||||
|
<Route path="wsdebug" element={<WSDebugRoute />} />
|
||||||
<Route path="*" element={<NotFoundRoute />} />
|
<Route path="*" element={<NotFoundRoute />} />
|
||||||
</Route>
|
</Route>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface UserInfo {
|
|||||||
time_update: number;
|
time_update: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
matrix_account_connected: boolean;
|
||||||
matrix_user_id?: string;
|
matrix_user_id?: string;
|
||||||
matrix_device_id?: string;
|
matrix_device_id?: string;
|
||||||
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
|
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { APIClient } from "./ApiClient";
|
|||||||
|
|
||||||
export class MatrixSyncApi {
|
export class MatrixSyncApi {
|
||||||
/**
|
/**
|
||||||
* Force sync thread startup
|
* Start sync thread
|
||||||
*/
|
*/
|
||||||
static async Start(): Promise<void> {
|
static async Start(): Promise<void> {
|
||||||
await APIClient.exec({
|
await APIClient.exec({
|
||||||
@@ -10,4 +10,25 @@ export class MatrixSyncApi {
|
|||||||
uri: "/matrix_sync/start",
|
uri: "/matrix_sync/start",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop sync thread
|
||||||
|
*/
|
||||||
|
static async Stop(): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: "/matrix_sync/stop",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sync thread status
|
||||||
|
*/
|
||||||
|
static async Status(): Promise<boolean> {
|
||||||
|
const res = await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/matrix_sync/status",
|
||||||
|
});
|
||||||
|
return res.data.started;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
matrixgw_frontend/src/api/WsApi.ts
Normal file
15
matrixgw_frontend/src/api/WsApi.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
|
export type WsMessage = {
|
||||||
|
type: string;
|
||||||
|
[k: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WsApi {
|
||||||
|
/**
|
||||||
|
* Get WebSocket URL
|
||||||
|
*/
|
||||||
|
static get WsURL(): string {
|
||||||
|
return APIClient.backendURL() + "/ws";
|
||||||
|
}
|
||||||
|
}
|
||||||
70
matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts
Normal file
70
matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
matrixgw_frontend/src/api/matrix/MatrixApiMedia.ts
Normal file
12
matrixgw_frontend/src/api/matrix/MatrixApiMedia.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
matrixgw_frontend/src/api/matrix/MatrixApiProfile.ts
Normal file
26
matrixgw_frontend/src/api/matrix/MatrixApiProfile.ts
Normal 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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts
Normal file
55
matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,12 @@ body,
|
|||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import { APIClient } from "../api/ApiClient";
|
|
||||||
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 {
|
||||||
const user = useUserInfo();
|
const user = useUserInfo();
|
||||||
|
|
||||||
if (!user.info.matrix_user_id) 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,21 @@ import CloseIcon from "@mui/icons-material/Close";
|
|||||||
import KeyIcon from "@mui/icons-material/Key";
|
import KeyIcon from "@mui/icons-material/Key";
|
||||||
import LinkIcon from "@mui/icons-material/Link";
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
import LinkOffIcon from "@mui/icons-material/LinkOff";
|
import LinkOffIcon from "@mui/icons-material/LinkOff";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
|
||||||
|
import StopIcon from "@mui/icons-material/Stop";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardActions,
|
CardActions,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Grid,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { MatrixLinkApi } from "../api/MatrixLinkApi";
|
import { MatrixLinkApi } from "../api/MatrixLinkApi";
|
||||||
|
import { MatrixSyncApi } from "../api/MatrixSyncApi";
|
||||||
import { SetRecoveryKeyDialog } from "../dialogs/SetRecoveryKeyDialog";
|
import { SetRecoveryKeyDialog } from "../dialogs/SetRecoveryKeyDialog";
|
||||||
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
|
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
|
||||||
import { useConfirm } from "../hooks/contexts_provider/ConfirmDialogProvider";
|
import { useConfirm } from "../hooks/contexts_provider/ConfirmDialogProvider";
|
||||||
@@ -27,10 +33,17 @@ export function MatrixLinkRoute(): React.ReactElement {
|
|||||||
{user.info.matrix_user_id === null ? (
|
{user.info.matrix_user_id === null ? (
|
||||||
<ConnectCard />
|
<ConnectCard />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Grid container spacing={2}>
|
||||||
<ConnectedCard />
|
<Grid size={{ sm: 12, md: 6 }}>
|
||||||
<EncryptionKeyStatus />
|
<ConnectedCard />
|
||||||
</>
|
</Grid>
|
||||||
|
<Grid size={{ sm: 12, md: 6 }}>
|
||||||
|
<EncryptionKeyStatus />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ sm: 12, md: 6 }}>
|
||||||
|
<SyncThreadStatus />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</MatrixGWRouteContainer>
|
</MatrixGWRouteContainer>
|
||||||
);
|
);
|
||||||
@@ -202,3 +215,115 @@ function EncryptionKeyStatus(): React.ReactElement {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SyncThreadStatus(): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const [started, setStarted] = React.useState<undefined | boolean>();
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
try {
|
||||||
|
setStarted(await MatrixSyncApi.Status());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to refresh sync thread status! ${e}`);
|
||||||
|
snackbar(`Failed to refresh sync thread status! ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartThread = async () => {
|
||||||
|
try {
|
||||||
|
setStarted(undefined);
|
||||||
|
await MatrixSyncApi.Start();
|
||||||
|
snackbar("Sync thread started");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to start sync thread! ${e}`);
|
||||||
|
alert(`Failed to start sync thread! ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopThread = async () => {
|
||||||
|
try {
|
||||||
|
setStarted(undefined);
|
||||||
|
await MatrixSyncApi.Stop();
|
||||||
|
snackbar("Sync thread stopped");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to stop sync thread! ${e}`);
|
||||||
|
alert(`Failed to stop sync thread! ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(loadStatus, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom>
|
||||||
|
Sync thread status
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" gutterBottom>
|
||||||
|
<p>
|
||||||
|
A thread is spawned on the server to watch for events on the
|
||||||
|
Matrix server. You can restart this thread from here in case of
|
||||||
|
issue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Current thread status:{" "}
|
||||||
|
{started === undefined ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={"1rem"}
|
||||||
|
style={{ verticalAlign: "middle" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : started === true ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon
|
||||||
|
style={{ display: "inline", verticalAlign: "middle" }}
|
||||||
|
/>{" "}
|
||||||
|
Started
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PowerSettingsNewIcon
|
||||||
|
style={{ display: "inline", verticalAlign: "middle" }}
|
||||||
|
/>
|
||||||
|
Stopped
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
{started === false && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
onClick={handleStartThread}
|
||||||
|
>
|
||||||
|
Start thread
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{started === true && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<StopIcon />}
|
||||||
|
onClick={handleStopThread}
|
||||||
|
>
|
||||||
|
Stop thread
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
78
matrixgw_frontend/src/routes/WSDebugRoute.tsx
Normal file
78
matrixgw_frontend/src/routes/WSDebugRoute.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { JsonView, darkStyles } from "react-json-view-lite";
|
||||||
|
import "react-json-view-lite/dist/index.css";
|
||||||
|
import { WsApi, type WsMessage } from "../api/WsApi";
|
||||||
|
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
|
||||||
|
import { time } from "../utils/DateUtils";
|
||||||
|
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
||||||
|
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
|
||||||
|
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
||||||
|
|
||||||
|
const State = {
|
||||||
|
Closed: "Closed",
|
||||||
|
Connected: "Connected",
|
||||||
|
Error: "Error",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TimestampedMessages = WsMessage & { time: number };
|
||||||
|
|
||||||
|
export function WSDebugRoute(): React.ReactElement {
|
||||||
|
const user = useUserInfo();
|
||||||
|
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
|
||||||
|
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const [state, setState] = React.useState<string>(State.Closed);
|
||||||
|
const wsRef = React.useRef<WebSocket | undefined>(undefined);
|
||||||
|
|
||||||
|
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const ws = new WebSocket(WsApi.WsURL);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => setState(State.Connected);
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error(`WS Debug error!`, e);
|
||||||
|
snackbar(`WebSocket error! ${e}`);
|
||||||
|
setState(State.Error);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
setState(State.Closed);
|
||||||
|
wsRef.current = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (msg) => {
|
||||||
|
const dec = JSON.parse(msg.data);
|
||||||
|
setMessages((l) => {
|
||||||
|
return [{ time: time(), ...dec }, ...l];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => ws.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MatrixGWRouteContainer label={"WebSocket Debug"}>
|
||||||
|
<div>
|
||||||
|
State:{" "}
|
||||||
|
<span style={{ color: state == State.Connected ? "green" : "red" }}>
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{messages.map((msg, id) => (
|
||||||
|
<div style={{ margin: "10px", backgroundColor: "black" }}>
|
||||||
|
<JsonView
|
||||||
|
key={id}
|
||||||
|
data={msg}
|
||||||
|
shouldExpandNode={(level) => level < 2}
|
||||||
|
style={{
|
||||||
|
...darkStyles,
|
||||||
|
container: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</MatrixGWRouteContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
matrixgw_frontend/src/utils/RoomEventsManager.ts
Normal file
101
matrixgw_frontend/src/utils/RoomEventsManager.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ 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";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useUserInfo } from "./BaseAuthenticatedPage";
|
||||||
import DashboardSidebarContext from "./DashboardSidebarContext";
|
import DashboardSidebarContext from "./DashboardSidebarContext";
|
||||||
import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
|
import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
|
||||||
import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
|
import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
|
||||||
@@ -27,10 +27,10 @@ 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();
|
||||||
|
const user = useUserInfo();
|
||||||
|
|
||||||
const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
|
const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
|
||||||
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
|
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
|
||||||
@@ -51,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);
|
||||||
@@ -64,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)}`}
|
||||||
@@ -82,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")
|
||||||
: {}),
|
: {}),
|
||||||
@@ -93,42 +90,59 @@ 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
|
||||||
|
disabled={!user.info.matrix_account_connected}
|
||||||
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}
|
||||||
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, hasDrawerTransitions, isFullyExpanded]
|
[
|
||||||
|
expanded,
|
||||||
|
hasDrawerTransitions,
|
||||||
|
isFullyExpanded,
|
||||||
|
user.info.matrix_account_connected,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
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",
|
||||||
@@ -145,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}>
|
||||||
@@ -170,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),
|
||||||
@@ -183,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),
|
||||||
@@ -195,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")}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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://")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
matrixgw_frontend/src/widgets/messages/RoomIcon.tsx
Normal file
33
matrixgw_frontend/src/widgets/messages/RoomIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
matrixgw_frontend/src/widgets/messages/RoomSelector.tsx
Normal file
80
matrixgw_frontend/src/widgets/messages/RoomSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
matrixgw_frontend/src/widgets/messages/RoomWidget.tsx
Normal file
30
matrixgw_frontend/src/widgets/messages/RoomWidget.tsx
Normal 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</>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
matrixgw_frontend/src/widgets/messages/SpaceSelector.tsx
Normal file
53
matrixgw_frontend/src/widgets/messages/SpaceSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user