175 Commits

Author SHA1 Message Date
149f3734b7 Update Rust crate lazy-regex to 3.5.1
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 00:32:59 +00:00
8f4480e555 Merge pull request 'Update dependency @types/node to ^24.10.8' (#154) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 00:32:38 +00:00
06e1f60314 Update dependency @types/node to ^24.10.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-14 00:31:34 +00:00
a7432a4014 Merge pull request 'Update dependency @mui/x-data-grid to ^8.24.0' (#153) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-14 00:31:25 +00:00
be5e7eb328 Update dependency @mui/x-data-grid to ^8.24.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-13 00:26:02 +00:00
c1e703c4b4 Merge pull request 'Update dependency @types/react to ^19.2.8' (#152) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-13 00:25:55 +00:00
071aad8147 Update dependency @types/react to ^19.2.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-12 00:26:27 +00:00
f4b3c0aa16 Merge pull request 'Update dependency @types/node to ^24.10.7' (#151) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 00:26:19 +00:00
daca7410d7 Update dependency @types/node to ^24.10.7
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-11 00:26:32 +00:00
23074ac354 Merge pull request 'Update dependency vite to v7.3.1' (#150) from renovate/vite-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-11 00:26:26 +00:00
36bd8d0672 Update dependency vite to v7.3.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-10 00:26:04 +00:00
6162555702 Merge pull request 'Update materialui to ^7.3.7' (#149) from renovate/materialui into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:25:57 +00:00
951d0db0b7 Update materialui to ^7.3.7
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-09 00:22:36 +00:00
ef90aba489 Merge pull request 'Update dependency react-router to ^7.12.0' (#148) from renovate/react-router-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-09 00:22:31 +00:00
6b39fd11bd Update dependency react-router to ^7.12.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-08 00:22:30 +00:00
bf6561fa87 Merge pull request 'Update Rust crate serde_json to 1.0.149' (#147) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-08 00:22:26 +00:00
e5feecc703 Update Rust crate serde_json to 1.0.149
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-07 00:22:38 +00:00
153ad14a51 Merge pull request 'Update Rust crate url to 2.5.8' (#146) from renovate/url-2.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-07 00:22:23 +00:00
e5494e51a3 Update Rust crate url to 2.5.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-06 00:22:56 +00:00
f04ab4591b Merge pull request 'Update Rust crate clap to 4.5.54' (#145) from renovate/clap-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-06 00:22:43 +00:00
ca2cdb2f79 Update Rust crate clap to 4.5.54
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-05 00:23:14 +00:00
795a12c8d0 Merge pull request 'Update dependency vite to v7.3.0' (#144) from renovate/vite-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-05 00:23:04 +00:00
79e78006fa Update dependency vite to v7.3.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-31 00:30:47 +00:00
18c0fbef3c Merge pull request 'Update dependency typescript-eslint to ^8.51.0' (#143) from renovate/typescript-eslint into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-31 00:30:41 +00:00
0ac6fc4ac3 Update dependency typescript-eslint to ^8.51.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-30 00:32:39 +00:00
2aaced17d8 Merge pull request 'Update dependency react-router to ^7.11.0' (#142) from renovate/react-router-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-30 00:32:13 +00:00
9da2a9e9b3 Update dependency react-router to ^7.11.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-29 00:32:02 +00:00
9595ff2e71 Merge pull request 'Update Rust crate serde_json to 1.0.148' (#141) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-29 00:31:44 +00:00
5bd62d7683 Update Rust crate serde_json to 1.0.148
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-28 00:30:14 +00:00
7e747b50f3 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.23.0' (#140) from renovate/mui-x-date-pickers-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-28 00:29:59 +00:00
a9a5d60edd Update dependency @mui/x-date-pickers to ^8.23.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-27 00:31:20 +00:00
89dbc252e8 Merge pull request 'Update dependency @mui/x-data-grid to ^8.23.0' (#139) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-27 00:31:12 +00:00
3a6b2c6cf2 Update dependency @mui/x-data-grid to ^8.23.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-25 00:33:13 +00:00
98c813b220 Merge pull request 'Update Rust crate serde_json to 1.0.147' (#138) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-25 00:33:00 +00:00
ceb7859169 Update Rust crate serde_json to 1.0.147
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-24 00:30:52 +00:00
96e597ca59 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.22.1' (#137) from renovate/mui-x-date-pickers-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-24 00:30:43 +00:00
d9630fbc4c Update dependency @mui/x-date-pickers to ^8.22.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-23 00:30:42 +00:00
4d7db2de2a Merge pull request 'Update dependency @mui/x-data-grid to ^8.22.1' (#136) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-23 00:30:22 +00:00
4c0be88570 Update dependency @mui/x-data-grid to ^8.22.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-22 00:32:09 +00:00
2f933a247f Merge pull request 'Update Rust crate ractor to 0.15.10' (#135) from renovate/ractor-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-22 00:31:59 +00:00
f6a7132d43 Update Rust crate ractor to 0.15.10
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-21 00:30:59 +00:00
1089b5a6a6 Merge pull request 'Update react to ^19.2.3' (#134) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-21 00:30:45 +00:00
28a1b5f4f0 Update react to ^19.2.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-20 00:29:35 +00:00
acf91c3f0e Merge pull request 'Update dependency eslint-plugin-react-refresh to ^0.4.26' (#133) from renovate/eslint-plugin-react-refresh-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-20 00:29:29 +00:00
fadb9e6d46 Update dependency eslint-plugin-react-refresh to ^0.4.26
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-19 00:29:43 +00:00
17bad4fcfd Merge pull request 'Update dependency eslint to ^9.39.2' (#132) from renovate/eslint-9.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-19 00:29:35 +00:00
b3dfc35103 Update dependency eslint to ^9.39.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-18 00:32:10 +00:00
602f663217 Merge pull request 'Update dependency date-and-time to ^4.1.2' (#131) from renovate/date-and-time-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-18 00:31:58 +00:00
5ebfbf6aec Update dependency date-and-time to ^4.1.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-17 00:30:33 +00:00
aad0a74ad5 Merge pull request 'Update dependency @types/node to ^24.10.4' (#130) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-17 00:30:28 +00:00
382e24e17b Update dependency @types/node to ^24.10.4
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-16 00:30:32 +00:00
1876c7b43d Merge pull request 'Update dependency @eslint/js to ^9.39.2' (#129) from renovate/eslint-js-9.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 00:30:25 +00:00
73af601a16 Update dependency @eslint/js to ^9.39.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-13 00:29:49 +00:00
6247463c70 Merge pull request 'Update dependency @types/node to ^24.10.3' (#127) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 00:29:56 +00:00
9425ed9a12 Update dependency @types/node to ^24.10.3
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is passing
2025-12-11 00:30:39 +00:00
430ad85c37 Merge pull request 'Update dependency @vitejs/plugin-react to ^5.1.2' (#126) from renovate/vitejs-plugin-react-5.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-11 00:30:33 +00:00
29e50bd70c Update dependency @vitejs/plugin-react to ^5.1.2
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-12-10 00:32:04 +00:00
0e83e804d8 Merge pull request 'Update dependency @types/node to ^24.10.2' (#125) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 00:31:56 +00:00
bd674bfb67 Update dependency @types/node to ^24.10.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-09 00:30:40 +00:00
fec81ac92e Remove encryption logic as it is handled by matrix-sdk e2e-encryption feature directly
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-08 19:41:40 +01:00
d3e25eed9e Merge pull request 'Update Rust crate matrix-sdk to 0.16.0' (#124) from renovate/matrix-sdk-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-08 00:31:02 +00:00
ba5f5f2557 Update Rust crate matrix-sdk to 0.16.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-12-08 00:30:55 +00:00
7e548ad5d1 Merge pull request 'Update react to ^19.2.1' (#123) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-08 00:30:43 +00:00
7b63bb0d05 Update react to ^19.2.1
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-12-07 00:31:00 +00:00
788018451a Merge pull request 'Update dependency react-router to ^7.10.1' (#122) from renovate/react-router-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-07 00:30:53 +00:00
7b3a2d6a3f Update dependency react-router to ^7.10.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-06 00:30:35 +00:00
0d462f848d Merge pull request 'Update dependency eslint-plugin-react-refresh to ^0.4.24' (#121) from renovate/eslint-plugin-react-refresh-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-06 00:30:30 +00:00
9c6c338919 Update dependency eslint-plugin-react-refresh to ^0.4.24
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-05 00:15:23 +00:00
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
1f22d5c41b Refactor rooms management 2025-11-28 18:41:43 +01:00
a656c077bc Follow unread messages 2025-11-28 18:06:40 +01:00
d10c4d1a1c Minor appearance improvement 2025-11-28 17:39:27 +01:00
62966473f0 Add support for more file formats 2025-11-28 17:37:30 +01:00
c360432911 Add support for unencrypted media 2025-11-28 17:22:58 +01:00
123e069d18 Add multi line messages supports 2025-11-28 17:15:33 +01:00
4b30d67706 Basic WS sync 2025-11-28 17:00:42 +01:00
799341f77c Display the list of people who reacted 2025-11-28 14:42:02 +01:00
6c11979ef2 Display reactions below messages 2025-11-28 14:37:17 +01:00
9f0bc3303c Add quick reactions 2025-11-28 12:05:29 +01:00
f5b16b6ce4 Can send reaction from picker 2025-11-28 11:41:19 +01:00
756780513b Fix dialog name 2025-11-28 10:41:30 +01:00
93487a5325 Can edit message content 2025-11-28 10:41:01 +01:00
3d27279a16 Can delete events from WebUI 2025-11-28 10:22:44 +01:00
94ce9c3c95 Add buttons bar 2025-11-28 09:55:21 +01:00
9f83a6fb66 Can send text messages in conversations 2025-11-27 19:13:16 +01:00
bda47a2770 Improve messages appearance 2025-11-27 18:55:30 +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
b7378aa4dc Can retrieve room media 2025-11-25 14:54:02 +01:00
2adbf146d0 Start to display messages list 2025-11-25 12:17:48 +01:00
5eab7c3e4f Process events list client side 2025-11-25 09:48:49 +01:00
a7bfd713c3 Ready to implement room widget 2025-11-24 17:59:12 +01:00
4be661d999 Fix appearance of unread conversations 2025-11-24 17:55:26 +01:00
1f4e374e66 Display rooms list 2025-11-24 17:50:31 +01:00
cce9b3de5d Hide menu by default on desktop 2025-11-24 16:36:36 +01:00
820b095be0 Display the list of spaces 2025-11-24 16:05:01 +01:00
0a37688116 Can react to event 2025-11-24 13:40:14 +01:00
4d72644a31 Can edit message 2025-11-24 13:18:23 +01:00
0a395b0d26 Can redact message 2025-11-24 13:06:31 +01:00
639cc6c737 Can send text message 2025-11-24 12:54:59 +01:00
bf119a34fb Can get room messages 2025-11-24 12:36:59 +01:00
7562a7fc61 Get latest message for a room 2025-11-24 11:20:20 +01:00
d23190f9d2 Can get spaces of user 2025-11-21 18:38:20 +01:00
35b53fee5c Can request any media file 2025-11-21 17:55:09 +01:00
934e6a4cc1 Can get multiple profiles information 2025-11-21 17:49:41 +01:00
b744265242 Can get single profile information 2025-11-21 17:14:23 +01:00
e8ce97eea0 Can get room avatar 2025-11-21 15:43:15 +01:00
ecbe4885c1 Can get information about rooms 2025-11-21 14:52:21 +01:00
1385afc974 Add more information to websocket messages 2025-11-21 11:40:51 +01:00
8d2cea5f82 Refactor messages propagation 2025-11-21 10:30:48 +01:00
751e3b8654 Redact more events 2025-11-21 09:35:21 +01:00
24f06a78a9 Block WS access if Matrix account is not linked 2025-11-21 09:12:13 +01: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
6b70842b61 Display state in color 2025-11-20 19:31:17 +01: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
79 changed files with 4641 additions and 1797 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
.idea
target
static

File diff suppressed because it is too large Load Diff

View File

@@ -5,32 +5,35 @@ 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.54", 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"
urlencoding = "2.1.3"
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 = "0.14.0"
url = "2.5.7"
ractor = "0.15.9"
serde_json = "1.0.145"
lazy-regex = "3.4.2"
actix-ws = "0.3.0"
matrix-sdk = { version = "0.16.0", features = ["e2e-encryption"] }
matrix-sdk-ui = "0.16.0"
url = "2.5.8"
ractor = "0.15.10"
serde_json = "1.0.149"
lazy-regex = "3.5.1"
actix-ws = "0.3.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

@@ -1,11 +1,22 @@
use crate::matrix_connection::sync_thread::MatrixSyncTaskID;
use crate::users::{APIToken, UserEmail};
use matrix_sdk::Room;
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::redaction::OriginalSyncRoomRedactionEvent;
use matrix_sdk::ruma::events::typing::SyncTypingEvent;
use matrix_sdk::sync::SyncResponse;
pub type BroadcastSender = tokio::sync::broadcast::Sender<BroadcastMessage>;
#[derive(Debug, Clone)]
pub struct BxRoomEvent<E> {
pub user: UserEmail,
pub data: Box<E>,
pub room: Room,
}
/// Broadcast messages
#[derive(Debug, Clone)]
pub enum BroadcastMessage {
@@ -18,11 +29,15 @@ pub enum BroadcastMessage {
/// Matrix sync thread has been interrupted
SyncThreadStopped(MatrixSyncTaskID),
/// New room message
RoomMessageEvent {
user: UserEmail,
event: Box<OriginalSyncRoomMessageEvent>,
room: Room,
},
RoomMessageEvent(BxRoomEvent<OriginalSyncRoomMessageEvent>),
/// New reaction message
ReactionEvent(BxRoomEvent<OriginalSyncReactionEvent>),
/// New room redaction
RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>),
/// Message fully read event
ReceiptEvent(BxRoomEvent<SyncReceiptEvent>),
/// User is typing message event
TypingEvent(BxRoomEvent<SyncTypingEvent>),
/// Raw Matrix sync response
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
}

View File

@@ -0,0 +1,302 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_media_controller;
use crate::controllers::matrix::matrix_media_controller::MediaQuery;
use crate::controllers::matrix::matrix_room_controller::RoomIdInPath;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::Room;
use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind};
use matrix_sdk::media::MediaEventContent;
use matrix_sdk::room::MessagesOptions;
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::receipt::ReceiptThread;
use matrix_sdk::ruma::events::relation::Annotation;
use matrix_sdk::ruma::events::room::message::{
MessageType, RoomMessageEvent, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
};
use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent};
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
#[derive(Serialize)]
pub struct APIEvent {
pub id: OwnedEventId,
time: MilliSecondsSinceUnixEpoch,
sender: OwnedUserId,
data: Box<RawValue>,
}
impl APIEvent {
pub async fn from_evt(msg: TimelineEvent, room_id: &RoomId) -> anyhow::Result<Self> {
let (event, raw) = match &msg.kind {
TimelineEventKind::Decrypted(d) => (d.event.deserialize()?, d.event.json()),
TimelineEventKind::UnableToDecrypt { event, .. }
| TimelineEventKind::PlainText { event } => (
event.deserialize()?.into_full_event(room_id.to_owned()),
event.json(),
),
};
Ok(Self {
id: event.event_id().to_owned(),
time: event.origin_server_ts(),
sender: event.sender().to_owned(),
data: raw.to_owned(),
})
}
}
#[derive(Serialize)]
pub struct APIEventsList {
pub start: String,
pub end: Option<String>,
pub events: Vec<APIEvent>,
}
/// Get messages for a given room
pub(super) async fn get_events(
room: &Room,
limit: u32,
from: Option<&str>,
filter: Option<RoomEventFilter>,
) -> anyhow::Result<APIEventsList> {
let mut msg_opts = MessagesOptions::backward();
msg_opts.from = from.map(str::to_string);
msg_opts.limit = UInt::from(limit);
if let Some(filter) = filter {
msg_opts.filter = filter;
}
let messages = room.messages(msg_opts).await?;
Ok(APIEventsList {
start: messages.start,
end: messages.end,
events: stream::iter(messages.chunk)
.then(async |msg| APIEvent::from_evt(msg, room.room_id()).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?,
})
}
#[derive(Deserialize)]
pub struct GetRoomEventsQuery {
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
from: Option<String>,
}
/// Get the events for a room
pub async fn get_for_room(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
query: web::Query<GetRoomEventsQuery>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(HttpResponse::Ok().json(
get_events(
&room,
query.limit.unwrap_or(500),
query.from.as_deref(),
None,
)
.await?,
))
}
#[derive(Deserialize)]
struct SendTextMessageRequest {
content: String,
}
pub async fn send_text_message(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
room.send(RoomMessageEventContent::text_plain(req.content))
.await?;
Ok(HttpResponse::Accepted().finish())
}
#[derive(serde::Deserialize)]
pub struct EventIdInPath {
pub(crate) event_id: OwnedEventId,
}
pub async fn set_text_content(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let edit_event = match room
.make_edit_event(
&event_path.event_id,
EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain(
req.content,
)),
)
.await
{
Ok(msg) => msg,
Err(e) => {
log::error!(
"Failed to created edit message event {}: {e}",
event_path.event_id
);
return Ok(HttpResponse::InternalServerError()
.json(format!("Failed to create edit message event! {e}")));
}
};
Ok(match room.send(edit_event).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to edit event message {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to edit event! {e}"))
}
})
}
pub async fn event_file(
req: HttpRequest,
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let event = match room.load_or_fetch_event(&event_path.event_id, None).await {
Ok(event) => event,
Err(e) => {
log::error!("Failed to load event information! {e}");
return Ok(HttpResponse::InternalServerError()
.json(format!("Failed to load event information! {e}")));
}
};
let event = match event.kind {
TimelineEventKind::Decrypted(dec) => dec.event.deserialize()?,
TimelineEventKind::UnableToDecrypt { event, .. }
| TimelineEventKind::PlainText { event } => event
.deserialize()?
.into_full_event(room.room_id().to_owned()),
};
let AnyTimelineEvent::MessageLike(message) = event else {
return Ok(HttpResponse::BadRequest().json("Event is not message like!"));
};
let AnyMessageLikeEvent::RoomMessage(message) = message else {
return Ok(HttpResponse::BadRequest().json("Event is not a room message!"));
};
let RoomMessageEvent::Original(message) = message else {
return Ok(HttpResponse::BadRequest().json("Event has been redacted!"));
};
let (source, thumb_source) = match message.content.msgtype {
MessageType::Audio(c) => (c.source(), c.thumbnail_source()),
MessageType::File(c) => (c.source(), c.thumbnail_source()),
MessageType::Image(c) => (c.source(), c.thumbnail_source()),
MessageType::Location(c) => (c.source(), c.thumbnail_source()),
MessageType::Video(c) => (c.source(), c.thumbnail_source()),
_ => (None, None),
};
let source = match (query.thumbnail, source, thumb_source) {
(false, Some(s), _) => s,
(true, _, Some(s)) => s,
_ => return Ok(HttpResponse::NotFound().json("Requested file not available!")),
};
matrix_media_controller::serve_media(req, source, false).await
}
#[derive(Deserialize)]
struct EventReactionBody {
key: String,
}
pub async fn react_to_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let body = client.auth.decode_json_body::<EventReactionBody>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let annotation = Annotation::new(event_path.event_id.to_owned(), body.key.to_owned());
room.send(ReactionEventContent::from(annotation)).await?;
Ok(HttpResponse::Accepted().finish())
}
pub async fn redact_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(match room.redact(&event_path.event_id, None, None).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to redact event {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to redact event! {e}"))
}
})
}
/// 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

@@ -0,0 +1,71 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::utils::crypt_utils::sha512;
use actix_web::dev::Payload;
use actix_web::http::header;
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
use matrix_sdk::ruma::events::room::MediaSource;
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
#[derive(serde::Deserialize)]
pub struct MediaMXCInPath {
mxc: OwnedMxcUri,
}
/// Serve media resource handler
pub async fn serve_mxc_handler(req: HttpRequest, media: web::Path<MediaMXCInPath>) -> HttpResult {
serve_mxc_file(req, media.into_inner().mxc).await
}
#[derive(serde::Deserialize)]
pub struct MediaQuery {
#[serde(default)]
pub thumbnail: bool,
}
pub async fn serve_mxc_file(req: HttpRequest, media: OwnedMxcUri) -> HttpResult {
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
serve_media(req, MediaSource::Plain(media), query.thumbnail).await
}
/// Serve a media file
pub async fn serve_media(req: HttpRequest, source: MediaSource, thumbnail: bool) -> HttpResult {
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
let media = client
.client
.client
.media()
.get_media_content(
&MediaRequestParameters {
source: source.clone(),
format: match thumbnail {
true => MediaFormat::Thumbnail(MediaThumbnailSettings::new(
UInt::new(100).unwrap(),
UInt::new(100).unwrap(),
)),
false => MediaFormat::File,
},
},
true,
)
.await?;
let digest = sha512(&media);
let mime_type = infer::get(&media).map(|x| x.mime_type());
// Check if the browser already knows the etag
if let Some(c) = req.headers().get(header::IF_NONE_MATCH)
&& c.to_str().unwrap_or("") == digest
{
return Ok(HttpResponse::NotModified().finish());
}
Ok(HttpResponse::Ok()
.content_type(mime_type.unwrap_or("application/octet-stream"))
.insert_header(("etag", digest))
.insert_header(("cache-control", "max-age=360000"))
.body(media))
}

View File

@@ -0,0 +1,67 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::ruma::api::client::profile::{AvatarUrl, DisplayName, get_profile};
use matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
#[derive(serde::Deserialize)]
pub struct UserIDInPath {
user_id: OwnedUserId,
}
#[derive(serde::Serialize)]
struct ProfileResponse {
user_id: OwnedUserId,
display_name: Option<String>,
avatar: Option<OwnedMxcUri>,
}
impl ProfileResponse {
pub fn from(user_id: OwnedUserId, r: get_profile::v3::Response) -> anyhow::Result<Self> {
Ok(Self {
user_id,
display_name: r.get_static::<DisplayName>()?,
avatar: r.get_static::<AvatarUrl>()?,
})
}
}
/// Get user profile
pub async fn get_profile(
client: MatrixClientExtractor,
path: web::Path<UserIDInPath>,
) -> HttpResult {
let profile = client
.client
.client
.account()
.fetch_user_profile_of(&path.user_id)
.await?;
Ok(HttpResponse::Ok().json(ProfileResponse::from(path.user_id.clone(), profile)?))
}
/// Get multiple users profiles
pub async fn get_multiple(client: MatrixClientExtractor) -> HttpResult {
let users = client.auth.decode_json_body::<Vec<OwnedUserId>>()?;
let list = stream::iter(users)
.then(async |user_id| {
client
.client
.client
.account()
.fetch_user_profile_of(&user_id)
.await
.map(|r| ProfileResponse::from(user_id, r))
})
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}

View File

@@ -0,0 +1,180 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_event_controller::{APIEvent, get_events};
use crate::controllers::matrix::matrix_media_controller;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpRequest, HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::notification_settings::{
IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode,
};
use matrix_sdk::room::ParentSpace;
use matrix_sdk::ruma::events::receipt::{ReceiptThread, ReceiptType};
use matrix_sdk::ruma::{
MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId,
};
use matrix_sdk::{Room, RoomMemberships};
#[derive(serde::Serialize)]
pub struct APIRoomInfo {
id: OwnedRoomId,
name: Option<String>,
members: Vec<OwnedUserId>,
avatar: Option<OwnedMxcUri>,
is_space: bool,
parents: Vec<OwnedRoomId>,
number_unread_messages: u64,
notifications: RoomNotificationMode,
latest_event: Option<APIEvent>,
}
impl APIRoomInfo {
async fn from_room(r: &Room, notif: &NotificationSettings) -> anyhow::Result<Self> {
// Get parent spaces
let parent_spaces = r
.parent_spaces()
.await?
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|d| match d {
ParentSpace::Reciprocal(r) | ParentSpace::WithPowerlevel(r) => {
Some(r.room_id().to_owned())
}
_ => None,
})
.collect::<Vec<_>>();
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 {
id: r.room_id().to_owned(),
name: r.name(),
members,
avatar: r.avatar_url(),
is_space: r.is_space(),
parents: parent_spaces,
number_unread_messages: r.unread_notification_counts().notification_count,
notifications,
latest_event: get_events(r, 1, None, None)
.await?
.events
.into_iter()
.next(),
})
}
}
/// Get the list of joined rooms of the user
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())
.then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
/// Get joined spaces rooms of user
pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
let list = stream::iter(client.client.client.joined_space_rooms())
.then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct RoomIdInPath {
pub(crate) room_id: OwnedRoomId,
}
/// Get the list of joined rooms of the user
pub async fn single_room_info(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
Ok(match client.client.client.get_room(&path.room_id) {
None => HttpResponse::NotFound().json("Room not found"),
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r, &notifs).await?),
})
}
/// Get room avatar
pub async fn room_avatar(
req: HttpRequest,
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found"));
};
let Some(uri) = room.avatar_url() else {
return Ok(HttpResponse::NotFound().json("Room has no avatar"));
};
matrix_media_controller::serve_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,34 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::HttpResponse;
use matrix_sdk_ui::spaces::SpaceService;
use matrix_sdk_ui::spaces::room_list::SpaceRoomListPaginationState;
use std::collections::HashMap;
/// Get space hierarchy
pub async fn hierarchy(client: MatrixClientExtractor) -> HttpResult {
let spaces = client.client.client.joined_space_rooms();
let space_service = SpaceService::new(client.client.client);
let mut hierarchy = HashMap::new();
for space in spaces {
let rooms = space_service
.space_room_list(space.room_id().to_owned())
.await;
while !matches!(
rooms.pagination_state(),
SpaceRoomListPaginationState::Idle { end_reached: true }
) {
rooms.paginate().await?;
}
hierarchy.insert(
space.room_id().to_owned(),
rooms
.rooms()
.into_iter()
.map(|room| room.room_id)
.collect::<Vec<_>>(),
);
}
Ok(HttpResponse::Ok().json(hierarchy))
}

View File

@@ -0,0 +1,5 @@
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

@@ -3,9 +3,11 @@ use actix_web::{HttpResponse, ResponseError};
use std::error::Error;
pub mod auth_controller;
pub mod matrix;
pub mod matrix_link_controller;
pub mod matrix_sync_thread_controller;
pub mod server_controller;
pub mod static_controller;
pub mod tokens_controller;
pub mod ws_controller;
@@ -21,6 +23,14 @@ pub enum HttpFailure {
InternalError(#[from] anyhow::Error),
#[error("Actix web error: {0}")]
ActixError(#[from] actix_web::Error),
#[error("Matrix error: {0}")]
MatrixError(#[from] matrix_sdk::Error),
#[error("Matrix decryptor error: {0}")]
MatrixDecryptorError(#[from] matrix_sdk::encryption::DecryptorError),
#[error("Serde JSON error: {0}")]
SerdeJSON(#[from] serde_json::Error),
#[error("Standard library error: {0}")]
StdLibError(#[from] std::io::Error),
}
impl ResponseError for HttpFailure {

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

@@ -5,27 +5,132 @@ use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::UserEmail;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, web};
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use actix_ws::Message;
use futures_util::StreamExt;
use matrix_sdk::ruma::OwnedRoomId;
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
use matrix_sdk::ruma::events::room::redaction::RoomRedactionEventContent;
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId};
use ractor::ActorRef;
use std::time::Instant;
use tokio::sync::broadcast;
use tokio::sync::broadcast::Receiver;
use tokio::time::interval;
#[derive(Debug, serde::Serialize)]
pub struct WsRoomEvent<E> {
pub room_id: OwnedRoomId,
pub event_id: OwnedEventId,
pub sender: OwnedUserId,
pub origin_server_ts: MilliSecondsSinceUnixEpoch,
pub data: Box<E>,
}
#[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
#[derive(Debug, serde::Serialize)]
#[serde(tag = "type")]
pub enum WsMessage {
/// Room message event
RoomMessageEvent {
event: RoomMessageEventContent,
room_id: OwnedRoomId,
},
RoomMessageEvent(WsRoomEvent<RoomMessageEventContent>),
/// Room reaction event
RoomReactionEvent(WsRoomEvent<ReactionEventContent>),
/// Room reaction event
RoomRedactionEvent(WsRoomEvent<RoomRedactionEventContent>),
/// Fully read message event
ReceiptEvent(WsReceiptEvent),
/// User is typing event
TypingEvent(WsTypingEvent),
}
impl WsMessage {
pub fn from_bx_message(msg: &BroadcastMessage, user: &UserEmail) -> Option<Self> {
match msg {
BroadcastMessage::RoomMessageEvent(evt) if &evt.user == user => {
Some(Self::RoomMessageEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
BroadcastMessage::ReactionEvent(evt) if &evt.user == user => {
Some(Self::RoomReactionEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
BroadcastMessage::RoomRedactionEvent(evt) if &evt.user == user => {
Some(Self::RoomRedactionEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
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,
}
}
}
/// Main WS route
@@ -38,6 +143,11 @@ pub async fn ws(
// Forcefully ignore request payload by manually extracting authentication information
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
// Check if Matrix link has been established first
if !client.client.is_client_connected() {
return Ok(HttpResponse::ExpectationFailed().json("Matrix link not established yet!"));
}
// Ensure sync thread is started
ractor::cast!(
manager,
@@ -89,8 +199,8 @@ pub async fn ws_handler(
Err(broadcast::error::RecvError::Lagged(_)) => continue,
};
match msg {
BroadcastMessage::APITokenDeleted(t) => {
match (&msg, WsMessage::from_bx_message(&msg, &auth.user.email)) {
(BroadcastMessage::APITokenDeleted(t), _) => {
match &auth.method{
AuthenticatedMethod::Token(tok) if tok.id == t.id => {
log::info!(
@@ -104,22 +214,21 @@ pub async fn ws_handler(
}
},
BroadcastMessage::UserDisconnectedFromMatrix(mail) if mail == auth.user.email => {
(BroadcastMessage::UserDisconnectedFromMatrix(mail), _) if mail == &auth.user.email => {
log::info!(
"closing WS session of user {mail:?} as user was disconnected from Matrix"
);
break None;
}
BroadcastMessage::RoomMessageEvent{user, event, room} if user == auth.user.email => {
(_, Some(message)) => {
// Send the message to the websocket
if let Ok(msg) = serde_json::to_string(&WsMessage::RoomMessageEvent {
event:event.content,
room_id: room.room_id().to_owned(),
}) && let Err(e) = session.text(msg).await {
if let Ok(msg) = serde_json::to_string(&message)
&& let Err(e) = session.text(msg).await {
log::error!("Failed to send SyncEvent: {e}");
}
}
_ => {}
};

View File

@@ -15,6 +15,7 @@ impl MatrixClientExtractor {
pub async fn to_extended_user_info(&self) -> anyhow::Result<ExtendedUserInfo> {
Ok(ExtendedUserInfo {
user: self.auth.user.clone(),
matrix_account_connected: self.client.is_client_connected(),
matrix_user_id: self.client.user_id().map(|id| id.to_string()),
matrix_device_id: self.client.device_id().map(|id| id.to_string()),
matrix_recovery_state: self.client.recovery_state(),

View File

@@ -9,9 +9,13 @@ use actix_web::{App, HttpServer, web};
use matrixgw_backend::app_config::AppConfig;
use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants;
use matrixgw_backend::controllers::matrix::{
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
matrix_room_controller, matrix_space_controller,
};
use matrixgw_backend::controllers::{
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::users::User;
@@ -134,6 +138,81 @@ 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",
web::get().to(matrix_room_controller::joined_rooms),
)
.route(
"/api/matrix/room/joined_spaces",
web::get().to(matrix_room_controller::get_joined_spaces),
)
.route(
"/api/matrix/room/{room_id}",
web::get().to(matrix_room_controller::single_room_info),
)
.route(
"/api/matrix/room/{room_id}/avatar",
web::get().to(matrix_room_controller::room_avatar),
)
.route(
"/api/matrix/room/{room_id}/receipts",
web::get().to(matrix_room_controller::receipts),
)
// Matrix profile controller
.route(
"/api/matrix/profile/{user_id}",
web::get().to(matrix_profile_controller::get_profile),
)
.route(
"/api/matrix/profile/get_multiple",
web::post().to(matrix_profile_controller::get_multiple),
)
// Matrix events controller
.route(
"/api/matrix/room/{room_id}/events",
web::get().to(matrix_event_controller::get_for_room),
)
.route(
"/api/matrix/room/{room_id}/send_text_message",
web::post().to(matrix_event_controller::send_text_message),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/set_text_content",
web::post().to(matrix_event_controller::set_text_content),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/file",
web::get().to(matrix_event_controller::event_file),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/react",
web::post().to(matrix_event_controller::react_to_event),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}",
web::delete().to(matrix_event_controller::redact_event),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/receipt",
web::post().to(matrix_event_controller::receipt),
)
// Matrix media controller
.route(
"/api/matrix/media/{mxc}",
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)
.bind(&AppConfig::get().listen_address)?

View File

@@ -91,7 +91,7 @@ pub struct FinishMatrixAuth {
pub struct MatrixClient {
manager: ActorRef<MatrixManagerMsg>,
pub email: UserEmail,
client: Client,
pub client: Client,
}
impl MatrixClient {
@@ -167,6 +167,9 @@ impl MatrixClient {
.encryption()
.wait_for_e2ee_initialization_tasks()
.await;
// Save stored session once
client.save_stored_session().await?;
}
// Automatically save session when token gets refreshed

View File

@@ -2,12 +2,16 @@
//!
//! This file contains the logic performed by the threads that synchronize with Matrix account.
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender, BxRoomEvent};
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use futures_util::StreamExt;
use matrix_sdk::Room;
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::redaction::OriginalSyncRoomRedactionEvent;
use matrix_sdk::ruma::events::typing::SyncTypingEvent;
use ractor::ActorRef;
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -48,18 +52,78 @@ async fn sync_thread_task(
let mut sync_stream = client.sync_stream().await;
let mut handlers = vec![];
let tx_msg_handle = tx.clone();
let user = client.email.clone();
let room_message_handle = client.add_event_handler(
let user_msg_mail = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomMessageEvent, room: Room| {
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent {
user: user.clone(),
event: Box::new(event),
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent(BxRoomEvent {
user: user_msg_mail.clone(),
data: Box::new(event),
room,
}) {
log::warn!("Failed to forward room event! {e}");
})) {
log::warn!("Failed to forward room message event! {e}");
}
},
));
let tx_reac_handle = tx.clone();
let user_reac_mail = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncReactionEvent, room: Room| {
if let Err(e) = tx_reac_handle.send(BroadcastMessage::ReactionEvent(BxRoomEvent {
user: user_reac_mail.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward reaction event! {e}");
}
},
));
let tx_redac_handle = tx.clone();
let user_redac_mail = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomRedactionEvent, room: Room| {
if let Err(e) =
tx_redac_handle.send(BroadcastMessage::RoomRedactionEvent(BxRoomEvent {
user: user_redac_mail.clone(),
data: Box::new(event),
room,
}))
{
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 {
@@ -103,7 +167,9 @@ async fn sync_thread_task(
}
}
client.remove_event_handler(room_message_handle);
for h in handlers {
client.remove_event_handler(h);
}
// Notify manager about termination, so this thread can be removed from the list
log::info!("Sync thread {id:?} terminated!");

View File

@@ -281,6 +281,7 @@ impl APIToken {
pub struct ExtendedUserInfo {
#[serde(flatten)]
pub user: User,
pub matrix_account_connected: bool,
pub matrix_user_id: Option<String>,
pub matrix_device_id: Option<String>,
pub matrix_recovery_state: EncryptionRecoveryState,

View File

@@ -1,6 +1,11 @@
use sha2::{Digest, Sha256};
use sha2::{Digest, Sha256, Sha512};
/// Compute SHA256sum of a given string
pub fn sha256str(input: &str) -> String {
hex::encode(Sha256::digest(input.as_bytes()))
}
/// Compute SHA256sum of a given byte array
pub fn sha512(input: &[u8]) -> String {
hex::encode(Sha512::digest(input))
}

View File

@@ -1,23 +1,26 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
"react-refresh/only-export-components": "off",
},
},
])
]);

File diff suppressed because it is too large Load Diff

View File

@@ -12,37 +12,37 @@
"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.7",
"@mui/material": "^7.3.7",
"@mui/x-data-grid": "^8.24.0",
"@mui/x-date-pickers": "^8.23.0",
"date-and-time": "^4.1.2",
"dayjs": "^1.11.19",
"emoji-picker-react": "^4.16.1",
"is-cidr": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-favicon": "^2.0.7",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.9.5"
"react-router": "^7.12.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-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"@eslint/js": "^9.39.2",
"@types/node": "^24.10.8",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "npm:rolldown-vite@7.1.14"
"typescript-eslint": "^8.51.0",
"vite": "npm:rolldown-vite@7.3.1"
},
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
"vite": "npm:rolldown-vite@7.3.1"
}
}

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;
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean;
jsonData?: any;
jsonData?: unknown;
formData?: FormData;
upProgress?: (progress: number) => void;
downProgress?: (e: { progress: number; total: number }) => void;
}
interface APIResponse {
data: any;
data: unknown;
status: number;
}
export class ApiError extends Error {
public code: number;
public data: number;
constructor(message: string, code: number, data: any) {
public data: unknown;
constructor(message: string, code: number, data: unknown) {
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
this.code = code;
this.data = data;
@@ -57,6 +57,7 @@ export class APIClient {
*/
static async exec(args: RequestParams): Promise<APIResponse> {
let body: string | undefined | FormData = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const headers: any = {};
// JSON request

View File

@@ -6,6 +6,7 @@ export interface UserInfo {
time_update: number;
name: string;
email: string;
matrix_account_connected: boolean;
matrix_user_id?: string;
matrix_device_id?: string;
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
@@ -44,7 +45,7 @@ export class AuthApi {
uri: "/auth/start_oidc",
method: "GET",
})
).data;
).data as { url: string };
}
/**
@@ -69,7 +70,7 @@ export class AuthApi {
uri: "/auth/info",
method: "GET",
})
).data;
).data as UserInfo;
}
/**

View File

@@ -10,7 +10,7 @@ export class MatrixLinkApi {
uri: "/matrix_link/start_auth",
method: "POST",
})
).data;
).data as { url: string };
}
/**

View File

@@ -29,6 +29,6 @@ export class MatrixSyncApi {
method: "GET",
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",
method: "GET",
})
).data;
).data as ServerConfig;
}
/**

View File

@@ -28,7 +28,7 @@ export class TokensApi {
uri: "/tokens",
method: "GET",
})
).data;
).data as Token[];
}
/**
@@ -41,18 +41,16 @@ export class TokensApi {
method: "POST",
jsonData: t,
})
).data;
).data as TokenWithSecret;
}
/**
* Delete a token
*/
static async Delete(t: Token): Promise<void> {
return (
await APIClient.exec({
uri: `/token/${t.id}`,
method: "DELETE",
})
).data;
await APIClient.exec({
uri: `/token/${t.id}`,
method: "DELETE",
});
}
}

View File

@@ -1,9 +1,79 @@
import { APIClient } from "./ApiClient";
import type { MessageType } from "./matrix/MatrixApiEvent";
export type WsMessage = {
type: string;
[k: string]: any;
};
interface BaseRoomEvent {
time: number;
room_id: string;
event_id: string;
sender: string;
origin_server_ts: number;
}
export interface RoomMessageEvent extends BaseRoomEvent {
type: "RoomMessageEvent";
data: {
msgtype: MessageType;
body: string;
"m.relates_to"?: {
rel_type?: "m.replace" | string;
event_id?: string;
"m.in_reply_to"?:{
event_id?:string
}
};
"m.new_content"?: {
msgtype?: MessageType;
body?: string;
};
url?: string;
file?: { url: string };
};
}
export interface RoomReactionEvent extends BaseRoomEvent {
type: "RoomReactionEvent";
data: {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
}
export interface RoomRedactionEvent extends BaseRoomEvent {
type: "RoomRedactionEvent";
data: {
redacts: string;
};
}
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 =
| RoomMessageEvent
| RoomReactionEvent
| RoomRedactionEvent
| RoomReceiptEvent
| RoomTypingEvent;
export class WsApi {
/**

View File

@@ -0,0 +1,156 @@
import { APIClient } from "../ApiClient";
import type { Room } from "./MatrixApiRoom";
export type MessageType =
| "m.text"
| "m.image"
| "m.audio"
| "m.file"
| "m.video"
| "_OTHER_";
export interface MatrixRoomMessage {
type: "m.room.message";
content: {
body: string;
msgtype: MessageType;
"m.relates_to"?: {
event_id?: string;
rel_type?: "m.replace" | string;
"m.in_reply_to"?: {
event_id?: string;
};
};
url?: string;
file?: {
url: string;
};
};
}
export interface MatrixReaction {
type: "m.reaction";
content: {
"m.relates_to": {
event_id: string;
key: string;
};
};
}
export interface MatrixRoomRedaction {
type: "m.room.redaction";
redacts: string;
}
export type MatrixEventData =
| MatrixRoomMessage
| MatrixReaction
| MatrixRoomRedaction
| { type: "other" };
export interface MatrixEvent {
id: string;
time: number;
sender: string;
data: MatrixEventData;
}
export interface MatrixEventsList {
start: string;
end?: string;
events: MatrixEvent[];
}
export class MatrixApiEvent {
/**
* Get Matrix room events
*/
static async GetRoomEvents(
room: Room,
from?: string
): Promise<MatrixEventsList> {
return (
await APIClient.exec({
method: "GET",
uri:
`/matrix/room/${encodeURIComponent(room.id)}/events?limit=200` +
(from ? `&from=${from}` : ""),
})
).data as MatrixEventsList;
}
/**
* Get Matrix event file URL
*/
static GetEventFileURL(
room: Room,
event_id: string,
thumbnail: boolean
): string {
return `${APIClient.ActualBackendURL()}/matrix/room/${
room.id
}/event/${event_id}/file?thumbnail=${thumbnail}`;
}
/**
* Send text message
*/
static async SendTextMessage(room: Room, content: string): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/send_text_message`,
jsonData: { content },
});
}
/**
* Edit text message content
*/
static async SetTextMessageContent(
room: Room,
event_id: string,
content: string
): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/event/${event_id}/set_text_content`,
jsonData: { content },
});
}
/**
* React to event
*/
static async ReactToEvent(
room: Room,
event_id: string,
key: string
): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/event/${event_id}/react`,
jsonData: { key },
});
}
/**
* Delete an event
*/
static async DeleteEvent(room: Room, event_id: string): Promise<void> {
await APIClient.exec({
method: "DELETE",
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

@@ -0,0 +1,12 @@
import { APIClient } from "../ApiClient";
export class MatrixApiMedia {
/**
* Get media URL
*/
static MediaURL(url: string, thumbnail: boolean): string {
return `${APIClient.ActualBackendURL()}/matrix/media/${encodeURIComponent(
url
)}?thumbnail=${thumbnail}`;
}
}

View File

@@ -0,0 +1,26 @@
import { APIClient } from "../ApiClient";
export interface UserProfile {
user_id: string;
display_name?: string;
avatar?: string;
}
export type UsersMap = Map<string, UserProfile>;
export class MatrixApiProfile {
/**
* Get multiple profiles information
*/
static async GetMultiple(ids: string[]): Promise<UsersMap> {
const list = (
await APIClient.exec({
method: "POST",
uri: "/matrix/profile/get_multiple",
jsonData: ids,
})
).data as UserProfile[];
return new Map(list.map((e) => [e.user_id, e]));
}
}

View File

@@ -0,0 +1,74 @@
import { APIClient } from "../ApiClient";
import type { UserInfo } from "../AuthApi";
import type { MatrixEvent } from "./MatrixApiEvent";
import type { UsersMap } from "./MatrixApiProfile";
export interface Room {
id: string;
name?: string;
members: string[];
avatar?: string;
is_space?: boolean;
parents: string[];
number_unread_messages: number;
notifications: "AllMessages" | "MentionsAndKeywordsOnly" | "Mute";
latest_event?: MatrixEvent;
}
export interface Receipt {
user: string;
event_id: string;
ts: number;
}
/**
* Find main member of room
*/
export function mainRoomMember(user: UserInfo, r: Room): string | undefined {
if (r.members.length <= 1) return r.members[0];
if (r.members.length < 2)
return r.members[0] == user.matrix_user_id ? r.members[1] : r.members[0];
return undefined;
}
/**
* Find room name
*/
export function roomName(user: UserInfo, r: Room, users: UsersMap): string {
if (r.name) return r.name;
const name = r.members
.filter((m) => m !== user.matrix_user_id)
.map((m) => users.get(m)?.display_name ?? m)
.join(",");
return name === "" ? "Empty room" : name;
}
export class MatrixApiRoom {
/**
* Get the list of joined rooms
*/
static async ListJoined(): Promise<Room[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/matrix/room/joined",
})
).data 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

@@ -7,3 +7,12 @@ body,
#root {
height: 100%;
}
#root {
display: flex;
flex-direction: column;
}
#root > div {
flex: 1;
}

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

@@ -1,22 +1,11 @@
import { MatrixSyncApi } from "../api/MatrixSyncApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MainMessageWidget } from "../widgets/messages/MainMessagesWidget";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
export function HomeRoute(): React.ReactElement {
const user = useUserInfo();
if (!user.info.matrix_user_id) return <NotLinkedAccountMessage />;
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
return (
<p>
Todo home route{" "}
<AsyncWidget
loadKey={1}
errMsg="Failed to start sync thread!"
load={MatrixSyncApi.Start}
build={() => <>sync started</>}
/>
</p>
);
return <MainMessageWidget />;
}

View File

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

View File

@@ -257,7 +257,7 @@ function SyncThreadStatus(): React.ReactElement {
const interval = setInterval(loadStatus, 1000);
return () => clearInterval(interval);
}, []);
});
return (
<>

View File

@@ -1,55 +1,43 @@
import React from "react";
import { JsonView, darkStyles } from "react-json-view-lite";
import "react-json-view-lite/dist/index.css";
import { WsApi, type WsMessage } from "../api/WsApi";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import { time } from "../utils/DateUtils";
import { type WsMessage } from "../api/WsApi";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
const State = {
Closed: "Closed",
Connected: "Connected",
Error: "Error",
} as const;
import { MatrixWS, WSState } from "../widgets/messages/MatrixWS";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
type TimestampedMessages = WsMessage & { time: number };
export function WSDebugRoute(): React.ReactElement {
const snackbar = useSnackbar();
const [state, setState] = React.useState<string>(State.Closed);
const wsRef = React.useRef<WebSocket | undefined>(undefined);
const user = useUserInfo();
const [state, setState] = React.useState<string>(WSState.Closed);
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
React.useEffect(() => {
const ws = new WebSocket(WsApi.WsURL);
wsRef.current = ws;
const handleMessage = (msg: WsMessage) => {
setMessages((l) => [...l, msg]);
};
ws.onopen = () => setState(State.Connected);
ws.onerror = (e) => {
console.error(`WS Debug error! ${e}`);
snackbar(`WebSocket error! ${e}`);
setState(State.Error);
};
ws.onclose = () => {
setState(State.Closed);
wsRef.current = undefined;
};
ws.onmessage = (msg) => {
const dec = JSON.parse(msg.data);
setMessages((l) => {
return [{ time: time(), ...dec }, ...l];
});
};
return () => ws.close();
}, []);
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
return (
<MatrixGWRouteContainer label={"WebSocket Debug"}>
State: {state}
{/* 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

@@ -6,7 +6,6 @@ import { svgIconClasses } from "@mui/material/SvgIcon";
import { typographyClasses } from "@mui/material/Typography";
import { gray, green, red } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const dataDisplayCustomizations: Components<Theme> = {
MuiList: {
styleOverrides: {

View File

@@ -1,7 +1,6 @@
import { type Theme, alpha, type Components } from "@mui/material/styles";
import { gray, orange } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const feedbackCustomizations: Components<Theme> = {
MuiAlert: {
styleOverrides: {

View File

@@ -8,7 +8,6 @@ import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const inputsCustomizations: Components<Theme> = {
MuiButtonBase: {
defaultProps: {

View File

@@ -9,7 +9,6 @@ import { tabClasses } from "@mui/material/Tab";
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const navigationCustomizations: Components<Theme> = {
MuiMenuItem: {
styleOverrides: {

View File

@@ -1,7 +1,6 @@
import { alpha, type Theme, type Components } from "@mui/material/styles";
import { gray } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const surfacesCustomizations: Components<Theme> = {
MuiAccordion: {
defaultProps: {

View File

@@ -24,8 +24,6 @@ declare module "@mui/material/styles" {
900: string;
}
interface PaletteColor extends ColorRange {}
interface Palette {
baseShadow: string;
}
@@ -405,10 +403,10 @@ export const shape = {
borderRadius: 8,
};
// @ts-ignore
const defaultShadows: Shadows = [
"none",
"var(--template-palette-baseShadow)",
...defaultTheme.shadows.slice(2),
];
] as never;
export const shadows = defaultShadows;

View File

@@ -1,3 +1,5 @@
import { format } from "date-and-time";
/**
* Get UNIX time
*
@@ -6,3 +8,71 @@
export function time(): number {
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

@@ -0,0 +1,241 @@
import dayjs from "dayjs";
import type {
MatrixEvent,
MatrixEventData,
MatrixEventsList,
MessageType,
} from "../api/matrix/MatrixApiEvent";
import type { Receipt, Room } from "../api/matrix/MatrixApiRoom";
import type { WsMessage } from "../api/WsApi";
import { timeMs } from "./DateUtils";
export interface MessageReaction {
event_id: string;
account: string;
key: string;
}
export interface Message {
event_id: string;
account: string;
time_sent: number;
time_sent_dayjs: dayjs.Dayjs;
modified: boolean;
inReplyTo?: string;
reactions: Map<string, MessageReaction[]>;
content: string;
type: MessageType;
file?: string;
}
export class RoomEventsManager {
readonly room: Room;
private events: MatrixEvent[];
private receipts: Receipt[];
messages: Message[];
endToken?: string;
typingUsers: string[];
receiptsEventsMap: Map<string, Receipt[]>;
get canLoadOlder(): boolean {
return !!this.endToken && this.events.length > 0;
}
constructor(
room: Room,
initialMessages: MatrixEventsList,
receipts: Receipt[]
) {
this.room = room;
this.events = [];
this.receipts = receipts;
this.messages = [];
this.typingUsers = [];
this.receiptsEventsMap = new Map();
this.processNewEvents(initialMessages);
}
/**
* Process events given by the API
*/
processNewEvents(evts: MatrixEventsList) {
this.endToken = evts.end;
this.events = [...this.events, ...evts.events];
this.rebuildMessagesList();
}
processWsMessage(m: WsMessage) {
if (m.room_id !== this.room.id) {
console.debug("Not an event for current room.");
return false;
}
let data: MatrixEventData;
if (m.type === "RoomReactionEvent") {
data = {
type: "m.reaction",
content: {
"m.relates_to": {
key: m.data["m.relates_to"].key,
event_id: m.data["m.relates_to"].event_id,
},
},
};
} else if (m.type === "RoomRedactionEvent") {
data = {
type: "m.room.redaction",
redacts: m.data.redacts,
};
} else if (m.type === "RoomMessageEvent") {
data = {
type: "m.room.message",
content: {
body: m.data["m.new_content"]?.body ?? m.data.body,
msgtype: m.data.msgtype,
"m.relates_to": m.data["m.relates_to"],
url: m.data.url,
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 {
// Ignore event
console.info("Event not supported => ignored");
return false;
}
this.events.push({
sender: m.sender,
id: m.event_id,
time: m.origin_server_ts,
data,
});
this.rebuildMessagesList();
return true;
}
private rebuildMessagesList() {
this.messages = [];
// Sorts events list to process oldest events first
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
const redacted = new Set(
this.events
.map((e) =>
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
)
.filter((e) => e !== undefined)
);
for (const evt of this.events) {
if (redacted.has(evt.id)) continue;
const data = evt.data;
// Message
if (data.type === "m.room.message") {
// Check if this message replaces another one
if (data.content["m.relates_to"]?.rel_type === "replace") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"]?.event_id
);
if (!message) continue;
message.modified = true;
message.content = data.content.body;
continue;
}
// 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({
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),
type: data.content.msgtype,
file: data.content.file?.url ?? data.content.url,
content: data.content.body,
});
}
// Reaction
if (data.type === "m.reaction") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"].event_id
);
const key = data.content["m.relates_to"].key;
if (!message) continue;
if (!message.reactions.has(key)) message.reactions.set(key, []);
message.reactions.get(key)!.push({
account: evt.sender,
event_id: evt.id,
key,
});
}
}
// 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 { useEffect, useRef, useState } from "react";
import React from "react";
const State = {
Loading: 0,
@@ -10,16 +10,14 @@ const State = {
type State = keyof typeof State;
export function AsyncWidget(p: {
loadKey: any;
loadKey: unknown;
load: () => Promise<void>;
errMsg: string;
build: () => React.ReactElement;
ready?: boolean;
errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement {
const [state, setState] = useState<number>(State.Loading);
const counter = useRef<any>(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

@@ -0,0 +1,31 @@
import { Emoji, EmojiStyle } from "emoji-picker-react";
function emojiUnicode(emoji: string): string {
let comp;
if (emoji.length === 1) {
comp = emoji.charCodeAt(0);
}
comp =
(emoji.charCodeAt(0) - 0xd800) * 0x400 +
(emoji.charCodeAt(1) - 0xdc00) +
0x10000;
if (comp < 0) {
comp = emoji.charCodeAt(0);
}
const s = comp.toString(16);
return s.includes("f") ? s : `${s}-fe0f`;
}
export function EmojiIcon(p: {
emojiKey: string;
size?: number;
}): React.ReactElement {
const unified = emojiUnicode(p.emojiKey);
return (
<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 {
return (
<div
@@ -6,9 +10,17 @@ export function NotLinkedAccountMessage(): React.ReactElement {
display: "flex",
alignItems: "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>
);
}

View File

@@ -1,65 +1,10 @@
import { Tooltip } from "@mui/material";
import { format } from "date-and-time";
import { time } from "../utils/DateUtils";
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());
}
import {
formatDateTime,
formatDate,
timeDiff,
timeDiffFromNow,
} from "../utils/DateUtils";
export function TimeWidget(p: {
time?: number;

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,7 +1,6 @@
import { Button } from "@mui/material";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import Toolbar from "@mui/material/Toolbar";
import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react";
import { Outlet, useNavigate } from "react-router";
@@ -105,20 +104,18 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
signOut,
}}
>
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<Box
ref={layoutRef}
sx={{
position: "relative",
display: "flex",
overflow: "hidden",
height: "100%",
width: "100%",
}}
>
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<DashboardSidebar
expanded={isNavigationExpanded}
setExpanded={setIsNavigationExpanded}
@@ -132,7 +129,6 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
minWidth: 0,
}}
>
<Toolbar sx={{ displayPrint: "none" }} />
<Box
component="main"
sx={{

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";
@@ -81,7 +81,11 @@ export default function DashboardHeader({
);
return (
<AppBar color="inherit" position="absolute" sx={{ displayPrint: "none" }}>
<AppBar
color="inherit"
position="static"
sx={{ displayPrint: "none", overflow: "hidden" }}
>
<Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}>
<Stack
direction="row"
@@ -97,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,13 +1,15 @@
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";
import Toolbar from "@mui/material/Toolbar";
import { useTheme } from "@mui/material/styles";
import type {} from "@mui/material/themeCssVarsAugmentation";
import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react";
import { useUserInfo } from "./BaseAuthenticatedPage";
import DashboardSidebarContext from "./DashboardSidebarContext";
import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
@@ -27,10 +29,10 @@ export interface DashboardSidebarProps {
export default function DashboardSidebar({
expanded = true,
setExpanded,
disableCollapsibleSidebar = false,
container,
}: DashboardSidebarProps) {
const theme = useTheme();
const user = useUserInfo();
const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
@@ -51,8 +53,6 @@ export default function DashboardSidebar({
return () => {};
}, [expanded, theme.transitions.duration.enteringScreen]);
const mini = !disableCollapsibleSidebar && !expanded;
const handleSetSidebarExpanded = React.useCallback(
(newExpanded: boolean) => () => {
setExpanded(newExpanded);
@@ -64,15 +64,13 @@ export default function DashboardSidebar({
if (!isOverSmViewport) {
setExpanded(false);
}
}, [mini, setExpanded, isOverSmViewport]);
}, [setExpanded, isOverSmViewport]);
const hasDrawerTransitions =
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
const getDrawerContent = React.useCallback(
(viewport: "phone" | "tablet" | "desktop") => (
(viewport: "phone" | "desktop") => (
<React.Fragment>
<Toolbar />
<Box
component="nav"
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
@@ -82,9 +80,10 @@ export default function DashboardSidebar({
flexDirection: "column",
justifyContent: "space-between",
overflow: "auto",
scrollbarGutter: mini ? "stable" : "auto",
scrollbarGutter: !expanded ? "stable" : "auto",
overflowX: "hidden",
pt: !mini ? 0 : 2,
pt: expanded ? 0 : 2,
paddingTop: 0,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
: {}),
@@ -93,42 +92,59 @@ export default function DashboardSidebar({
<List
dense
sx={{
padding: mini ? 0 : 0.5,
padding: !expanded ? 0 : 0.5,
mb: 4,
width: mini ? MINI_DRAWER_WIDTH : "auto",
width: !expanded ? MINI_DRAWER_WIDTH : "auto",
}}
>
<DashboardSidebarPageItem
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"}
/>
</List>
</Box>
</React.Fragment>
),
[mini, hasDrawerTransitions, isFullyExpanded]
[
expanded,
hasDrawerTransitions,
isFullyExpanded,
user.info.matrix_account_connected,
]
);
const getDrawerSharedSx = React.useCallback(
(isTemporary: boolean) => {
const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH;
(isTemporary: boolean, desktop?: boolean) => {
const drawerWidth = desktop
? expanded
? MINI_DRAWER_WIDTH
: 0
: !expanded
? MINI_DRAWER_WIDTH
: DRAWER_WIDTH;
return {
displayPrint: "none",
@@ -145,17 +161,16 @@ export default function DashboardSidebar({
},
};
},
[expanded, mini]
[expanded]
);
const sidebarContextValue = React.useMemo(() => {
return {
onPageItemClick: handlePageItemClick,
mini,
fullyExpanded: isFullyExpanded,
hasDrawerTransitions,
};
}, [handlePageItemClick, mini, isFullyExpanded, hasDrawerTransitions]);
}, [handlePageItemClick, isFullyExpanded, hasDrawerTransitions]);
return (
<DashboardSidebarContext.Provider value={sidebarContextValue}>
@@ -170,7 +185,7 @@ export default function DashboardSidebar({
sx={{
display: {
xs: "block",
sm: disableCollapsibleSidebar ? "block" : "none",
sm: "none",
md: "none",
},
...getDrawerSharedSx(true),
@@ -181,21 +196,8 @@ export default function DashboardSidebar({
<Drawer
variant="permanent"
sx={{
display: {
xs: "none",
sm: disableCollapsibleSidebar ? "none" : "block",
md: "none",
},
...getDrawerSharedSx(false),
}}
>
{getDrawerContent("tablet")}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
...getDrawerSharedSx(false),
display: { xs: "none", sm: "block", md: "block" },
...getDrawerSharedSx(false, true),
}}
>
{getDrawerContent("desktop")}

View File

@@ -2,7 +2,6 @@ import * as React from "react";
const DashboardSidebarContext = React.createContext<{
onPageItemClick: () => void;
mini: boolean;
fullyExpanded: boolean;
hasDrawerTransitions: boolean;
} | null>(null);

View File

@@ -17,6 +17,7 @@ export interface DashboardSidebarPageItemProps {
href: string;
action?: React.ReactNode;
disabled?: boolean;
mini?: boolean;
}
export default function DashboardSidebarPageItem({
@@ -25,6 +26,7 @@ export default function DashboardSidebarPageItem({
href,
action,
disabled = false,
mini = false,
}: DashboardSidebarPageItemProps) {
const { pathname } = useLocation();
@@ -32,11 +34,7 @@ export default function DashboardSidebarPageItem({
if (!sidebarContext) {
throw new Error("Sidebar context was used without a provider.");
}
const {
onPageItemClick,
mini = false,
fullyExpanded = true,
} = sidebarContext;
const { onPageItemClick, fullyExpanded = true } = sidebarContext;
const hasExternalHref = href
? href.startsWith("http://") || href.startsWith("https://")

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

@@ -0,0 +1,19 @@
import { Avatar } from "@mui/material";
import type { UserProfile } from "../../api/matrix/MatrixApiProfile";
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
export function AccountIcon(p: {
user: UserProfile;
size?: number;
}): React.ReactElement {
return (
<Avatar
src={
p.user.avatar ? MatrixApiMedia.MediaURL(p.user.avatar, true) : undefined
}
sx={{ width: p.size, height: p.size }}
>
{p.user.display_name?.slice(0, 1)}
</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

@@ -0,0 +1,210 @@
import { Divider } from "@mui/material";
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import {
MatrixApiProfile,
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";
import { AsyncWidget } from "../AsyncWidget";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { AppIconModifier } from "./AppIconModifier";
import { MatrixWS } from "./MatrixWS";
import { RoomSelector } from "./RoomSelector";
import { RoomWidget } from "./RoomWidget";
import { SpaceSelector } from "./SpaceSelector";
export function MainMessageWidget(): React.ReactElement {
const [rooms, setRooms] = React.useState<Room[] | undefined>();
const [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) => {
r.members.forEach((m) => prev.add(m));
return prev;
}, new Set<string>());
setUsers(await MatrixApiProfile.GetMultiple([...users]));
};
return (
<AsyncWidget
loadKey={1}
load={loadRoomsList}
ready={!!rooms && !!users && !!hierarchy}
errMsg="Failed to initialize messaging component!"
build={() => (
<MainMessageWidgetInner
rooms={rooms!}
hierarchy={hierarchy!}
users={users!}
onRoomsListUpdate={(cb) => setRooms((r) => cb(r!))}
/>
)}
/>
);
}
function MainMessageWidgetInner(p: {
rooms: Room[];
hierarchy: SpaceHierarchy;
users: UsersMap;
onRoomsListUpdate: (cb: (a: Room[]) => Room[]) => void;
}): React.ReactElement {
const user = useUserInfo();
const [space, setSpace] = React.useState<string | undefined>();
const [currentRoom, setCurrentRoom] = React.useState<Room | undefined>();
const spaceRooms = React.useMemo(() => {
return p.rooms
.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, p.hierarchy]);
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 loadRoom = async () => {
setRoomMgr(undefined);
if (!currentRoom) {
console.warn("Cannot load manager for no room!");
return;
}
const messages = await MatrixApiEvent.GetRoomEvents(currentRoom);
const receipts = await MatrixApiRoom.RoomReceipts(currentRoom);
const mgr = new RoomEventsManager(currentRoom!, messages, receipts);
setRoomMgr(mgr);
};
const [wsState, setWsState] = React.useState("");
const handleWsEvent = (m: WsMessage) => {
// Process messages for current room
if (roomMgr?.processWsMessage(m)) {
console.info("Current room updated!");
setRefreshCount((c) => c + 1);
}
// Add a new unread message on left sidebar
if (
m.type === "RoomMessageEvent" &&
!m.data["m.new_content"] &&
m.sender !== user.info.matrix_user_id
) {
p.onRoomsListUpdate((r) => {
const n = [...r];
const idx = r.findIndex((el) => el.id === m.room_id);
if (idx && n[idx]?.notifications === "AllMessages")
n[idx] = {
...n[idx],
number_unread_messages: n[idx].number_unread_messages + 1,
};
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 (
<div style={{ display: "flex", height: "100%" }}>
{/* Websocket */}
<div style={{ position: "absolute", right: "0px", padding: "10px" }}>
<MatrixWS onMessage={handleWsEvent} onStateChange={setWsState} />
</div>
{/** Application icon modifier */}
<AppIconModifier numberUnread={unreadRooms} state={wsState} />
{/* Space selector */}
<SpaceSelector {...p} selectedSpace={space} onChange={setSpace} />
{/* Separator */}
<Divider orientation="vertical" />
{/* Room selector */}
<RoomSelector
{...p}
rooms={spaceRooms}
currRoom={currentRoom}
onChange={setCurrentRoom}
/>
{/* Separator */}
<Divider orientation="vertical" />
{/* If no room is selected */}
{currentRoom === undefined && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flex: 1,
}}
>
No room selected.
</div>
)}
{/* In case of room */}
{currentRoom && (
<AsyncWidget
loadKey={currentRoom.id}
ready={!!roomMgr}
load={loadRoom}
errMsg="Failed to load room!"
build={() => (
<RoomWidget {...p} manager={roomMgr!} room={currentRoom} />
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import React from "react";
import { WsApi, type WsMessage } from "../../api/WsApi";
import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
import CircleIcon from "@mui/icons-material/Circle";
import { Tooltip } from "@mui/material";
export const WSState = {
Closed: "Closed",
Connected: "Connected",
Error: "Error",
} as const;
export function MatrixWS(p: {
onMessage: (msg: WsMessage) => void;
onStateChange?: (state: string) => void;
}): React.ReactElement {
const snackbar = useSnackbar();
// Keep only the latest version of onMessage
const cbRef = React.useRef(p.onMessage);
React.useEffect(() => {
cbRef.current = p.onMessage;
}, [p.onMessage]);
// 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 [connCount, setConnCount] = React.useState(0);
React.useEffect(() => {
const id = Math.random();
const ws = new WebSocket(WsApi.WsURL);
wsId.current = id;
// Open
ws.onopen = () => {
if (wsId.current != id) return;
setState(WSState.Connected);
stateCbRef.current?.(WSState.Connected);
};
// Error
ws.onerror = (e) => {
if (wsId.current != id) return;
console.error(`WS Debug error!`, e);
snackbar(`WebSocket error!`);
setState(WSState.Error);
stateCbRef.current?.(WSState.Error);
setTimeout(() => setConnCount(connCount + 1), 500);
};
// Close
ws.onclose = () => {
if (wsId.current !== id) return;
setState(WSState.Closed);
stateCbRef.current?.(WSState.Closed);
wsId.current = undefined;
};
// Message
ws.onmessage = (msg) => {
if (wsId.current !== id) return;
const dec = JSON.parse(msg.data);
console.info("WS message", dec);
cbRef.current(dec);
};
return () => ws.close();
}, [connCount, snackbar]);
return (
<Tooltip title={state}>
<CircleIcon
color={
state === WSState.Connected
? "success"
: state === WSState.Error
? "error"
: undefined
}
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,33 @@
import { Avatar } from "@mui/material";
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import {
mainRoomMember,
roomName,
type Room,
} from "../../api/matrix/MatrixApiRoom";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
export function RoomIcon(p: {
room: Room;
users: UsersMap;
}): React.ReactElement {
const user = useUserInfo();
let url = p.room.avatar;
if (!url) {
const member = mainRoomMember(user.info, p.room);
if (member) url = p.users.get(member)?.avatar;
}
const name = roomName(user.info, p.room, p.users);
return (
<Avatar
variant={p.room.is_space ? "square" : undefined}
src={url ? MatrixApiMedia.MediaURL(url, true) : undefined}
>
{name.slice(0, 1)}
</Avatar>
);
}

View File

@@ -0,0 +1,627 @@
import AddReactionIcon from "@mui/icons-material/AddReaction";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import EditIcon from "@mui/icons-material/Edit";
import {
Box,
Button,
ButtonGroup,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import EmojiPicker, { EmojiStyle, Theme } from "emoji-picker-react";
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Receipt, Room } from "../../api/matrix/MatrixApiRoom";
import { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider";
import { useConfirm } from "../../hooks/contexts_provider/ConfirmDialogProvider";
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
import type {
Message,
MessageReaction,
RoomEventsManager,
} from "../../utils/RoomEventsManager";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { EmojiIcon } from "../EmojiIcon";
import { AccountIcon } from "./AccountIcon";
export function RoomMessagesList(p: {
room: Room;
users: UsersMap;
manager: RoomEventsManager;
}): React.ReactElement {
const snackbar = useSnackbar();
const [loadingOlder, setLoadingOlder] = React.useState(false);
const listContainerRef = React.createRef<HTMLDivElement>();
const messagesEndRef = React.createRef<HTMLDivElement>();
// Automatically scroll to bottom when number of messages change
const lastEventId = p.manager.messages.at(-1)?.event_id;
React.useEffect(() => {
if (messagesEndRef)
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
}, [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 (
<div
onScroll={handleScroll}
ref={listContainerRef}
style={{
flex: 1,
width: "100%",
paddingRight: "50px",
overflow: "scroll",
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) => (
<RoomMessage
key={m.event_id}
{...p}
message={m}
previousFromSamePerson={
idx > 0 &&
p.manager.messages[idx - 1].account === m.account &&
m.time_sent - p.manager.messages[idx - 1].time_sent < 60 * 3 * 1000
}
firstMessageOfDay={
idx === 0 ||
m.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
}
/>
))}
<div ref={messagesEndRef} style={{ height: "10px" }} />
</div>
);
}
function RoomMessage(p: {
room: Room;
users: UsersMap;
message: Message;
previousFromSamePerson: boolean;
firstMessageOfDay: boolean;
receipts?: Receipt[];
repliedMessage?: Message;
}): React.ReactElement {
const theme = useTheme();
const user = useUserInfo();
const alert = useAlert();
const confirm = useConfirm();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const [showImageFullScreen, setShowImageFullScreen] = React.useState(false);
const [editMessage, setEditMessage] = React.useState<string | undefined>();
const [pickReaction, setPickReaction] = React.useState(false);
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;
try {
await MatrixApiEvent.DeleteEvent(p.room, p.message.event_id);
} catch (e) {
console.error(`Failed to delete message!`, e);
alert(`Failed to delete message!${e}`);
}
};
const handleEditMessage = () => setEditMessage(p.message.content);
const handleCancelEditMessage = () => setEditMessage(undefined);
const handleSubmitEditMessage = async (event: React.FormEvent) => {
event.preventDefault();
try {
loadingMessage.show(`Updating message content...`);
await MatrixApiEvent.SetTextMessageContent(
p.room,
p.message.event_id,
editMessage!
);
setEditMessage(undefined);
} catch (e) {
console.error(`Failed to edit message!`, e);
alert(`Failed to edit message content! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleAddReaction = () => setPickReaction(true);
const handleCancelAddReaction = () => setPickReaction(false);
const handleSelectEmoji = async (key: string) => {
loadingMessage.show("Setting reaction...");
try {
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, key);
setPickReaction(false);
} catch (e) {
console.error("Failed to select emoji!", e);
alert(`Failed to select emoji! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleToggleReaction = async (
key: string,
reaction: MessageReaction | undefined
) => {
try {
if (!reaction)
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, key);
else await MatrixApiEvent.DeleteEvent(p.room, reaction.event_id);
} catch (e) {
console.error(`Failed to toggle reaction!`, e);
snackbar(`Failed to toggle reaction! ${e}`);
}
};
return (
<>
{/* Print date if required */}
{p.firstMessageOfDay && (
<Typography
variant="caption"
component={"div"}
style={{ textAlign: "center", marginTop: "50px" }}
>
{p.message.time_sent_dayjs.format("DD/MM/YYYY")}
</Typography>
)}
{/* Give person name if required */}
{(!p.previousFromSamePerson || p.firstMessageOfDay) && sender && (
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "20px",
}}
>
<AccountIcon user={sender} />
&nbsp;&nbsp;&nbsp;
{sender.display_name}
</div>
)}
{/* Message content */}
<Box
style={{
wordBreak: "break-all",
wordWrap: "break-word",
maxWidth: "100%",
transition: "all 0.01s ease-in",
position: "relative",
display: "flex",
flexDirection: "row",
}}
component="div"
sx={{
[theme.getColorSchemeSelector("dark") + "&:hover"]: {
backgroundColor: "#ffffff2b",
},
[theme.getColorSchemeSelector("light") + "&:hover"]: {
backgroundColor: "#00000039",
},
"&:hover *": { visibility: "visible" },
}}
>
<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
onClick={() => setShowImageFullScreen(true)}
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
true
)}
style={{
maxWidth: "200px",
}}
/>
)}
{/* Audio */}
{p.message.type === "m.audio" && (
<audio controls>
<source
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</audio>
)}
{/* Video */}
{p.message.type === "m.video" && (
<video controls style={{ maxHeight: "300px", maxWidth: "300px" }}>
<source
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</video>
)}
{/* File */}
{p.message.type === "m.file" && (
<a
href={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
target="_blank"
rel="noopener"
>
<Button variant="outlined" startIcon={<DownloadIcon />}>
{p.message.content}
</Button>
</a>
)}
{/* Text message */}
{p.message.type === "m.text" && (
<div style={{ margin: "2px 0px" }}>{p.message.content}</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
className="buttons"
size="small"
style={{
position: "absolute",
visibility: "hidden",
display: "block",
top: "-34px",
right: "0px",
}}
>
{/* Common reactions */}
<ReactionButton {...p} emojiKey="👍" /> {/* 👍 */}
<ReactionButton {...p} emojiKey="♥️" /> {/* ♥️ */}
<ReactionButton {...p} emojiKey="😂" /> {/* 😂 */}
{/* Add reaction */}
<Button onClick={handleAddReaction}>
<AddReactionIcon />
</Button>
{/* Edit text message */}
{p.message.account === user.info.matrix_user_id &&
!p.message.file && (
<Button onClick={handleEditMessage}>
<EditIcon />
</Button>
)}
{/* Delete message */}
{p.message.account === user.info.matrix_user_id && (
<Button onClick={handleDeleteMessage}>
<DeleteIcon color="error" />
</Button>
)}
</ButtonGroup>
</Box>
{/* Reactions */}
<Box sx={{ marginLeft: "50px" }}>
{[...p.message.reactions.keys()].map((r) => {
const reactions = p.message.reactions.get(r)!;
const userReaction = reactions.find(
(r) => r.account === user.info.matrix_user_id
);
return (
<Tooltip
enterDelay={50}
placement="top"
arrow
title={
<span style={{ whiteSpace: "pre-wrap" }}>
{reactions
.map((r) => p.users.get(r.account)?.display_name)
.join("\n")}
</span>
}
>
<Chip
size="small"
style={{
height: "2em",
marginRight: "5px",
maxHeight: "unset",
cursor: "pointer",
}}
slotProps={{
root: {
onClick: () => handleToggleReaction(r, userReaction),
},
label: { style: { height: "2em" } },
}}
color={userReaction !== undefined ? "success" : undefined}
variant="filled"
label={
<span
style={{
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<div style={{ margin: "0px 3px" }}>
<EmojiIcon emojiKey={r} size={16} />
</div>
<div style={{ marginLeft: "2px" }}>{reactions.length}</div>
</span>
}
/>
</Tooltip>
);
})}
</Box>
{/* Full screen image dialog */}
<Dialog open={showImageFullScreen} onClose={closeImageFullScreen}>
<img
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</Dialog>
{/* Pick reaction dialog */}
<Dialog open={pickReaction} onClose={handleCancelAddReaction}>
<EmojiPicker
emojiStyle={EmojiStyle.NATIVE}
theme={Theme.AUTO}
onEmojiClick={(emoji) => handleSelectEmoji(emoji.emoji)}
/>
</Dialog>
{/* Edit message dialog */}
<Dialog open={!!editMessage} onClose={handleCancelEditMessage} fullWidth>
<DialogTitle>Edit message content</DialogTitle>
<DialogContent>
<DialogContentText>Enter new message content:</DialogContentText>
<form
onSubmit={handleSubmitEditMessage}
id={`edit-message-${p.message.event_id}`}
>
<TextField
autoFocus
required
margin="dense"
label="New content"
type="text"
fullWidth
variant="standard"
multiline
value={editMessage}
onChange={(e) => setEditMessage(e.target.value)}
/>
</form>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelEditMessage}>Cancel</Button>
<Button type="submit" form={`edit-message-${p.message.event_id}`}>
Edit
</Button>
</DialogActions>
</Dialog>
</>
);
}
function ReactionButton(p: {
room: Room;
message: Message;
emojiKey: string;
}): React.ReactElement {
const alert = useAlert();
const user = useUserInfo();
const sendEmoji = async () => {
try {
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, p.emojiKey);
} catch (e) {
console.error("Failed to send reaction!", e);
alert(`Failed to send reaction! ${e}`);
}
};
// Do not offer to react to existing reactions
if (
p.message.reactions
.get(p.emojiKey)
?.find(
(r) => r.key === p.emojiKey && r.account === user.info.matrix_user_id
) !== undefined
)
return <></>;
return (
<Button
onClick={sendEmoji}
sx={{
paddingTop: "1px !important",
display: "inline-flex",
alignItems: "start",
}}
>
<EmojiIcon {...p} />
</Button>
);
}

View File

@@ -0,0 +1,128 @@
import SearchIcon from "@mui/icons-material/Search";
import {
Chip,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
TextField,
} from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import { roomName, type Room } from "../../api/matrix/MatrixApiRoom";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { RoomIcon } from "./RoomIcon";
const ROOM_SELECTOR_WIDTH = "300px";
export function RoomSelector(p: {
users: UsersMap;
rooms: Room[];
currRoom?: Room;
onChange: (r: Room) => void;
}): React.ReactElement {
const user = useUserInfo();
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)
return (
<div
style={{
width: ROOM_SELECTOR_WIDTH,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
No room to display.
</div>
);
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" }}>
<Chip
label="Unread"
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
>
<ListItemButton
role={undefined}
onClick={() => p.onChange(r)}
dense
selected={p.currRoom?.id === r.id}
>
<ListItemIcon>
<RoomIcon room={r} {...p} />
</ListItemIcon>
<ListItemText
primary={
<span
style={{
fontWeight:
r.number_unread_messages > 0 ? "bold" : undefined,
}}
>
{roomName(user.info, r, p.users)}
</span>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
import { RoomEventsManager } from "../../utils/RoomEventsManager";
import { RoomMessagesList } from "./RoomMessagesList";
import { SendMessageForm } from "./SendMessageForm";
import { TypingNotice } from "./TypingNotice";
export function RoomWidget(p: {
room: Room;
users: UsersMap;
manager: RoomEventsManager;
}): 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 (
<div
style={{ display: "flex", flexDirection: "column", flex: 1 }}
onClick={handleRoomClick}
>
<RoomMessagesList {...p} />
<TypingNotice {...p} />
<SendMessageForm {...p} />
</div>
);
}

View File

@@ -0,0 +1,63 @@
import SendIcon from "@mui/icons-material/Send";
import { IconButton, TextField } from "@mui/material";
import React, { type FormEvent } from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
export function SendMessageForm(p: { room: Room }): React.ReactElement {
const loadingMessage = useLoadingMessage();
const alert = useAlert();
const [text, setText] = React.useState("");
const handleTextSubmit = async (e: FormEvent) => {
e.preventDefault();
if (text === "") return;
loadingMessage.show("Sending message...");
try {
await MatrixApiEvent.SendTextMessage(p.room, text);
setText("");
} catch (e) {
console.error(`Failed to send message! ${e}`);
alert(`Failed to send message! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<form onSubmit={handleTextSubmit}>
<div
style={{
padding: "10px",
paddingLeft: "20px",
display: "flex",
flexDirection: "row",
}}
>
<TextField
placeholder="Send a message..."
variant="standard"
fullWidth
style={{}}
slotProps={{ input: { disableUnderline: true } }}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<IconButton
size="small"
style={{ visibility: text === "" ? "hidden" : "visible" }}
onClick={handleTextSubmit}
>
<SendIcon />
</IconButton>
</div>
</form>
);
}

View File

@@ -0,0 +1,58 @@
import HomeIcon from "@mui/icons-material/Home";
import { Button } from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import 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.hierarchy.has(r.id)),
[p.rooms, p.hierarchy]
);
// Do not display space bar if your is not member of any space
if (spaces.length === 0) return <></>;
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<SpaceButton
icon={<HomeIcon />}
onClick={() => p.onChange()}
selected={p.selectedSpace === undefined}
/>
{spaces.map((s) => (
<SpaceButton
key={s.id}
icon={<RoomIcon room={s} {...p} />}
onClick={() => p.onChange(s.id)}
selected={p.selectedSpace === s.id}
/>
))}
</div>
);
}
function SpaceButton(p: {
selected?: boolean;
icon: React.ReactElement;
onClick: () => void;
}): React.ReactElement {
return (
<Button
variant={p.selected ? "contained" : "text"}
style={{ margin: "2px 5px", padding: "25px 10px", fontSize: "200%" }}
onClick={p.onClick}
>
{p.icon}
</Button>
);
}

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