148 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
f0e8c799ff Merge pull request 'Update Rust crate actix-web to 4.12.1' (#119) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-27 00:16:23 +00:00
b4e7cb8718 Update Rust crate actix-web to 4.12.1
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-27 00:16:15 +00:00
7a590e882b Merge pull request 'Update Rust crate ruma to 0.14.0' (#118) from renovate/ruma-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-21 00:12:11 +00:00
9a643ced94 Update Rust crate ruma to 0.14.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-21 00:11:59 +00:00
5f2a6478a7 Merge pull request 'Update Rust crate clap to 4.5.53' (#117) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-20 00:09:04 +00:00
1db929a31b Update Rust crate clap to 4.5.53
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-20 00:08:50 +00:00
0b2c4071e8 Merge pull request 'Update Rust crate clap to 4.5.52' (#116) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-18 00:10:34 +00:00
61ecfc5af1 Update Rust crate clap to 4.5.52
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-18 00:10:24 +00:00
661793f58d Merge pull request 'Update Rust crate actix-web to 4.12.0' (#115) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-17 00:09:32 +00:00
d253e73099 Update Rust crate actix-web to 4.12.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-17 00:09:22 +00:00
f0d3d311e9 Merge pull request 'Update Rust crate bytes to 1.11.0' (#114) from renovate/bytes-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-15 00:09:38 +00:00
592203aa4a Update Rust crate bytes to 1.11.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-15 00:09:30 +00:00
aeb35029c3 Merge pull request 'Update Rust crate sha2 to 0.11.0-rc.3' (#113) from renovate/sha2-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-06 00:11:57 +00:00
1dc56d5ec1 Update Rust crate sha2 to 0.11.0-rc.3
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-06 00:11:50 +00:00
51b1ab380c Merge pull request 'Update Rust crate rust-embed to 8.9.0' (#112) from renovate/rust-embed-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-03 00:09:55 +00:00
b5abddaacb Update Rust crate rust-embed to 8.9.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-03 00:09:54 +00:00
66 changed files with 3188 additions and 1897 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"
base16ct = { version = "0.3.0", features = ["alloc"] }
futures-util = "0.3.31"
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
thiserror = "2.0.17"
uuid = { version = "1.18.1", features = ["v4", "serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
ipnet = { version = "2.11.0", features = ["serde"] }
rand = "0.9.2"
hex = "0.4.3"
mailchecker = "6.0.19"
matrix-sdk = { version = "0.14.0" }
url = "2.5.7"
ractor = "0.15.9"
serde_json = "1.0.145"
lazy-regex = "3.4.2"
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"
infer = "0.19.0"
rust-embed = "8.9.0"
mime_guess = "2.0.5"

View File

@@ -0,0 +1,10 @@
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y libcurl4 libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/*
COPY matrixgw_backend /usr/local/bin/matrixgw_backend
ENTRYPOINT ["/usr/local/bin/matrixgw_backend"]

View File

@@ -2,8 +2,10 @@ use crate::matrix_connection::sync_thread::MatrixSyncTaskID;
use crate::users::{APIToken, UserEmail};
use 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>;
@@ -32,6 +34,10 @@ pub enum BroadcastMessage {
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

@@ -11,7 +11,10 @@ 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,
@@ -23,7 +26,7 @@ use serde_json::value::RawValue;
#[derive(Serialize)]
pub struct APIEvent {
id: OwnedEventId,
pub id: OwnedEventId,
time: MilliSecondsSinceUnixEpoch,
sender: OwnedUserId,
data: Box<RawValue>,
@@ -61,10 +64,14 @@ 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 {
@@ -97,8 +104,15 @@ pub async fn get_for_room(
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(HttpResponse::Ok()
.json(get_events(&room, query.limit.unwrap_or(500), query.from.as_deref()).await?))
Ok(HttpResponse::Ok().json(
get_events(
&room,
query.limit.unwrap_or(500),
query.from.as_deref(),
None,
)
.await?,
))
}
#[derive(Deserialize)]
@@ -217,8 +231,6 @@ pub async fn event_file(
_ => (None, None),
};
println!("{source:#?} {thumb_source:#?}");
let source = match (query.thumbnail, source, thumb_source) {
(false, Some(s), _) => s,
(true, _, Some(s)) => s,
@@ -268,3 +280,23 @@ pub async fn redact_event(
}
})
}
/// Send receipt for event
pub async fn receipt(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found"));
};
room.send_single_receipt(
ReceiptType::Read,
ReceiptThread::Main,
event_path.event_id.clone(),
)
.await?;
Ok(HttpResponse::Accepted().finish())
}

View File

@@ -4,11 +4,9 @@ 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::crypto::{AttachmentDecryptor, MediaEncryptionInfo};
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
use matrix_sdk::ruma::events::room::MediaSource;
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
use std::io::{Cursor, Read};
#[derive(serde::Deserialize)]
pub struct MediaMXCInPath {
@@ -54,21 +52,6 @@ pub async fn serve_media(req: HttpRequest, source: MediaSource, thumbnail: bool)
)
.await?;
// Decrypt file if needed
let media = if let MediaSource::Encrypted(file) = source {
let mut cursor = Cursor::new(media);
let mut decryptor =
AttachmentDecryptor::new(&mut cursor, MediaEncryptionInfo::from(*file))?;
let mut decrypted_data = Vec::new();
decryptor.read_to_end(&mut decrypted_data)?;
decrypted_data
} else {
media
};
let digest = sha512(&media);
let mime_type = infer::get(&media).map(|x| x.mime_type());

View File

@@ -4,8 +4,14 @@ 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::{OwnedMxcUri, OwnedRoomId, OwnedUserId};
use matrix_sdk::ruma::events::receipt::{ReceiptThread, ReceiptType};
use matrix_sdk::ruma::{
MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId,
};
use matrix_sdk::{Room, RoomMemberships};
#[derive(serde::Serialize)]
@@ -17,11 +23,12 @@ pub struct APIRoomInfo {
is_space: bool,
parents: Vec<OwnedRoomId>,
number_unread_messages: u64,
notifications: RoomNotificationMode,
latest_event: Option<APIEvent>,
}
impl APIRoomInfo {
async fn from_room(r: &Room) -> anyhow::Result<Self> {
async fn from_room(r: &Room, notif: &NotificationSettings) -> anyhow::Result<Self> {
// Get parent spaces
let parent_spaces = r
.parent_spaces()
@@ -39,28 +46,48 @@ impl APIRoomInfo {
})
.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: r
.members(RoomMemberships::ACTIVE)
.await?
.into_iter()
.map(|r| r.user_id().to_owned())
.collect::<Vec<_>>(),
members,
avatar: r.avatar_url(),
is_space: r.is_space(),
parents: parent_spaces,
number_unread_messages: r.unread_notification_counts().notification_count,
latest_event: get_events(r, 1, None).await?.events.into_iter().next(),
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).await)
.then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>()
.await
.into_iter()
@@ -71,8 +98,10 @@ pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
/// 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).await)
.then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>()
.await
.into_iter()
@@ -91,9 +120,11 @@ 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).await?),
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r, &notifs).await?),
})
}
@@ -113,3 +144,37 @@ pub async fn room_avatar(
matrix_media_controller::serve_mxc_file(req, uri).await
}
#[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

@@ -2,3 +2,4 @@ pub mod matrix_event_controller;
pub mod matrix_media_controller;
pub mod matrix_profile_controller;
pub mod matrix_room_controller;
pub mod matrix_space_controller;

View File

@@ -7,6 +7,7 @@ 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;

View File

@@ -0,0 +1,45 @@
#[cfg(debug_assertions)]
pub use serve_static_debug::{root_index, serve_static_content};
#[cfg(not(debug_assertions))]
pub use serve_static_release::{root_index, serve_static_content};
#[cfg(debug_assertions)]
mod serve_static_debug {
use actix_web::{HttpResponse, Responder};
pub async fn root_index() -> impl Responder {
HttpResponse::Ok().body("Hello world! Debug=on for Matrix Gateway!")
}
pub async fn serve_static_content() -> impl Responder {
HttpResponse::NotFound().body("Hello world! Static assets are not served in debug mode")
}
}
#[cfg(not(debug_assertions))]
mod serve_static_release {
use actix_web::{HttpResponse, Responder, web};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "static/"]
struct Asset;
fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
match (Asset::get(path), can_fallback) {
(Some(content), _) => HttpResponse::Ok()
.content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref())
.body(content.data.into_owned()),
(None, false) => HttpResponse::NotFound().body("404 Not Found"),
(None, true) => handle_embedded_file("index.html", false),
}
}
pub async fn root_index() -> impl Responder {
handle_embedded_file("index.html", false)
}
pub async fn serve_static_content(path: web::Path<String>) -> impl Responder {
handle_embedded_file(path.as_ref(), !path.as_ref().starts_with("static/"))
}
}

View File

@@ -29,6 +29,25 @@ pub struct WsRoomEvent<E> {
pub data: Box<E>,
}
#[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")]
@@ -41,6 +60,12 @@ pub enum WsMessage {
/// Room reaction event
RoomRedactionEvent(WsRoomEvent<RoomRedactionEventContent>),
/// Fully read message event
ReceiptEvent(WsReceiptEvent),
/// User is typing event
TypingEvent(WsTypingEvent),
}
impl WsMessage {
@@ -55,6 +80,7 @@ impl WsMessage {
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(),
@@ -64,6 +90,7 @@ impl WsMessage {
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(),
@@ -73,6 +100,34 @@ impl WsMessage {
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,
}
}

View File

@@ -11,11 +11,11 @@ use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants;
use matrixgw_backend::controllers::matrix::{
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
matrix_room_controller,
matrix_room_controller, matrix_space_controller,
};
use matrixgw_backend::controllers::{
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
tokens_controller, ws_controller,
static_controller, tokens_controller, ws_controller,
};
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
use matrixgw_backend::users::User;
@@ -138,6 +138,11 @@ async fn main() -> std::io::Result<()> {
web::get().to(matrix_sync_thread_controller::status),
)
.service(web::resource("/api/ws").route(web::get().to(ws_controller::ws)))
// Matrix spaces controller
.route(
"/api/matrix/space/hierarchy",
web::get().to(matrix_space_controller::hierarchy),
)
// Matrix room controller
.route(
"/api/matrix/room/joined",
@@ -155,6 +160,10 @@ async fn main() -> std::io::Result<()> {
"/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}",
@@ -189,11 +198,21 @@ async fn main() -> std::io::Result<()> {
"/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

@@ -8,8 +8,10 @@ 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)]
@@ -53,11 +55,11 @@ async fn sync_thread_task(
let mut handlers = vec![];
let tx_msg_handle = tx.clone();
let user_msg_handle = client.email.clone();
let user_msg_mail = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomMessageEvent, room: Room| {
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent(BxRoomEvent {
user: user_msg_handle.clone(),
user: user_msg_mail.clone(),
data: Box::new(event),
room,
})) {
@@ -67,11 +69,11 @@ async fn sync_thread_task(
));
let tx_reac_handle = tx.clone();
let user_reac_handle = client.email.clone();
let user_reac_mail = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncReactionEvent, room: Room| {
if let Err(e) = tx_reac_handle.send(BroadcastMessage::ReactionEvent(BxRoomEvent {
user: user_reac_handle.clone(),
user: user_reac_mail.clone(),
data: Box::new(event),
room,
})) {
@@ -81,21 +83,49 @@ async fn sync_thread_task(
));
let tx_redac_handle = tx.clone();
let user_redac_handle = client.email.clone();
let user_redac_mail = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomRedactionEvent, room: Room| {
if let Err(e) =
tx_redac_handle.send(BroadcastMessage::RoomRedactionEvent(BxRoomEvent {
user: user_redac_handle.clone(),
user: user_redac_mail.clone(),
data: Box::new(event),
room,
}))
{
log::warn!("Failed to forward reaction event! {e}");
log::warn!("Failed to forward redaction event! {e}");
}
},
));
let tx_receipt_handle = tx.clone();
let user_receipt_mail = client.email.clone();
handlers.push(
client.add_event_handler(async move |event: SyncReceiptEvent, room: Room| {
if let Err(e) = tx_receipt_handle.send(BroadcastMessage::ReceiptEvent(BxRoomEvent {
user: user_receipt_mail.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward receipt event! {e}");
}
}),
);
let tx_typing_handle = tx.clone();
let user_typing_mail = client.email.clone();
handlers.push(
client.add_event_handler(async move |event: SyncTypingEvent, room: Room| {
if let Err(e) = tx_typing_handle.send(BroadcastMessage::TypingEvent(BxRoomEvent {
user: user_typing_mail.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward typing event! {e}");
}
}),
);
loop {
tokio::select! {
// Message from tokio broadcast

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

@@ -45,7 +45,7 @@ export class AuthApi {
uri: "/auth/start_oidc",
method: "GET",
})
).data;
).data as { url: string };
}
/**
@@ -70,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

@@ -1,15 +1,27 @@
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: "m.text" | "m.image" | string;
msgtype: MessageType;
"m.relates_to"?: {
event_id: string;
rel_type: "m.replace" | string;
event_id?: string;
rel_type?: "m.replace" | string;
"m.in_reply_to"?: {
event_id?: string;
};
};
url?: string;
file?: {
url: string;
};
@@ -62,10 +74,10 @@ export class MatrixApiEvent {
await APIClient.exec({
method: "GET",
uri:
`/matrix/room/${encodeURIComponent(room.id)}/events` +
(from ? `?from=${from}` : ""),
`/matrix/room/${encodeURIComponent(room.id)}/events?limit=200` +
(from ? `&from=${from}` : ""),
})
).data;
).data as MatrixEventsList;
}
/**
@@ -91,4 +103,54 @@ export class MatrixApiEvent {
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

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

View File

@@ -11,9 +11,16 @@ export interface Room {
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
*/
@@ -50,6 +57,18 @@ export class MatrixApiRoom {
method: "GET",
uri: "/matrix/room/joined",
})
).data;
).data as Room[];
}
/**
* Get a room receipts
*/
static async RoomReceipts(room: Room): Promise<Receipt[]> {
return (
await APIClient.exec({
method: "GET",
uri: `/matrix/room/${room.id}/receipts`,
})
).data as Receipt[];
}
}

View File

@@ -0,0 +1,40 @@
import { APIClient } from "../ApiClient";
export type SpaceHierarchy = Map<string, string[]>;
export class MatrixApiSpace {
/**
* Request Matrix space hierarchy
*/
static async Hierarchy(): Promise<SpaceHierarchy> {
const hierarchy = new Map(
Object.entries(
(
await APIClient.exec({
method: "GET",
uri: "/matrix/space/hierarchy",
})
).data as { [s: string]: string[] }
)
) as SpaceHierarchy;
// Simplify hierarchy
while (true) {
let changed = false;
for (const [roomid, children] of hierarchy) {
for (const child of children) {
if (!hierarchy.has(child)) continue;
hierarchy.set(roomid, [
...hierarchy.get(roomid)!,
...hierarchy.get(child)!,
]);
hierarchy.delete(child);
changed = true;
}
}
if (!changed) break;
}
return hierarchy;
}
}

View File

@@ -0,0 +1,18 @@
import { Icon } from "@mui/material";
import { useActualColorMode } from "../widgets/dashboard/ThemeSwitcher";
export function AppIcon(p: { src: string; size?: string }): React.ReactElement {
const { mode } = useActualColorMode();
return (
<Icon style={{ display: "inline-flex", width: p.size, height: p.size }}>
<img
style={{
height: "100%",
flex: 1,
backgroundColor: mode === "dark" ? "white" : "black",
mask: `url("${p.src}")`,
}}
/>
</Icon>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 5C7.9 5 7 5.9 7 7V21L11 17H20C21.1 17 22 16.1 22 15V7C22 5.9 21.1 5 20 5H9M3 7C2.4 7 2 7.4 2 8S2.4 9 3 9H5V7H3M11 8H19V10H11V8M2 11C1.4 11 1 11.4 1 12S1.4 13 2 13H5V11H2M11 12H16V14H11V12M1 15C.4 15 0 15.4 0 16C0 16.6 .4 17 1 17H5V15H1Z" /></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14,2L11,3.5V19.94C7,19.5 4,17.46 4,15C4,12.75 6.5,10.85 10,10.22V8.19C4.86,8.88 1,11.66 1,15C1,18.56 5.36,21.5 11,21.94C11.03,21.94 11.06,21.94 11.09,21.94L14,20.5V2M15,8.19V10.22C16.15,10.43 17.18,10.77 18.06,11.22L16.5,12L23,13.5L22.5,9L20.5,10C19,9.12 17.12,8.47 15,8.19Z" /></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -19,7 +19,7 @@ import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { time } from "../utils/DateUtils";
export function APITokensRoute(): React.ReactElement {
const count = React.useRef(0);
const [count, setCount] = React.useState(0);
const [openCreateTokenDialog, setOpenCreateTokenDialog] =
React.useState(false);
@@ -34,7 +34,7 @@ export function APITokensRoute(): React.ReactElement {
};
const handleRefreshTokensList = () => {
count.current += 1;
setCount((c) => c + 1);
setList(undefined);
};
@@ -79,7 +79,7 @@ export function APITokensRoute(): React.ReactElement {
{/* Tokens list */}
<AsyncWidget
loadKey={count.current}
loadKey={count}
ready={list !== undefined}
load={load}
errMsg="Failed to load the list of tokens!"

View File

@@ -41,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,65 +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";
import { MatrixWS, WSState } from "../widgets/messages/MatrixWS";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
const State = {
Closed: "Closed",
Connected: "Connected",
Error: "Error",
} as const;
type TimestampedMessages = WsMessage & { time: number };
export function WSDebugRoute(): React.ReactElement {
const user = useUserInfo();
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
const snackbar = useSnackbar();
const [state, setState] = React.useState<string>(State.Closed);
const wsRef = React.useRef<WebSocket | undefined>(undefined);
const [state, setState] = React.useState<string>(WSState.Closed);
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
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"}>
<div>
State:{" "}
<span style={{ color: state == State.Connected ? "green" : "red" }}>
{/* Status bar */}
<div style={{ display: "flex", alignItems: "center" }}>
<span style={{ marginRight: "0.5em" }}>State: </span>
<span
style={{
marginRight: "0.5em",
color: state == WSState.Connected ? "green" : "red",
}}
>
{state}
</span>
<MatrixWS onStateChange={setState} onMessage={handleMessage} />
</div>
{/* WS messages list */}
{messages.map((msg, id) => (
<div style={{ margin: "10px", backgroundColor: "black" }}>
<JsonView

View File

@@ -1,9 +1,9 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import Icon from "@mdi/react";
import { mdiOpenid } from "@mdi/js";
import { ServerApi } from "../../api/ServerApi";
import React from "react";
import { AuthApi } from "../../api/AuthApi";
import { ServerApi } from "../../api/ServerApi";
import { AppIcon } from "../../icons/AppIcon";
import openid from "../../icons/openid.svg";
export function LoginRoute(): React.ReactElement {
const [loading, setLoading] = React.useState(false);
@@ -40,7 +40,7 @@ export function LoginRoute(): React.ReactElement {
fullWidth
variant="outlined"
onClick={authWithOpenID}
startIcon={<Icon path={mdiOpenid} size={1} />}
startIcon={<AppIcon src={openid} />}
>
Sign in with {ServerApi.Config.oidc_provider_name}
</Button>

View File

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

@@ -1,9 +1,13 @@
import dayjs from "dayjs";
import type {
MatrixEvent,
MatrixEventData,
MatrixEventsList,
MessageType,
} from "../api/matrix/MatrixApiEvent";
import type { Room } from "../api/matrix/MatrixApiRoom";
import type { Receipt, Room } from "../api/matrix/MatrixApiRoom";
import type { WsMessage } from "../api/WsApi";
import { timeMs } from "./DateUtils";
export interface MessageReaction {
event_id: string;
@@ -17,21 +21,37 @@ export interface Message {
time_sent: number;
time_sent_dayjs: dayjs.Dayjs;
modified: boolean;
reactions: MessageReaction[];
inReplyTo?: string;
reactions: Map<string, MessageReaction[]>;
content: string;
image?: 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[]>;
constructor(room: Room, initialMessages: MatrixEventsList) {
get canLoadOlder(): boolean {
return !!this.endToken && this.events.length > 0;
}
constructor(
room: Room,
initialMessages: MatrixEventsList,
receipts: Receipt[]
) {
this.room = room;
this.events = [];
this.receipts = receipts;
this.messages = [];
this.typingUsers = [];
this.receiptsEventsMap = new Map();
this.processNewEvents(initialMessages);
}
@@ -45,12 +65,95 @@ export class RoomEventsManager {
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
let redacted = new Set(
const redacted = new Set(
this.events
.map((e) =>
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
@@ -66,7 +169,7 @@ export class RoomEventsManager {
// Message
if (data.type === "m.room.message") {
// Check if this message replaces another one
if (data.content["m.relates_to"]) {
if (data.content["m.relates_to"]?.rel_type === "replace") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"]?.event_id
);
@@ -76,14 +179,34 @@ export class RoomEventsManager {
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,
reactions: [],
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),
image: data.content.file?.url,
type: data.content.msgtype,
file: data.content.file?.url ?? data.content.url,
content: data.content.body,
});
}
@@ -93,14 +216,26 @@ export class RoomEventsManager {
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;
message.reactions.push({
if (!message.reactions.has(key)) message.reactions.set(key, []);
message.reactions.get(key)!.push({
account: evt.sender,
event_id: evt.id,
key: data.content["m.relates_to"].key,
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,5 +1,3 @@
import { mdiMessageTextFast } from "@mdi/js";
import Icon from "@mdi/react";
import LogoutIcon from "@mui/icons-material/Logout";
import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
@@ -13,6 +11,8 @@ import Toolbar from "@mui/material/Toolbar";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { AppIcon } from "../../icons/AppIcon";
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
import { RouterLink } from "../RouterLink";
import { useUserInfo } from "./BaseAuthenticatedPage";
import ThemeSwitcher from "./ThemeSwitcher";
@@ -101,7 +101,7 @@ export default function DashboardHeader({
<RouterLink to="/">
<Stack direction="row" alignItems="center">
<LogoContainer>
<Icon path={mdiMessageTextFast} size="2em" />
<AppIcon src={mdiMessageTextFast} size="2em" />
</LogoContainer>
<Typography
variant="h6"

View File

@@ -1,5 +1,7 @@
import { mdiBug, mdiForum, mdiKeyVariant, mdiLinkLock } from "@mdi/js";
import Icon from "@mdi/react";
import BugReportIcon from "@mui/icons-material/BugReport";
import ForumIcon from "@mui/icons-material/Forum";
import KeyIcon from "@mui/icons-material/Key";
import LinkIcon from "@mui/icons-material/Link";
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
@@ -62,12 +64,12 @@ export default function DashboardSidebar({
if (!isOverSmViewport) {
setExpanded(false);
}
}, [expanded, setExpanded, isOverSmViewport]);
}, [setExpanded, isOverSmViewport]);
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
const getDrawerContent = React.useCallback(
(viewport: "phone" | "tablet" | "desktop") => (
(viewport: "phone" | "desktop") => (
<React.Fragment>
<Box
component="nav"
@@ -98,27 +100,27 @@ export default function DashboardSidebar({
<DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="Messages"
icon={<Icon path={mdiForum} size={"1.5em"} />}
icon={<ForumIcon style={{ height: "1em", width: "1em" }} />}
href="/"
mini={viewport === "desktop"}
/>
<DashboardSidebarDividerItem />
<DashboardSidebarPageItem
title="Matrix link"
icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
icon={<LinkIcon style={{ height: "1em", width: "1em" }} />}
href="/matrix_link"
mini={viewport === "desktop"}
/>
<DashboardSidebarPageItem
title="API tokens"
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
icon={<KeyIcon style={{ height: "1em", width: "1em" }} />}
href="/tokens"
mini={viewport === "desktop"}
/>
<DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="WS Debug"
icon={<Icon path={mdiBug} size={"1.5em"} />}
icon={<BugReportIcon style={{ height: "1em", width: "1em" }} />}
href="/wsdebug"
mini={viewport === "desktop"}
/>
@@ -159,7 +161,7 @@ export default function DashboardSidebar({
},
};
},
[expanded, !expanded]
[expanded]
);
const sidebarContextValue = React.useMemo(() => {
@@ -168,7 +170,7 @@ export default function DashboardSidebar({
fullyExpanded: isFullyExpanded,
hasDrawerTransitions,
};
}, [handlePageItemClick, !expanded, isFullyExpanded, hasDrawerTransitions]);
}, [handlePageItemClick, isFullyExpanded, hasDrawerTransitions]);
return (
<DashboardSidebarContext.Provider value={sidebarContextValue}>
@@ -194,20 +196,7 @@ export default function DashboardSidebar({
<Drawer
variant="permanent"
sx={{
display: {
xs: "none",
sm: "block",
md: "none",
},
...getDrawerSharedSx(false),
}}
>
{getDrawerContent("tablet")}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
display: { xs: "none", sm: "block", md: "block" },
...getDrawerSharedSx(false, true),
}}
>

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

@@ -2,12 +2,16 @@ import { Avatar } from "@mui/material";
import type { UserProfile } from "../../api/matrix/MatrixApiProfile";
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
export function AccountIcon(p: { user: UserProfile }): React.ReactElement {
export function AccountIcon(p: {
user: UserProfile;
size?: number;
}): React.ReactElement {
return (
<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

@@ -1,25 +1,41 @@
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 load = async () => {
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) => {
@@ -33,41 +49,138 @@ export function MainMessageWidget(): React.ReactElement {
return (
<AsyncWidget
loadKey={1}
load={load}
ready={!!rooms && !!users}
load={loadRoomsList}
ready={!!rooms && !!users && !!hierarchy}
errMsg="Failed to initialize messaging component!"
build={() => <_MainMessageWidget rooms={rooms!} users={users!} />}
build={() => (
<MainMessageWidgetInner
rooms={rooms!}
hierarchy={hierarchy!}
users={users!}
onRoomsListUpdate={(cb) => setRooms((r) => cb(r!))}
/>
)}
/>
);
}
function _MainMessageWidget(p: {
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 [room, setRoom] = React.useState<Room | undefined>();
const [currentRoom, setCurrentRoom] = React.useState<Room | undefined>();
const spaceRooms = React.useMemo(() => {
return p.rooms
.filter((r) => !r.is_space && (!space || r.parents.includes(space)))
.filter(
(r) => !r.is_space && (!space || p.hierarchy.get(space)?.includes(r.id))
)
.sort(
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
);
}, [space, p.rooms]);
}, [space, p.rooms, p.hierarchy]);
const unreadRooms = React.useMemo(
() =>
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={room}
onChange={setRoom}
currRoom={currentRoom}
onChange={setCurrentRoom}
/>
{/* Separator */}
<Divider orientation="vertical" />
{room === undefined && (
{/* If no room is selected */}
{currentRoom === undefined && (
<div
style={{
display: "flex",
@@ -79,7 +192,19 @@ function _MainMessageWidget(p: {
No room selected.
</div>
)}
{room && <RoomWidget {...p} room={room} />}
{/* 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

@@ -1,26 +1,96 @@
import { Dialog, Typography } from "@mui/material";
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 { Room } from "../../api/matrix/MatrixApiRoom";
import type { Message, RoomEventsManager } from "../../utils/RoomEventsManager";
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";
import React from "react";
export function RoomMessagesList(p: {
room: Room;
users: UsersMap;
mgr: RoomEventsManager;
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: "smooth" });
}, [p.mgr.messages.length]);
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%",
@@ -29,20 +99,82 @@ export function RoomMessagesList(p: {
paddingLeft: "20px",
}}
>
{p.mgr.messages.map((m, idx) => (
{/** 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.mgr.messages[idx - 1].account === m.account &&
m.time_sent - p.mgr.messages[idx - 1].time_sent < 60 * 3 * 1000
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.mgr.messages[idx - 1].time_sent_dayjs.startOf("day").unix()
p.manager.messages[idx - 1].time_sent_dayjs.startOf("day").unix()
}
receipts={p.manager.receiptsEventsMap.get(m.event_id)}
repliedMessage={
(m.inReplyTo &&
p.manager.messages.find((s) => s.event_id === m.inReplyTo)) ||
undefined
}
/>
))}
@@ -58,15 +190,90 @@ function RoomMessage(p: {
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 user = p.users.get(p.message.account);
const sender = p.users.get(p.message.account);
const repliedMsgSender =
p.repliedMessage && p.users.get(p.repliedMessage.account);
const handleDeleteMessage = async () => {
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"
@@ -76,7 +283,9 @@ function RoomMessage(p: {
{p.message.time_sent_dayjs.format("DD/MM/YYYY")}
</Typography>
)}
{(!p.previousFromSamePerson || p.firstMessageOfDay) && user && (
{/* Give person name if required */}
{(!p.previousFromSamePerson || p.firstMessageOfDay) && sender && (
<div
style={{
display: "flex",
@@ -85,40 +294,244 @@ function RoomMessage(p: {
marginTop: "20px",
}}
>
<AccountIcon user={user} />
<AccountIcon user={sender} />
&nbsp;&nbsp;&nbsp;
{user.display_name}
{sender.display_name}
</div>
)}
<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">
&nbsp; {p.message.time_sent_dayjs.format("HH:mm")}
</Typography>{" "}
&nbsp;&nbsp;&nbsp;
{p.message.image ? (
<img
onClick={() => setShowImageFullScreen(true)}
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
true
)}
style={{
maxWidth: "200px",
}}
/>
) : (
p.message.content
)}
</div>
<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(
@@ -128,6 +541,87 @@ function RoomMessage(p: {
)}
/>
</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

@@ -1,3 +1,4 @@
import SearchIcon from "@mui/icons-material/Search";
import {
Chip,
List,
@@ -5,7 +6,9 @@ import {
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";
@@ -21,6 +24,21 @@ export function RoomSelector(p: {
}): React.ReactElement {
const user = useUserInfo();
const [filter, setFilter] = React.useState("");
const [unread, setUnread] = React.useState(false);
const shownRooms = React.useMemo(
() =>
p.rooms
.filter((r) => !unread || r.number_unread_messages > 0)
.filter(
(r) =>
filter === "" ||
r.name?.toLocaleLowerCase()?.includes(filter.toLocaleLowerCase())
),
[p.rooms, unread, filter]
);
if (p.rooms.length === 0)
return (
<div
@@ -36,45 +54,75 @@ export function RoomSelector(p: {
);
return (
<List
style={{
width: ROOM_SELECTOR_WIDTH,
}}
>
{p.rooms.map((r) => (
<ListItem
key={r.id}
secondaryAction={
r.number_unread_messages === 0 ? undefined : (
<Chip color="error" label={r.number_unread_messages} />
)
}
disablePadding
>
<ListItemButton
role={undefined}
onClick={() => p.onChange(r)}
dense
selected={p.currRoom?.id === r.id}
<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
>
<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>
<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

@@ -2,36 +2,43 @@ 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 { AsyncWidget } from "../AsyncWidget";
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 [roomMgr, setRoomMgr] = React.useState<undefined | RoomEventsManager>();
const snackbar = useSnackbar();
const load = async () => {
setRoomMgr(undefined);
const messages = await MatrixApiEvent.GetRoomEvents(p.room);
const mgr = new RoomEventsManager(p.room, messages);
setRoomMgr(mgr);
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 (
<AsyncWidget
loadKey={p.room.id}
ready={!!roomMgr}
load={load}
errMsg="Failed to load room!"
build={() => (
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
<RoomMessagesList mgr={roomMgr!} {...p} />
<SendMessageForm {...p} />
</div>
)}
/>
<div
style={{ display: "flex", flexDirection: "column", flex: 1 }}
onClick={handleRoomClick}
>
<RoomMessagesList {...p} />
<TypingNotice {...p} />
<SendMessageForm {...p} />
</div>
);
}

View File

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

View File

@@ -0,0 +1,35 @@
import { Typography } from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { RoomEventsManager } from "../../utils/RoomEventsManager";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
export function TypingNotice(p: {
users: UsersMap;
manager: RoomEventsManager;
}): React.ReactElement {
const user = useUserInfo();
const users = React.useMemo(
() =>
[...p.users.values()].filter(
(u) =>
p.manager.typingUsers.includes(u.user_id) &&
u.user_id !== user.info.matrix_user_id
),
[p.manager.typingUsers, p.users, user.info.matrix_user_id]
);
if (users.length === 0) return <></>;
return (
<Typography
variant="caption"
component="div"
style={{ paddingLeft: "20px" }}
>
{users.map((u) => u.display_name ?? u.display_name).join(", ")}{" "}
{users.length > 1 ? "are" : "is"} typing...
</Typography>
);
}