Compare commits
130 Commits
196671d0fb
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe9fa4a22 | |||
| 96b02dd73a | |||
| 5dd4aa6c0e | |||
| 8f4480e555 | |||
| 06e1f60314 | |||
| a7432a4014 | |||
| be5e7eb328 | |||
| c1e703c4b4 | |||
| 071aad8147 | |||
| f4b3c0aa16 | |||
| daca7410d7 | |||
| 23074ac354 | |||
| 36bd8d0672 | |||
| 6162555702 | |||
| 951d0db0b7 | |||
| ef90aba489 | |||
| 6b39fd11bd | |||
| bf6561fa87 | |||
| e5feecc703 | |||
| 153ad14a51 | |||
| e5494e51a3 | |||
| f04ab4591b | |||
| ca2cdb2f79 | |||
| 795a12c8d0 | |||
| 79e78006fa | |||
| 18c0fbef3c | |||
| 0ac6fc4ac3 | |||
| 2aaced17d8 | |||
| 9da2a9e9b3 | |||
| 9595ff2e71 | |||
| 5bd62d7683 | |||
| 7e747b50f3 | |||
| a9a5d60edd | |||
| 89dbc252e8 | |||
| 3a6b2c6cf2 | |||
| 98c813b220 | |||
| ceb7859169 | |||
| 96e597ca59 | |||
| d9630fbc4c | |||
| 4d7db2de2a | |||
| 4c0be88570 | |||
| 2f933a247f | |||
| f6a7132d43 | |||
| 1089b5a6a6 | |||
| 28a1b5f4f0 | |||
| acf91c3f0e | |||
| fadb9e6d46 | |||
| 17bad4fcfd | |||
| b3dfc35103 | |||
| 602f663217 | |||
| 5ebfbf6aec | |||
| aad0a74ad5 | |||
| 382e24e17b | |||
| 1876c7b43d | |||
| 73af601a16 | |||
| 6247463c70 | |||
| 9425ed9a12 | |||
| 430ad85c37 | |||
| 29e50bd70c | |||
| 0e83e804d8 | |||
| bd674bfb67 | |||
| fec81ac92e | |||
| d3e25eed9e | |||
| ba5f5f2557 | |||
| 7e548ad5d1 | |||
| 7b63bb0d05 | |||
| 788018451a | |||
| 7b3a2d6a3f | |||
| 0d462f848d | |||
| 9c6c338919 | |||
| 8a4570a044 | |||
| e51fc6b4bb | |||
| 0f68d59798 | |||
| 5ad23005be | |||
| 4e096a1d49 | |||
| ac2a361b77 | |||
| 24f8d67020 | |||
| 5bcee2ea9d | |||
| 48d9444dde | |||
| bcdfe87107 | |||
| 5088699c15 | |||
| 854b474970 | |||
| 336aea463b | |||
| fe9c692e12 | |||
| b47ec37a76 | |||
| 996534c62b | |||
| 3ba6543cb4 | |||
| f087b27b53 | |||
| dfcf764a9b | |||
| fb35fca56e | |||
| f6568cf059 | |||
| bbf558bbf9 | |||
| 1090a59aaf | |||
| 30518f3ca3 | |||
| e215fe6484 | |||
| 6392c0a2c7 | |||
| 4110f4d063 | |||
| 1a5a021711 | |||
| 8b299bcf8f | |||
| ab136ef6d0 | |||
| 1fa98cf6e3 | |||
| 118b73fce9 | |||
| 95fb095205 | |||
| 3274d07635 | |||
| 6d78930b89 | |||
| 7356a66e4a | |||
| 30e63bfdb4 | |||
| e80d54d0e7 | |||
| b91b61f4f0 | |||
| 32354f79ea | |||
| 077c64be28 | |||
| dac20f60e0 | |||
| 9359dc5be0 | |||
| 849aef9343 | |||
| f0e8c799ff | |||
| b4e7cb8718 | |||
| 7a590e882b | |||
| 9a643ced94 | |||
| 5f2a6478a7 | |||
| 1db929a31b | |||
| 0b2c4071e8 | |||
| 61ecfc5af1 | |||
| 661793f58d | |||
| d253e73099 | |||
| f0d3d311e9 | |||
| 592203aa4a | |||
| aeb35029c3 | |||
| 1dc56d5ec1 | |||
| 51b1ab380c | |||
| b5abddaacb |
104
.drone.yml
Normal file
104
.drone.yml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Frontend
|
||||||
|
- name: web_build
|
||||||
|
image: node:25
|
||||||
|
volumes:
|
||||||
|
- name: web_app
|
||||||
|
path: /tmp/web_build
|
||||||
|
commands:
|
||||||
|
- node -v
|
||||||
|
- npm -v
|
||||||
|
- cd matrixgw_frontend
|
||||||
|
- npm install
|
||||||
|
- npm run lint
|
||||||
|
- npm run build
|
||||||
|
- mv dist /tmp/web_build
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
- name: backend_fetch_deps
|
||||||
|
image: rust
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- cargo fetch
|
||||||
|
|
||||||
|
- name: backend_code_quality
|
||||||
|
image: rust
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
depends_on:
|
||||||
|
- backend_fetch_deps
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- rustup component add clippy
|
||||||
|
- cargo clippy -- -D warnings
|
||||||
|
- cargo clippy --example api_curl -- -D warnings
|
||||||
|
|
||||||
|
- name: backend_test
|
||||||
|
image: rust
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
depends_on:
|
||||||
|
- backend_code_quality
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- cargo test
|
||||||
|
|
||||||
|
- name: backend_build
|
||||||
|
image: rust
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
- name: web_app
|
||||||
|
path: /tmp/web_build
|
||||||
|
- name: release
|
||||||
|
path: /tmp/release
|
||||||
|
depends_on:
|
||||||
|
- backend_test
|
||||||
|
- web_build
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- mv /tmp/web_build/dist static
|
||||||
|
- cargo build --release
|
||||||
|
- cargo build --release --example api_curl
|
||||||
|
- ls -lah target/release/matrixgw_backend target/release/examples/api_curl
|
||||||
|
- cp target/release/matrixgw_backend target/release/examples/api_curl /tmp/release
|
||||||
|
|
||||||
|
# Release
|
||||||
|
- name: gitea_release
|
||||||
|
image: plugins/gitea-release
|
||||||
|
depends_on:
|
||||||
|
- backend_build
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
volumes:
|
||||||
|
- name: release
|
||||||
|
path: /tmp/release
|
||||||
|
environment:
|
||||||
|
PLUGIN_API_KEY:
|
||||||
|
from_secret: GITEA_API_KEY # needs permission write:repository
|
||||||
|
settings:
|
||||||
|
base_url: https://gitea.communiquons.org
|
||||||
|
files: /tmp/release/*
|
||||||
|
checksum: sha512
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
temp: {}
|
||||||
|
- name: web_app
|
||||||
|
temp: {}
|
||||||
|
- name: release
|
||||||
|
temp: {}
|
||||||
18
Makefile
Normal file
18
Makefile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
DOCKER_TEMP_DIR=temp
|
||||||
|
|
||||||
|
all: frontend backend
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
cd matrixgw_frontend && npm run build && cd ..
|
||||||
|
rm -rf matrixgw_backend/static
|
||||||
|
mv matrixgw_frontend/dist matrixgw_backend/static
|
||||||
|
|
||||||
|
backend: frontend
|
||||||
|
cd matrixgw_backend && cargo clippy -- -D warnings && cargo build --release
|
||||||
|
|
||||||
|
backend_docker: backend
|
||||||
|
rm -rf $(DOCKER_TEMP_DIR)
|
||||||
|
mkdir $(DOCKER_TEMP_DIR)
|
||||||
|
cp matrixgw_backend/target/release/matrixgw_backend $(DOCKER_TEMP_DIR)
|
||||||
|
docker build -t pierre42100/matrix_gateway -f matrixgw_backend/docker/matrixgw_backend/Dockerfile "$(DOCKER_TEMP_DIR)"
|
||||||
|
rm -rf $(DOCKER_TEMP_DIR)
|
||||||
1
matrixgw_backend/.gitignore
vendored
1
matrixgw_backend/.gitignore
vendored
@@ -2,3 +2,4 @@ storage
|
|||||||
app_storage
|
app_storage
|
||||||
.idea
|
.idea
|
||||||
target
|
target
|
||||||
|
static
|
||||||
1553
matrixgw_backend/Cargo.lock
generated
1553
matrixgw_backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,32 +5,35 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
log = "0.4.28"
|
log = "0.4.29"
|
||||||
clap = { version = "4.5.51", features = ["derive", "env"] }
|
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
actix-web = "4.11.0"
|
actix-web = "4.12.1"
|
||||||
actix-session = { version = "0.11.0", features = ["redis-session"] }
|
actix-session = { version = "0.11.0", features = ["redis-session"] }
|
||||||
actix-remote-ip = "0.1.0"
|
actix-remote-ip = "0.1.0"
|
||||||
actix-cors = "0.7.1"
|
actix-cors = "0.7.1"
|
||||||
light-openid = "1.0.4"
|
light-openid = "1.0.4"
|
||||||
bytes = "1.10.1"
|
bytes = "1.11.0"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
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"] }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||||
ipnet = { version = "2.11.0", features = ["serde"] }
|
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 = { version = "0.14.0" }
|
matrix-sdk = { version = "0.16.0", features = ["e2e-encryption"] }
|
||||||
url = "2.5.7"
|
matrix-sdk-ui = "0.16.0"
|
||||||
ractor = "0.15.9"
|
url = "2.5.8"
|
||||||
serde_json = "1.0.145"
|
ractor = "0.15.10"
|
||||||
|
serde_json = "1.0.149"
|
||||||
lazy-regex = "3.4.2"
|
lazy-regex = "3.4.2"
|
||||||
actix-ws = "0.3.0"
|
actix-ws = "0.3.1"
|
||||||
infer = "0.19.0"
|
infer = "0.19.0"
|
||||||
|
rust-embed = "8.9.0"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
10
matrixgw_backend/docker/matrixgw_backend/Dockerfile
Normal file
10
matrixgw_backend/docker/matrixgw_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y libcurl4 libsqlite3-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
COPY matrixgw_backend /usr/local/bin/matrixgw_backend
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/matrixgw_backend"]
|
||||||
@@ -5,6 +5,7 @@ use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
|
|||||||
use matrix_sdk::ruma::events::receipt::SyncReceiptEvent;
|
use matrix_sdk::ruma::events::receipt::SyncReceiptEvent;
|
||||||
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::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
|
||||||
|
use matrix_sdk::ruma::events::typing::SyncTypingEvent;
|
||||||
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>;
|
||||||
@@ -35,6 +36,8 @@ pub enum BroadcastMessage {
|
|||||||
RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>),
|
RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>),
|
||||||
/// Message fully read event
|
/// Message fully read event
|
||||||
ReceiptEvent(BxRoomEvent<SyncReceiptEvent>),
|
ReceiptEvent(BxRoomEvent<SyncReceiptEvent>),
|
||||||
|
/// User is typing message event
|
||||||
|
TypingEvent(BxRoomEvent<SyncTypingEvent>),
|
||||||
/// Raw Matrix sync response
|
/// Raw Matrix sync response
|
||||||
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
|
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind};
|
|||||||
use matrix_sdk::media::MediaEventContent;
|
use matrix_sdk::media::MediaEventContent;
|
||||||
use matrix_sdk::room::MessagesOptions;
|
use matrix_sdk::room::MessagesOptions;
|
||||||
use matrix_sdk::room::edit::EditedContent;
|
use matrix_sdk::room::edit::EditedContent;
|
||||||
|
use matrix_sdk::ruma::api::client::filter::RoomEventFilter;
|
||||||
|
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
|
||||||
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
|
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
|
||||||
|
use matrix_sdk::ruma::events::receipt::ReceiptThread;
|
||||||
use matrix_sdk::ruma::events::relation::Annotation;
|
use matrix_sdk::ruma::events::relation::Annotation;
|
||||||
use matrix_sdk::ruma::events::room::message::{
|
use matrix_sdk::ruma::events::room::message::{
|
||||||
MessageType, RoomMessageEvent, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
MessageType, RoomMessageEvent, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
||||||
@@ -23,7 +26,7 @@ use serde_json::value::RawValue;
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct APIEvent {
|
pub struct APIEvent {
|
||||||
id: OwnedEventId,
|
pub id: OwnedEventId,
|
||||||
time: MilliSecondsSinceUnixEpoch,
|
time: MilliSecondsSinceUnixEpoch,
|
||||||
sender: OwnedUserId,
|
sender: OwnedUserId,
|
||||||
data: Box<RawValue>,
|
data: Box<RawValue>,
|
||||||
@@ -61,10 +64,14 @@ pub(super) async fn get_events(
|
|||||||
room: &Room,
|
room: &Room,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
from: Option<&str>,
|
from: Option<&str>,
|
||||||
|
filter: Option<RoomEventFilter>,
|
||||||
) -> anyhow::Result<APIEventsList> {
|
) -> anyhow::Result<APIEventsList> {
|
||||||
let mut msg_opts = MessagesOptions::backward();
|
let mut msg_opts = MessagesOptions::backward();
|
||||||
msg_opts.from = from.map(str::to_string);
|
msg_opts.from = from.map(str::to_string);
|
||||||
msg_opts.limit = UInt::from(limit);
|
msg_opts.limit = UInt::from(limit);
|
||||||
|
if let Some(filter) = filter {
|
||||||
|
msg_opts.filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
let messages = room.messages(msg_opts).await?;
|
let messages = room.messages(msg_opts).await?;
|
||||||
Ok(APIEventsList {
|
Ok(APIEventsList {
|
||||||
@@ -97,8 +104,15 @@ pub async fn get_for_room(
|
|||||||
return Ok(HttpResponse::NotFound().json("Room not found!"));
|
return Ok(HttpResponse::NotFound().json("Room not found!"));
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(HttpResponse::Ok()
|
Ok(HttpResponse::Ok().json(
|
||||||
.json(get_events(&room, query.limit.unwrap_or(500), query.from.as_deref()).await?))
|
get_events(
|
||||||
|
&room,
|
||||||
|
query.limit.unwrap_or(500),
|
||||||
|
query.from.as_deref(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -266,3 +280,23 @@ pub async fn redact_event(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send receipt for event
|
||||||
|
pub async fn receipt(
|
||||||
|
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"));
|
||||||
|
};
|
||||||
|
|
||||||
|
room.send_single_receipt(
|
||||||
|
ReceiptType::Read,
|
||||||
|
ReceiptThread::Main,
|
||||||
|
event_path.event_id.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().finish())
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ use crate::utils::crypt_utils::sha512;
|
|||||||
use actix_web::dev::Payload;
|
use actix_web::dev::Payload;
|
||||||
use actix_web::http::header;
|
use actix_web::http::header;
|
||||||
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
|
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
|
||||||
use matrix_sdk::crypto::{AttachmentDecryptor, MediaEncryptionInfo};
|
|
||||||
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
|
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
|
||||||
use matrix_sdk::ruma::events::room::MediaSource;
|
use matrix_sdk::ruma::events::room::MediaSource;
|
||||||
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
|
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
|
||||||
use std::io::{Cursor, Read};
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct MediaMXCInPath {
|
pub struct MediaMXCInPath {
|
||||||
@@ -54,21 +52,6 @@ pub async fn serve_media(req: HttpRequest, source: MediaSource, thumbnail: bool)
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Decrypt file if needed
|
|
||||||
let media = if let MediaSource::Encrypted(file) = source {
|
|
||||||
let mut cursor = Cursor::new(media);
|
|
||||||
let mut decryptor =
|
|
||||||
AttachmentDecryptor::new(&mut cursor, MediaEncryptionInfo::from(*file))?;
|
|
||||||
|
|
||||||
let mut decrypted_data = Vec::new();
|
|
||||||
|
|
||||||
decryptor.read_to_end(&mut decrypted_data)?;
|
|
||||||
|
|
||||||
decrypted_data
|
|
||||||
} else {
|
|
||||||
media
|
|
||||||
};
|
|
||||||
|
|
||||||
let digest = sha512(&media);
|
let digest = sha512(&media);
|
||||||
|
|
||||||
let mime_type = infer::get(&media).map(|x| x.mime_type());
|
let mime_type = infer::get(&media).map(|x| x.mime_type());
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use matrix_sdk::notification_settings::{
|
|||||||
IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode,
|
IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode,
|
||||||
};
|
};
|
||||||
use matrix_sdk::room::ParentSpace;
|
use matrix_sdk::room::ParentSpace;
|
||||||
use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomId, OwnedUserId};
|
use matrix_sdk::ruma::events::receipt::{ReceiptThread, ReceiptType};
|
||||||
|
use matrix_sdk::ruma::{
|
||||||
|
MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId,
|
||||||
|
};
|
||||||
use matrix_sdk::{Room, RoomMemberships};
|
use matrix_sdk::{Room, RoomMemberships};
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@@ -71,7 +74,11 @@ impl APIRoomInfo {
|
|||||||
parents: parent_spaces,
|
parents: parent_spaces,
|
||||||
number_unread_messages: r.unread_notification_counts().notification_count,
|
number_unread_messages: r.unread_notification_counts().notification_count,
|
||||||
notifications,
|
notifications,
|
||||||
latest_event: get_events(r, 1, None).await?.events.into_iter().next(),
|
latest_event: get_events(r, 1, None, None)
|
||||||
|
.await?
|
||||||
|
.events
|
||||||
|
.into_iter()
|
||||||
|
.next(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,3 +144,37 @@ pub async fn room_avatar(
|
|||||||
|
|
||||||
matrix_media_controller::serve_mxc_file(req, uri).await
|
matrix_media_controller::serve_mxc_file(req, uri).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct UserReceipt {
|
||||||
|
user: OwnedUserId,
|
||||||
|
event_id: OwnedEventId,
|
||||||
|
ts: Option<MilliSecondsSinceUnixEpoch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get room receipts
|
||||||
|
pub async fn receipts(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 members = room.members(RoomMemberships::ACTIVE).await?;
|
||||||
|
|
||||||
|
let mut receipts = Vec::new();
|
||||||
|
for m in members {
|
||||||
|
let Some((event_id, receipt)) = room
|
||||||
|
.load_user_receipt(ReceiptType::Read, ReceiptThread::Main, m.user_id())
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
receipts.push(UserReceipt {
|
||||||
|
user: m.user_id().to_owned(),
|
||||||
|
event_id,
|
||||||
|
ts: receipt.ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(receipts))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
use actix_web::HttpResponse;
|
||||||
|
use matrix_sdk_ui::spaces::SpaceService;
|
||||||
|
use matrix_sdk_ui::spaces::room_list::SpaceRoomListPaginationState;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Get space hierarchy
|
||||||
|
pub async fn hierarchy(client: MatrixClientExtractor) -> HttpResult {
|
||||||
|
let spaces = client.client.client.joined_space_rooms();
|
||||||
|
let space_service = SpaceService::new(client.client.client);
|
||||||
|
let mut hierarchy = HashMap::new();
|
||||||
|
for space in spaces {
|
||||||
|
let rooms = space_service
|
||||||
|
.space_room_list(space.room_id().to_owned())
|
||||||
|
.await;
|
||||||
|
while !matches!(
|
||||||
|
rooms.pagination_state(),
|
||||||
|
SpaceRoomListPaginationState::Idle { end_reached: true }
|
||||||
|
) {
|
||||||
|
rooms.paginate().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
hierarchy.insert(
|
||||||
|
space.room_id().to_owned(),
|
||||||
|
rooms
|
||||||
|
.rooms()
|
||||||
|
.into_iter()
|
||||||
|
.map(|room| room.room_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(HttpResponse::Ok().json(hierarchy))
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ pub mod matrix_event_controller;
|
|||||||
pub mod matrix_media_controller;
|
pub mod matrix_media_controller;
|
||||||
pub mod matrix_profile_controller;
|
pub mod matrix_profile_controller;
|
||||||
pub mod matrix_room_controller;
|
pub mod matrix_room_controller;
|
||||||
|
pub mod matrix_space_controller;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 static_controller;
|
||||||
pub mod tokens_controller;
|
pub mod tokens_controller;
|
||||||
pub mod ws_controller;
|
pub mod ws_controller;
|
||||||
|
|
||||||
|
|||||||
45
matrixgw_backend/src/controllers/static_controller.rs
Normal file
45
matrixgw_backend/src/controllers/static_controller.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub use serve_static_debug::{root_index, serve_static_content};
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub use serve_static_release::{root_index, serve_static_content};
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
mod serve_static_debug {
|
||||||
|
use actix_web::{HttpResponse, Responder};
|
||||||
|
|
||||||
|
pub async fn root_index() -> impl Responder {
|
||||||
|
HttpResponse::Ok().body("Hello world! Debug=on for Matrix Gateway!")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_static_content() -> impl Responder {
|
||||||
|
HttpResponse::NotFound().body("Hello world! Static assets are not served in debug mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
mod serve_static_release {
|
||||||
|
use actix_web::{HttpResponse, Responder, web};
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "static/"]
|
||||||
|
struct Asset;
|
||||||
|
|
||||||
|
fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
|
||||||
|
match (Asset::get(path), can_fallback) {
|
||||||
|
(Some(content), _) => HttpResponse::Ok()
|
||||||
|
.content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref())
|
||||||
|
.body(content.data.into_owned()),
|
||||||
|
(None, false) => HttpResponse::NotFound().body("404 Not Found"),
|
||||||
|
(None, true) => handle_embedded_file("index.html", false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn root_index() -> impl Responder {
|
||||||
|
handle_embedded_file("index.html", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_static_content(path: web::Path<String>) -> impl Responder {
|
||||||
|
handle_embedded_file(path.as_ref(), !path.as_ref().starts_with("static/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,12 @@ pub struct WsReceiptEvent {
|
|||||||
pub receipts: Vec<WsReceiptEntry>,
|
pub receipts: Vec<WsReceiptEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct WsTypingEvent {
|
||||||
|
pub room_id: OwnedRoomId,
|
||||||
|
pub user_ids: Vec<OwnedUserId>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Messages sent to the client
|
/// Messages sent to the client
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
@@ -57,6 +63,9 @@ pub enum WsMessage {
|
|||||||
|
|
||||||
/// Fully read message event
|
/// Fully read message event
|
||||||
ReceiptEvent(WsReceiptEvent),
|
ReceiptEvent(WsReceiptEvent),
|
||||||
|
|
||||||
|
/// User is typing event
|
||||||
|
TypingEvent(WsTypingEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WsMessage {
|
impl WsMessage {
|
||||||
@@ -71,6 +80,7 @@ impl WsMessage {
|
|||||||
data: Box::new(evt.data.content.clone()),
|
data: Box::new(evt.data.content.clone()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
BroadcastMessage::ReactionEvent(evt) if &evt.user == user => {
|
BroadcastMessage::ReactionEvent(evt) if &evt.user == user => {
|
||||||
Some(Self::RoomReactionEvent(WsRoomEvent {
|
Some(Self::RoomReactionEvent(WsRoomEvent {
|
||||||
room_id: evt.room.room_id().to_owned(),
|
room_id: evt.room.room_id().to_owned(),
|
||||||
@@ -80,6 +90,7 @@ impl WsMessage {
|
|||||||
data: Box::new(evt.data.content.clone()),
|
data: Box::new(evt.data.content.clone()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
BroadcastMessage::RoomRedactionEvent(evt) if &evt.user == user => {
|
BroadcastMessage::RoomRedactionEvent(evt) if &evt.user == user => {
|
||||||
Some(Self::RoomRedactionEvent(WsRoomEvent {
|
Some(Self::RoomRedactionEvent(WsRoomEvent {
|
||||||
room_id: evt.room.room_id().to_owned(),
|
room_id: evt.room.room_id().to_owned(),
|
||||||
@@ -89,6 +100,7 @@ impl WsMessage {
|
|||||||
data: Box::new(evt.data.content.clone()),
|
data: Box::new(evt.data.content.clone()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
BroadcastMessage::ReceiptEvent(evt) if &evt.user == user => {
|
BroadcastMessage::ReceiptEvent(evt) if &evt.user == user => {
|
||||||
let mut receipts = vec![];
|
let mut receipts = vec![];
|
||||||
for (event_id, r) in &evt.data.content.0 {
|
for (event_id, r) in &evt.data.content.0 {
|
||||||
@@ -108,6 +120,14 @@ impl WsMessage {
|
|||||||
receipts,
|
receipts,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BroadcastMessage::TypingEvent(evt) if &evt.user == user => {
|
||||||
|
Some(Self::TypingEvent(WsTypingEvent {
|
||||||
|
room_id: evt.room.room_id().to_owned(),
|
||||||
|
user_ids: evt.data.content.user_ids.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ use matrixgw_backend::broadcast_messages::BroadcastMessage;
|
|||||||
use matrixgw_backend::constants;
|
use matrixgw_backend::constants;
|
||||||
use matrixgw_backend::controllers::matrix::{
|
use matrixgw_backend::controllers::matrix::{
|
||||||
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
|
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
|
||||||
matrix_room_controller,
|
matrix_room_controller, matrix_space_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, ws_controller,
|
static_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;
|
||||||
@@ -138,6 +138,11 @@ async fn main() -> std::io::Result<()> {
|
|||||||
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)))
|
.service(web::resource("/api/ws").route(web::get().to(ws_controller::ws)))
|
||||||
|
// Matrix spaces controller
|
||||||
|
.route(
|
||||||
|
"/api/matrix/space/hierarchy",
|
||||||
|
web::get().to(matrix_space_controller::hierarchy),
|
||||||
|
)
|
||||||
// Matrix room controller
|
// Matrix room controller
|
||||||
.route(
|
.route(
|
||||||
"/api/matrix/room/joined",
|
"/api/matrix/room/joined",
|
||||||
@@ -155,6 +160,10 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/matrix/room/{room_id}/avatar",
|
"/api/matrix/room/{room_id}/avatar",
|
||||||
web::get().to(matrix_room_controller::room_avatar),
|
web::get().to(matrix_room_controller::room_avatar),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/{room_id}/receipts",
|
||||||
|
web::get().to(matrix_room_controller::receipts),
|
||||||
|
)
|
||||||
// Matrix profile controller
|
// Matrix profile controller
|
||||||
.route(
|
.route(
|
||||||
"/api/matrix/profile/{user_id}",
|
"/api/matrix/profile/{user_id}",
|
||||||
@@ -189,11 +198,21 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/matrix/room/{room_id}/event/{event_id}",
|
"/api/matrix/room/{room_id}/event/{event_id}",
|
||||||
web::delete().to(matrix_event_controller::redact_event),
|
web::delete().to(matrix_event_controller::redact_event),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/{room_id}/event/{event_id}/receipt",
|
||||||
|
web::post().to(matrix_event_controller::receipt),
|
||||||
|
)
|
||||||
// Matrix media controller
|
// Matrix media controller
|
||||||
.route(
|
.route(
|
||||||
"/api/matrix/media/{mxc}",
|
"/api/matrix/media/{mxc}",
|
||||||
web::get().to(matrix_media_controller::serve_mxc_handler),
|
web::get().to(matrix_media_controller::serve_mxc_handler),
|
||||||
)
|
)
|
||||||
|
// Static assets
|
||||||
|
.route("/", web::get().to(static_controller::root_index))
|
||||||
|
.route(
|
||||||
|
"/{tail:.*}",
|
||||||
|
web::get().to(static_controller::serve_static_content),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.workers(4)
|
.workers(4)
|
||||||
.bind(&AppConfig::get().listen_address)?
|
.bind(&AppConfig::get().listen_address)?
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
|
|||||||
use matrix_sdk::ruma::events::receipt::SyncReceiptEvent;
|
use matrix_sdk::ruma::events::receipt::SyncReceiptEvent;
|
||||||
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::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
|
||||||
|
use matrix_sdk::ruma::events::typing::SyncTypingEvent;
|
||||||
use ractor::ActorRef;
|
use ractor::ActorRef;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -54,11 +55,11 @@ async fn sync_thread_task(
|
|||||||
let mut handlers = vec![];
|
let mut handlers = vec![];
|
||||||
|
|
||||||
let tx_msg_handle = tx.clone();
|
let tx_msg_handle = tx.clone();
|
||||||
let user_msg_handle = client.email.clone();
|
let user_msg_mail = client.email.clone();
|
||||||
handlers.push(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(BxRoomEvent {
|
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent(BxRoomEvent {
|
||||||
user: user_msg_handle.clone(),
|
user: user_msg_mail.clone(),
|
||||||
data: Box::new(event),
|
data: Box::new(event),
|
||||||
room,
|
room,
|
||||||
})) {
|
})) {
|
||||||
@@ -68,11 +69,11 @@ async fn sync_thread_task(
|
|||||||
));
|
));
|
||||||
|
|
||||||
let tx_reac_handle = tx.clone();
|
let tx_reac_handle = tx.clone();
|
||||||
let user_reac_handle = client.email.clone();
|
let user_reac_mail = client.email.clone();
|
||||||
handlers.push(client.add_event_handler(
|
handlers.push(client.add_event_handler(
|
||||||
async move |event: OriginalSyncReactionEvent, room: Room| {
|
async move |event: OriginalSyncReactionEvent, room: Room| {
|
||||||
if let Err(e) = tx_reac_handle.send(BroadcastMessage::ReactionEvent(BxRoomEvent {
|
if let Err(e) = tx_reac_handle.send(BroadcastMessage::ReactionEvent(BxRoomEvent {
|
||||||
user: user_reac_handle.clone(),
|
user: user_reac_mail.clone(),
|
||||||
data: Box::new(event),
|
data: Box::new(event),
|
||||||
room,
|
room,
|
||||||
})) {
|
})) {
|
||||||
@@ -82,12 +83,12 @@ async fn sync_thread_task(
|
|||||||
));
|
));
|
||||||
|
|
||||||
let tx_redac_handle = tx.clone();
|
let tx_redac_handle = tx.clone();
|
||||||
let user_redac_handle = client.email.clone();
|
let user_redac_mail = client.email.clone();
|
||||||
handlers.push(client.add_event_handler(
|
handlers.push(client.add_event_handler(
|
||||||
async move |event: OriginalSyncRoomRedactionEvent, room: Room| {
|
async move |event: OriginalSyncRoomRedactionEvent, room: Room| {
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
tx_redac_handle.send(BroadcastMessage::RoomRedactionEvent(BxRoomEvent {
|
tx_redac_handle.send(BroadcastMessage::RoomRedactionEvent(BxRoomEvent {
|
||||||
user: user_redac_handle.clone(),
|
user: user_redac_mail.clone(),
|
||||||
data: Box::new(event),
|
data: Box::new(event),
|
||||||
room,
|
room,
|
||||||
}))
|
}))
|
||||||
@@ -98,11 +99,11 @@ async fn sync_thread_task(
|
|||||||
));
|
));
|
||||||
|
|
||||||
let tx_receipt_handle = tx.clone();
|
let tx_receipt_handle = tx.clone();
|
||||||
let user_receipt_handle = client.email.clone();
|
let user_receipt_mail = client.email.clone();
|
||||||
handlers.push(
|
handlers.push(
|
||||||
client.add_event_handler(async move |event: SyncReceiptEvent, room: Room| {
|
client.add_event_handler(async move |event: SyncReceiptEvent, room: Room| {
|
||||||
if let Err(e) = tx_receipt_handle.send(BroadcastMessage::ReceiptEvent(BxRoomEvent {
|
if let Err(e) = tx_receipt_handle.send(BroadcastMessage::ReceiptEvent(BxRoomEvent {
|
||||||
user: user_receipt_handle.clone(),
|
user: user_receipt_mail.clone(),
|
||||||
data: Box::new(event),
|
data: Box::new(event),
|
||||||
room,
|
room,
|
||||||
})) {
|
})) {
|
||||||
@@ -111,6 +112,20 @@ async fn sync_thread_task(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let tx_typing_handle = tx.clone();
|
||||||
|
let user_typing_mail = client.email.clone();
|
||||||
|
handlers.push(
|
||||||
|
client.add_event_handler(async move |event: SyncTypingEvent, room: Room| {
|
||||||
|
if let Err(e) = tx_typing_handle.send(BroadcastMessage::TypingEvent(BxRoomEvent {
|
||||||
|
user: user_typing_mail.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
})) {
|
||||||
|
log::warn!("Failed to forward typing event! {e}");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// Message from tokio broadcast
|
// Message from tokio broadcast
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js";
|
||||||
import globals from 'globals'
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint";
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs['recommended-latest'],
|
reactHooks.configs.flat.recommended,
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"react-refresh/only-export-components": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
|
|||||||
1051
matrixgw_frontend/package-lock.json
generated
1051
matrixgw_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,39 +12,37 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/roboto": "^5.2.8",
|
"@fontsource/roboto": "^5.2.9",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mui/icons-material": "^7.3.7",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mui/material": "^7.3.7",
|
||||||
"@mui/icons-material": "^7.3.5",
|
"@mui/x-data-grid": "^8.24.0",
|
||||||
"@mui/material": "^7.3.5",
|
"@mui/x-date-pickers": "^8.23.0",
|
||||||
"@mui/x-data-grid": "^8.18.0",
|
"date-and-time": "^4.1.2",
|
||||||
"@mui/x-date-pickers": "^8.17.0",
|
|
||||||
"date-and-time": "^4.1.0",
|
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"emoji-picker-react": "^4.16.1",
|
"emoji-picker-react": "^4.16.1",
|
||||||
"is-cidr": "^6.0.1",
|
"is-cidr": "^6.0.1",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.2.3",
|
||||||
"react-favicon": "^2.0.7",
|
"react-favicon": "^2.0.7",
|
||||||
"react-json-view-lite": "^2.5.0",
|
"react-json-view-lite": "^2.5.0",
|
||||||
"react-router": "^7.9.5"
|
"react-router": "^7.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^25.0.9",
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.5.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.51.0",
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
"vite": "npm:rolldown-vite@7.3.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
"vite": "npm:rolldown-vite@7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
matrixgw_frontend/public/favicon.png
Normal file
BIN
matrixgw_frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -4,21 +4,21 @@ interface RequestParams {
|
|||||||
uri: string;
|
uri: string;
|
||||||
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||||
allowFail?: boolean;
|
allowFail?: boolean;
|
||||||
jsonData?: any;
|
jsonData?: unknown;
|
||||||
formData?: FormData;
|
formData?: FormData;
|
||||||
upProgress?: (progress: number) => void;
|
upProgress?: (progress: number) => void;
|
||||||
downProgress?: (e: { progress: number; total: number }) => void;
|
downProgress?: (e: { progress: number; total: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface APIResponse {
|
interface APIResponse {
|
||||||
data: any;
|
data: unknown;
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
public code: number;
|
public code: number;
|
||||||
public data: number;
|
public data: unknown;
|
||||||
constructor(message: string, code: number, data: any) {
|
constructor(message: string, code: number, data: unknown) {
|
||||||
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
|
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.data = data;
|
this.data = data;
|
||||||
@@ -57,6 +57,7 @@ export class APIClient {
|
|||||||
*/
|
*/
|
||||||
static async exec(args: RequestParams): Promise<APIResponse> {
|
static async exec(args: RequestParams): Promise<APIResponse> {
|
||||||
let body: string | undefined | FormData = undefined;
|
let body: string | undefined | FormData = undefined;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
|
|
||||||
// JSON request
|
// JSON request
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class AuthApi {
|
|||||||
uri: "/auth/start_oidc",
|
uri: "/auth/start_oidc",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
).data;
|
).data as { url: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +70,7 @@ export class AuthApi {
|
|||||||
uri: "/auth/info",
|
uri: "/auth/info",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
).data;
|
).data as UserInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class MatrixLinkApi {
|
|||||||
uri: "/matrix_link/start_auth",
|
uri: "/matrix_link/start_auth",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
).data;
|
).data as { url: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ export class MatrixSyncApi {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
uri: "/matrix_sync/status",
|
uri: "/matrix_sync/status",
|
||||||
});
|
});
|
||||||
return res.data.started;
|
return (res.data as { started: boolean }).started;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class ServerApi {
|
|||||||
uri: "/server/config",
|
uri: "/server/config",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
).data;
|
).data as ServerConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class TokensApi {
|
|||||||
uri: "/tokens",
|
uri: "/tokens",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
).data;
|
).data as Token[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,18 +41,16 @@ export class TokensApi {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
jsonData: t,
|
jsonData: t,
|
||||||
})
|
})
|
||||||
).data;
|
).data as TokenWithSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a token
|
* Delete a token
|
||||||
*/
|
*/
|
||||||
static async Delete(t: Token): Promise<void> {
|
static async Delete(t: Token): Promise<void> {
|
||||||
return (
|
await APIClient.exec({
|
||||||
await APIClient.exec({
|
uri: `/token/${t.id}`,
|
||||||
uri: `/token/${t.id}`,
|
method: "DELETE",
|
||||||
method: "DELETE",
|
});
|
||||||
})
|
|
||||||
).data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface RoomMessageEvent extends BaseRoomEvent {
|
|||||||
"m.relates_to"?: {
|
"m.relates_to"?: {
|
||||||
rel_type?: "m.replace" | string;
|
rel_type?: "m.replace" | string;
|
||||||
event_id?: string;
|
event_id?: string;
|
||||||
|
"m.in_reply_to"?:{
|
||||||
|
event_id?:string
|
||||||
|
}
|
||||||
};
|
};
|
||||||
"m.new_content"?: {
|
"m.new_content"?: {
|
||||||
msgtype?: MessageType;
|
msgtype?: MessageType;
|
||||||
@@ -58,11 +61,19 @@ export interface RoomReceiptEvent {
|
|||||||
receipts: ReceiptEventEntry[];
|
receipts: ReceiptEventEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoomTypingEvent {
|
||||||
|
time: number;
|
||||||
|
type: "TypingEvent";
|
||||||
|
room_id: string;
|
||||||
|
user_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type WsMessage =
|
export type WsMessage =
|
||||||
| RoomMessageEvent
|
| RoomMessageEvent
|
||||||
| RoomReactionEvent
|
| RoomReactionEvent
|
||||||
| RoomRedactionEvent
|
| RoomRedactionEvent
|
||||||
| RoomReceiptEvent;
|
| RoomReceiptEvent
|
||||||
|
| RoomTypingEvent;
|
||||||
|
|
||||||
export class WsApi {
|
export class WsApi {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ export interface MatrixRoomMessage {
|
|||||||
body: string;
|
body: string;
|
||||||
msgtype: MessageType;
|
msgtype: MessageType;
|
||||||
"m.relates_to"?: {
|
"m.relates_to"?: {
|
||||||
event_id: string;
|
event_id?: string;
|
||||||
rel_type: "m.replace" | string;
|
rel_type?: "m.replace" | string;
|
||||||
|
"m.in_reply_to"?: {
|
||||||
|
event_id?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
url?: string;
|
url?: string;
|
||||||
file?: {
|
file?: {
|
||||||
@@ -71,10 +74,10 @@ export class MatrixApiEvent {
|
|||||||
await APIClient.exec({
|
await APIClient.exec({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
uri:
|
uri:
|
||||||
`/matrix/room/${encodeURIComponent(room.id)}/events` +
|
`/matrix/room/${encodeURIComponent(room.id)}/events?limit=200` +
|
||||||
(from ? `?from=${from}` : ""),
|
(from ? `&from=${from}` : ""),
|
||||||
})
|
})
|
||||||
).data;
|
).data as MatrixEventsList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,4 +143,14 @@ export class MatrixApiEvent {
|
|||||||
uri: `/matrix/room/${room.id}/event/${event_id}`,
|
uri: `/matrix/room/${room.id}/event/${event_id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send event receipt
|
||||||
|
*/
|
||||||
|
static async SendReceipt(room: Room, event_id: string): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/matrix/room/${room.id}/event/${event_id}/receipt`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ export class MatrixApiProfile {
|
|||||||
* Get multiple profiles information
|
* Get multiple profiles information
|
||||||
*/
|
*/
|
||||||
static async GetMultiple(ids: string[]): Promise<UsersMap> {
|
static async GetMultiple(ids: string[]): Promise<UsersMap> {
|
||||||
const list: UserProfile[] = (
|
const list = (
|
||||||
await APIClient.exec({
|
await APIClient.exec({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
uri: "/matrix/profile/get_multiple",
|
uri: "/matrix/profile/get_multiple",
|
||||||
jsonData: ids,
|
jsonData: ids,
|
||||||
})
|
})
|
||||||
).data;
|
).data as UserProfile[];
|
||||||
|
|
||||||
return new Map(list.map((e) => [e.user_id, e]));
|
return new Map(list.map((e) => [e.user_id, e]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export interface Room {
|
|||||||
latest_event?: MatrixEvent;
|
latest_event?: MatrixEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
user: string;
|
||||||
|
event_id: string;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find main member of room
|
* Find main member of room
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +57,18 @@ export class MatrixApiRoom {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
uri: "/matrix/room/joined",
|
uri: "/matrix/room/joined",
|
||||||
})
|
})
|
||||||
).data;
|
).data as Room[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a room receipts
|
||||||
|
*/
|
||||||
|
static async RoomReceipts(room: Room): Promise<Receipt[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: `/matrix/room/${room.id}/receipts`,
|
||||||
|
})
|
||||||
|
).data as Receipt[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
matrixgw_frontend/src/api/matrix/MatrixApiSpace.ts
Normal file
40
matrixgw_frontend/src/api/matrix/MatrixApiSpace.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { APIClient } from "../ApiClient";
|
||||||
|
|
||||||
|
export type SpaceHierarchy = Map<string, string[]>;
|
||||||
|
|
||||||
|
export class MatrixApiSpace {
|
||||||
|
/**
|
||||||
|
* Request Matrix space hierarchy
|
||||||
|
*/
|
||||||
|
static async Hierarchy(): Promise<SpaceHierarchy> {
|
||||||
|
const hierarchy = new Map(
|
||||||
|
Object.entries(
|
||||||
|
(
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/matrix/space/hierarchy",
|
||||||
|
})
|
||||||
|
).data as { [s: string]: string[] }
|
||||||
|
)
|
||||||
|
) as SpaceHierarchy;
|
||||||
|
|
||||||
|
// Simplify hierarchy
|
||||||
|
while (true) {
|
||||||
|
let changed = false;
|
||||||
|
for (const [roomid, children] of hierarchy) {
|
||||||
|
for (const child of children) {
|
||||||
|
if (!hierarchy.has(child)) continue;
|
||||||
|
hierarchy.set(roomid, [
|
||||||
|
...hierarchy.get(roomid)!,
|
||||||
|
...hierarchy.get(child)!,
|
||||||
|
]);
|
||||||
|
hierarchy.delete(child);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hierarchy;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
matrixgw_frontend/src/icons/AppIcon.tsx
Normal file
18
matrixgw_frontend/src/icons/AppIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Icon } from "@mui/material";
|
||||||
|
import { useActualColorMode } from "../widgets/dashboard/ThemeSwitcher";
|
||||||
|
|
||||||
|
export function AppIcon(p: { src: string; size?: string }): React.ReactElement {
|
||||||
|
const { mode } = useActualColorMode();
|
||||||
|
return (
|
||||||
|
<Icon style={{ display: "inline-flex", width: p.size, height: p.size }}>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: mode === "dark" ? "white" : "black",
|
||||||
|
mask: `url("${p.src}")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
matrixgw_frontend/src/icons/message-text-fast.svg
Normal file
1
matrixgw_frontend/src/icons/message-text-fast.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 5C7.9 5 7 5.9 7 7V21L11 17H20C21.1 17 22 16.1 22 15V7C22 5.9 21.1 5 20 5H9M3 7C2.4 7 2 7.4 2 8S2.4 9 3 9H5V7H3M11 8H19V10H11V8M2 11C1.4 11 1 11.4 1 12S1.4 13 2 13H5V11H2M11 12H16V14H11V12M1 15C.4 15 0 15.4 0 16C0 16.6 .4 17 1 17H5V15H1Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
1
matrixgw_frontend/src/icons/openid.svg
Normal file
1
matrixgw_frontend/src/icons/openid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14,2L11,3.5V19.94C7,19.5 4,17.46 4,15C4,12.75 6.5,10.85 10,10.22V8.19C4.86,8.88 1,11.66 1,15C1,18.56 5.36,21.5 11,21.94C11.03,21.94 11.06,21.94 11.09,21.94L14,20.5V2M15,8.19V10.22C16.15,10.43 17.18,10.77 18.06,11.22L16.5,12L23,13.5L22.5,9L20.5,10C19,9.12 17.12,8.47 15,8.19Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
@@ -19,7 +19,7 @@ import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
|
|||||||
import { time } from "../utils/DateUtils";
|
import { time } from "../utils/DateUtils";
|
||||||
|
|
||||||
export function APITokensRoute(): React.ReactElement {
|
export function APITokensRoute(): React.ReactElement {
|
||||||
const count = React.useRef(0);
|
const [count, setCount] = React.useState(0);
|
||||||
|
|
||||||
const [openCreateTokenDialog, setOpenCreateTokenDialog] =
|
const [openCreateTokenDialog, setOpenCreateTokenDialog] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
@@ -34,7 +34,7 @@ export function APITokensRoute(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshTokensList = () => {
|
const handleRefreshTokensList = () => {
|
||||||
count.current += 1;
|
setCount((c) => c + 1);
|
||||||
setList(undefined);
|
setList(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export function APITokensRoute(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Tokens list */}
|
{/* Tokens list */}
|
||||||
<AsyncWidget
|
<AsyncWidget
|
||||||
loadKey={count.current}
|
loadKey={count}
|
||||||
ready={list !== undefined}
|
ready={list !== undefined}
|
||||||
load={load}
|
load={load}
|
||||||
errMsg="Failed to load the list of tokens!"
|
errMsg="Failed to load the list of tokens!"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function MatrixAuthCallback(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [code, state]);
|
}, [code, state]);
|
||||||
|
|
||||||
if (error)
|
if (error)
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ function SyncThreadStatus(): React.ReactElement {
|
|||||||
const interval = setInterval(loadStatus, 1000);
|
const interval = setInterval(loadStatus, 1000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,65 +1,43 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { JsonView, darkStyles } from "react-json-view-lite";
|
import { JsonView, darkStyles } from "react-json-view-lite";
|
||||||
import "react-json-view-lite/dist/index.css";
|
import "react-json-view-lite/dist/index.css";
|
||||||
import { WsApi, type WsMessage } from "../api/WsApi";
|
import { type WsMessage } from "../api/WsApi";
|
||||||
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
|
|
||||||
import { time } from "../utils/DateUtils";
|
|
||||||
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
||||||
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
|
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
|
||||||
|
import { MatrixWS, WSState } from "../widgets/messages/MatrixWS";
|
||||||
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
||||||
|
|
||||||
const State = {
|
|
||||||
Closed: "Closed",
|
|
||||||
Connected: "Connected",
|
|
||||||
Error: "Error",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type TimestampedMessages = WsMessage & { time: number };
|
type TimestampedMessages = WsMessage & { time: number };
|
||||||
|
|
||||||
export function WSDebugRoute(): React.ReactElement {
|
export function WSDebugRoute(): React.ReactElement {
|
||||||
const user = useUserInfo();
|
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 [state, setState] = React.useState<string>(WSState.Closed);
|
||||||
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
|
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleMessage = (msg: WsMessage) => {
|
||||||
const ws = new WebSocket(WsApi.WsURL);
|
setMessages((l) => [...l, msg]);
|
||||||
wsRef.current = ws;
|
};
|
||||||
|
|
||||||
ws.onopen = () => setState(State.Connected);
|
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
|
||||||
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 (
|
return (
|
||||||
<MatrixGWRouteContainer label={"WebSocket Debug"}>
|
<MatrixGWRouteContainer label={"WebSocket Debug"}>
|
||||||
<div>
|
{/* Status bar */}
|
||||||
State:{" "}
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
<span style={{ color: state == State.Connected ? "green" : "red" }}>
|
<span style={{ marginRight: "0.5em" }}>State: </span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginRight: "0.5em",
|
||||||
|
color: state == WSState.Connected ? "green" : "red",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{state}
|
{state}
|
||||||
</span>
|
</span>
|
||||||
|
<MatrixWS onStateChange={setState} onMessage={handleMessage} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* WS messages list */}
|
||||||
{messages.map((msg, id) => (
|
{messages.map((msg, id) => (
|
||||||
<div style={{ margin: "10px", backgroundColor: "black" }}>
|
<div style={{ margin: "10px", backgroundColor: "black" }}>
|
||||||
<JsonView
|
<JsonView
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import { mdiOpenid } from "@mdi/js";
|
|
||||||
import { ServerApi } from "../../api/ServerApi";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { AuthApi } from "../../api/AuthApi";
|
import { AuthApi } from "../../api/AuthApi";
|
||||||
|
import { ServerApi } from "../../api/ServerApi";
|
||||||
|
import { AppIcon } from "../../icons/AppIcon";
|
||||||
|
import openid from "../../icons/openid.svg";
|
||||||
|
|
||||||
export function LoginRoute(): React.ReactElement {
|
export function LoginRoute(): React.ReactElement {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
@@ -40,7 +40,7 @@ export function LoginRoute(): React.ReactElement {
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={authWithOpenID}
|
onClick={authWithOpenID}
|
||||||
startIcon={<Icon path={mdiOpenid} size={1} />}
|
startIcon={<AppIcon src={openid} />}
|
||||||
>
|
>
|
||||||
Sign in with {ServerApi.Config.oidc_provider_name}
|
Sign in with {ServerApi.Config.oidc_provider_name}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { svgIconClasses } from "@mui/material/SvgIcon";
|
|||||||
import { typographyClasses } from "@mui/material/Typography";
|
import { typographyClasses } from "@mui/material/Typography";
|
||||||
import { gray, green, red } from "../themePrimitives";
|
import { gray, green, red } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const dataDisplayCustomizations: Components<Theme> = {
|
export const dataDisplayCustomizations: Components<Theme> = {
|
||||||
MuiList: {
|
MuiList: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { type Theme, alpha, type Components } from "@mui/material/styles";
|
import { type Theme, alpha, type Components } from "@mui/material/styles";
|
||||||
import { gray, orange } from "../themePrimitives";
|
import { gray, orange } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const feedbackCustomizations: Components<Theme> = {
|
export const feedbackCustomizations: Components<Theme> = {
|
||||||
MuiAlert: {
|
MuiAlert: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
|
|||||||
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
|
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
|
||||||
import { gray, brand } from "../themePrimitives";
|
import { gray, brand } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const inputsCustomizations: Components<Theme> = {
|
export const inputsCustomizations: Components<Theme> = {
|
||||||
MuiButtonBase: {
|
MuiButtonBase: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { tabClasses } from "@mui/material/Tab";
|
|||||||
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
|
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
|
||||||
import { gray, brand } from "../themePrimitives";
|
import { gray, brand } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const navigationCustomizations: Components<Theme> = {
|
export const navigationCustomizations: Components<Theme> = {
|
||||||
MuiMenuItem: {
|
MuiMenuItem: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { alpha, type Theme, type Components } from "@mui/material/styles";
|
import { alpha, type Theme, type Components } from "@mui/material/styles";
|
||||||
import { gray } from "../themePrimitives";
|
import { gray } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const surfacesCustomizations: Components<Theme> = {
|
export const surfacesCustomizations: Components<Theme> = {
|
||||||
MuiAccordion: {
|
MuiAccordion: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ declare module "@mui/material/styles" {
|
|||||||
900: string;
|
900: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaletteColor extends ColorRange {}
|
|
||||||
|
|
||||||
interface Palette {
|
interface Palette {
|
||||||
baseShadow: string;
|
baseShadow: string;
|
||||||
}
|
}
|
||||||
@@ -405,10 +403,10 @@ export const shape = {
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const defaultShadows: Shadows = [
|
const defaultShadows: Shadows = [
|
||||||
"none",
|
"none",
|
||||||
"var(--template-palette-baseShadow)",
|
"var(--template-palette-baseShadow)",
|
||||||
...defaultTheme.shadows.slice(2),
|
...defaultTheme.shadows.slice(2),
|
||||||
];
|
] as never;
|
||||||
|
|
||||||
export const shadows = defaultShadows;
|
export const shadows = defaultShadows;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { format } from "date-and-time";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get UNIX time
|
* Get UNIX time
|
||||||
*
|
*
|
||||||
@@ -6,3 +8,71 @@
|
|||||||
export function time(): number {
|
export function time(): number {
|
||||||
return Math.floor(new Date().getTime() / 1000);
|
return Math.floor(new Date().getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UNIX time
|
||||||
|
*
|
||||||
|
* @returns Number of milliseconds since Epoch
|
||||||
|
*/
|
||||||
|
export function timeMs(): number {
|
||||||
|
return new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(time: number): string {
|
||||||
|
const t = new Date();
|
||||||
|
t.setTime(1000 * time);
|
||||||
|
return format(t, "DD/MM/YYYY HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(time: number): string {
|
||||||
|
const t = new Date();
|
||||||
|
t.setTime(1000 * time);
|
||||||
|
return format(t, "DD/MM/YYYY");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeDiff(a: number, b: number): string {
|
||||||
|
let diff = b - a;
|
||||||
|
|
||||||
|
if (diff === 0) return "now";
|
||||||
|
if (diff === 1) return "1 second";
|
||||||
|
|
||||||
|
if (diff < 60) {
|
||||||
|
return `${diff} seconds`;
|
||||||
|
}
|
||||||
|
|
||||||
|
diff = Math.floor(diff / 60);
|
||||||
|
|
||||||
|
if (diff === 1) return "1 minute";
|
||||||
|
if (diff < 60) {
|
||||||
|
return `${diff} minutes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
diff = Math.floor(diff / 60);
|
||||||
|
|
||||||
|
if (diff === 1) return "1 hour";
|
||||||
|
if (diff < 24) {
|
||||||
|
return `${diff} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.floor(diff / 24);
|
||||||
|
|
||||||
|
if (diffDays === 1) return "1 day";
|
||||||
|
if (diffDays < 31) {
|
||||||
|
return `${diffDays} days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
diff = Math.floor(diffDays / 31);
|
||||||
|
|
||||||
|
if (diff < 12) {
|
||||||
|
return `${diff} month`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffYears = Math.floor(diffDays / 365);
|
||||||
|
|
||||||
|
if (diffYears === 1) return "1 year";
|
||||||
|
return `${diffYears} years`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeDiffFromNow(t: number): string {
|
||||||
|
return timeDiff(t, time());
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import type {
|
|||||||
MatrixEventsList,
|
MatrixEventsList,
|
||||||
MessageType,
|
MessageType,
|
||||||
} from "../api/matrix/MatrixApiEvent";
|
} from "../api/matrix/MatrixApiEvent";
|
||||||
import type { Room } from "../api/matrix/MatrixApiRoom";
|
import type { Receipt, Room } from "../api/matrix/MatrixApiRoom";
|
||||||
import type { WsMessage } from "../api/WsApi";
|
import type { WsMessage } from "../api/WsApi";
|
||||||
|
import { timeMs } from "./DateUtils";
|
||||||
|
|
||||||
export interface MessageReaction {
|
export interface MessageReaction {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
@@ -20,6 +21,7 @@ export interface Message {
|
|||||||
time_sent: number;
|
time_sent: number;
|
||||||
time_sent_dayjs: dayjs.Dayjs;
|
time_sent_dayjs: dayjs.Dayjs;
|
||||||
modified: boolean;
|
modified: boolean;
|
||||||
|
inReplyTo?: string;
|
||||||
reactions: Map<string, MessageReaction[]>;
|
reactions: Map<string, MessageReaction[]>;
|
||||||
content: string;
|
content: string;
|
||||||
type: MessageType;
|
type: MessageType;
|
||||||
@@ -29,13 +31,27 @@ export interface Message {
|
|||||||
export class RoomEventsManager {
|
export class RoomEventsManager {
|
||||||
readonly room: Room;
|
readonly room: Room;
|
||||||
private events: MatrixEvent[];
|
private events: MatrixEvent[];
|
||||||
|
private receipts: Receipt[];
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
endToken?: string;
|
endToken?: string;
|
||||||
|
typingUsers: string[];
|
||||||
|
receiptsEventsMap: Map<string, Receipt[]>;
|
||||||
|
|
||||||
constructor(room: Room, initialMessages: MatrixEventsList) {
|
get canLoadOlder(): boolean {
|
||||||
|
return !!this.endToken && this.events.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
room: Room,
|
||||||
|
initialMessages: MatrixEventsList,
|
||||||
|
receipts: Receipt[]
|
||||||
|
) {
|
||||||
this.room = room;
|
this.room = room;
|
||||||
this.events = [];
|
this.events = [];
|
||||||
|
this.receipts = receipts;
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
|
this.typingUsers = [];
|
||||||
|
this.receiptsEventsMap = new Map();
|
||||||
|
|
||||||
this.processNewEvents(initialMessages);
|
this.processNewEvents(initialMessages);
|
||||||
}
|
}
|
||||||
@@ -77,17 +93,35 @@ export class RoomEventsManager {
|
|||||||
content: {
|
content: {
|
||||||
body: m.data["m.new_content"]?.body ?? m.data.body,
|
body: m.data["m.new_content"]?.body ?? m.data.body,
|
||||||
msgtype: m.data.msgtype,
|
msgtype: m.data.msgtype,
|
||||||
"m.relates_to":
|
"m.relates_to": m.data["m.relates_to"],
|
||||||
m.data["m.relates_to"] && m.data["m.relates_to"].event_id
|
|
||||||
? {
|
|
||||||
event_id: m.data["m.relates_to"].event_id!,
|
|
||||||
rel_type: m.data["m.relates_to"].rel_type ?? "",
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
url: m.data.url,
|
url: m.data.url,
|
||||||
file: m.data.file,
|
file: m.data.file,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} else if (m.type === "ReceiptEvent") {
|
||||||
|
for (const r of m.receipts) {
|
||||||
|
const prevReceipt = this.receipts.find(
|
||||||
|
(needle) => r.user === needle.user
|
||||||
|
);
|
||||||
|
// Create new receipt
|
||||||
|
if (!prevReceipt)
|
||||||
|
this.receipts.push({
|
||||||
|
user: r.user,
|
||||||
|
event_id: r.event,
|
||||||
|
ts: r.ts ?? timeMs(),
|
||||||
|
});
|
||||||
|
// Update receipt
|
||||||
|
else {
|
||||||
|
prevReceipt.event_id = r.event;
|
||||||
|
prevReceipt.ts = r.ts ?? timeMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rebuildMessagesList();
|
||||||
|
return true; // Emphemeral event
|
||||||
|
} else if (m.type === "TypingEvent") {
|
||||||
|
this.typingUsers = m.user_ids;
|
||||||
|
return true; // Not a real event
|
||||||
} else {
|
} else {
|
||||||
// Ignore event
|
// Ignore event
|
||||||
console.info("Event not supported => ignored");
|
console.info("Event not supported => ignored");
|
||||||
@@ -112,8 +146,14 @@ export class RoomEventsManager {
|
|||||||
// Sorts events list to process oldest events first
|
// Sorts events list to process oldest events first
|
||||||
this.events.sort((a, b) => a.time - b.time);
|
this.events.sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
|
// Process receipts (users map)
|
||||||
|
const receiptsUsersMap = new Map<string, Receipt>();
|
||||||
|
for (const r of this.receipts) {
|
||||||
|
receiptsUsersMap.set(r.user, { ...r });
|
||||||
|
}
|
||||||
|
|
||||||
// First, process redactions to skip redacted events
|
// First, process redactions to skip redacted events
|
||||||
let redacted = new Set(
|
const redacted = new Set(
|
||||||
this.events
|
this.events
|
||||||
.map((e) =>
|
.map((e) =>
|
||||||
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
|
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
|
||||||
@@ -129,7 +169,7 @@ export class RoomEventsManager {
|
|||||||
// Message
|
// Message
|
||||||
if (data.type === "m.room.message") {
|
if (data.type === "m.room.message") {
|
||||||
// Check if this message replaces another one
|
// Check if this message replaces another one
|
||||||
if (data.content["m.relates_to"]) {
|
if (data.content["m.relates_to"]?.rel_type === "replace") {
|
||||||
const message = this.messages.find(
|
const message = this.messages.find(
|
||||||
(m) => m.event_id === data.content["m.relates_to"]?.event_id
|
(m) => m.event_id === data.content["m.relates_to"]?.event_id
|
||||||
);
|
);
|
||||||
@@ -139,10 +179,29 @@ export class RoomEventsManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Else it is a new message; update receipts if needed
|
||||||
|
else {
|
||||||
|
const userReceipt = receiptsUsersMap.get(evt.sender);
|
||||||
|
|
||||||
|
// Create fake receipt if none is available
|
||||||
|
if (!userReceipt)
|
||||||
|
receiptsUsersMap.set(evt.sender, {
|
||||||
|
event_id: evt.id,
|
||||||
|
ts: evt.time,
|
||||||
|
user: evt.sender,
|
||||||
|
});
|
||||||
|
// If the message is more recent than user receipt, replace the receipt
|
||||||
|
else if (userReceipt.ts < evt.time) {
|
||||||
|
userReceipt.event_id = evt.id;
|
||||||
|
userReceipt.ts = evt.time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.messages.push({
|
this.messages.push({
|
||||||
event_id: evt.id,
|
event_id: evt.id,
|
||||||
account: evt.sender,
|
account: evt.sender,
|
||||||
modified: false,
|
modified: false,
|
||||||
|
inReplyTo: data.content["m.relates_to"]?.["m.in_reply_to"]?.event_id,
|
||||||
reactions: new Map(),
|
reactions: new Map(),
|
||||||
time_sent: evt.time,
|
time_sent: evt.time,
|
||||||
time_sent_dayjs: dayjs.unix(evt.time / 1000),
|
time_sent_dayjs: dayjs.unix(evt.time / 1000),
|
||||||
@@ -170,5 +229,13 @@ export class RoomEventsManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adapt receipts to be event-indexed
|
||||||
|
this.receiptsEventsMap.clear();
|
||||||
|
for (const receipt of [...receiptsUsersMap.values()]) {
|
||||||
|
if (!this.receiptsEventsMap.has(receipt.event_id))
|
||||||
|
this.receiptsEventsMap.set(receipt.event_id, [receipt]);
|
||||||
|
else this.receiptsEventsMap.get(receipt.event_id)!.push(receipt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
const State = {
|
const State = {
|
||||||
Loading: 0,
|
Loading: 0,
|
||||||
@@ -10,16 +10,14 @@ const State = {
|
|||||||
type State = keyof typeof State;
|
type State = keyof typeof State;
|
||||||
|
|
||||||
export function AsyncWidget(p: {
|
export function AsyncWidget(p: {
|
||||||
loadKey: any;
|
loadKey: unknown;
|
||||||
load: () => Promise<void>;
|
load: () => Promise<void>;
|
||||||
errMsg: string;
|
errMsg: string;
|
||||||
build: () => React.ReactElement;
|
build: () => React.ReactElement;
|
||||||
ready?: boolean;
|
ready?: boolean;
|
||||||
errAdditionalElement?: () => React.ReactElement;
|
errAdditionalElement?: () => React.ReactElement;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const [state, setState] = useState<number>(State.Loading);
|
const [state, setState] = React.useState<number>(State.Loading);
|
||||||
|
|
||||||
const counter = useRef<any>(null);
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,12 +30,10 @@ export function AsyncWidget(p: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (counter.current === p.loadKey) return;
|
|
||||||
counter.current = p.loadKey;
|
|
||||||
|
|
||||||
load();
|
load();
|
||||||
});
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [p.loadKey]);
|
||||||
|
|
||||||
if (state === State.Error)
|
if (state === State.Error)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,9 +16,16 @@ function emojiUnicode(emoji: string): string {
|
|||||||
return s.includes("f") ? s : `${s}-fe0f`;
|
return s.includes("f") ? s : `${s}-fe0f`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiIcon(p: { emojiKey: string }): React.ReactElement {
|
export function EmojiIcon(p: {
|
||||||
|
emojiKey: string;
|
||||||
|
size?: number;
|
||||||
|
}): React.ReactElement {
|
||||||
const unified = emojiUnicode(p.emojiKey);
|
const unified = emojiUnicode(p.emojiKey);
|
||||||
return (
|
return (
|
||||||
<Emoji unified={unified ?? ""} emojiStyle={EmojiStyle.GOOGLE} size={18} />
|
<Emoji
|
||||||
|
unified={unified ?? ""}
|
||||||
|
emojiStyle={EmojiStyle.NATIVE}
|
||||||
|
size={p.size ?? 18}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||||
|
import { Button } from "@mui/material";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
export function NotLinkedAccountMessage(): React.ReactElement {
|
export function NotLinkedAccountMessage(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -6,9 +10,17 @@ export function NotLinkedAccountMessage(): React.ReactElement {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Your Matrix account is not linked yet!
|
<div style={{ marginBottom: "50px" }}>
|
||||||
|
Your Matrix account is not linked yet!
|
||||||
|
</div>
|
||||||
|
<Link to={"/matrix_link"}>
|
||||||
|
<Button variant="outlined" startIcon={<ArrowForwardIcon />}>
|
||||||
|
Go to Matrix Link settings
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,10 @@
|
|||||||
import { Tooltip } from "@mui/material";
|
import { Tooltip } from "@mui/material";
|
||||||
import { format } from "date-and-time";
|
import {
|
||||||
import { time } from "../utils/DateUtils";
|
formatDateTime,
|
||||||
|
formatDate,
|
||||||
export function formatDateTime(time: number): string {
|
timeDiff,
|
||||||
const t = new Date();
|
timeDiffFromNow,
|
||||||
t.setTime(1000 * time);
|
} from "../utils/DateUtils";
|
||||||
return format(t, "DD/MM/YYYY HH:mm:ss");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDate(time: number): string {
|
|
||||||
const t = new Date();
|
|
||||||
t.setTime(1000 * time);
|
|
||||||
return format(t, "DD/MM/YYYY");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timeDiff(a: number, b: number): string {
|
|
||||||
let diff = b - a;
|
|
||||||
|
|
||||||
if (diff === 0) return "now";
|
|
||||||
if (diff === 1) return "1 second";
|
|
||||||
|
|
||||||
if (diff < 60) {
|
|
||||||
return `${diff} seconds`;
|
|
||||||
}
|
|
||||||
|
|
||||||
diff = Math.floor(diff / 60);
|
|
||||||
|
|
||||||
if (diff === 1) return "1 minute";
|
|
||||||
if (diff < 60) {
|
|
||||||
return `${diff} minutes`;
|
|
||||||
}
|
|
||||||
|
|
||||||
diff = Math.floor(diff / 60);
|
|
||||||
|
|
||||||
if (diff === 1) return "1 hour";
|
|
||||||
if (diff < 24) {
|
|
||||||
return `${diff} hours`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffDays = Math.floor(diff / 24);
|
|
||||||
|
|
||||||
if (diffDays === 1) return "1 day";
|
|
||||||
if (diffDays < 31) {
|
|
||||||
return `${diffDays} days`;
|
|
||||||
}
|
|
||||||
|
|
||||||
diff = Math.floor(diffDays / 31);
|
|
||||||
|
|
||||||
if (diff < 12) {
|
|
||||||
return `${diff} month`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffYears = Math.floor(diffDays / 365);
|
|
||||||
|
|
||||||
if (diffYears === 1) return "1 year";
|
|
||||||
return `${diffYears} years`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timeDiffFromNow(t: number): string {
|
|
||||||
return timeDiff(t, time());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimeWidget(p: {
|
export function TimeWidget(p: {
|
||||||
time?: number;
|
time?: number;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { mdiMessageTextFast } from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import { Typography } from "@mui/material";
|
import { Typography } from "@mui/material";
|
||||||
import MuiCard from "@mui/material/Card";
|
import MuiCard from "@mui/material/Card";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { styled } from "@mui/material/styles";
|
import { styled } from "@mui/material/styles";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
|
import { AppIcon } from "../../icons/AppIcon";
|
||||||
|
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
|
||||||
|
|
||||||
const Card = styled(MuiCard)(({ theme }) => ({
|
const Card = styled(MuiCard)(({ theme }) => ({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -57,12 +57,7 @@ export function BaseLoginPage(): React.ReactElement {
|
|||||||
variant="h4"
|
variant="h4"
|
||||||
sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }}
|
sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }}
|
||||||
>
|
>
|
||||||
<Icon
|
<AppIcon src={mdiMessageTextFast} size={"2em"} /> MatrixGW
|
||||||
path={mdiMessageTextFast}
|
|
||||||
size={"1em"}
|
|
||||||
style={{ display: "inline-table" }}
|
|
||||||
/>{" "}
|
|
||||||
MatrixGW
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { mdiMessageTextFast } from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import LogoutIcon from "@mui/icons-material/Logout";
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
import MenuIcon from "@mui/icons-material/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||||
@@ -13,6 +11,8 @@ import Toolbar from "@mui/material/Toolbar";
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { AppIcon } from "../../icons/AppIcon";
|
||||||
|
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
|
||||||
import { RouterLink } from "../RouterLink";
|
import { RouterLink } from "../RouterLink";
|
||||||
import { useUserInfo } from "./BaseAuthenticatedPage";
|
import { useUserInfo } from "./BaseAuthenticatedPage";
|
||||||
import ThemeSwitcher from "./ThemeSwitcher";
|
import ThemeSwitcher from "./ThemeSwitcher";
|
||||||
@@ -101,7 +101,7 @@ export default function DashboardHeader({
|
|||||||
<RouterLink to="/">
|
<RouterLink to="/">
|
||||||
<Stack direction="row" alignItems="center">
|
<Stack direction="row" alignItems="center">
|
||||||
<LogoContainer>
|
<LogoContainer>
|
||||||
<Icon path={mdiMessageTextFast} size="2em" />
|
<AppIcon src={mdiMessageTextFast} size="2em" />
|
||||||
</LogoContainer>
|
</LogoContainer>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { mdiBug, mdiForum, mdiKeyVariant, mdiLinkLock } from "@mdi/js";
|
import BugReportIcon from "@mui/icons-material/BugReport";
|
||||||
import Icon from "@mdi/react";
|
import ForumIcon from "@mui/icons-material/Forum";
|
||||||
|
import KeyIcon from "@mui/icons-material/Key";
|
||||||
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
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";
|
||||||
@@ -62,7 +64,7 @@ export default function DashboardSidebar({
|
|||||||
if (!isOverSmViewport) {
|
if (!isOverSmViewport) {
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}
|
}
|
||||||
}, [expanded, setExpanded, isOverSmViewport]);
|
}, [setExpanded, isOverSmViewport]);
|
||||||
|
|
||||||
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
|
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
|
||||||
|
|
||||||
@@ -98,27 +100,27 @@ export default function DashboardSidebar({
|
|||||||
<DashboardSidebarPageItem
|
<DashboardSidebarPageItem
|
||||||
disabled={!user.info.matrix_account_connected}
|
disabled={!user.info.matrix_account_connected}
|
||||||
title="Messages"
|
title="Messages"
|
||||||
icon={<Icon path={mdiForum} size={"1.5em"} />}
|
icon={<ForumIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
href="/"
|
href="/"
|
||||||
mini={viewport === "desktop"}
|
mini={viewport === "desktop"}
|
||||||
/>
|
/>
|
||||||
<DashboardSidebarDividerItem />
|
<DashboardSidebarDividerItem />
|
||||||
<DashboardSidebarPageItem
|
<DashboardSidebarPageItem
|
||||||
title="Matrix link"
|
title="Matrix link"
|
||||||
icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
|
icon={<LinkIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
href="/matrix_link"
|
href="/matrix_link"
|
||||||
mini={viewport === "desktop"}
|
mini={viewport === "desktop"}
|
||||||
/>
|
/>
|
||||||
<DashboardSidebarPageItem
|
<DashboardSidebarPageItem
|
||||||
title="API tokens"
|
title="API tokens"
|
||||||
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
|
icon={<KeyIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
href="/tokens"
|
href="/tokens"
|
||||||
mini={viewport === "desktop"}
|
mini={viewport === "desktop"}
|
||||||
/>
|
/>
|
||||||
<DashboardSidebarPageItem
|
<DashboardSidebarPageItem
|
||||||
disabled={!user.info.matrix_account_connected}
|
disabled={!user.info.matrix_account_connected}
|
||||||
title="WS Debug"
|
title="WS Debug"
|
||||||
icon={<Icon path={mdiBug} size={"1.5em"} />}
|
icon={<BugReportIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
href="/wsdebug"
|
href="/wsdebug"
|
||||||
mini={viewport === "desktop"}
|
mini={viewport === "desktop"}
|
||||||
/>
|
/>
|
||||||
@@ -159,7 +161,7 @@ export default function DashboardSidebar({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[expanded, !expanded]
|
[expanded]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarContextValue = React.useMemo(() => {
|
const sidebarContextValue = React.useMemo(() => {
|
||||||
@@ -168,7 +170,7 @@ export default function DashboardSidebar({
|
|||||||
fullyExpanded: isFullyExpanded,
|
fullyExpanded: isFullyExpanded,
|
||||||
hasDrawerTransitions,
|
hasDrawerTransitions,
|
||||||
};
|
};
|
||||||
}, [handlePageItemClick, !expanded, isFullyExpanded, hasDrawerTransitions]);
|
}, [handlePageItemClick, isFullyExpanded, hasDrawerTransitions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardSidebarContext.Provider value={sidebarContextValue}>
|
<DashboardSidebarContext.Provider value={sidebarContextValue}>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import DarkModeIcon from "@mui/icons-material/DarkMode";
|
|||||||
import LightModeIcon from "@mui/icons-material/LightMode";
|
import LightModeIcon from "@mui/icons-material/LightMode";
|
||||||
import type {} from "@mui/material/themeCssVarsAugmentation";
|
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||||
|
|
||||||
export default function ThemeSwitcher() {
|
export function useActualColorMode(): {
|
||||||
const theme = useTheme();
|
mode: "light" | "dark";
|
||||||
|
setMode: (mode: "light" | "dark") => void;
|
||||||
|
} {
|
||||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||||
const preferredMode = prefersDarkMode ? "dark" : "light";
|
const preferredMode = prefersDarkMode ? "dark" : "light";
|
||||||
|
|
||||||
@@ -17,21 +18,27 @@ export default function ThemeSwitcher() {
|
|||||||
|
|
||||||
const paletteMode = !mode || mode === "system" ? preferredMode : mode;
|
const paletteMode = !mode || mode === "system" ? preferredMode : mode;
|
||||||
|
|
||||||
|
return { mode: paletteMode, setMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSwitcher() {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const { mode, setMode } = useActualColorMode();
|
||||||
|
|
||||||
const toggleMode = React.useCallback(() => {
|
const toggleMode = React.useCallback(() => {
|
||||||
setMode(paletteMode === "dark" ? "light" : "dark");
|
setMode(mode === "dark" ? "light" : "dark");
|
||||||
}, [setMode, paletteMode]);
|
}, [mode, setMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={`${paletteMode === "dark" ? "Light" : "Dark"} mode`}
|
title={`${mode === "dark" ? "Light" : "Dark"} mode`}
|
||||||
enterDelay={1000}
|
enterDelay={1000}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={`Switch to ${
|
aria-label={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
|
||||||
paletteMode === "dark" ? "light" : "dark"
|
|
||||||
} mode`}
|
|
||||||
onClick={toggleMode}
|
onClick={toggleMode}
|
||||||
>
|
>
|
||||||
<LightModeIcon
|
<LightModeIcon
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { Avatar } from "@mui/material";
|
|||||||
import type { UserProfile } from "../../api/matrix/MatrixApiProfile";
|
import type { UserProfile } from "../../api/matrix/MatrixApiProfile";
|
||||||
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
|
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
|
||||||
|
|
||||||
export function AccountIcon(p: { user: UserProfile }): React.ReactElement {
|
export function AccountIcon(p: {
|
||||||
|
user: UserProfile;
|
||||||
|
size?: number;
|
||||||
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
src={
|
src={
|
||||||
p.user.avatar ? MatrixApiMedia.MediaURL(p.user.avatar, true) : undefined
|
p.user.avatar ? MatrixApiMedia.MediaURL(p.user.avatar, true) : undefined
|
||||||
}
|
}
|
||||||
|
sx={{ width: p.size, height: p.size }}
|
||||||
>
|
>
|
||||||
{p.user.display_name?.slice(0, 1)}
|
{p.user.display_name?.slice(0, 1)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function getInitialFavicon(): HTMLLinkElement[] {
|
|||||||
return icons;
|
return icons;
|
||||||
}
|
}
|
||||||
|
|
||||||
let iconPath = getInitialFavicon()[0].getAttribute("href")!;
|
const iconPath = getInitialFavicon()[0].getAttribute("href")!;
|
||||||
|
|
||||||
export function AppIconModifier(p: {
|
export function AppIconModifier(p: {
|
||||||
numberUnread: number;
|
numberUnread: number;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
type UsersMap,
|
type UsersMap,
|
||||||
} from "../../api/matrix/MatrixApiProfile";
|
} from "../../api/matrix/MatrixApiProfile";
|
||||||
import { MatrixApiRoom, type Room } from "../../api/matrix/MatrixApiRoom";
|
import { MatrixApiRoom, type Room } from "../../api/matrix/MatrixApiRoom";
|
||||||
|
import {
|
||||||
|
MatrixApiSpace,
|
||||||
|
type SpaceHierarchy,
|
||||||
|
} from "../../api/matrix/MatrixApiSpace";
|
||||||
import { MatrixSyncApi } from "../../api/MatrixSyncApi";
|
import { MatrixSyncApi } from "../../api/MatrixSyncApi";
|
||||||
import type { WsMessage } from "../../api/WsApi";
|
import type { WsMessage } from "../../api/WsApi";
|
||||||
import { RoomEventsManager } from "../../utils/RoomEventsManager";
|
import { RoomEventsManager } from "../../utils/RoomEventsManager";
|
||||||
@@ -19,13 +23,19 @@ import { SpaceSelector } from "./SpaceSelector";
|
|||||||
|
|
||||||
export function MainMessageWidget(): React.ReactElement {
|
export function MainMessageWidget(): React.ReactElement {
|
||||||
const [rooms, setRooms] = React.useState<Room[] | undefined>();
|
const [rooms, setRooms] = React.useState<Room[] | undefined>();
|
||||||
|
const [hierarchy, setHierarchy] = React.useState<
|
||||||
|
SpaceHierarchy | undefined
|
||||||
|
>();
|
||||||
const [users, setUsers] = React.useState<UsersMap | undefined>();
|
const [users, setUsers] = React.useState<UsersMap | undefined>();
|
||||||
|
|
||||||
const loadRoomsList = async () => {
|
const loadRoomsList = async () => {
|
||||||
await MatrixSyncApi.Start();
|
await MatrixSyncApi.Start();
|
||||||
|
|
||||||
const rooms = await MatrixApiRoom.ListJoined();
|
const rooms = await MatrixApiRoom.ListJoined();
|
||||||
|
const hierarchy = await MatrixApiSpace.Hierarchy();
|
||||||
|
|
||||||
setRooms(rooms);
|
setRooms(rooms);
|
||||||
|
setHierarchy(hierarchy);
|
||||||
|
|
||||||
// Get the list of users in rooms
|
// Get the list of users in rooms
|
||||||
const users = rooms.reduce((prev, r) => {
|
const users = rooms.reduce((prev, r) => {
|
||||||
@@ -40,11 +50,12 @@ export function MainMessageWidget(): React.ReactElement {
|
|||||||
<AsyncWidget
|
<AsyncWidget
|
||||||
loadKey={1}
|
loadKey={1}
|
||||||
load={loadRoomsList}
|
load={loadRoomsList}
|
||||||
ready={!!rooms && !!users}
|
ready={!!rooms && !!users && !!hierarchy}
|
||||||
errMsg="Failed to initialize messaging component!"
|
errMsg="Failed to initialize messaging component!"
|
||||||
build={() => (
|
build={() => (
|
||||||
<_MainMessageWidget
|
<MainMessageWidgetInner
|
||||||
rooms={rooms!}
|
rooms={rooms!}
|
||||||
|
hierarchy={hierarchy!}
|
||||||
users={users!}
|
users={users!}
|
||||||
onRoomsListUpdate={(cb) => setRooms((r) => cb(r!))}
|
onRoomsListUpdate={(cb) => setRooms((r) => cb(r!))}
|
||||||
/>
|
/>
|
||||||
@@ -53,8 +64,9 @@ export function MainMessageWidget(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _MainMessageWidget(p: {
|
function MainMessageWidgetInner(p: {
|
||||||
rooms: Room[];
|
rooms: Room[];
|
||||||
|
hierarchy: SpaceHierarchy;
|
||||||
users: UsersMap;
|
users: UsersMap;
|
||||||
onRoomsListUpdate: (cb: (a: Room[]) => Room[]) => void;
|
onRoomsListUpdate: (cb: (a: Room[]) => Room[]) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
@@ -65,11 +77,13 @@ function _MainMessageWidget(p: {
|
|||||||
|
|
||||||
const spaceRooms = React.useMemo(() => {
|
const spaceRooms = React.useMemo(() => {
|
||||||
return p.rooms
|
return p.rooms
|
||||||
.filter((r) => !r.is_space && (!space || r.parents.includes(space)))
|
.filter(
|
||||||
|
(r) => !r.is_space && (!space || p.hierarchy.get(space)?.includes(r.id))
|
||||||
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
|
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
|
||||||
);
|
);
|
||||||
}, [space, p.rooms]);
|
}, [space, p.rooms, p.hierarchy]);
|
||||||
|
|
||||||
const unreadRooms = React.useMemo(
|
const unreadRooms = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -79,7 +93,7 @@ function _MainMessageWidget(p: {
|
|||||||
[p.rooms]
|
[p.rooms]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [_refreshCount, setRefreshCount] = React.useState(0);
|
const setRefreshCount = React.useState(0)[1];
|
||||||
const [roomMgr, setRoomMgr] = React.useState<undefined | RoomEventsManager>();
|
const [roomMgr, setRoomMgr] = React.useState<undefined | RoomEventsManager>();
|
||||||
|
|
||||||
const loadRoom = async () => {
|
const loadRoom = async () => {
|
||||||
@@ -89,7 +103,8 @@ function _MainMessageWidget(p: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const messages = await MatrixApiEvent.GetRoomEvents(currentRoom);
|
const messages = await MatrixApiEvent.GetRoomEvents(currentRoom);
|
||||||
const mgr = new RoomEventsManager(currentRoom!, messages);
|
const receipts = await MatrixApiRoom.RoomReceipts(currentRoom);
|
||||||
|
const mgr = new RoomEventsManager(currentRoom!, messages, receipts);
|
||||||
setRoomMgr(mgr);
|
setRoomMgr(mgr);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,7 +125,7 @@ function _MainMessageWidget(p: {
|
|||||||
p.onRoomsListUpdate((r) => {
|
p.onRoomsListUpdate((r) => {
|
||||||
const n = [...r];
|
const n = [...r];
|
||||||
const idx = r.findIndex((el) => el.id === m.room_id);
|
const idx = r.findIndex((el) => el.id === m.room_id);
|
||||||
if (idx)
|
if (idx && n[idx]?.notifications === "AllMessages")
|
||||||
n[idx] = {
|
n[idx] = {
|
||||||
...n[idx],
|
...n[idx],
|
||||||
number_unread_messages: n[idx].number_unread_messages + 1,
|
number_unread_messages: n[idx].number_unread_messages + 1,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function MatrixWS(p: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => ws.close();
|
return () => ws.close();
|
||||||
}, [connCount]);
|
}, [connCount, snackbar]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={state}>
|
<Tooltip title={state}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Chip,
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,7 +22,7 @@ import EmojiPicker, { EmojiStyle, Theme } from "emoji-picker-react";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
|
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
|
||||||
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
||||||
import type { Room } from "../../api/matrix/MatrixApiRoom";
|
import type { Receipt, Room } from "../../api/matrix/MatrixApiRoom";
|
||||||
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";
|
||||||
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
|
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
|
||||||
@@ -40,16 +41,56 @@ export function RoomMessagesList(p: {
|
|||||||
users: UsersMap;
|
users: UsersMap;
|
||||||
manager: RoomEventsManager;
|
manager: RoomEventsManager;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const [loadingOlder, setLoadingOlder] = React.useState(false);
|
||||||
|
|
||||||
|
const listContainerRef = React.createRef<HTMLDivElement>();
|
||||||
const messagesEndRef = React.createRef<HTMLDivElement>();
|
const messagesEndRef = React.createRef<HTMLDivElement>();
|
||||||
|
|
||||||
// Automatically scroll to bottom when number of messages change
|
// Automatically scroll to bottom when number of messages change
|
||||||
|
const lastEventId = p.manager.messages.at(-1)?.event_id;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (messagesEndRef)
|
if (messagesEndRef)
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
|
||||||
}, [p.manager.messages.length]);
|
}, [lastEventId, messagesEndRef]);
|
||||||
|
|
||||||
|
const loadOlderMessages = async () => {
|
||||||
|
if (loadingOlder || !p.manager.canLoadOlder) return;
|
||||||
|
|
||||||
|
setLoadingOlder(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const older = await MatrixApiEvent.GetRoomEvents(
|
||||||
|
p.room,
|
||||||
|
p.manager.endToken
|
||||||
|
);
|
||||||
|
p.manager.processNewEvents(older);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load older messages!", e);
|
||||||
|
snackbar(`Failed to load older messages for conversation! ${e}`);
|
||||||
|
} finally {
|
||||||
|
setLoadingOlder(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch scroll to detect when user reach the top to load older messages
|
||||||
|
const handleScroll = async () => {
|
||||||
|
if (!listContainerRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop } = listContainerRef.current;
|
||||||
|
|
||||||
|
if (scrollTop !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOlderMessages();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onScroll={handleScroll}
|
||||||
|
ref={listContainerRef}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -58,6 +99,62 @@ export function RoomMessagesList(p: {
|
|||||||
paddingLeft: "20px",
|
paddingLeft: "20px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/** Begining of conversation */}
|
||||||
|
{!p.manager.canLoadOlder && p.manager.messages.length > 0 && (
|
||||||
|
<Typography
|
||||||
|
component={"div"}
|
||||||
|
variant="caption"
|
||||||
|
style={{ textAlign: "center", marginTop: "10px" }}
|
||||||
|
>
|
||||||
|
Begining of conversation
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/** Load older messages button */}
|
||||||
|
{p.manager.canLoadOlder && !loadingOlder && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
padding: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onClick={loadOlderMessages} variant="outlined">
|
||||||
|
Load older messages
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/** Loading older messages spinner */}
|
||||||
|
{loadingOlder && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
padding: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty conversation notice */}
|
||||||
|
{p.manager.messages.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No message in this conversation yet!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/** Messages themselves */}
|
||||||
{p.manager.messages.map((m, idx) => (
|
{p.manager.messages.map((m, idx) => (
|
||||||
<RoomMessage
|
<RoomMessage
|
||||||
key={m.event_id}
|
key={m.event_id}
|
||||||
@@ -73,6 +170,12 @@ export function RoomMessagesList(p: {
|
|||||||
m.time_sent_dayjs.startOf("day").unix() !=
|
m.time_sent_dayjs.startOf("day").unix() !=
|
||||||
p.manager.messages[idx - 1].time_sent_dayjs.startOf("day").unix()
|
p.manager.messages[idx - 1].time_sent_dayjs.startOf("day").unix()
|
||||||
}
|
}
|
||||||
|
receipts={p.manager.receiptsEventsMap.get(m.event_id)}
|
||||||
|
repliedMessage={
|
||||||
|
(m.inReplyTo &&
|
||||||
|
p.manager.messages.find((s) => s.event_id === m.inReplyTo)) ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -87,6 +190,8 @@ function RoomMessage(p: {
|
|||||||
message: Message;
|
message: Message;
|
||||||
previousFromSamePerson: boolean;
|
previousFromSamePerson: boolean;
|
||||||
firstMessageOfDay: boolean;
|
firstMessageOfDay: boolean;
|
||||||
|
receipts?: Receipt[];
|
||||||
|
repliedMessage?: Message;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const user = useUserInfo();
|
const user = useUserInfo();
|
||||||
@@ -103,14 +208,16 @@ function RoomMessage(p: {
|
|||||||
const closeImageFullScreen = () => setShowImageFullScreen(false);
|
const closeImageFullScreen = () => setShowImageFullScreen(false);
|
||||||
|
|
||||||
const sender = p.users.get(p.message.account);
|
const sender = p.users.get(p.message.account);
|
||||||
|
const repliedMsgSender =
|
||||||
|
p.repliedMessage && p.users.get(p.repliedMessage.account);
|
||||||
|
|
||||||
const handleDeleteMessage = async () => {
|
const handleDeleteMessage = async () => {
|
||||||
if (!(await confirm(`Do you really want to delete this message?`))) return;
|
if (!(await confirm(`Do you really want to delete this message?`))) return;
|
||||||
try {
|
try {
|
||||||
await MatrixApiEvent.DeleteEvent(p.room, p.message.event_id);
|
await MatrixApiEvent.DeleteEvent(p.room, p.message.event_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to delete message!`, e),
|
console.error(`Failed to delete message!`, e);
|
||||||
alert(`Failed to delete message!${e}`);
|
alert(`Failed to delete message!${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,12 +322,37 @@ function RoomMessage(p: {
|
|||||||
"&:hover *": { visibility: "visible" },
|
"&:hover *": { visibility: "visible" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">
|
<Typography
|
||||||
{p.message.time_sent_dayjs.format("HH:mm")}
|
variant="caption"
|
||||||
|
style={{
|
||||||
|
paddingLeft: "2px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.message.time_sent_dayjs.format("HH:mm")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/** Message itself */}
|
{/** Message itself */}
|
||||||
<div style={{ marginLeft: "15px", whiteSpace: "pre-wrap" }}>
|
<div style={{ marginLeft: "15px", whiteSpace: "pre-wrap", flex: 1 }}>
|
||||||
|
{/** In case of reply */}
|
||||||
|
{p.repliedMessage && repliedMsgSender && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderLeft: "1px red solid",
|
||||||
|
paddingLeft: "10px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountIcon user={repliedMsgSender} size={16} />
|
||||||
|
<div style={{ marginLeft: "10px" }}>
|
||||||
|
{p.repliedMessage?.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
{p.message.type === "m.image" && (
|
{p.message.type === "m.image" && (
|
||||||
<img
|
<img
|
||||||
@@ -284,6 +416,29 @@ function RoomMessage(p: {
|
|||||||
<div style={{ margin: "2px 0px" }}>{p.message.content}</div>
|
<div style={{ margin: "2px 0px" }}>{p.message.content}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Read receipts */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(p.receipts ?? []).map((r) => {
|
||||||
|
const u = p.users.get(r.user);
|
||||||
|
|
||||||
|
if (!u || u.user_id === user.info.matrix_user_id) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginRight: "2px" }}>
|
||||||
|
<AccountIcon key={u.user_id} user={u} size={16} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/** Button bar */}
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
className="buttons"
|
className="buttons"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -319,7 +474,7 @@ function RoomMessage(p: {
|
|||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Reaction */}
|
{/* Reactions */}
|
||||||
<Box sx={{ marginLeft: "50px" }}>
|
<Box sx={{ marginLeft: "50px" }}>
|
||||||
{[...p.message.reactions.keys()].map((r) => {
|
{[...p.message.reactions.keys()].map((r) => {
|
||||||
const reactions = p.message.reactions.get(r)!;
|
const reactions = p.message.reactions.get(r)!;
|
||||||
@@ -364,12 +519,10 @@ function RoomMessage(p: {
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ margin: "3px 3px" }}>
|
<div style={{ margin: "0px 3px" }}>
|
||||||
<EmojiIcon emojiKey={r} />
|
<EmojiIcon emojiKey={r} size={16} />
|
||||||
</div>
|
|
||||||
<div style={{ height: "2em", marginLeft: "2px" }}>
|
|
||||||
{reactions.length}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginLeft: "2px" }}>{reactions.length}</div>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -392,7 +545,7 @@ function RoomMessage(p: {
|
|||||||
{/* Pick reaction dialog */}
|
{/* Pick reaction dialog */}
|
||||||
<Dialog open={pickReaction} onClose={handleCancelAddReaction}>
|
<Dialog open={pickReaction} onClose={handleCancelAddReaction}>
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
emojiStyle={EmojiStyle.GOOGLE}
|
emojiStyle={EmojiStyle.NATIVE}
|
||||||
theme={Theme.AUTO}
|
theme={Theme.AUTO}
|
||||||
onEmojiClick={(emoji) => handleSelectEmoji(emoji.emoji)}
|
onEmojiClick={(emoji) => handleSelectEmoji(emoji.emoji)}
|
||||||
/>
|
/>
|
||||||
@@ -460,7 +613,14 @@ function ReactionButton(p: {
|
|||||||
return <></>;
|
return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={sendEmoji}>
|
<Button
|
||||||
|
onClick={sendEmoji}
|
||||||
|
sx={{
|
||||||
|
paddingTop: "1px !important",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<EmojiIcon {...p} />
|
<EmojiIcon {...p} />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import {
|
import {
|
||||||
Chip,
|
Chip,
|
||||||
List,
|
List,
|
||||||
@@ -5,7 +6,9 @@ import {
|
|||||||
ListItemButton,
|
ListItemButton,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
TextField,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
||||||
import { roomName, type Room } from "../../api/matrix/MatrixApiRoom";
|
import { roomName, type Room } from "../../api/matrix/MatrixApiRoom";
|
||||||
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
|
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
|
||||||
@@ -21,6 +24,21 @@ export function RoomSelector(p: {
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const user = useUserInfo();
|
const user = useUserInfo();
|
||||||
|
|
||||||
|
const [filter, setFilter] = React.useState("");
|
||||||
|
const [unread, setUnread] = React.useState(false);
|
||||||
|
|
||||||
|
const shownRooms = React.useMemo(
|
||||||
|
() =>
|
||||||
|
p.rooms
|
||||||
|
.filter((r) => !unread || r.number_unread_messages > 0)
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
filter === "" ||
|
||||||
|
r.name?.toLocaleLowerCase()?.includes(filter.toLocaleLowerCase())
|
||||||
|
),
|
||||||
|
[p.rooms, unread, filter]
|
||||||
|
);
|
||||||
|
|
||||||
if (p.rooms.length === 0)
|
if (p.rooms.length === 0)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -36,45 +54,75 @@ export function RoomSelector(p: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
style={{
|
{/** Filter bar */}
|
||||||
width: ROOM_SELECTOR_WIDTH,
|
<TextField
|
||||||
}}
|
placeholder="Filter rooms"
|
||||||
>
|
slotProps={{
|
||||||
{p.rooms.map((r) => (
|
input: {
|
||||||
<ListItem
|
startAdornment: <SearchIcon style={{ marginRight: "10px" }} />,
|
||||||
key={r.id}
|
},
|
||||||
secondaryAction={
|
}}
|
||||||
r.number_unread_messages === 0 ? undefined : (
|
style={{ margin: "5px" }}
|
||||||
<Chip color="error" label={r.number_unread_messages} />
|
value={filter}
|
||||||
)
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
}
|
/>
|
||||||
disablePadding
|
|
||||||
>
|
{/** Chip bar */}
|
||||||
<ListItemButton
|
<div style={{ padding: "5px 10px", marginTop: "5px" }}>
|
||||||
role={undefined}
|
<span onClick={() => setUnread(!unread)} style={{ cursor: "pointer" }}>
|
||||||
onClick={() => p.onChange(r)}
|
<Chip
|
||||||
dense
|
label="Unread"
|
||||||
selected={p.currRoom?.id === r.id}
|
size="medium"
|
||||||
|
color={unread ? "success" : undefined}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/** Rooms list */}
|
||||||
|
<List
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
width: ROOM_SELECTOR_WIDTH,
|
||||||
|
overflow: "scroll",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shownRooms.map((r) => (
|
||||||
|
<ListItem
|
||||||
|
key={r.id}
|
||||||
|
secondaryAction={
|
||||||
|
r.number_unread_messages === 0 ? undefined : (
|
||||||
|
<Chip color="error" label={r.number_unread_messages} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disablePadding
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<ListItemButton
|
||||||
<RoomIcon room={r} {...p} />
|
role={undefined}
|
||||||
</ListItemIcon>
|
onClick={() => p.onChange(r)}
|
||||||
<ListItemText
|
dense
|
||||||
primary={
|
selected={p.currRoom?.id === r.id}
|
||||||
<span
|
>
|
||||||
style={{
|
<ListItemIcon>
|
||||||
fontWeight:
|
<RoomIcon room={r} {...p} />
|
||||||
r.number_unread_messages > 0 ? "bold" : undefined,
|
</ListItemIcon>
|
||||||
}}
|
<ListItemText
|
||||||
>
|
primary={
|
||||||
{roomName(user.info, r, p.users)}
|
<span
|
||||||
</span>
|
style={{
|
||||||
}
|
fontWeight:
|
||||||
/>
|
r.number_unread_messages > 0 ? "bold" : undefined,
|
||||||
</ListItemButton>
|
}}
|
||||||
</ListItem>
|
>
|
||||||
))}
|
{roomName(user.info, r, p.users)}
|
||||||
</List>
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,43 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
|
||||||
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
||||||
import type { Room } from "../../api/matrix/MatrixApiRoom";
|
import type { Room } from "../../api/matrix/MatrixApiRoom";
|
||||||
|
import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
|
||||||
import { RoomEventsManager } from "../../utils/RoomEventsManager";
|
import { RoomEventsManager } from "../../utils/RoomEventsManager";
|
||||||
import { RoomMessagesList } from "./RoomMessagesList";
|
import { RoomMessagesList } from "./RoomMessagesList";
|
||||||
import { SendMessageForm } from "./SendMessageForm";
|
import { SendMessageForm } from "./SendMessageForm";
|
||||||
|
import { TypingNotice } from "./TypingNotice";
|
||||||
|
|
||||||
export function RoomWidget(p: {
|
export function RoomWidget(p: {
|
||||||
room: Room;
|
room: Room;
|
||||||
users: UsersMap;
|
users: UsersMap;
|
||||||
manager: RoomEventsManager;
|
manager: RoomEventsManager;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const receiptId = React.useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const handleRoomClick = async () => {
|
||||||
|
if (p.manager.messages.length === 0) return;
|
||||||
|
const latest = p.manager.messages[p.manager.messages.length - 1];
|
||||||
|
if (latest.event_id === receiptId.current) return;
|
||||||
|
|
||||||
|
receiptId.current = latest.event_id;
|
||||||
|
try {
|
||||||
|
await MatrixApiEvent.SendReceipt(p.room, latest.event_id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to send read receipt!", e);
|
||||||
|
snackbar(`Failed to send read receipt! ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", flex: 1 }}
|
||||||
|
onClick={handleRoomClick}
|
||||||
|
>
|
||||||
<RoomMessagesList {...p} />
|
<RoomMessagesList {...p} />
|
||||||
|
<TypingNotice {...p} />
|
||||||
<SendMessageForm {...p} />
|
<SendMessageForm {...p} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ import { Button } from "@mui/material";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
||||||
import type { Room } from "../../api/matrix/MatrixApiRoom";
|
import type { Room } from "../../api/matrix/MatrixApiRoom";
|
||||||
|
import type { SpaceHierarchy } from "../../api/matrix/MatrixApiSpace";
|
||||||
import { RoomIcon } from "./RoomIcon";
|
import { RoomIcon } from "./RoomIcon";
|
||||||
|
|
||||||
export function SpaceSelector(p: {
|
export function SpaceSelector(p: {
|
||||||
rooms: Room[];
|
rooms: Room[];
|
||||||
|
hierarchy: SpaceHierarchy;
|
||||||
users: UsersMap;
|
users: UsersMap;
|
||||||
selectedSpace?: string;
|
selectedSpace?: string;
|
||||||
onChange: (space?: string) => void;
|
onChange: (space?: string) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const spaces = React.useMemo(
|
const spaces = React.useMemo(
|
||||||
() => p.rooms.filter((r) => r.is_space),
|
() => p.rooms.filter((r) => r.is_space && p.hierarchy.has(r.id)),
|
||||||
[p.rooms]
|
[p.rooms, p.hierarchy]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Do not display space bar if your is not member of any space
|
||||||
|
if (spaces.length === 0) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
<SpaceButton
|
<SpaceButton
|
||||||
|
|||||||
35
matrixgw_frontend/src/widgets/messages/TypingNotice.tsx
Normal file
35
matrixgw_frontend/src/widgets/messages/TypingNotice.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Typography } from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
|
||||||
|
import type { RoomEventsManager } from "../../utils/RoomEventsManager";
|
||||||
|
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
|
||||||
|
|
||||||
|
export function TypingNotice(p: {
|
||||||
|
users: UsersMap;
|
||||||
|
manager: RoomEventsManager;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const user = useUserInfo();
|
||||||
|
|
||||||
|
const users = React.useMemo(
|
||||||
|
() =>
|
||||||
|
[...p.users.values()].filter(
|
||||||
|
(u) =>
|
||||||
|
p.manager.typingUsers.includes(u.user_id) &&
|
||||||
|
u.user_id !== user.info.matrix_user_id
|
||||||
|
),
|
||||||
|
[p.manager.typingUsers, p.users, user.info.matrix_user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
component="div"
|
||||||
|
style={{ paddingLeft: "20px" }}
|
||||||
|
>
|
||||||
|
{users.map((u) => u.display_name ?? u.display_name).join(", ")}{" "}
|
||||||
|
{users.length > 1 ? "are" : "is"} typing...
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user