65 Commits

Author SHA1 Message Date
8a4570a044 Fix Drone configuration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-12-04 17:23:06 +01:00
e51fc6b4bb Fix ESLint issues
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-12-04 16:32:06 +01:00
0f68d59798 Fix spaces support in UI
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 15:35:16 +01:00
5ad23005be Fix time alignment
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 15:03:38 +01:00
4e096a1d49 Can get spaces hierarchy
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 09:18:32 +01:00
ac2a361b77 Can filter rooms list
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 08:53:11 +01:00
24f8d67020 Fix bug in screen 2025-12-04 08:36:48 +01:00
5bcee2ea9d Reduce the number of loaded messages per conversations
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 20:39:26 +01:00
48d9444dde Add a button to manually load older messages
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 20:18:11 +01:00
bcdfe87107 Add reply support through WebSocket
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 19:22:53 +01:00
5088699c15 Add replies support
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 19:20:17 +01:00
854b474970 Fix lock file
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 16:28:21 +01:00
336aea463b Merge branch 'migrate-to-matrix-sdk'
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 16:22:49 +01:00
fe9c692e12 Fix alignment inside WSDebugRoute
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 16:18:03 +01:00
b47ec37a76 Remove unused imports in login route
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 16:12:00 +01:00
996534c62b Fix emoji size
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 16:10:56 +01:00
3ba6543cb4 Remove @mdi/js as a dependency
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 16:07:34 +01:00
f087b27b53 Updated frontend dependencies
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 15:13:01 +01:00
dfcf764a9b Updated backend dependencies 2025-12-03 15:00:58 +01:00
fb35fca56e Fix build issues
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 14:53:06 +01:00
f6568cf059 Fix ESLint issues
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 14:38:58 +01:00
bbf558bbf9 WIP ESLint fixes
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 11:16:14 +01:00
1090a59aaf Quick ESLint issues fix
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 09:48:03 +01:00
30518f3ca3 Fix Web build
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 09:46:51 +01:00
e215fe6484 Add Renovate config
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-12-03 09:45:46 +01:00
6392c0a2c7 Add production Makefile 2025-12-03 09:43:04 +01:00
4110f4d063 Can load older messages in conversations 2025-12-03 09:11:02 +01:00
1a5a021711 Can filter to show only unread rooms 2025-12-03 08:53:44 +01:00
8b299bcf8f Merge pull request 'Update Rust crate serde_json to 1.0.145' (#94) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 00:15:17 +00:00
ab136ef6d0 Display a message when the conversation is empty 2025-12-02 13:59:14 +01:00
1fa98cf6e3 Update Rust crate serde_json to 1.0.145
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-02 00:14:39 +00:00
118b73fce9 Improve receipts spacing 2025-12-01 19:32:50 +01:00
95fb095205 Switch to native emojies 2025-12-01 19:29:18 +01:00
3274d07635 Do not display spaces bar if it is useless 2025-12-01 19:06:58 +01:00
6d78930b89 Add link on unlinked account message 2025-12-01 19:05:37 +01:00
7356a66e4a Handle read receipts on web ui 2025-12-01 18:30:22 +01:00
30e63bfdb4 Handle typing events 2025-12-01 17:23:15 +01:00
e80d54d0e7 Add auto-release configuration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-12-01 11:27:13 +01:00
b91b61f4f0 Downgrade ruma
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-01 11:24:36 +01:00
32354f79ea Add typing event definition 2025-12-01 11:18:58 +01:00
077c64be28 Forward typing event in WebSocket 2025-12-01 11:17:02 +01:00
dac20f60e0 Can get read receipts 2025-12-01 11:09:14 +01:00
9359dc5be0 Send read receipts 2025-12-01 10:42:19 +01:00
849aef9343 Update unread messages count only if room is not muted 2025-12-01 10:30:54 +01:00
196671d0fb Remove unread marker when receiving proper read receipt 2025-12-01 10:25:14 +01:00
b93100413c Propagate read receipt events 2025-12-01 10:12:11 +01:00
57797e933a Can check if rooms are muted 2025-12-01 09:12:58 +01:00
7acb0cbafa Display WS state in favicon 2025-12-01 08:44:30 +01:00
64985bb39e Display application icon 2025-12-01 08:36:29 +01:00
f0e8c799ff Merge pull request 'Update Rust crate actix-web to 4.12.1' (#119) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-27 00:16:23 +00:00
b4e7cb8718 Update Rust crate actix-web to 4.12.1
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-27 00:16:15 +00:00
7a590e882b Merge pull request 'Update Rust crate ruma to 0.14.0' (#118) from renovate/ruma-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-21 00:12:11 +00:00
9a643ced94 Update Rust crate ruma to 0.14.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-21 00:11:59 +00:00
5f2a6478a7 Merge pull request 'Update Rust crate clap to 4.5.53' (#117) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-20 00:09:04 +00:00
1db929a31b Update Rust crate clap to 4.5.53
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-20 00:08:50 +00:00
0b2c4071e8 Merge pull request 'Update Rust crate clap to 4.5.52' (#116) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-18 00:10:34 +00:00
61ecfc5af1 Update Rust crate clap to 4.5.52
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-18 00:10:24 +00:00
661793f58d Merge pull request 'Update Rust crate actix-web to 4.12.0' (#115) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-17 00:09:32 +00:00
d253e73099 Update Rust crate actix-web to 4.12.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-17 00:09:22 +00:00
f0d3d311e9 Merge pull request 'Update Rust crate bytes to 1.11.0' (#114) from renovate/bytes-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-15 00:09:38 +00:00
592203aa4a Update Rust crate bytes to 1.11.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-15 00:09:30 +00:00
aeb35029c3 Merge pull request 'Update Rust crate sha2 to 0.11.0-rc.3' (#113) from renovate/sha2-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-06 00:11:57 +00:00
1dc56d5ec1 Update Rust crate sha2 to 0.11.0-rc.3
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-06 00:11:50 +00:00
51b1ab380c Merge pull request 'Update Rust crate rust-embed to 8.9.0' (#112) from renovate/rust-embed-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-03 00:09:55 +00:00
b5abddaacb Update Rust crate rust-embed to 8.9.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-03 00:09:54 +00:00
65 changed files with 2136 additions and 1146 deletions

104
.drone.yml Normal file
View File

@@ -0,0 +1,104 @@
---
kind: pipeline
type: docker
name: default
steps:
# Frontend
- name: web_build
image: node:23
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
View 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)

View File

@@ -2,3 +2,4 @@ storage
app_storage app_storage
.idea .idea
target target
static

File diff suppressed because it is too large Load Diff

View File

@@ -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.53", 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.14.0" }
matrix-sdk-ui = "0.14.0"
url = "2.5.7" url = "2.5.7"
ractor = "0.15.9" ractor = "0.15.9"
serde_json = "1.0.145" serde_json = "1.0.145"
lazy-regex = "3.4.2" lazy-regex = "3.4.2"
actix-ws = "0.3.0" actix-ws = "0.3.0"
infer = "0.19.0" infer = "0.19.0"
rust-embed = "8.9.0"
mime_guess = "2.0.5"

View 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"]

View File

@@ -2,8 +2,10 @@ use crate::matrix_connection::sync_thread::MatrixSyncTaskID;
use crate::users::{APIToken, UserEmail}; use crate::users::{APIToken, UserEmail};
use matrix_sdk::Room; use matrix_sdk::Room;
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent; use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
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>;
@@ -32,6 +34,10 @@ pub enum BroadcastMessage {
ReactionEvent(BxRoomEvent<OriginalSyncReactionEvent>), ReactionEvent(BxRoomEvent<OriginalSyncReactionEvent>),
/// New room redaction /// New room redaction
RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>), RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>),
/// Message fully read event
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 },
} }

View File

@@ -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)]
@@ -216,7 +230,7 @@ pub async fn event_file(
MessageType::Video(c) => (c.source(), c.thumbnail_source()), MessageType::Video(c) => (c.source(), c.thumbnail_source()),
_ => (None, None), _ => (None, None),
}; };
let source = match (query.thumbnail, source, thumb_source) { let source = match (query.thumbnail, source, thumb_source) {
(false, Some(s), _) => s, (false, Some(s), _) => s,
(true, _, Some(s)) => s, (true, _, Some(s)) => s,
@@ -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())
}

View File

@@ -4,8 +4,14 @@ use crate::controllers::matrix::matrix_media_controller;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor; use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpRequest, HttpResponse, web};
use futures_util::{StreamExt, stream}; use futures_util::{StreamExt, stream};
use matrix_sdk::notification_settings::{
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)]
@@ -17,11 +23,12 @@ pub struct APIRoomInfo {
is_space: bool, is_space: bool,
parents: Vec<OwnedRoomId>, parents: Vec<OwnedRoomId>,
number_unread_messages: u64, number_unread_messages: u64,
notifications: RoomNotificationMode,
latest_event: Option<APIEvent>, latest_event: Option<APIEvent>,
} }
impl APIRoomInfo { impl APIRoomInfo {
async fn from_room(r: &Room) -> anyhow::Result<Self> { async fn from_room(r: &Room, notif: &NotificationSettings) -> anyhow::Result<Self> {
// Get parent spaces // Get parent spaces
let parent_spaces = r let parent_spaces = r
.parent_spaces() .parent_spaces()
@@ -39,28 +46,48 @@ impl APIRoomInfo {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let members = r
.members(RoomMemberships::ACTIVE)
.await?
.into_iter()
.map(|r| r.user_id().to_owned())
.collect::<Vec<_>>();
let notifications = notif
.get_user_defined_room_notification_mode(r.room_id())
.await
.unwrap_or(
notif
.get_default_room_notification_mode(
IsEncrypted::from(r.encryption_state().is_encrypted()),
IsOneToOne::from(members.len() == 2),
)
.await,
);
Ok(Self { Ok(Self {
id: r.room_id().to_owned(), id: r.room_id().to_owned(),
name: r.name(), name: r.name(),
members: r members,
.members(RoomMemberships::ACTIVE)
.await?
.into_iter()
.map(|r| r.user_id().to_owned())
.collect::<Vec<_>>(),
avatar: r.avatar_url(), avatar: r.avatar_url(),
is_space: r.is_space(), is_space: r.is_space(),
parents: parent_spaces, parents: parent_spaces,
number_unread_messages: r.unread_notification_counts().notification_count, number_unread_messages: r.unread_notification_counts().notification_count,
latest_event: get_events(r, 1, None).await?.events.into_iter().next(), notifications,
latest_event: get_events(r, 1, None, None)
.await?
.events
.into_iter()
.next(),
}) })
} }
} }
/// Get the list of joined rooms of the user /// Get the list of joined rooms of the user
pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult { pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
let list = stream::iter(client.client.client.joined_rooms()) let list = stream::iter(client.client.client.joined_rooms())
.then(async |room| APIRoomInfo::from_room(&room).await) .then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.await .await
.into_iter() .into_iter()
@@ -71,8 +98,10 @@ pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
/// Get joined spaces rooms of user /// Get joined spaces rooms of user
pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult { pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
let list = stream::iter(client.client.client.joined_space_rooms()) let list = stream::iter(client.client.client.joined_space_rooms())
.then(async |room| APIRoomInfo::from_room(&room).await) .then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.await .await
.into_iter() .into_iter()
@@ -91,9 +120,11 @@ pub async fn single_room_info(
client: MatrixClientExtractor, client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>, path: web::Path<RoomIdInPath>,
) -> HttpResult { ) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
Ok(match client.client.client.get_room(&path.room_id) { Ok(match client.client.client.get_room(&path.room_id) {
None => HttpResponse::NotFound().json("Room not found"), None => HttpResponse::NotFound().json("Room not found"),
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r).await?), Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r, &notifs).await?),
}) })
} }
@@ -113,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))
}

View File

@@ -0,0 +1,32 @@
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());
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))
}

View File

@@ -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;

View File

@@ -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;

View 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/"))
}
}

View File

@@ -29,6 +29,25 @@ pub struct WsRoomEvent<E> {
pub data: Box<E>, pub data: Box<E>,
} }
#[derive(Debug, serde::Serialize)]
pub struct WsReceiptEntry {
event: OwnedEventId,
user: OwnedUserId,
ts: Option<MilliSecondsSinceUnixEpoch>,
}
#[derive(Debug, serde::Serialize)]
pub struct WsReceiptEvent {
pub room_id: OwnedRoomId,
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")]
@@ -41,6 +60,12 @@ pub enum WsMessage {
/// Room reaction event /// Room reaction event
RoomRedactionEvent(WsRoomEvent<RoomRedactionEventContent>), RoomRedactionEvent(WsRoomEvent<RoomRedactionEventContent>),
/// Fully read message event
ReceiptEvent(WsReceiptEvent),
/// User is typing event
TypingEvent(WsTypingEvent),
} }
impl WsMessage { impl WsMessage {
@@ -55,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(),
@@ -64,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(),
@@ -73,6 +100,34 @@ impl WsMessage {
data: Box::new(evt.data.content.clone()), data: Box::new(evt.data.content.clone()),
})) }))
} }
BroadcastMessage::ReceiptEvent(evt) if &evt.user == user => {
let mut receipts = vec![];
for (event_id, r) in &evt.data.content.0 {
for user_receipts in r.values() {
for (user, receipt) in user_receipts {
receipts.push(WsReceiptEntry {
event: event_id.clone(),
user: user.clone(),
ts: receipt.ts,
})
}
}
}
Some(Self::ReceiptEvent(WsReceiptEvent {
room_id: evt.room.room_id().to_owned(),
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,
} }
} }

View File

@@ -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)?

View File

@@ -8,8 +8,10 @@ use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use futures_util::StreamExt; use futures_util::StreamExt;
use matrix_sdk::Room; use matrix_sdk::Room;
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent; use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
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)]
@@ -53,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,
})) { })) {
@@ -67,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,
})) { })) {
@@ -81,21 +83,49 @@ 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,
})) }))
{ {
log::warn!("Failed to forward reaction event! {e}"); log::warn!("Failed to forward redaction event! {e}");
} }
}, },
)); ));
let tx_receipt_handle = tx.clone();
let user_receipt_mail = client.email.clone();
handlers.push(
client.add_event_handler(async move |event: SyncReceiptEvent, room: Room| {
if let Err(e) = tx_receipt_handle.send(BroadcastMessage::ReceiptEvent(BxRoomEvent {
user: user_receipt_mail.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward receipt event! {e}");
}
}),
);
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

View File

@@ -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",
},
}, },
]) ]);

File diff suppressed because it is too large Load Diff

View File

@@ -12,38 +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.6",
"@mdi/react": "^1.6.1", "@mui/material": "^7.3.6",
"@mui/icons-material": "^7.3.5", "@mui/x-data-grid": "^8.20.0",
"@mui/material": "^7.3.5", "@mui/x-date-pickers": "^8.19.0",
"@mui/x-data-grid": "^8.18.0", "date-and-time": "^4.1.1",
"@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.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"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.10.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.39.1",
"@types/node": "^24.6.0", "@types/node": "^24.10.1",
"@types/react": "^19.1.16", "@types/react": "^19.2.7",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.36.0", "eslint": "^9.39.1",
"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.22",
"globals": "^16.4.0", "globals": "^16.5.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.48.1",
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.2.10"
}, },
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.2.10"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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;
} }
/** /**

View File

@@ -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 };
} }
/** /**

View File

@@ -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;
} }
} }

View File

@@ -35,7 +35,7 @@ export class ServerApi {
uri: "/server/config", uri: "/server/config",
method: "GET", method: "GET",
}) })
).data; ).data as ServerConfig;
} }
/** /**

View File

@@ -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;
} }
} }

View File

@@ -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;
@@ -45,10 +48,32 @@ export interface RoomRedactionEvent extends BaseRoomEvent {
}; };
} }
export interface ReceiptEventEntry {
event: string;
user: string;
ts?: number;
}
export interface RoomReceiptEvent {
time: number;
type: "ReceiptEvent";
room_id: string;
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
| RoomTypingEvent;
export class WsApi { export class WsApi {
/** /**

View File

@@ -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`,
});
}
} }

View File

@@ -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]));
} }

View File

@@ -11,9 +11,16 @@ export interface Room {
is_space?: boolean; is_space?: boolean;
parents: string[]; parents: string[];
number_unread_messages: number; number_unread_messages: number;
notifications: "AllMessages" | "MentionsAndKeywordsOnly" | "Mute";
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
*/ */
@@ -50,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[];
} }
} }

View 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;
}
}

View 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>
);
}

View 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

View 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

View File

@@ -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!"

View File

@@ -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)

View File

@@ -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 (
<> <>

View File

@@ -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

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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());
}

View File

@@ -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);
}
} }
} }

View File

@@ -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 (

View File

@@ -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}
/>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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;

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}>

View File

@@ -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

View File

@@ -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>

View File

@@ -0,0 +1,35 @@
import Favicon from "react-favicon";
import { WSState } from "./MatrixWS";
// Taken from https://github.com/element-hq/element-web/blob/0577e245dac944bd85eea07b93a9762a93062f62/src/favicon.ts
function getInitialFavicon(): HTMLLinkElement[] {
const icons: HTMLLinkElement[] = [];
const links = window.document
.getElementsByTagName("head")[0]
.getElementsByTagName("link");
for (const link of links) {
if (
link.hasAttribute("rel") &&
/(^|\s)icon(\s|$)/i.test(link.getAttribute("rel")!)
) {
icons.push(link);
}
}
return icons;
}
const iconPath = getInitialFavicon()[0].getAttribute("href")!;
export function AppIconModifier(p: {
numberUnread: number;
state: string;
}): React.ReactElement {
const isError = p.state === WSState.Error || p.state === WSState.Closed;
return (
<Favicon
url={iconPath}
alertFillColor={isError ? "orange" : undefined}
alertCount={isError ? "x" : p.numberUnread}
/>
);
}

View File

@@ -6,11 +6,16 @@ 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";
import { AsyncWidget } from "../AsyncWidget"; import { AsyncWidget } from "../AsyncWidget";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage"; import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { AppIconModifier } from "./AppIconModifier";
import { MatrixWS } from "./MatrixWS"; import { MatrixWS } from "./MatrixWS";
import { RoomSelector } from "./RoomSelector"; import { RoomSelector } from "./RoomSelector";
import { RoomWidget } from "./RoomWidget"; import { RoomWidget } from "./RoomWidget";
@@ -18,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) => {
@@ -39,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!))}
/> />
@@ -52,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 {
@@ -64,13 +77,23 @@ 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 [_refreshCount, setRefreshCount] = React.useState(0); const unreadRooms = React.useMemo(
() =>
p.rooms.filter(
(r) => r.number_unread_messages > 0 && r.notifications === "AllMessages"
).length,
[p.rooms]
);
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 () => {
@@ -80,10 +103,12 @@ 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);
}; };
const [wsState, setWsState] = React.useState("");
const handleWsEvent = (m: WsMessage) => { const handleWsEvent = (m: WsMessage) => {
// Process messages for current room // Process messages for current room
if (roomMgr?.processWsMessage(m)) { if (roomMgr?.processWsMessage(m)) {
@@ -100,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,
@@ -108,15 +133,35 @@ function _MainMessageWidget(p: {
return n; return n;
}); });
} }
// Remove unread message on left sidebar
if (
m.type === "ReceiptEvent" &&
m.receipts.find((r) => r.user === user.info.matrix_user_id) !== undefined
) {
p.onRoomsListUpdate((r) => {
const n = [...r];
const idx = r.findIndex((el) => el.id === m.room_id);
if (idx)
n[idx] = {
...n[idx],
number_unread_messages: 0,
};
return n;
});
}
}; };
return ( return (
<div style={{ display: "flex", height: "100%" }}> <div style={{ display: "flex", height: "100%" }}>
{/* Websocket */} {/* Websocket */}
<div style={{ position: "absolute", right: "0px", padding: "10px" }}> <div style={{ position: "absolute", right: "0px", padding: "10px" }}>
<MatrixWS onMessage={handleWsEvent} /> <MatrixWS onMessage={handleWsEvent} onStateChange={setWsState} />
</div> </div>
{/** Application icon modifier */}
<AppIconModifier numberUnread={unreadRooms} state={wsState} />
{/* Space selector */} {/* Space selector */}
<SpaceSelector {...p} selectedSpace={space} onChange={setSpace} /> <SpaceSelector {...p} selectedSpace={space} onChange={setSpace} />

View File

@@ -4,7 +4,7 @@ import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
import CircleIcon from "@mui/icons-material/Circle"; import CircleIcon from "@mui/icons-material/Circle";
import { Tooltip } from "@mui/material"; import { Tooltip } from "@mui/material";
const State = { export const WSState = {
Closed: "Closed", Closed: "Closed",
Connected: "Connected", Connected: "Connected",
Error: "Error", Error: "Error",
@@ -12,6 +12,7 @@ const State = {
export function MatrixWS(p: { export function MatrixWS(p: {
onMessage: (msg: WsMessage) => void; onMessage: (msg: WsMessage) => void;
onStateChange?: (state: string) => void;
}): React.ReactElement { }): React.ReactElement {
const snackbar = useSnackbar(); const snackbar = useSnackbar();
@@ -21,7 +22,13 @@ export function MatrixWS(p: {
cbRef.current = p.onMessage; cbRef.current = p.onMessage;
}, [p.onMessage]); }, [p.onMessage]);
const [state, setState] = React.useState<string>(State.Closed); // Keep only the latest version of onStateChange
const stateCbRef = React.useRef(p.onStateChange);
React.useEffect(() => {
stateCbRef.current = p.onStateChange;
}, [p.onStateChange]);
const [state, setState] = React.useState<string>(WSState.Closed);
const wsId = React.useRef<number | undefined>(undefined); const wsId = React.useRef<number | undefined>(undefined);
const [connCount, setConnCount] = React.useState(0); const [connCount, setConnCount] = React.useState(0);
@@ -30,23 +37,35 @@ export function MatrixWS(p: {
const ws = new WebSocket(WsApi.WsURL); const ws = new WebSocket(WsApi.WsURL);
wsId.current = id; wsId.current = id;
ws.onopen = () => setState(State.Connected); // Open
ws.onopen = () => {
if (wsId.current != id) return;
setState(WSState.Connected);
stateCbRef.current?.(WSState.Connected);
};
// Error
ws.onerror = (e) => { ws.onerror = (e) => {
if (wsId.current != id) return; if (wsId.current != id) return;
console.error(`WS Debug error!`, e); console.error(`WS Debug error!`, e);
snackbar(`WebSocket error!`); snackbar(`WebSocket error!`);
setState(State.Error); setState(WSState.Error);
stateCbRef.current?.(WSState.Error);
setTimeout(() => setConnCount(connCount + 1), 500); setTimeout(() => setConnCount(connCount + 1), 500);
}; };
// Close
ws.onclose = () => { ws.onclose = () => {
if (wsId.current !== id) return; if (wsId.current !== id) return;
setState(State.Closed); setState(WSState.Closed);
stateCbRef.current?.(WSState.Closed);
wsId.current = undefined; wsId.current = undefined;
}; };
// Message
ws.onmessage = (msg) => { ws.onmessage = (msg) => {
if (wsId.current !== id) return; if (wsId.current !== id) return;
@@ -56,15 +75,15 @@ export function MatrixWS(p: {
}; };
return () => ws.close(); return () => ws.close();
}, [connCount]); }, [connCount, snackbar]);
return ( return (
<Tooltip title={state}> <Tooltip title={state}>
<CircleIcon <CircleIcon
color={ color={
state === State.Connected state === WSState.Connected
? "success" ? "success"
: state === State.Error : state === WSState.Error
? "error" ? "error"
: undefined : undefined
} }

View File

@@ -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
&nbsp; {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>
); );

View File

@@ -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>
); );
} }

View File

@@ -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>
); );

View File

@@ -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

View 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>
);
}