39 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
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
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
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
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
30 changed files with 1141 additions and 1034 deletions

View File

@@ -4,102 +4,101 @@ 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
# 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
# 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_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_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
- 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: API_KEY
settings:
base_url: https://gitea.communiquons.org
files: /tmp/release/*
checksum: sha512
# 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: {}
- name: rust_registry
temp: {}
- name: web_app
temp: {}
- name: release
temp: {}

File diff suppressed because it is too large Load Diff

View File

@@ -5,29 +5,30 @@ edition = "2024"
[dependencies]
env_logger = "0.11.8"
log = "0.4.28"
clap = { version = "4.5.51", features = ["derive", "env"] }
log = "0.4.29"
clap = { version = "4.5.53", features = ["derive", "env"] }
lazy_static = "1.5.0"
anyhow = "1.0.100"
serde = { version = "1.0.228", features = ["derive"] }
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-remote-ip = "0.1.0"
actix-cors = "0.7.1"
light-openid = "1.0.4"
bytes = "1.10.1"
bytes = "1.11.0"
sha2 = "0.10.9"
base16ct = { version = "0.3.0", features = ["alloc"] }
futures-util = "0.3.31"
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
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"] }
rand = "0.9.2"
hex = "0.4.3"
mailchecker = "6.0.19"
matrix-sdk = { version = "0.14.0" }
matrix-sdk-ui = "0.14.0"
url = "2.5.7"
ractor = "0.15.9"
serde_json = "1.0.145"

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_profile_controller;
pub mod matrix_room_controller;
pub mod matrix_space_controller;

View File

@@ -11,7 +11,7 @@ use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants;
use matrixgw_backend::controllers::matrix::{
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
matrix_room_controller,
matrix_room_controller, matrix_space_controller,
};
use matrixgw_backend::controllers::{
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
@@ -138,6 +138,11 @@ async fn main() -> std::io::Result<()> {
web::get().to(matrix_sync_thread_controller::status),
)
.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
.route(
"/api/matrix/room/joined",

View File

@@ -12,7 +12,7 @@ export default defineConfig([
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {

File diff suppressed because it is too large Load Diff

View File

@@ -12,14 +12,12 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.18.0",
"@mui/x-date-pickers": "^8.17.0",
"date-and-time": "^4.1.0",
"@fontsource/roboto": "^5.2.9",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"@mui/x-data-grid": "^8.20.0",
"@mui/x-date-pickers": "^8.19.0",
"date-and-time": "^4.1.1",
"dayjs": "^1.11.19",
"emoji-picker-react": "^4.16.1",
"is-cidr": "^6.0.1",
@@ -28,23 +26,23 @@
"react-dom": "^19.1.1",
"react-favicon": "^2.0.7",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.9.5"
"react-router": "^7.10.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "npm:rolldown-vite@7.1.14"
"typescript-eslint": "^8.48.1",
"vite": "npm:rolldown-vite@7.2.10"
},
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
"vite": "npm:rolldown-vite@7.2.10"
}
}

View File

@@ -17,6 +17,9 @@ export interface RoomMessageEvent extends BaseRoomEvent {
"m.relates_to"?: {
rel_type?: "m.replace" | string;
event_id?: string;
"m.in_reply_to"?:{
event_id?:string
}
};
"m.new_content"?: {
msgtype?: MessageType;

View File

@@ -15,8 +15,11 @@ export interface MatrixRoomMessage {
body: string;
msgtype: MessageType;
"m.relates_to"?: {
event_id: string;
rel_type: "m.replace" | string;
event_id?: string;
rel_type?: "m.replace" | string;
"m.in_reply_to"?: {
event_id?: string;
};
};
url?: string;
file?: {
@@ -71,8 +74,8 @@ export class MatrixApiEvent {
await APIClient.exec({
method: "GET",
uri:
`/matrix/room/${encodeURIComponent(room.id)}/events` +
(from ? `?from=${from}` : ""),
`/matrix/room/${encodeURIComponent(room.id)}/events?limit=200` +
(from ? `&from=${from}` : ""),
})
).data as MatrixEventsList;
}

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";
export function APITokensRoute(): React.ReactElement {
const count = React.useRef(0);
const [count, setCount] = React.useState(0);
const [openCreateTokenDialog, setOpenCreateTokenDialog] =
React.useState(false);
@@ -34,7 +34,7 @@ export function APITokensRoute(): React.ReactElement {
};
const handleRefreshTokensList = () => {
count.current += 1;
setCount((c) => c + 1);
setList(undefined);
};
@@ -79,7 +79,7 @@ export function APITokensRoute(): React.ReactElement {
{/* Tokens list */}
<AsyncWidget
loadKey={count.current}
loadKey={count}
ready={list !== undefined}
load={load}
errMsg="Failed to load the list of tokens!"

View File

@@ -41,7 +41,8 @@ export function MatrixAuthCallback(): React.ReactElement {
};
load();
}, [code, info, navigate, snackbar, state]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code, state]);
if (error)
return (

View File

@@ -23,13 +23,21 @@ export function WSDebugRoute(): React.ReactElement {
return (
<MatrixGWRouteContainer label={"WebSocket Debug"}>
<div>
State:{" "}
<span style={{ color: state == WSState.Connected ? "green" : "red" }}>
{/* Status bar */}
<div style={{ display: "flex", alignItems: "center" }}>
<span style={{ marginRight: "0.5em" }}>State: </span>
<span
style={{
marginRight: "0.5em",
color: state == WSState.Connected ? "green" : "red",
}}
>
{state}
</span>
<MatrixWS onStateChange={setState} onMessage={handleMessage} />
</div>
{/* WS messages list */}
{messages.map((msg, id) => (
<div style={{ margin: "10px", backgroundColor: "black" }}>
<JsonView

View File

@@ -1,9 +1,9 @@
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 { 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 {
const [loading, setLoading] = React.useState(false);
@@ -40,7 +40,7 @@ export function LoginRoute(): React.ReactElement {
fullWidth
variant="outlined"
onClick={authWithOpenID}
startIcon={<Icon path={mdiOpenid} size={1} />}
startIcon={<AppIcon src={openid} />}
>
Sign in with {ServerApi.Config.oidc_provider_name}
</Button>

View File

@@ -21,6 +21,7 @@ export interface Message {
time_sent: number;
time_sent_dayjs: dayjs.Dayjs;
modified: boolean;
inReplyTo?: string;
reactions: Map<string, MessageReaction[]>;
content: string;
type: MessageType;
@@ -37,7 +38,7 @@ export class RoomEventsManager {
receiptsEventsMap: Map<string, Receipt[]>;
get canLoadOlder(): boolean {
return !!this.endToken;
return !!this.endToken && this.events.length > 0;
}
constructor(
@@ -92,13 +93,7 @@ export class RoomEventsManager {
content: {
body: m.data["m.new_content"]?.body ?? m.data.body,
msgtype: m.data.msgtype,
"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,
"m.relates_to": m.data["m.relates_to"],
url: m.data.url,
file: m.data.file,
},
@@ -174,7 +169,7 @@ export class RoomEventsManager {
// Message
if (data.type === "m.room.message") {
// 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(
(m) => m.event_id === data.content["m.relates_to"]?.event_id
);
@@ -206,6 +201,7 @@ export class RoomEventsManager {
event_id: evt.id,
account: evt.sender,
modified: false,
inReplyTo: data.content["m.relates_to"]?.["m.in_reply_to"]?.event_id,
reactions: new Map(),
time_sent: evt.time,
time_sent_dayjs: dayjs.unix(evt.time / 1000),

View File

@@ -1,5 +1,5 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import React from "react";
const State = {
Loading: 0,
@@ -17,9 +17,7 @@ export function AsyncWidget(p: {
ready?: boolean;
errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement {
const [state, setState] = useState<number>(State.Loading);
const counter = useRef<unknown>(null);
const [state, setState] = React.useState<number>(State.Loading);
const load = async () => {
try {
@@ -32,12 +30,10 @@ export function AsyncWidget(p: {
}
};
useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
React.useEffect(() => {
load();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [p.loadKey]);
if (state === State.Error)
return (

View File

@@ -16,9 +16,16 @@ function emojiUnicode(emoji: string): string {
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);
return (
<Emoji unified={unified ?? ""} emojiStyle={EmojiStyle.NATIVE} size={18} />
<Emoji
unified={unified ?? ""}
emojiStyle={EmojiStyle.NATIVE}
size={p.size ?? 18}
/>
);
}

View File

@@ -1,10 +1,10 @@
import { mdiMessageTextFast } from "@mdi/js";
import Icon from "@mdi/react";
import { Typography } from "@mui/material";
import MuiCard from "@mui/material/Card";
import Stack from "@mui/material/Stack";
import { styled } from "@mui/material/styles";
import { Outlet } from "react-router";
import { AppIcon } from "../../icons/AppIcon";
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
const Card = styled(MuiCard)(({ theme }) => ({
display: "flex",
@@ -57,12 +57,7 @@ export function BaseLoginPage(): React.ReactElement {
variant="h4"
sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }}
>
<Icon
path={mdiMessageTextFast}
size={"1em"}
style={{ display: "inline-table" }}
/>{" "}
MatrixGW
<AppIcon src={mdiMessageTextFast} size={"2em"} /> MatrixGW
</Typography>
<Outlet />
</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 MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
@@ -13,6 +11,8 @@ import Toolbar from "@mui/material/Toolbar";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { AppIcon } from "../../icons/AppIcon";
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
import { RouterLink } from "../RouterLink";
import { useUserInfo } from "./BaseAuthenticatedPage";
import ThemeSwitcher from "./ThemeSwitcher";
@@ -101,7 +101,7 @@ export default function DashboardHeader({
<RouterLink to="/">
<Stack direction="row" alignItems="center">
<LogoContainer>
<Icon path={mdiMessageTextFast} size="2em" />
<AppIcon src={mdiMessageTextFast} size="2em" />
</LogoContainer>
<Typography
variant="h6"

View File

@@ -1,5 +1,7 @@
import { mdiBug, mdiForum, mdiKeyVariant, mdiLinkLock } from "@mdi/js";
import Icon from "@mdi/react";
import BugReportIcon from "@mui/icons-material/BugReport";
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 Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
@@ -98,27 +100,27 @@ export default function DashboardSidebar({
<DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="Messages"
icon={<Icon path={mdiForum} size={"1.5em"} />}
icon={<ForumIcon style={{ height: "1em", width: "1em" }} />}
href="/"
mini={viewport === "desktop"}
/>
<DashboardSidebarDividerItem />
<DashboardSidebarPageItem
title="Matrix link"
icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
icon={<LinkIcon style={{ height: "1em", width: "1em" }} />}
href="/matrix_link"
mini={viewport === "desktop"}
/>
<DashboardSidebarPageItem
title="API tokens"
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
icon={<KeyIcon style={{ height: "1em", width: "1em" }} />}
href="/tokens"
mini={viewport === "desktop"}
/>
<DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="WS Debug"
icon={<Icon path={mdiBug} size={"1.5em"} />}
icon={<BugReportIcon style={{ height: "1em", width: "1em" }} />}
href="/wsdebug"
mini={viewport === "desktop"}
/>

View File

@@ -7,9 +7,10 @@ import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import type {} from "@mui/material/themeCssVarsAugmentation";
export default function ThemeSwitcher() {
const theme = useTheme();
export function useActualColorMode(): {
mode: "light" | "dark";
setMode: (mode: "light" | "dark") => void;
} {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const preferredMode = prefersDarkMode ? "dark" : "light";
@@ -17,21 +18,27 @@ export default function ThemeSwitcher() {
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(() => {
setMode(paletteMode === "dark" ? "light" : "dark");
}, [setMode, paletteMode]);
setMode(mode === "dark" ? "light" : "dark");
}, [mode, setMode]);
return (
<Tooltip
title={`${paletteMode === "dark" ? "Light" : "Dark"} mode`}
title={`${mode === "dark" ? "Light" : "Dark"} mode`}
enterDelay={1000}
>
<div>
<IconButton
size="small"
aria-label={`Switch to ${
paletteMode === "dark" ? "light" : "dark"
} mode`}
aria-label={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
onClick={toggleMode}
>
<LightModeIcon

View File

@@ -6,6 +6,10 @@ import {
type UsersMap,
} from "../../api/matrix/MatrixApiProfile";
import { MatrixApiRoom, type Room } from "../../api/matrix/MatrixApiRoom";
import {
MatrixApiSpace,
type SpaceHierarchy,
} from "../../api/matrix/MatrixApiSpace";
import { MatrixSyncApi } from "../../api/MatrixSyncApi";
import type { WsMessage } from "../../api/WsApi";
import { RoomEventsManager } from "../../utils/RoomEventsManager";
@@ -19,13 +23,19 @@ import { SpaceSelector } from "./SpaceSelector";
export function MainMessageWidget(): React.ReactElement {
const [rooms, setRooms] = React.useState<Room[] | undefined>();
const [hierarchy, setHierarchy] = React.useState<
SpaceHierarchy | undefined
>();
const [users, setUsers] = React.useState<UsersMap | undefined>();
const loadRoomsList = async () => {
await MatrixSyncApi.Start();
const rooms = await MatrixApiRoom.ListJoined();
const hierarchy = await MatrixApiSpace.Hierarchy();
setRooms(rooms);
setHierarchy(hierarchy);
// Get the list of users in rooms
const users = rooms.reduce((prev, r) => {
@@ -40,11 +50,12 @@ export function MainMessageWidget(): React.ReactElement {
<AsyncWidget
loadKey={1}
load={loadRoomsList}
ready={!!rooms && !!users}
ready={!!rooms && !!users && !!hierarchy}
errMsg="Failed to initialize messaging component!"
build={() => (
<MainMessageWidgetInner
rooms={rooms!}
hierarchy={hierarchy!}
users={users!}
onRoomsListUpdate={(cb) => setRooms((r) => cb(r!))}
/>
@@ -55,6 +66,7 @@ export function MainMessageWidget(): React.ReactElement {
function MainMessageWidgetInner(p: {
rooms: Room[];
hierarchy: SpaceHierarchy;
users: UsersMap;
onRoomsListUpdate: (cb: (a: Room[]) => Room[]) => void;
}): React.ReactElement {
@@ -65,11 +77,13 @@ function MainMessageWidgetInner(p: {
const spaceRooms = React.useMemo(() => {
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(
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
);
}, [space, p.rooms]);
}, [space, p.rooms, p.hierarchy]);
const unreadRooms = React.useMemo(
() =>
@@ -111,7 +125,7 @@ function MainMessageWidgetInner(p: {
p.onRoomsListUpdate((r) => {
const n = [...r];
const idx = r.findIndex((el) => el.id === m.room_id);
if (idx && n[idx].notifications === "AllMessages")
if (idx && n[idx]?.notifications === "AllMessages")
n[idx] = {
...n[idx],
number_unread_messages: n[idx].number_unread_messages + 1,

View File

@@ -55,16 +55,8 @@ export function RoomMessagesList(p: {
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
}, [lastEventId, messagesEndRef]);
// Watch scroll to detect when user reach the top to load older messages
const handleScroll = async () => {
if (!listContainerRef.current || loadingOlder || !p.manager.canLoadOlder)
return;
const { scrollTop } = listContainerRef.current;
if (scrollTop !== 0) {
return;
}
const loadOlderMessages = async () => {
if (loadingOlder || !p.manager.canLoadOlder) return;
setLoadingOlder(true);
@@ -82,6 +74,19 @@ export function RoomMessagesList(p: {
}
};
// 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 (
<div
onScroll={handleScroll}
@@ -94,22 +99,8 @@ export function RoomMessagesList(p: {
paddingLeft: "20px",
}}
>
{/* 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>
)}
{/** Begining of conversation */}
{!p.manager.canLoadOlder && (
{!p.manager.canLoadOlder && p.manager.messages.length > 0 && (
<Typography
component={"div"}
variant="caption"
@@ -119,6 +110,22 @@ export function RoomMessagesList(p: {
</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
@@ -133,6 +140,21 @@ export function RoomMessagesList(p: {
</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) => (
<RoomMessage
key={m.event_id}
@@ -149,6 +171,11 @@ export function RoomMessagesList(p: {
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
}
/>
))}
@@ -164,6 +191,7 @@ function RoomMessage(p: {
previousFromSamePerson: boolean;
firstMessageOfDay: boolean;
receipts?: Receipt[];
repliedMessage?: Message;
}): React.ReactElement {
const theme = useTheme();
const user = useUserInfo();
@@ -180,6 +208,8 @@ function RoomMessage(p: {
const closeImageFullScreen = () => setShowImageFullScreen(false);
const sender = p.users.get(p.message.account);
const repliedMsgSender =
p.repliedMessage && p.users.get(p.repliedMessage.account);
const handleDeleteMessage = async () => {
if (!(await confirm(`Do you really want to delete this message?`))) return;
@@ -292,12 +322,37 @@ function RoomMessage(p: {
"&:hover *": { visibility: "visible" },
}}
>
<Typography variant="caption">
&nbsp; {p.message.time_sent_dayjs.format("HH:mm")}
<Typography
variant="caption"
style={{
paddingLeft: "2px",
display: "inline-flex",
alignItems: "center",
}}
>
{p.message.time_sent_dayjs.format("HH:mm")}
</Typography>
{/** Message itself */}
<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 */}
{p.message.type === "m.image" && (
<img
@@ -465,11 +520,9 @@ function RoomMessage(p: {
}}
>
<div style={{ margin: "0px 3px" }}>
<EmojiIcon emojiKey={r} />
</div>
<div style={{ height: "2em", marginLeft: "2px" }}>
{reactions.length}
<EmojiIcon emojiKey={r} size={16} />
</div>
<div style={{ marginLeft: "2px" }}>{reactions.length}</div>
</span>
}
/>

View File

@@ -1,3 +1,4 @@
import SearchIcon from "@mui/icons-material/Search";
import {
Chip,
List,
@@ -5,6 +6,7 @@ import {
ListItemButton,
ListItemIcon,
ListItemText,
TextField,
} from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
@@ -22,11 +24,19 @@ export function RoomSelector(p: {
}): React.ReactElement {
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),
[p.rooms, unread]
() =>
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)
@@ -45,6 +55,19 @@ export function RoomSelector(p: {
return (
<div style={{ display: "flex", flexDirection: "column" }}>
{/** Filter bar */}
<TextField
placeholder="Filter rooms"
slotProps={{
input: {
startAdornment: <SearchIcon style={{ marginRight: "10px" }} />,
},
}}
style={{ margin: "5px" }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/** Chip bar */}
<div style={{ padding: "5px 10px", marginTop: "5px" }}>
<span onClick={() => setUnread(!unread)} style={{ cursor: "pointer" }}>

View File

@@ -3,17 +3,19 @@ import { Button } from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import type { SpaceHierarchy } from "../../api/matrix/MatrixApiSpace";
import { RoomIcon } from "./RoomIcon";
export function SpaceSelector(p: {
rooms: Room[];
hierarchy: SpaceHierarchy;
users: UsersMap;
selectedSpace?: string;
onChange: (space?: string) => void;
}): React.ReactElement {
const spaces = React.useMemo(
() => p.rooms.filter((r) => r.is_space),
[p.rooms]
() => p.rooms.filter((r) => r.is_space && p.hierarchy.has(r.id)),
[p.rooms, p.hierarchy]
);
// Do not display space bar if your is not member of any space