Compare commits
17 Commits
ecbe4885c1
...
migrate-to
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eab7c3e4f | |||
| a7bfd713c3 | |||
| 4be661d999 | |||
| 1f4e374e66 | |||
| cce9b3de5d | |||
| 820b095be0 | |||
| 0a37688116 | |||
| 4d72644a31 | |||
| 0a395b0d26 | |||
| 639cc6c737 | |||
| bf119a34fb | |||
| 7562a7fc61 | |||
| d23190f9d2 | |||
| 35b53fee5c | |||
| 934e6a4cc1 | |||
| b744265242 | |||
| e8ce97eea0 |
21
matrixgw_backend/Cargo.lock
generated
21
matrixgw_backend/Cargo.lock
generated
@@ -777,6 +777,17 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -2357,6 +2368,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -3048,6 +3068,7 @@ dependencies = [
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"infer",
|
||||
"ipnet",
|
||||
"jwt-simple",
|
||||
"lazy-regex",
|
||||
|
||||
@@ -33,3 +33,4 @@ ractor = "0.15.9"
|
||||
serde_json = "1.0.145"
|
||||
lazy-regex = "3.4.2"
|
||||
actix-ws = "0.3.0"
|
||||
infer = "0.19.0"
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
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::{HttpResponse, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use futures_util::{StreamExt, stream};
|
||||
use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId};
|
||||
use matrix_sdk::room::ParentSpace;
|
||||
use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomId, OwnedUserId};
|
||||
use matrix_sdk::{Room, RoomMemberships};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -10,11 +13,32 @@ pub struct APIRoomInfo {
|
||||
id: OwnedRoomId,
|
||||
name: Option<String>,
|
||||
members: Vec<OwnedUserId>,
|
||||
has_avatar: bool,
|
||||
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(),
|
||||
@@ -24,7 +48,11 @@ impl APIRoomInfo {
|
||||
.into_iter()
|
||||
.map(|r| r.user_id().to_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
has_avatar: r.avatar_url().is_some(),
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -41,9 +69,21 @@ pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
|
||||
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 {
|
||||
id: OwnedRoomId,
|
||||
pub(crate) room_id: OwnedRoomId,
|
||||
}
|
||||
|
||||
/// Get the list of joined rooms of the user
|
||||
@@ -51,8 +91,25 @@ pub async fn single_room_info(
|
||||
client: MatrixClientExtractor,
|
||||
path: web::Path<RoomIdInPath>,
|
||||
) -> HttpResult {
|
||||
Ok(match client.client.client.get_room(&path.id) {
|
||||
Ok(match client.client.client.get_room(&path.room_id) {
|
||||
None => HttpResponse::NotFound().json("Room not found"),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
pub mod matrix_event_controller;
|
||||
pub mod matrix_media_controller;
|
||||
pub mod matrix_profile_controller;
|
||||
pub mod matrix_room_controller;
|
||||
|
||||
@@ -22,6 +22,8 @@ pub enum HttpFailure {
|
||||
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 {
|
||||
|
||||
@@ -9,7 +9,10 @@ use actix_web::{App, HttpServer, web};
|
||||
use matrixgw_backend::app_config::AppConfig;
|
||||
use matrixgw_backend::broadcast_messages::BroadcastMessage;
|
||||
use matrixgw_backend::constants;
|
||||
use matrixgw_backend::controllers::matrix::matrix_room_controller;
|
||||
use matrixgw_backend::controllers::matrix::{
|
||||
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
|
||||
matrix_room_controller,
|
||||
};
|
||||
use matrixgw_backend::controllers::{
|
||||
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
|
||||
tokens_controller, ws_controller,
|
||||
@@ -141,9 +144,52 @@ async fn main() -> std::io::Result<()> {
|
||||
web::get().to(matrix_room_controller::joined_rooms),
|
||||
)
|
||||
.route(
|
||||
"/api/matrix/room/{id}",
|
||||
"/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)
|
||||
.bind(&AppConfig::get().listen_address)?
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
/// Compute SHA256sum of a given string
|
||||
pub fn sha256str(input: &str) -> String {
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#root > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MatrixSyncApi } from "../api/MatrixSyncApi";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
||||
import { MainMessageWidget } from "../widgets/messages/MainMessagesWidget";
|
||||
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
||||
|
||||
export function HomeRoute(): React.ReactElement {
|
||||
@@ -8,15 +7,5 @@ export function HomeRoute(): React.ReactElement {
|
||||
|
||||
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
|
||||
|
||||
return (
|
||||
<p>
|
||||
Todo home route{" "}
|
||||
<AsyncWidget
|
||||
loadKey={1}
|
||||
errMsg="Failed to start sync thread!"
|
||||
load={MatrixSyncApi.Start}
|
||||
build={() => <>sync started</>}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
return <MainMessageWidget />;
|
||||
}
|
||||
|
||||
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 Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import * as React from "react";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
@@ -105,20 +104,18 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
|
||||
signOut,
|
||||
}}
|
||||
>
|
||||
<DashboardHeader
|
||||
menuOpen={isNavigationExpanded}
|
||||
onToggleMenu={handleToggleHeaderMenu}
|
||||
/>
|
||||
<Box
|
||||
ref={layoutRef}
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<DashboardHeader
|
||||
menuOpen={isNavigationExpanded}
|
||||
onToggleMenu={handleToggleHeaderMenu}
|
||||
/>
|
||||
<DashboardSidebar
|
||||
expanded={isNavigationExpanded}
|
||||
setExpanded={setIsNavigationExpanded}
|
||||
@@ -132,7 +129,6 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ displayPrint: "none" }} />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
|
||||
@@ -81,7 +81,11 @@ export default function DashboardHeader({
|
||||
);
|
||||
|
||||
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 } }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
|
||||
@@ -3,7 +3,6 @@ import Icon from "@mdi/react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import List from "@mui/material/List";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -28,7 +27,6 @@ export interface DashboardSidebarProps {
|
||||
export default function DashboardSidebar({
|
||||
expanded = true,
|
||||
setExpanded,
|
||||
disableCollapsibleSidebar = false,
|
||||
container,
|
||||
}: DashboardSidebarProps) {
|
||||
const theme = useTheme();
|
||||
@@ -53,8 +51,6 @@ export default function DashboardSidebar({
|
||||
return () => {};
|
||||
}, [expanded, theme.transitions.duration.enteringScreen]);
|
||||
|
||||
const mini = !disableCollapsibleSidebar && !expanded;
|
||||
|
||||
const handleSetSidebarExpanded = React.useCallback(
|
||||
(newExpanded: boolean) => () => {
|
||||
setExpanded(newExpanded);
|
||||
@@ -66,15 +62,13 @@ export default function DashboardSidebar({
|
||||
if (!isOverSmViewport) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [mini, setExpanded, isOverSmViewport]);
|
||||
}, [expanded, setExpanded, isOverSmViewport]);
|
||||
|
||||
const hasDrawerTransitions =
|
||||
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
|
||||
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
|
||||
|
||||
const getDrawerContent = React.useCallback(
|
||||
(viewport: "phone" | "tablet" | "desktop") => (
|
||||
<React.Fragment>
|
||||
<Toolbar />
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
|
||||
@@ -84,9 +78,10 @@ export default function DashboardSidebar({
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
overflow: "auto",
|
||||
scrollbarGutter: mini ? "stable" : "auto",
|
||||
scrollbarGutter: !expanded ? "stable" : "auto",
|
||||
overflowX: "hidden",
|
||||
pt: !mini ? 0 : 2,
|
||||
pt: expanded ? 0 : 2,
|
||||
paddingTop: 0,
|
||||
...(hasDrawerTransitions
|
||||
? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
|
||||
: {}),
|
||||
@@ -95,9 +90,9 @@ export default function DashboardSidebar({
|
||||
<List
|
||||
dense
|
||||
sx={{
|
||||
padding: mini ? 0 : 0.5,
|
||||
padding: !expanded ? 0 : 0.5,
|
||||
mb: 4,
|
||||
width: mini ? MINI_DRAWER_WIDTH : "auto",
|
||||
width: !expanded ? MINI_DRAWER_WIDTH : "auto",
|
||||
}}
|
||||
>
|
||||
<DashboardSidebarPageItem
|
||||
@@ -105,30 +100,34 @@ export default function DashboardSidebar({
|
||||
title="Messages"
|
||||
icon={<Icon path={mdiForum} size={"1.5em"} />}
|
||||
href="/"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
<DashboardSidebarDividerItem />
|
||||
<DashboardSidebarPageItem
|
||||
title="Matrix link"
|
||||
icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
|
||||
href="/matrix_link"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
<DashboardSidebarPageItem
|
||||
title="API tokens"
|
||||
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
|
||||
href="/tokens"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
<DashboardSidebarPageItem
|
||||
disabled={!user.info.matrix_account_connected}
|
||||
title="WS Debug"
|
||||
icon={<Icon path={mdiBug} size={"1.5em"} />}
|
||||
href="/wsdebug"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
</List>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
),
|
||||
[
|
||||
mini,
|
||||
expanded,
|
||||
hasDrawerTransitions,
|
||||
isFullyExpanded,
|
||||
user.info.matrix_account_connected,
|
||||
@@ -136,8 +135,14 @@ export default function DashboardSidebar({
|
||||
);
|
||||
|
||||
const getDrawerSharedSx = React.useCallback(
|
||||
(isTemporary: boolean) => {
|
||||
const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH;
|
||||
(isTemporary: boolean, desktop?: boolean) => {
|
||||
const drawerWidth = desktop
|
||||
? expanded
|
||||
? MINI_DRAWER_WIDTH
|
||||
: 0
|
||||
: !expanded
|
||||
? MINI_DRAWER_WIDTH
|
||||
: DRAWER_WIDTH;
|
||||
|
||||
return {
|
||||
displayPrint: "none",
|
||||
@@ -154,17 +159,16 @@ export default function DashboardSidebar({
|
||||
},
|
||||
};
|
||||
},
|
||||
[expanded, mini]
|
||||
[expanded, !expanded]
|
||||
);
|
||||
|
||||
const sidebarContextValue = React.useMemo(() => {
|
||||
return {
|
||||
onPageItemClick: handlePageItemClick,
|
||||
mini,
|
||||
fullyExpanded: isFullyExpanded,
|
||||
hasDrawerTransitions,
|
||||
};
|
||||
}, [handlePageItemClick, mini, isFullyExpanded, hasDrawerTransitions]);
|
||||
}, [handlePageItemClick, !expanded, isFullyExpanded, hasDrawerTransitions]);
|
||||
|
||||
return (
|
||||
<DashboardSidebarContext.Provider value={sidebarContextValue}>
|
||||
@@ -179,7 +183,7 @@ export default function DashboardSidebar({
|
||||
sx={{
|
||||
display: {
|
||||
xs: "block",
|
||||
sm: disableCollapsibleSidebar ? "block" : "none",
|
||||
sm: "none",
|
||||
md: "none",
|
||||
},
|
||||
...getDrawerSharedSx(true),
|
||||
@@ -192,7 +196,7 @@ export default function DashboardSidebar({
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: disableCollapsibleSidebar ? "none" : "block",
|
||||
sm: "block",
|
||||
md: "none",
|
||||
},
|
||||
...getDrawerSharedSx(false),
|
||||
@@ -204,7 +208,7 @@ export default function DashboardSidebar({
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
...getDrawerSharedSx(false),
|
||||
...getDrawerSharedSx(false, true),
|
||||
}}
|
||||
>
|
||||
{getDrawerContent("desktop")}
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
|
||||
const DashboardSidebarContext = React.createContext<{
|
||||
onPageItemClick: () => void;
|
||||
mini: boolean;
|
||||
fullyExpanded: boolean;
|
||||
hasDrawerTransitions: boolean;
|
||||
} | null>(null);
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface DashboardSidebarPageItemProps {
|
||||
href: string;
|
||||
action?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
mini?: boolean;
|
||||
}
|
||||
|
||||
export default function DashboardSidebarPageItem({
|
||||
@@ -25,6 +26,7 @@ export default function DashboardSidebarPageItem({
|
||||
href,
|
||||
action,
|
||||
disabled = false,
|
||||
mini = false,
|
||||
}: DashboardSidebarPageItemProps) {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -32,11 +34,7 @@ export default function DashboardSidebarPageItem({
|
||||
if (!sidebarContext) {
|
||||
throw new Error("Sidebar context was used without a provider.");
|
||||
}
|
||||
const {
|
||||
onPageItemClick,
|
||||
mini = false,
|
||||
fullyExpanded = true,
|
||||
} = sidebarContext;
|
||||
const { onPageItemClick, fullyExpanded = true } = sidebarContext;
|
||||
|
||||
const hasExternalHref = href
|
||||
? 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