259 Commits
v0.0.2 ... main

Author SHA1 Message Date
Ulyssa
93fc47d019 Release v0.0.11 2026-01-19 19:22:41 -05:00
vaw
a32149f604 Fix CI on main branch (#545)
Co-authored-by: Benjamin Große <ste3ls@gmail.com>
2025-10-26 07:44:38 -07:00
vaw
3149f79d11 Add :replied to go to the message the selected message replied to (#452) 2025-10-26 14:36:46 +00:00
vaw
7ccb1cbf2c Upgrade Matrix SDK to 0.14 (#521) 2025-10-25 16:23:59 -07:00
Benjamin Grosse
1ec311590d Use cargo crane in Nix flake and set up cachix action (#539) 2025-10-25 22:44:19 +00:00
Thierry Delafontaine
0ddded3b8b Remove deprecated Apple SDK frameworks pattern (#543) 2025-10-25 14:43:25 -07:00
vaw
a8cbc352ff Indicate encryption state of room in messagebar (#522) 2025-10-25 14:41:08 -07:00
vaw
dfa0937077 Remove blocking timeout for first sync on startup (#529) 2025-10-25 13:54:47 -07:00
Sandro Santilli
43485270ee Document how to build from sources (#513) 2025-10-25 20:54:19 +00:00
vaw
28fea03625 Improve error message for UnknownToken on login (#514) 2025-10-25 13:53:47 -07:00
vaw
e021d4a55d Add :forget to forget all left rooms (#507) 2025-10-25 13:41:34 -07:00
vaw
b01dbe5a5d Add more compatibility for unreads (#451) 2025-10-25 20:22:14 +00:00
vaw
4b2382bf93 Fix image preview placeholder rendering (#483) 2025-10-25 13:00:49 -07:00
Electria
0f2442566f Fix incorrect empty unreads window message (#541) 2025-10-25 19:59:06 +00:00
vaw
8c9a2714a1 Fix rustfmt warning (#523) 2025-10-25 12:55:23 -07:00
vaw
d44f861871 Respect user color of replied message with message_user_color (#532) 2025-10-25 12:54:16 -07:00
vaw
14aa97251c Expand ~ and shell variables in dirs config (#538) 2025-10-25 12:52:14 -07:00
vaw
55456dbc1e Treat unknown html tags as plain text (#509) 2025-09-13 13:38:47 -07:00
vaw
d5c330ac72 Fix most clippy warnigs (#501) 2025-09-13 13:32:25 -07:00
weird
7b1dc93f3a Update Nix flake and its lockfile (#500) 2025-09-02 22:10:16 -07:00
vaw
745f547904 Fall back to showing body for unknown message types (#496) 2025-09-02 22:02:21 -07:00
Akseli
6ebb7ac7fd Add config option for playing sound-hints with desktop notifications (#481) 2025-08-22 14:47:33 -07:00
vaw
1bb93c18fb Search :members by display name and user id (#482) 2025-08-22 14:30:57 -07:00
vaw
e3090e537f Handle attachment file names more robustly (#494) 2025-08-22 14:24:35 -07:00
vaw
ad10082c2f Upgrade matrix sdk 0.13 (#485)
Co-authored-by: Ken Rachynski <chief@troublemaker.dev>
2025-08-22 14:16:01 -07:00
Ulyssa
67603d0623 Update to modalkit{,-ratatui}@0.0.24 (#492) 2025-08-16 23:40:59 +00:00
vaw
e9cdb3371a Clear desktop notification when message is read (#427)
Co-authored-by: Ulyssa <git@ulyssa.dev>
2025-07-23 05:19:23 +00:00
vaw
0ff8828a1c Add config option to allow resetting mode after sending a message (#459)
Co-authored-by: Ulyssa <git@ulyssa.dev>
2025-07-23 04:05:40 +00:00
vaw
331a6bca89 Make blockquotes in message visually distict (#466) 2025-07-22 17:26:29 -07:00
Thierry Delafontaine
963ce3c7c2 Support XDG_CONFIG_HOME on macOS for config directory resolution (#478) 2025-07-22 17:19:18 -07:00
vaw
ec88f4441e Recognise URLs in plain text message bodies (#476) 2025-07-22 17:05:23 -07:00
vaw
34d3b844af Highlight border of focused window (#470) 2025-07-05 00:25:38 +00:00
Ulyssa
52010d44d7 Update to modalkit{,-ratatui}@0.0.23 (#473) 2025-07-05 00:12:50 +00:00
vaw
0ef5c39f7f Make merging of configuration options consistent (#471) 2025-06-25 13:14:04 -07:00
VAWVAW
fed19d7a4b Improve image preview placeholder (#453) 2025-06-21 11:25:46 -07:00
VAWVAW
ed9ee26854 Add missing <s> tag in HTML parsing (#465) 2025-06-21 11:22:21 -07:00
VAWVAW
2e6c711644 Make scrollback display stable with typing_notice_display = false (#469) 2025-06-21 10:43:26 -07:00
VAWVAW
d1b03880f3 Remove duplicate documentation from manpage (#454) 2025-06-16 18:35:38 -07:00
Pavlo Rudy
d961fe3f7b Document settings.state_event_display in manual page (#455) 2025-06-16 18:31:01 -07:00
VAWVAW
9e40b49e5e Fix display of tabs in code blocks (#463) 2025-06-16 18:30:07 -07:00
Ulyssa
33d3407694 Apply user highlighting to display name changes (#449) 2025-06-06 02:46:32 +00:00
VAWVAW
f880358a83 Implement receipts per thread (#438) 2025-06-06 01:11:57 +00:00
VAWVAW
f0de97a049 Remove image preview on message redaction (#448) 2025-06-05 10:16:01 -07:00
VAWVAW
a9cb5608f0 Document every client command in the manual page (#441) 2025-06-05 04:57:06 +00:00
Ulyssa
c420c9dd65 Add configuration option for hiding state events (#447) 2025-06-05 02:36:21 +00:00
Ulyssa
ba7d0392d8 Do proper Unicode collation on room names (#440) 2025-05-31 12:52:15 -07:00
Ulyssa
9ed9400b67 Support automatically toggling room focus (#337) 2025-05-31 09:29:49 -07:00
Ulyssa
84eaadc09a Show state events in the timeline (#437) 2025-05-30 23:06:19 -07:00
Ulyssa
998e50f4a5 Update lockfile dependencies (#436) 2025-05-31 03:42:38 +00:00
VAWVAW
f39261ff84 Fix most incorrect unreads on startup (#433) 2025-05-30 08:56:46 -07:00
Ulyssa
98aa2f871d Update to ratatui-image@8.0.1 (#434) 2025-05-30 15:39:13 +00:00
VAWVAW
952374aab0 Show more text in notifications and use "normal" urgency for dbus notifications (#430) 2025-05-29 19:28:08 -07:00
VAWVAW
e99674b245 Query user for profile at startup when none have been specified (#432) 2025-05-29 19:25:07 -07:00
Aleš Katona
82ed796a91 Add support for scrolling w/ mouse when explicitly enabled (#389)
Co-authored-by: Ulyssa <git@ulyssa.dev>
2025-05-29 04:48:10 +00:00
VAWVAW
3296f58859 Omit room name on desktop notifications for DMs (#428) 2025-05-28 20:23:26 -07:00
VAWVAW
26802bab55 Fix Clippy warnings for 1.83 (#429) 2025-05-28 19:59:42 -07:00
VAWVAW
fd3fef5c9e Allow spaces to be searched by name (#404) 2025-05-23 09:26:17 -07:00
Ulyssa
af96bfbb41 Update to latest modalkit, modalkit-ratatui and ratatui-image (#422) 2025-05-16 18:02:43 -07:00
Ulyssa
5f927ce9c3 Binaries worklog should override rust-toolchain.yml (#420) 2025-05-15 21:21:05 -07:00
Jihyeon Kim (김지현)
6e923f3878 Update modalkit and modalkit-ratatui to SHA 45855daeeb (#358) 2025-05-16 03:09:12 +00:00
Ulyssa
ebd89423e9 Bump minimum supported Rust version to 1.83 (#420) 2025-05-16 01:11:34 +00:00
Ulyssa
9fce71f896 Display <unknown> for unknown room history visibility (#397) 2025-05-15 17:56:43 -07:00
Ken Rachynski
93502f9993 Bump matrix-sdk dependency to 0.10.0 (#397) 2025-05-15 17:56:35 -07:00
Ulyssa
6529e61963 Update binaries workflow to mozilla-actions/sccache-action@v0.0.9 (#419) 2025-05-15 09:26:41 -07:00
Andrew Collins
a9c1e69a89 Fix image preview in replies and threads (#366) 2025-05-15 04:23:39 +00:00
VAWVAW
3e45ca3d2c Support adding rooms to spaces (#407) 2025-05-15 03:26:35 +00:00
Felix Van der Jeugt
7dd09e32a8 Support an "invite" field in the room sorting settings (#395)
Co-authored-by: Felix Van der Jeugt <felix.vanderjeugt@posteo.net>
2025-05-14 19:39:22 -07:00
daef
1dcd658928 Support :room topic show (#380) 2025-05-14 19:05:58 -07:00
Repoman
382a72a468 Mention Gentoo's GURU ebuild in the README (#374) 2025-05-15 01:51:19 +00:00
Benjamin Bouvier
591fc0af83 Address some warnings and typos (#408) 2025-05-15 01:46:13 +00:00
Ulyssa
2b6363f529 Update to mozilla-actions/sccache-action@v0.0.9 (#419) 2025-05-15 01:38:22 +00:00
VAWVAW
6470e845e0 Fix warning from cargo doc (#413) 2025-05-14 18:22:27 -07:00
Odd Eivind Ebbesen
b023e38f77 Updated rust version and added sqlite in flake.nix (#396) 2025-02-24 03:16:46 +00:00
Stu Black
e66a8c6716 Bump matrix-sdk dependency to 0.8 (#386) 2025-02-18 03:22:16 +00:00
Nemo157
9a9bdb4862 Support enabling multiple notification sinks (#344) 2024-09-16 22:15:36 -07:00
Nemo157
e40a8a8d2e Fix ratatui-image tmux detection when used with a configured image protocol (#352) 2024-09-16 22:12:16 -07:00
Nemo157
f4492c9f77 Fix Clippy warning for unused format! in 1.81 (#343) 2024-08-30 09:10:15 -07:00
Ulyssa
a32915b7e9 Update Cargo.toml to v0.0.11-alpha.1 (#346) 2024-08-30 16:08:12 +00:00
Ulyssa
3355eb2d26 Do not use icons in MetaInfo (#336) 2024-08-23 18:35:32 +00:00
Ulyssa
7b6c5df268 Update MetaInfo for v0.0.10 release (#335) 2024-08-21 16:10:56 +00:00
Ulyssa
2e6376ff86 Release v0.0.10 (#333) 2024-08-20 22:26:52 -07:00
Ulyssa
480888a1fc Add commands for viewing and clearing unreads (#332) 2024-08-20 19:33:46 -07:00
Ulyssa
4fc05c7b40 Handle message marks on non-64-bit platforms (#329) 2024-08-18 08:31:42 +00:00
Ulyssa
3003f0a528 Add command for setting room history visibility (#328) 2024-08-18 07:33:45 +00:00
Ulyssa
df3896df9c Add ban/unban/kick room commands (#327) 2024-08-18 01:50:48 +00:00
Tony
2a66496913 Add command to set per-room notification levels (#305) 2024-08-17 14:43:19 -07:00
Ulyssa
b4fc574163 Include room name in desktop notifications (#326) 2024-08-17 01:03:13 -07:00
Ulyssa
e63341fe32 Avoid treating simple messages as Markdown (#325) 2024-08-16 23:56:30 -07:00
Ulyssa
657e61fe2e Remove modifyOtherKeys enablement (#324) 2024-08-17 03:06:35 +00:00
Ulyssa
94999dc4c0 Build cross-platform binaries and packages of main (#323) 2024-08-16 10:06:26 -07:00
Ulyssa
54cb7991be Fix underflow panics when using TextPrinter::push_span_nobreak (#322) 2024-08-15 03:48:04 +00:00
Ulyssa
c94d7d0ad7 Add metadata for cargo-deb and cargo-generate-rpm (#321) 2024-08-15 03:37:56 +00:00
Ulyssa
d44961c461 Support reacting literally with non-Emojis (#320) 2024-08-13 06:21:11 +00:00
Ulyssa
6d80b516f8 Update to modalkit{,-ratatui}@0.0.20 (#319) 2024-08-12 04:59:32 +00:00
Ulyssa
04480eda1b Add message slash commands (#317) 2024-08-08 05:49:54 +00:00
Ulyssa
653287478e Add FreeDesktop MetaInfo file (#315) 2024-08-01 07:45:50 +00:00
lymkwi
4571788678 Implement set/unset/show for alternative and canonical aliases (#279) 2024-08-01 06:51:01 +00:00
Andrew Collins
9a1adfb287 Allow notifications on open room if terminal not focused (#281) 2024-08-01 03:37:21 +00:00
Backroom8816
cb4455655f Add Hombrew as install method on MacOS (#303) 2024-08-01 03:10:31 +00:00
Jarkko Sakkinen
4fc71c9291 Fix newer Clippy warnings for 1.80 (#301) 2024-08-01 03:02:42 +00:00
Veronika
d8d8e91295 Remove timeout for desktop notifications (#314) 2024-08-01 02:56:33 +00:00
Aurore Poirier
497be7f099 Display file sizes for attachments (#278) 2024-05-25 16:16:04 -07:00
mordquist
64e4f67e43 Add error for missing username on :logout (#277) 2024-05-25 15:53:52 -07:00
Andrew Collins
a18d0f54eb Trim :editor output and check if it's empty (#275) 2024-05-25 15:52:41 -07:00
Lars E
59e1862e9c Add FreeBSD installation instructions (#280) 2024-05-25 20:42:46 +00:00
Joshua Smith
14415a30fc Fix openSUSE link and installation command in README (#283) 2024-05-25 20:40:25 +00:00
Gabor Pihaj
6c0d126f4b Add missing darwin build dependency (#286) 2024-05-25 20:38:01 +00:00
Ulyssa
c6982c9737 Fix LICENSE file (#274) 2024-04-24 06:59:00 +00:00
Ulyssa
46f6d37f76 Update to modalkit{,-ratatui}@0.0.19 (#273) 2024-04-24 06:30:01 +00:00
Ulyssa
3971801aa3 Allow typing newline with <S-Enter> and enable keyboard enhancement protocol (#272) 2024-04-21 18:19:53 -07:00
Ulyssa
7bc34c8145 Update Cargo.toml to v0.0.10-alpha.1 and update dependencies (#269) 2024-04-17 08:06:08 +00:00
Ethan Reynolds
91ca50aecb Fix image preview placement when messages are preceded by a date in the timeline (#257) 2024-04-13 15:47:08 -07:00
Ulyssa
949100bdc7 Update Welcome window to reference TOML instead of JSON (#254) 2024-04-12 06:20:05 +00:00
Ethan Reynolds
b995906c79 Add external_edit_file_suffix to config (#253) 2024-04-11 20:50:26 -07:00
Ulyssa
e5b284ed19 Use color overrides for users when message_user_color is enabled (#245) 2024-04-02 15:42:27 +00:00
Matthias Ahouansou
0f17bbfa17 Fix reaction count when there are duplicate reaction events from a user (#239) 2024-04-02 15:40:25 +00:00
Matthias Ahouansou
aba72aa64d Prevent sending duplicate reaction events (#240) 2024-04-02 15:21:24 +00:00
Benjamin Grosse
72d35431de Update to ratatui-image@1.0.0 (#241) 2024-04-02 08:01:00 -07:00
Ulyssa
a98bbd97be Support marking a room as a direct message room (#92) 2024-03-31 00:12:57 -07:00
Ulyssa
82645c8828 Release v0.0.9 (#236) 2024-03-29 04:35:38 +00:00
Ulyssa
5a2a7b028d Wait to log in before starting background tasks (#234) 2024-03-29 04:14:37 +00:00
Ulyssa
2327658e8c Add commands for importing and exporting room keys (#233) 2024-03-28 20:58:34 -07:00
Ulyssa
b4e9c213e6 Add an icon for iamb (#232) 2024-03-28 16:20:27 +00:00
Ulyssa
79f6b5b75c Reset message bar when ! is passed with :cancel (#231) 2024-03-27 19:35:15 -07:00
Ulyssa
6600685dd5 Update manual pages to use mdoc(7) and list commands (#230) 2024-03-26 15:55:22 +00:00
Ulyssa
ed1b88c197 Support loading a TOML configuration (#229) 2024-03-25 21:30:35 -07:00
Ulyssa
99996e275b Support notifications via terminal bell (#227)
Co-authored-by: Benjamin Grosse <ste3ls@gmail.com>
2024-03-24 17:19:34 +00:00
Ulyssa
db9cb92737 Enable autolinking when rendering Markdown (#226) 2024-03-24 03:06:33 +00:00
Ulyssa
d3b717d1be Fix image previews in replies (#225) 2024-03-24 02:41:05 +00:00
Ulyssa
2ac71da9a6 Fix entering thread view when there's no messages yet (#224) 2024-03-24 02:20:06 +00:00
Ulyssa
1e9b6cc271 Provide better error message for M_UNKNOWN_TOKEN (#101) 2024-03-23 19:09:11 -07:00
mordquist
46e081b1e4 Support configuring user gutter width (#223) 2024-03-23 18:54:26 -07:00
Bernhard Bliem
23a729e565 Support displaying shortcodes instead of Emojis in messages (#222) 2024-03-23 16:35:10 -07:00
Benjamin Grosse
0c52375e06 Add support for desktop notifications (#192) 2024-03-21 17:46:46 -07:00
Ulyssa
c63f8d98d5 Fix odd Windows-only compile error (#221) 2024-03-20 22:30:14 -07:00
Ulyssa
013214899a Ignore key releases on platforms that support it (#220) 2024-03-21 05:13:47 +00:00
Ulyssa
8a5049fb25 GitHub workflow should use --locked to avoid broken Cargo.lock (#219) 2024-03-20 15:29:04 +00:00
Ulyssa
9c6ff58b96 Support linking against system OpenSSL (#218) 2024-03-19 21:55:14 -07:00
Thomas Vodrazka
b41faff9b7 Add example of mapping "V" to toggle message selection mode (#195) 2024-03-09 22:57:35 -08:00
Ulyssa
e7f158ffcd Add support for custom key macros (#217) 2024-03-10 06:49:40 +00:00
Ulyssa
ef868175cb Add support for threads (#216) 2024-03-09 00:47:05 -08:00
Benjamin Grosse
8ee203c9a9 Update to ratatui-image@0.8.1 (#215) 2024-03-08 20:04:52 -08:00
Ryan
95f2c7af30 Nix flake updates (#214) 2024-03-08 20:03:55 -08:00
Ali Elnwegy
c71cec1f54 Fix Nix flake hashes (#206) 2024-03-08 06:06:02 +00:00
Ulyssa
ec81b72f2c Load receipts for room before acquiring lock (#213) 2024-03-07 07:49:35 +00:00
Ulyssa
dd001af365 Download rooms keys from backups if they exist (#211) 2024-03-02 23:55:27 +00:00
Ulyssa
9732971fc2 Update to matrix-sdk@0.7.1 (#200) 2024-03-02 23:00:29 +00:00
Alan Pope
1948d80ec8 Add snap install instructions (#210) 2024-03-02 21:48:46 +00:00
Ulyssa
84bc6be822 Support following the .well-known entries for a username's domain (#209) 2024-02-29 07:21:31 +00:00
Ulyssa
c5999bffc8 Pull in modalkit repository with a Cargo.lock (#208) 2024-02-29 07:00:25 +00:00
Ulyssa
aa878f7569 Move LTO into its own "release-lto" profile (#207) 2024-02-29 06:31:00 +00:00
Ulyssa
a2a708f1ae Indicate and sort on rooms with unread messages (#205)
Fixes #83
2024-02-28 09:03:28 -08:00
Benjamin Grosse
3ed87aae05 Support coloring entire message with the user color (#193) 2024-02-28 06:52:24 +00:00
Ulyssa
1325295d2b Update modalkit dependencies (#204) 2024-02-28 05:21:05 +00:00
Benjamin Lee
1cb280df8b Fix truncation/padding for non-ASCII sender names (#182) 2024-02-27 21:09:37 -08:00
Rerum02
5be886301b Update README.md to add openSUSE Tumbleweed (#191) 2024-02-28 03:43:03 +00:00
O. C. Taskin
3e3b771b2e Rename Nix flake build input from pkgconfig to pkg-config (#203) 2024-02-28 03:23:17 +00:00
FormindVER
b7ae01499b Add a new :chats window that lists both DMs and Rooms (#184)
Fixes #172
2024-02-27 18:37:10 -08:00
Benjamin Grosse
88af9bfec3 Fix crash on small image preview (#198) 2024-01-27 23:35:07 -08:00
Benjamin Grosse
999399a70f Fix not showing display names in already synced rooms (#171)
Fixes #149
2023-12-18 20:55:04 -08:00
sem pruijs
b33759cbc3 Enable direnv for Nix flakes (#183) 2023-12-19 00:53:17 +00:00
Benjamin Grosse
4236d9f53e Update to ratatui-image@0.4.3 to use native sixel lib (#181) 2023-11-24 15:22:39 -08:00
Benjamin Grosse
1ae22086f6 Fix image preview offset (#179) 2023-11-20 13:22:15 -08:00
Benjamin Grosse
221faa828d Add support for previewing images in room scrollback (#108) 2023-11-16 08:36:22 -08:00
Ron Waldon-Howe
974775b29b feat: desktop file for GUI environment launchers (#178) 2023-11-14 12:19:54 -08:00
chloe
25eef55eb7 Add support for logging in with SSO (#160) 2023-11-04 21:39:17 +00:00
Ulyssa
8943909f06 Support custom sorting for room and user lists (#170) 2023-10-21 02:32:33 +00:00
Ulyssa
443ad241b4 Use mozilla-actions/sccache-action for caching builds (#169) 2023-10-21 01:48:06 +00:00
Aaditya Dhruv
3b86be0545 Add new command for logging out of iamb session (#162) 2023-10-19 21:40:22 -07:00
Benjamin Lee
b2b47ed7a0 Reduce CPU usage by instead fetching read receipts after related sync events (#168) 2023-10-16 01:12:39 +00:00
Ulyssa
df3148b9f5 Links should be "openable" (#43) 2023-10-07 18:25:25 -07:00
Benjamin Große
95af00ba93 Update modalkit for newer ratatui and crossterm 2023-10-07 17:21:48 -07:00
Ulyssa
9197864c5c Add more documentation (#166) 2023-10-06 22:35:27 -07:00
Ulyssa
2673cfaeb9 Fix CI workflow (#164) 2023-10-05 18:37:31 -07:00
Ulyssa
c7864cb869 Enable sending strikethrough text (#141) 2023-09-12 17:27:04 -07:00
Ulyssa
7fdb5f98e3 Update Cargo.lock file (#157) 2023-09-12 17:17:29 -07:00
Leonid Dyachkov
0565b6eb05 Support composing messages in an external editor (#155) 2023-09-12 17:07:56 -07:00
balejk
47e650c2be Fix example config (#140) 2023-09-12 16:50:37 -07:00
Ulyssa
89bb107c87 Release v0.0.8 (fix cargo publish issues) (#134) 2023-07-07 23:21:47 -07:00
Ulyssa
ca4c0034d9 Release v0.0.8 (#134) 2023-07-07 22:46:08 -07:00
Ulyssa
bb30cecc63 Handle sync failure after successful password entry (#133) 2023-07-07 22:35:33 -07:00
Ulyssa
7b050f82aa Indicate when there are new messages below scrollback viewport (#131) 2023-07-07 22:16:57 -07:00
Ulyssa
b1ccec6732 Code blocks get rendered without line breaks (#122) 2023-07-07 21:46:13 -07:00
Ulyssa
6e8e12b579 Need fallback behaviour when dirs::download_dir returns None (#118) 2023-07-07 20:35:01 -07:00
Ulyssa
3da9835a17 Profile session token should only be readable by the user (#130) 2023-07-07 20:34:52 -07:00
Ulyssa
64891ec68f Support hiding server part of username in message scrollback (#71) 2023-07-06 23:15:58 -07:00
Ulyssa
61aba80be1 Reduce number of Tokio workers (#129) 2023-07-05 15:25:42 -07:00
Ulyssa
8d4539831f Remove trailing newlines in body (#125) 2023-06-30 21:18:08 -07:00
Ulyssa
7c39e88ba2 Restore opened tabs and windows upon restart (#72) 2023-06-28 23:42:31 -07:00
satoqz
758397eb5a Fix Nix flake build on Darwin (#117) 2023-06-22 23:05:50 -07:00
u2on
1a0af6df37 Link to AUR pkg in README (#121) 2023-06-22 23:01:07 -07:00
Ulyssa
885b56038f Use terminal window focus to determine when a message has actually been seen (#94) 2023-06-14 22:42:23 -07:00
Benjamin Große
430c601ff2 Support configuring which program :open runs (#95) 2023-06-14 21:36:23 -07:00
Moritz Poldrack
0ddefcd7b3 Add manual pages (#88) 2023-06-14 21:14:23 -07:00
Ulyssa
2a573b6056 Show Git SHA information when printing version information (#120) 2023-06-14 20:28:01 -07:00
mikoto
a020b860dd Indicate number of members in room (#110) 2023-06-14 19:42:53 -07:00
jasalltime
6c031f589e Mention Minimum Supported Rust Version in README (#115) 2023-06-14 19:28:23 -07:00
Ulyssa
b0256d7120 Replace GitHub actions using deprecated features (#114) 2023-05-28 21:46:43 -07:00
Ulyssa
0f870367b3 Show errors fetching space hierarchy when list is empty (#113) 2023-05-28 13:16:37 -07:00
Ulyssa
8d22b83d85 Support sending and completing Emoji shortcodes in the message bar (#100) 2023-05-24 21:14:13 -07:00
Pavlo Rudy
529073f4d4 Upload artifacts built in GitHub Actions (#105) 2023-05-22 17:20:17 -07:00
Ulyssa
17c87a617e Cache build directory in GitHub Actions (#107) 2023-05-19 18:25:09 -07:00
Benjamin Große
2899d4f45a Support uploading image attachments from clipboard (#36) 2023-05-19 17:38:23 -07:00
Ulyssa
ad8b4a60d2 ChatStore::set_receipts locks up app for bad connections (#99) 2023-05-12 17:42:25 -07:00
Ulyssa
4935899aed Indicate when you're editing a message (#75) 2023-05-01 22:14:08 -07:00
Ulyssa
cc1d2f3bf8 Gracefully handle verification events that are unknown locally (#90) 2023-05-01 21:33:12 -07:00
Ulyssa
5df9fe7960 Tab completion panics for unrecognized commands (#81) 2023-05-01 21:14:19 -07:00
Ulyssa
a5c25f2487 Support leaving rooms (#45) 2023-04-28 16:52:33 -07:00
Benjamin Große
50023bad40 Append suffix to download filenames to avoid overwrites (#35) 2023-04-28 15:56:14 -07:00
Moritz Poldrack
b6a318dfe3 Fix error message for undefined download directory (#87) 2023-04-25 13:57:03 -07:00
Ulyssa
ad3b40d538 Interpret newlines as line breaks when converting Markdown to HTML (#74) 2023-04-06 16:10:48 -07:00
Ulyssa
953be6a195 Add FUNDING.yml to project (#77) 2023-03-31 18:38:13 -07:00
Benjamin Große
463d46b8ab Add Nix flake (#73) 2023-03-31 11:43:22 -07:00
Ulyssa
274234ce4c Update locked Cargo dependencies (#70) 2023-03-23 13:39:57 -07:00
Ulyssa
a2590b6bbb Release v0.0.7 (#68) 2023-03-22 21:28:34 -07:00
Ulyssa
725ebb9fd6 Redacted messages should have their HTML removed (#67) 2023-03-22 21:25:37 -07:00
jahway603
ca395097e1 Update README.md to include Arch Linux package (#66) 2023-03-22 20:13:25 -07:00
Ulyssa
e98d58a8cc Emote messages should always show sender (#65) 2023-03-21 14:02:42 -07:00
Ulyssa
e6cdd02f22 HTML self-closing tags are getting parsed incorrectly (#63) 2023-03-20 17:53:55 -07:00
Ulyssa
0bc4ff07b0 Lazy load room state events on initial sync (#62) 2023-03-20 16:17:59 -07:00
Ulyssa
14fe916d94 Allow log level to be configured (#58) 2023-03-13 16:43:08 -07:00
Ulyssa
db35581d07 Indicate when an encrypted room event has been redacted (#59) 2023-03-13 16:43:04 -07:00
Ulyssa
7c1c62897a Show events that couldn't be decrypted (#57) 2023-03-13 15:18:53 -07:00
Ulyssa
61897ea6f2 Fetch scrollback history independently of main loop (#39) 2023-03-13 10:46:26 -07:00
Ulyssa
6a0722795a Fix empty message check when sending (#56) 2023-03-13 09:26:49 -07:00
Ulyssa
f3bbc6ad9f Support configuring client request timeout (#54) 2023-03-12 15:43:13 -07:00
Ulyssa
2dd8c0fddf Link to iamb space in README (#55) 2023-03-10 18:08:42 -08:00
Pavlo Rudy
a786369b14 Create release profile with LTO (#52) 2023-03-10 16:41:32 -08:00
pin
066f60ad32 Add NetBSD install instructions (#51) 2023-03-09 09:27:40 -08:00
Ulyssa
10b142c071 Release v0.0.6 (#48) 2023-03-05 13:28:08 -08:00
Ulyssa
ac6ff63d25 Avoid breaking up words during wrapping when possible (#47) 2023-03-05 12:59:34 -08:00
Ulyssa
54a0e76823 Edited messages need to have their HTML reprocessed (#46) 2023-03-05 12:48:31 -08:00
Ulyssa
93eff79f79 Support creating new rooms and spaces (#40) 2023-03-04 12:23:17 -08:00
Ulyssa
11625262f1 Direct message rooms should be encrypted from creation (#29) 2023-03-03 16:37:11 -08:00
Ulyssa
0ed1d53946 Support completing commands, usernames, and room names (#44) 2023-03-01 18:46:33 -08:00
Ulyssa
e3be8c16cb Release v0.0.5 (#38) 2023-02-09 23:22:19 -08:00
Ulyssa
4c5c57e26c Window keybindings should be mapped in Visual mode (#37) 2023-02-09 23:05:02 -08:00
Benjamin Große
8eef8787cc fix: attachment download flags + exists check (#34)
Fix files never downloading (unless it has been downloaded in the past
and using `!` force flag).

The logic should be:

* If file does not exist, or `!` force flag used, then download it
* Else if neither `!` or `:open` flag used, then error out

and then return downloaded-message or open-and-message.

I.e. `:open` should still open the file if it has already been
downloaded. Otherwise the only way to open it is to use `!` and
re-download it.
2023-02-09 22:31:01 -08:00
Ulyssa
c9c547acc1 Support sending and displaying message reactions (#2) 2023-02-09 17:53:33 -08:00
Ulyssa
3629f15e0d Fix newer Clippy warnings for 1.67.0 (#33) 2023-01-30 13:51:32 -08:00
Ulyssa
fd72cf5c4e Update CI workflow to reduce warnings (#32) 2023-01-30 13:24:35 -08:00
Benjamin Große
1d93461183 Add :open attachments command (#31)
Fixes #27
2023-01-30 13:14:11 -08:00
Ulyssa
a1574c6b8d Show current date and local time for messages (#30) 2023-01-29 18:07:00 -08:00
Ulyssa
e8205df21d Support bracketed paste (#28) 2023-01-28 18:01:17 -08:00
Ulyssa
8c010d7e7e Release v0.0.4 (#26) 2023-01-28 14:24:08 -08:00
Benjamin Große
4337be108b Add "default_room" to profile settings (#25) 2023-01-28 14:12:30 -08:00
Ulyssa
b968d8c4a2 Focus should switch to message bar after :edit (#22) 2023-01-26 16:07:18 -08:00
Ulyssa
5683a2e7a8 Blank lines in table cells of selected message should be highlighted (#23) 2023-01-26 16:07:13 -08:00
Ulyssa
afe892c7fe Support sending and displaying read markers (#11) 2023-01-26 15:40:16 -08:00
Ulyssa
d8713141f2 Display room tags in list of direct messages (#21) 2023-01-26 15:23:15 -08:00
Ulyssa
a6888bbc93 Support displaying and editing room tags (#15) 2023-01-25 17:54:16 -08:00
Ulyssa
4f2261e66f Support sending and displaying formatted messages (#10) 2023-01-23 17:08:11 -08:00
Ulyssa
8966644f6e Support editing messages (#4) 2023-01-19 16:05:02 -08:00
Ulyssa
69125e3fc4 Release v0.0.3 (#20) 2023-01-13 18:02:58 -08:00
Ulyssa
56ec90523c Support redacting messages (#5) 2023-01-13 17:53:54 -08:00
Ulyssa
d13d4b9f7f Support replying to messages (#3) 2023-01-12 21:20:32 -08:00
Ulyssa
54ce042384 Support sending and accepting room invitations (#7) 2023-01-11 17:54:49 -08:00
Ulyssa
b6f4b03c12 Support uploading and downloading message attachments (#13) 2023-01-10 19:59:30 -08:00
Ulyssa
504b520fe1 Support configuring a user's color and name (#19) 2023-01-06 16:56:28 -08:00
49 changed files with 22339 additions and 3306 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

4
.gitattributes vendored
View File

@@ -1 +1,3 @@
* text eol=lf
*.rs text eol=lf
*.toml text eol=lf
*.md text eol=lf

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
ko_fi: ulyssam

94
.github/workflows/binaries.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
on:
push:
branches:
- main
name: Binaries
jobs:
package:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
arch: [x86_64, aarch64]
exclude:
- platform: windows-latest
arch: aarch64
include:
- platform: ubuntu-latest
arch: x86_64
triple: unknown-linux-musl
- platform: ubuntu-latest
arch: aarch64
triple: unknown-linux-gnu
- platform: macos-latest
triple: apple-darwin
- platform: windows-latest
triple: pc-windows-msvc
runs-on: ${{ matrix.platform }}
env:
TARGET: ${{ matrix.arch }}-${{ matrix.triple }}
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: "sccache"
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ env.TARGET }}
- name: Install C cross-compilation toolchain
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt install -f -y build-essential crossbuild-essential-arm64 musl-dev
# Cross-compilation env vars for x86_64-unknown-linux-musl
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc >> $GITHUB_ENV
echo AR_x86_64_unknown_linux_musl=x86_64-linux-gnu-ar >> $GITHUB_ENV
echo CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc >> $GITHUB_ENV
echo CXX_x86_64_unknown_linux_musl=x86_64-linux-gnu-g++ >> $GITHUB_ENV
# Cross-compilation env vars for aarch64-unknown-linux-gnu
echo CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc >> $GITHUB_ENV
echo AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar >> $GITHUB_ENV
echo CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc >> $GITHUB_ENV
echo CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ >> $GITHUB_ENV
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.9
- name: 'Build: binary'
run: cargo +stable build --release --locked --target ${{ env.TARGET }}
- name: 'Upload: binary'
uses: actions/upload-artifact@v4
with:
name: iamb-${{ env.TARGET }}-binary
path: |
./target/${{ env.TARGET }}/release/iamb
./target/${{ env.TARGET }}/release/iamb.exe
- name: 'Package: deb'
if: matrix.platform == 'ubuntu-latest'
run: |
cargo +stable install --locked cargo-deb
cargo +stable deb --no-strip --target ${{ env.TARGET }}
- name: 'Upload: deb'
if: matrix.platform == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: iamb-${{ env.TARGET }}-deb
path: ./target/${{ env.TARGET }}/debian/iamb*.deb
- name: 'Package: rpm'
if: matrix.platform == 'ubuntu-latest'
run: |
cargo +stable install --locked cargo-generate-rpm
cargo +stable generate-rpm --target ${{ env.TARGET }}
- name: 'Upload: rpm'
if: matrix.platform == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: iamb-${{ env.TARGET }}-rpm
path: ./target/${{ env.TARGET }}/generate-rpm/iamb*.rpm

View File

@@ -9,52 +9,61 @@ on:
name: CI
jobs:
clippy_check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v1
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
components: clippy
override: true
- name: Check Clippy
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
toolchain: stable
args:
test:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
env:
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: "sccache"
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
- name: Install Rust (1.83 w/ clippy)
uses: dtolnay/rust-toolchain@1.83
with:
toolchain: nightly
override: true
components: rustfmt, clippy
components: clippy
- name: Install Rust (nightly w/ rustfmt)
run: rustup toolchain install nightly --component rustfmt
- name: Cache cargo registry
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Check formatting
uses: actions-rs/cargo@v1
run: cargo +nightly fmt --all -- --check
- name: Check Clippy
if: matrix.platform == 'ubuntu-latest'
uses: giraffate/clippy-action@v1
with:
command: fmt
args: --all -- --check
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: 'github-check'
- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test
run: cargo test --locked
nix-flake-test:
name: Flake checks ❄️
strategy:
matrix:
platform: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- uses: cachix/cachix-action@v15
with:
name: iamb-prs
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Flake check
run: |
nix flake show
nix flake check --print-build-logs

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target
/result
/TODO
.direnv

View File

@@ -1,6 +1,6 @@
unstable_features = true
max_width = 100
fn_call_width = 90
fn_call_width = 88
struct_lit_width = 50
struct_variant_width = 50
chain_width = 75

6362
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "iamb"
version = "0.0.2"
version = "0.0.11"
edition = "2018"
authors = ["Ulyssa <git@ulyssa.dev>"]
repository = "https://github.com/ulyssa/iamb"
@@ -10,29 +10,129 @@ description = "A Matrix chat client that uses Vim keybindings"
license = "Apache-2.0"
exclude = [".github", "CONTRIBUTING.md"]
keywords = ["matrix", "chat", "tui", "vim"]
rust-version = "1.66"
categories = ["command-line-utilities"]
rust-version = "1.88"
build = "build.rs"
[features]
default = ["bundled", "desktop"]
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
desktop = ["dep:notify-rust", "modalkit/clipboard"]
native-tls = ["matrix-sdk/native-tls"]
rustls-tls = ["matrix-sdk/rustls-tls"]
[build-dependencies.vergen]
version = "8"
default-features = false
features = ["build", "git", "gitcl",]
[dependencies]
anyhow = "1.0"
bitflags = "^2.3"
chrono = "0.4"
clap = {version = "4.0", features = ["derive"]}
clap = {version = "~4.3", features = ["derive"]}
css-color-parser = "0.1.2"
dirs = "4.0.0"
futures = "0.3.21"
emojis = "0.5"
feruca = "0.10.1"
futures = "0.3"
gethostname = "0.4.1"
matrix-sdk = {version = "0.6", default-features = false, features = ["e2e-encryption", "sled", "rustls-tls"]}
modalkit = "0.0.9"
html5ever = "0.26.0"
image = "^0.25.6"
libc = "0.2"
markup5ever_rcdom = "0.2.0"
mime = "^0.3.16"
mime_guess = "^2.0.4"
nom = "7.0.0"
open = "3.2.0"
rand = "0.8.5"
ratatui = "0.29.0"
ratatui-image = { version = "~8.0.1", features = ["serde"] }
regex = "^1.5"
rpassword = "^7.2"
serde = "^1.0"
serde_json = "^1.0"
sled = "0.34"
sled = "0.34.7"
temp-dir = "0.1.12"
thiserror = "^1.0.37"
tokio = {version = "1.17.0", features = ["full"]}
toml = "^0.8.12"
tracing = "~0.1.36"
tracing-appender = "~0.2.2"
tracing-subscriber = "0.3.16"
unicode-segmentation = "^1.7"
unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]}
edit = "0.1.4"
humansize = "2.0.0"
linkify = "0.10.0"
shellexpand = "3.1.1"
[dependencies.comrak]
version = "0.22.0"
default-features = false
features = ["shortcodes"]
[dependencies.notify-rust]
version = "~4.10.0"
default-features = false
features = ["zbus", "serde"]
optional = true
[dependencies.modalkit]
version = "0.0.24"
default-features = false
#git = "https://github.com/ulyssa/modalkit"
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
[dependencies.modalkit-ratatui]
version = "0.0.24"
#git = "https://github.com/ulyssa/modalkit"
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
[dependencies.matrix-sdk]
version = "0.14.0"
default-features = false
features = ["e2e-encryption", "sqlite", "sso-login"]
[dependencies.tokio]
version = "1.24.1"
features = ["macros", "net", "rt-multi-thread", "sync", "time"]
[dev-dependencies]
lazy_static = "1.4.0"
pretty_assertions = "1.4.0"
[profile.release-lto]
inherits = "release"
incremental = false
lto = true
[package.metadata.deb]
section = "net"
license-file = ["LICENSE", "0"]
assets = [
# Binary:
["target/release/iamb", "usr/bin/iamb", "755"],
# Manual pages:
["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"],
["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"],
# Other assets:
["iamb.desktop", "usr/share/applications/iamb.desktop", "644"],
["config.example.toml", "usr/share/iamb/config.example.toml", "644"],
["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"],
["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"],
]
[package.metadata.generate-rpm]
assets = [
# Binary:
{ source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" },
# Manual pages:
{ source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" },
{ source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" },
# Other assets:
{ source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" },
{ source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"},
{ source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"},
{ source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"},
]

View File

@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2024 Ulyssa Mello
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

44
PACKAGING.md Normal file
View File

@@ -0,0 +1,44 @@
# Notes For Package Maintainers
## Linking Against System Packages
The default Cargo features for __iamb__ will bundle SQLite and use [rustls] for
TLS. Package maintainers may want to link against the system's native SQLite
and TLS libraries instead. To do so, you'll want to build without the default
features and specify that it should build with `native-tls`:
```
% cargo build --release --no-default-features --features=native-tls
```
## Enabling LTO
Enabling LTO can result in smaller binaries. There is a separate profile to
enable it when building:
```
% cargo build --profile release-lto
```
Note that this [can fail][ring-lto] in some build environments if both Clang
and GCC are present.
## Documentation
In addition to the compiled binary, there are other files in the repo that
you'll want to install as part of a package:
<!-- Please keep in sync w/ the `deb`/`generate-rpm` sections of `Cargo.toml` -->
| Repository Path | Installed Path (may vary per OS) |
| ----------------------- | ----------------------------------------------- |
| /iamb.desktop | /usr/share/applications/iamb.desktop |
| /config.example.toml | /usr/share/iamb/config.example.toml |
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
[ring-lto]: https://github.com/briansmith/ring/issues/1444
[rustls]: https://crates.io/crates/rustls

212
README.md
View File

@@ -1,101 +1,157 @@
# iamb
<div align="center">
<h1><img width="200" height="200" src="docs/iamb.svg"></h1>
[![Build Status](https://github.com/ulyssa/iamb/actions/workflows/ci.yml/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
[![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)][crates-io-iamb]
[![#iamb:0x.badd.cafe](https://img.shields.io/badge/matrix-%23iamb:0x.badd.cafe-blue)](https://matrix.to/#/#iamb:0x.badd.cafe)
[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)][crates-io-iamb]
[![iamb](https://snapcraft.io/iamb/badge.svg)](https://snapcraft.io/iamb)
![Example Usage](https://iamb.chat/static/images/iamb-demo.gif)
</div>
## About
`iamb` is a Matrix client for the terminal that uses Vim keybindings.
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
This project is a work-in-progress, and there's still a lot to be implemented,
but much of the basic client functionality is already present.
- Threads, spaces, E2EE, and read receipts
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
- Notifications via terminal bell or desktop environment
- Send Markdown, HTML or plaintext messages
- Creating, joining, and leaving rooms
- Sending and accepting room invitations
- Editing, redacting, and reacting to messages
- Custom keybindings
- Multiple profiles
_You may want to [see this page as it was when the latest version was published][crates-io-iamb]._
## Documentation
You can find documentation for installing, configuring, and using iamb on its
website, [iamb.chat].
## Installation
Install Rust and Cargo, and then run:
```
cargo install iamb
```
## Configuration
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
```json
{
"profiles": {
"example.com": {
"url": "https://example.com",
"user_id": "@user:example.com"
}
}
}
```toml
[profiles."example.com"]
user_id = "@user:example.com"
```
## Comparison With Other Clients
If you homeserver is located on a different domain than the server part of the
`user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then
you can explicitly specify the homeserver URL to use:
To get an idea of what is and isn't yet implemented, here is a subset of the
Matrix website's [features comparison table][client-comparison-matrix], showing
two other TUI clients and Element Web:
```toml
[profiles."example.com"]
url = "https://example.com"
user_id = "@user:example.com"
```
## Installation (from source)
Install Rust and Cargo using [rustup], and then run from the directory
containing the sources (ie: from a git clone):
```
cargo install --locked --path .
```
## Installation (via `crates.io`)
Install Rust (1.83.0 or above) and Cargo, and then run:
```
cargo install --locked iamb
```
See [Configuration](#configuration) for getting a profile set up.
## Installation (via package managers)
### Arch Linux
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
```
paru iamb-git
```
### FreeBSD
On FreeBSD a package is available from the official repositories. To install it simply run:
```
pkg install iamb
```
### Gentoo
On Gentoo, an ebuild is available from the community-managed
[GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU).
You can enable the GURU overlay with:
```
eselect repository enable guru
emerge --sync guru
```
And then install `iamb` with:
```
emerge --ask iamb
```
### macOS
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
repository. To install it simply run:
```
brew install iamb
```
### NetBSD
On NetBSD a package is available from the official repositories. To install it simply run:
```
pkgin install iamb
```
### Nix / NixOS (flake)
```
nix profile install "github:ulyssa/iamb"
```
### openSUSE Tumbleweed
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/iamb) is available from the official repositories. To install it simply run:
```
zypper install iamb
```
### Snap
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
```
snap install iamb
```
| | iamb | [gomuks] | [weechat-matrix] | Element Web/Desktop |
| --------------------------------------- | :----------------- | :----------------: | :----------------: | :-----------------: |
| Room directory | :x: ([#14]) | :x: | :heavy_check_mark: | :heavy_check_mark: |
| Room tag showing | :x: ([#15]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Room tag editing | :x: ([#15]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Search joined rooms | :x: ([#16]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Room user list | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Display Room Description | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Edit Room Description | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: |
| Highlights | :x: ([#8]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Pushrules | :x: | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Send read markers | :x: ([#11]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Display read markers | :x: ([#11]) | :x: | :x: | :heavy_check_mark: |
| Sending Invites | :x: ([#7]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Accepting Invites | :x: ([#7]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Typing Notification | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| E2E | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Replies | :x: ([#3]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Attachment uploading | :x: ([#13]) | :x: | :heavy_check_mark: | :heavy_check_mark: |
| Attachment downloading | :x: ([#13]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Send stickers | :x: | :x: | :x: | :heavy_check_mark: |
| Send formatted messages (markdown) | :x: ([#10]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Rich Text Editor for formatted messages | :x: | :x: | :x: | :heavy_check_mark: |
| Display formatted messages | :x: ([#10]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Redacting | :x: ([#5]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| Multiple Matrix Accounts | :heavy_check_mark: | :x: | :heavy_check_mark: | :x: |
| New user registration | :x: | :x: | :x: | :heavy_check_mark: |
| VOIP | :x: | :x: | :x: | :heavy_check_mark: |
| Reactions | :x: ([#2]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Message editing | :x: ([#4]) | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Room upgrades | :x: | :heavy_check_mark: | :x: | :heavy_check_mark: |
| Localisations | :x: | 1 | :x: | 44 |
| SSO Support | :x: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
## License
iamb is released under the [Apache License, Version 2.0].
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
[client-comparison-matrix]: https://matrix.org/clients-matrix/
[crates-io-iamb]: https://crates.io/crates/iamb
[iamb.chat]: https://iamb.chat
[gomuks]: https://github.com/tulir/gomuks
[weechat-matrix]: https://github.com/poljar/weechat-matrix
[#2]: https://github.com/ulyssa/iamb/issues/2
[#3]: https://github.com/ulyssa/iamb/issues/3
[#4]: https://github.com/ulyssa/iamb/issues/4
[#5]: https://github.com/ulyssa/iamb/issues/5
[#6]: https://github.com/ulyssa/iamb/issues/6
[#7]: https://github.com/ulyssa/iamb/issues/7
[#8]: https://github.com/ulyssa/iamb/issues/8
[#9]: https://github.com/ulyssa/iamb/issues/9
[#10]: https://github.com/ulyssa/iamb/issues/10
[#11]: https://github.com/ulyssa/iamb/issues/11
[#12]: https://github.com/ulyssa/iamb/issues/12
[#13]: https://github.com/ulyssa/iamb/issues/13
[#14]: https://github.com/ulyssa/iamb/issues/14
[#15]: https://github.com/ulyssa/iamb/issues/15
[#16]: https://github.com/ulyssa/iamb/issues/16
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
[rustup]: https://rustup.rs/

9
build.rs Normal file
View File

@@ -0,0 +1,9 @@
use std::error::Error;
use vergen::EmitBuilder;
fn main() -> Result<(), Box<dyn Error>> {
EmitBuilder::builder().git_sha(true).emit()?;
Ok(())
}

58
config.example.toml Normal file
View File

@@ -0,0 +1,58 @@
default_profile = "default"
[profiles.default]
user_id = "@user:matrix.org"
url = "https://matrix.org"
[settings]
default_room = "#iamb-users:0x.badd.cafe"
external_edit_file_suffix = ".md"
log_level = "warn"
message_shortcode_display = false
open_command = ["my-open", "--file"]
reaction_display = true
reaction_shortcode_display = false
read_receipt_display = true
read_receipt_send = true
request_timeout = 10000
typing_notice_display = true
typing_notice_send = true
user_gutter_width = 30
username_display = "username"
[settings.image_preview]
protocol.type = "sixel"
size = { "width" = 66, "height" = 10 }
[settings.sort]
rooms = ["favorite", "lowpriority", "unread", "name"]
members = ["power", "id"]
[settings.users]
"@user:matrix.org" = { "name" = "John Doe", "color" = "magenta" }
[layout]
style = "config"
[[layout.tabs]]
window = "iamb://dms"
[[layout.tabs]]
window = "iamb://rooms"
[[layout.tabs]]
split = [
{ "window" = "#iamb-users:0x.badd.cafe" },
{ "window" = "#iamb-dev:0x.badd.cafe" }
]
[macros.insert]
"jj" = "<Esc>"
[macros."normal|visual"]
"V" = "<C-W>m"
[dirs]
cache = "/home/user/.cache/iamb/"
logs = "/home/user/.local/share/iamb/logs/"
downloads = "/home/user/Downloads/"

BIN
docs/iamb-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
docs/iamb-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

325
docs/iamb.1 Normal file
View File

@@ -0,0 +1,325 @@
.\" iamb(1) manual page
.\"
.\" This manual page is written using the mdoc(7) macros. For more
.\" information, see <https://manpages.bsd.lv/mdoc.html>.
.\"
.\" You can preview this file with:
.\" $ man ./docs/iamb.1
.Dd Mar 24, 2024
.Dt IAMB 1
.Os
.Sh NAME
.Nm iamb
.Nd a terminal-based client for Matrix for the Vim addict
.Sh SYNOPSIS
.Nm
.Op Fl hV
.Op Fl P Ar profile
.Op Fl C Ar dir
.Sh DESCRIPTION
.Nm
is a client for the Matrix communication protocol.
It provides a terminal user interface with familiar Vim keybindings, and
includes support for multiple profiles, threads, spaces, notifications,
reactions, custom keybindings, and more.
.Pp
This manual page includes a quick rundown of the available commands in
.Nm .
For example usage and a full description of each one and its arguments, please
refer to the full documentation online.
.Sh OPTIONS
.Bl -tag -width Ds
.It Fl P , Fl Fl profile
The profile to start
.Nm
with.
If this flag is not specified,
then it defaults to using
.Sy default_profile
(see
.Xr iamb 5 ) .
.It Fl C , Fl Fl config-directory
Path to the directory the configuration file is located in.
.It Fl h , Fl Fl help
Show the help text and quit.
.It Fl V , Fl Fl version
Show the current
.Nm
version and quit.
.El
.Sh "GENERAL COMMANDS"
.Bl -tag -width Ds
.It Sy ":chats"
View a list of joined rooms and direct messages.
.It Sy ":dms"
View a list of direct messages.
.It Sy ":logout [user id]"
Log out of
.Nm .
.It Sy ":rooms"
View a list of joined rooms.
.It Sy ":spaces"
View a list of joined spaces.
.It Sy ":unreads"
View a list of unread rooms.
.It Sy ":unreads clear"
Mark all rooms as read.
.It Sy ":welcome"
View the startup Welcome window.
.It Sy ":forget"
Remove all left rooms from the internal database.
.El
.Sh "E2EE COMMANDS"
.Bl -tag -width Ds
.It Sy ":keys export [path] [passphrase]"
Export and encrypt keys to
.Pa path .
.It Sy ":keys import [path] [passphrase]"
Import and decrypt keys from
.Pa path .
.It Sy ":verify"
View a list of ongoing E2EE verifications.
.It Sy ":verify accept [key]"
Accept a verification request.
.It Sy ":verify cancel [key]"
Cancel an in-progress verification.
.It Sy ":verify confirm [key]"
Confirm an in-progress verification.
.It Sy ":verify mismatch [key]"
Reject an in-progress verification due to mismatched Emoji.
.It Sy ":verify request [user id]"
Request a new verification with the specified user.
.El
.Sh "MESSAGE COMMANDS"
.Bl -tag -width Ds
.It Sy ":download [path]"
Download an attachment from the selected message and save it to the optional path.
.It Sy ":open [path]"
Download and then open an attachment, or open a link in a message.
.It Sy ":edit"
Edit the selected message.
.It Sy ":editor"
Open an external
.Ev $EDITOR
to compose a message.
.It Sy ":react [shortcode]"
React to the selected message with an Emoji.
.It Sy ":unreact [shortcode]"
Remove your reaction from the selected message.
When no arguments are given, remove all of your reactions from the message.
.It Sy ":redact [reason]"
Redact the selected message with the optional reason.
.It Sy ":reply"
Reply to the selected message.
.It Sy ":cancel"
Cancel the currently drafted message including replies.
.It Sy ":replied"
Go to the message the current message replied to.
.It Sy ":upload [path]"
Upload an attachment and send it to the currently selected room.
.El
.Sh "ROOM COMMANDS"
.Bl -tag -width Ds
.It Sy ":create [arguments]"
Create a new room. Arguments can be
.Dq ++alias=[alias] ,
.Dq ++public ,
.Dq ++space ,
and
.Dq ++encrypted .
.It Sy ":invite accept"
Accept an invitation to the currently focused room.
.It Sy ":invite reject"
Reject an invitation to the currently focused room.
.It Sy ":invite send [user]"
Send an invitation to a user to join the currently focused room.
.It Sy ":join [room]"
Join a room or open it if you are already joined.
.It Sy ":leave"
Leave the currently focused room.
.It Sy ":members"
View a list of members of the currently focused room.
.It Sy ":room name set [name]"
Set the name of the currently focused room.
.It Sy ":room name unset"
Unset the name of the currently focused room.
.It Sy ":room dm set"
Mark the currently focused room as a direct message.
.It Sy ":room dm unset"
Mark the currently focused room as a normal room.
.It Sy ":room notify set [level]"
Set a notification level for the currently focused room.
Valid levels are
.Dq mute ,
.Dq mentions ,
.Dq keywords ,
and
.Dq all .
Note that
.Dq mentions
and
.Dq keywords
are aliases for the same behaviour.
.It Sy ":room notify unset"
Unset any room-level notification configuration.
.It Sy ":room notify show"
Show the current room-level notification configuration.
If the room is using the account-level default, then this will print
.Dq default .
.It Sy ":room tag set [tag]"
Add a tag to the currently focused room.
.It Sy ":room tag unset [tag]"
Remove a tag from the currently focused room.
.It Sy ":room topic set [topic]"
Set the topic of the currently focused room.
.It Sy ":room topic unset"
Unset the topic of the currently focused room.
.It Sy ":room topic show"
Show the topic of the currently focused room.
.It Sy ":room alias set [alias]"
Create and point the given alias to the room.
.It Sy ":room alias unset [alias]"
Delete the provided alias from the room's alternative alias list.
.It Sy ":room alias show"
Show alternative aliases to the room, if any are set.
.It Sy ":room id show"
Show the Matrix identifier for the room.
.It Sy ":room canon set [alias]"
Set the room's canonical alias to the one provided, and make the previous one an alternative alias.
.It Sy ":room canon unset [alias]"
Delete the room's canonical alias.
.It Sy ":room canon show"
Show the room's canonical alias, if any is set.
.It Sy ":room ban [user] [reason]"
Ban a user from this room with an optional reason.
.It Sy ":room unban [user] [reason]"
Unban a user from this room with an optional reason.
.It Sy ":room kick [user] [reason]"
Kick a user from this room with an optional reason.
.El
.Sh "SPACE COMMANDS"
.Bl -tag -width Ds
.It Sy ":space child set [room_id] [arguments]"
Add a room to the currently focused space.
.Dq ++suggested
marks the room as a suggested child.
.Dq ++order=[string]
specifies a string by which children are lexicographically ordered.
.It Sy ":space child remove"
Remove the selected room from the currently focused space.
.El
.Sh "WINDOW COMMANDS"
.Bl -tag -width Ds
.It Sy ":horizontal [cmd]"
Change the behaviour of the given command to be horizontal.
.It Sy ":leftabove [cmd]"
Change the behaviour of the given command to open before the current window.
.It Sy ":only" , Sy ":on"
Quit all but one window in the current tab.
.It Sy ":quit" , Sy ":q"
Quit a window.
.It Sy ":quitall" , Sy ":qa"
Quit all windows in the current tab.
.It Sy ":resize"
Resize a window.
.It Sy ":rightbelow [cmd]"
Change the behaviour of the given command to open after the current window.
.It Sy ":split" , Sy ":sp"
Horizontally split a window.
.It Sy ":vertical [cmd]"
Change the layout of the following command to be vertical.
.It Sy ":vsplit" , Sy ":vsp"
Vertically split a window.
.El
.Sh "TAB COMMANDS"
.Bl -tag -width Ds
.It Sy ":tab [cmd]"
Run a command that opens a window in a new tab.
.It Sy ":tabclose" , Sy ":tabc"
Close a tab.
.It Sy ":tabedit [room]" , Sy ":tabe"
Open a room in a new tab.
.It Sy ":tabrewind" , Sy ":tabr"
Go to the first tab.
.It Sy ":tablast" , Sy ":tabl"
Go to the last tab.
.It Sy ":tabnext" , Sy ":tabn"
Go to the next tab.
.It Sy ":tabonly" , Sy ":tabo"
Close all but one tab.
.It Sy ":tabprevious" , Sy ":tabp"
Go to the preview tab.
.El
.Sh "SLASH COMMANDS"
.Bl -tag -width Ds
.It Sy "/markdown" , Sy "/md"
Interpret the message body as Markdown markup.
This is the default behaviour.
.It Sy "/html" , Sy "/h"
Send the message body as literal HTML.
.It Sy "/plaintext" , Sy "/plain" , Sy "/p"
Do not interpret any markup in the message body and send it as it is.
.It Sy "/me"
Send an emote message.
.It Sy "/confetti"
Produces no effect in
.Nm ,
but will display confetti in Matrix clients that support doing so.
.It Sy "/fireworks"
Produces no effect in
.Nm ,
but will display fireworks in Matrix clients that support doing so.
.It Sy "/hearts"
Produces no effect in
.Nm ,
but will display floating hearts in Matrix clients that support doing so.
.It Sy "/rainfall"
Produces no effect in
.Nm ,
but will display rainfall in Matrix clients that support doing so.
.It Sy "/snowfall"
Produces no effect in
.Nm ,
but will display snowfall in Matrix clients that support doing so.
.It Sy "/spaceinvaders"
Produces no effect in
.Nm ,
but will display aliens from Space Invaders in Matrix clients that support doing so.
.El
.Sh EXAMPLES
.Ss Example 1: Starting with a specific profile
To start with a profile named
.Sy personal
instead of the
.Sy default_profile
value:
.Bd -literal -offset indent
$ iamb -P personal
.Ed
.Ss Example 2: Using an alternate configuration directory
By default,
.Nm
will use the XDG directories, but you may sometimes want to store
your configuration elsewhere.
.Bd -literal -offset indent
$ iamb -C ~/src/iamb-dev/dev-config/
.Ed
.Sh "REPORTING BUGS"
Please report bugs in
.Nm
or its manual pages at
.Lk https://github.com/ulyssa/iamb/issues
.Sh "SEE ALSO"
.Xr iamb 5
.Pp
Extended documentation is available online at
.Lk https://iamb.chat

590
docs/iamb.5 Normal file
View File

@@ -0,0 +1,590 @@
.\" iamb(7) manual page
.\"
.\" This manual page is written using the mdoc(7) macros. For more
.\" information, see <https://manpages.bsd.lv/mdoc.html>.
.\"
.\" You can preview this file with:
.\" $ man ./docs/iamb.1
.Dd Mar 24, 2024
.Dt IAMB 5
.Os
.Sh NAME
.Nm config.toml
.Nd configuration file for
.Sy iamb
.Sh DESCRIPTION
Configuration must be placed under
.Pa ~/.config/iamb/
and named
.Nm .
(If
.Ev $XDG_CONFIG_HOME
is set, then
.Sy iamb
will look for a directory named
.Pa iamb
there instead.)
.Pp
Example configuration usually comes bundled with your installation and can
typically be found in
.Pa /usr/share/iamb .
.Pp
As implied by the filename, the configuration is formatted in TOML.
It's structure and fields are described below.
.Sh CONFIGURATION
These options are sections at the top-level of the file.
.Bl -tag -width Ds
.It Sy profiles
A map of profile names containing per-account information.
See
.Sx PROFILES .
.It Sy default_profile
The name of the default profile to connect to, unless overwritten by a
commandline switch.
It should be one of the names defined in the
.Sy profiles
section.
.It Sy settings
Overwrite general settings for
.Sy iamb .
See
.Sx SETTINGS
for a description of possible values.
.It Sy layout
Configure the default window layout to use when starting
.Sy iamb .
See
.Sx "STARTUP LAYOUT"
for more information on how to configure this object.
.It Sy macros
Map keybindings to other keybindings.
See
.Sx "CUSTOM KEYBINDINGS"
for how to configure this object.
.It Sy dirs
Configure the directories to use for data, logs, and more.
See
.Sx DIRECTORIES
for the possible values you can set in this object.
.El
.Sh PROFILES
These options are configured as fields in the
.Sy profiles
object.
.Bl -tag -width Ds
.It Sy user_id
The user ID to use when connecting to the server.
For example "user" in "@user:matrix.org".
.It Sy url
The URL of the user's server.
(For example "https://matrix.org" for "@user:matrix.org".)
This is only needed when the server does not have a
.Pa /.well-known/matrix/client
entry.
.El
.Pp
In addition to the above fields, you can also reuse the following fields to set
per-profile overrides of their global values:
.Bl -bullet -offset indent -width 1m
.It
.Sy dirs
.It
.Sy layout
.It
.Sy macros
.It
.Sy settings
.El
.Ss Example 1: A single profile
.Bd -literal -offset indent
[profiles.personal]
user_id = "@user:matrix.org"
.Ed
.Ss Example 2: Two profiles with a default
In the following example, there are two profiles,
.Dq personal
(set to be the default) and
.Dq work .
The
.Dq work
profile has an explicit URL set for its homeserver.
.Bd -literal -offset indent
default_profile = "personal"
[profiles.personal]
user_id = "@user:matrix.org"
[profiles.work]
user_id = "@user:example.com"
url = "https://matrix.example.com"
.Ed
.Sh SETTINGS
These options are configured as an object under the
.Sy settings
key and can be overridden as described in
.Sx PROFILES .
.Bl -tag -width Ds
.It Sy external_edit_file_suffix
Suffix to append to temporary file names when using the :editor command. Defaults to .md.
.It Sy default_room
The room to show by default instead of the
.Sy :welcome
window.
.It Sy image_preview
Enable image previews and configure it.
An empty object will enable the feature with default settings, omitting it will disable the feature.
The available fields in this object are:
.Bl -tag -width Ds
.It Sy size
An optional object with
.Sy width
and
.Sy height
fields to specify the preview size in cells.
Defaults to 66 and 10.
.It Sy protocol
An optional object to override settings that will normally be guessed automatically:
.Bl -tag -width Ds
.It Sy type
An optional string set to one of the protocol types:
.Dq Sy sixel ,
.Dq Sy kitty , and
.Dq Sy halfblocks .
.It Sy font_size
An optional list of two numbers representing font width and height in pixels.
.El
.El
.It Sy log_level
Specifies the lowest log level that should be shown.
Possible values are:
.Dq Sy trace ,
.Dq Sy debug ,
.Dq Sy info ,
.Dq Sy warn , and
.Dq Sy error .
.It Sy message_shortcode_display
Defines whether or not Emoji characters in messages should be replaced by their
respective shortcodes.
.It Sy message_user_color
Defines whether or not the message body is colored like the username.
.It Sy normal_after_send
Defines whether to reset input to Normal mode after sending a message.
.It Sy notifications
When this subsection is present, you can enable and configure push notifications.
See
.Sx NOTIFICATIONS
for more details.
.It Sy open_command
Defines a custom command and its arguments to run when opening downloads instead of the default.
(For example,
.Sy ["my-open",\ "--file"] . )
.It Sy reaction_display
Defines whether or not reactions should be shown.
.It Sy reaction_shortcode_display
Defines whether or not reactions should be shown as their respective shortcode.
.It Sy read_receipt_send
Defines whether or not read confirmations are sent.
.It Sy read_receipt_display
Defines whether or not read confirmations are displayed.
.It Sy request_timeout
Defines the maximum time per request in seconds.
.It Sy sort
Configures how to sort the lists shown in windows like
.Sy :rooms
or
.Sy :members .
See
.Sx "SORTING LISTS"
for more details.
.It Sy state_event_display
Defines whether the state events like joined or left are shown.
.It Sy typing_notice_send
Defines whether or not the typing state is sent.
.It Sy typing_notice_display
Defines whether or not the typing state is displayed.
.It Sy user
Overrides values for the specified user.
See
.Sx "USER OVERRIDES"
for details on the format.
.It Sy username_display
Defines how usernames are shown for message senders.
Possible values are
.Dq Sy username ,
.Dq Sy localpart , or
.Dq Sy displayname .
.It Sy user_gutter_width
Specify the width of the column where usernames are displayed in a room.
Usernames that are too long are truncated.
Defaults to 30.
.It Sy tabstop
Number of spaces that a <Tab> counts for.
Defaults to 4.
.El
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support)
.Bd -literal -offset indent
[settings]
username = "username"
message_shortcode_display = true
reaction_shortcode_display = true
.Ed
.Ss Example 2: Increase request timeout to 2 minutes for a slow homeserver
.Bd -literal -offset indent
[settings]
request_timeout = 120
.Ed
.Sh NOTIFICATIONS
The
.Sy settings.notifications
subsection allows configuring how notifications for new messages behave.
The available fields in this subsection are:
.Bl -tag -width Ds
.It Sy enabled
Defaults to
.Sy false .
Setting this field to
.Sy true
enables notifications.
.It Sy via
Defaults to
.Dq Sy desktop
to use the desktop mechanism (default).
Setting this field to
.Dq Sy bell
will use the terminal bell instead.
Both can be used via
.Dq Sy desktop|bell .
.It Sy show_message
controls whether to show the message in the desktop notification, and defaults to
.Sy true .
Messages are truncated beyond a small length.
The notification rules are stored server side, loaded once at startup, and are currently not configurable in iamb.
In other words, you can simply change the rules with another client.
.El
.Ss Example 1: Enable notifications with default options
.Bd -literal -offset indent
[settings]
notifications = {}
.Ed
.Ss Example 2: Enable notifications using terminal bell
.Bd -literal -offset indent
[settings.notifications]
via = "bell"
show_message = false
.Ed
.Sh "SORTING LISTS"
The
.Sy settings.sort
subsection allows configuring how different windows have their contents sorted.
Fields available within this subsection are:
.Bl -tag -width Ds
.It Sy rooms
How to sort the
.Sy :rooms
window.
Defaults to
.Sy ["favorite",\ "lowpriority",\ "unread",\ "name"] .
.It Sy chats
How to sort the
.Sy :chats
window.
Defaults to the
.Sy rooms
value.
.It Sy dms
How to sort the
.Sy :dms
window.
Defaults to the
.Sy rooms
value.
.It Sy spaces
How to sort the
.Sy :spaces
window.
Defaults to the
.Sy rooms
value.
.It Sy members
How to sort the
.Sy :members
window.
Defaults to
.Sy ["power",\ "id"] .
.El
The available values are:
.Bl -tag -width Ds
.It Sy favorite
Put favorite rooms before other rooms.
.It Sy lowpriority
Put lowpriority rooms after other rooms.
.It Sy name
Sort rooms by alphabetically ascending room name.
.It Sy alias
Sort rooms by alphabetically ascending canonical room alias.
.It Sy id
Sort rooms by alphabetically ascending Matrix room identifier.
.It Sy unread
Put unread rooms before other rooms.
.It Sy recent
Sort rooms by most recent message timestamp.
.It Sy invite
Put invites before other rooms.
.El
.El
.Ss Example 1: Group room members by their server first
.Bd -literal -offset indent
[settings.sort]
members = ["server", "localpart"]
.Ed
.Sh "USER OVERRIDES"
The
.Sy settings.users
subsections allows overriding how specific senders are displayed.
Overrides are mapped onto Matrix User IDs such as
.Sy @user:matrix.org ,
and are typically written as inline tables containing the following keys:
.Bl -tag -width Ds
.It Sy name
Change the display name of the user.
.It Sy color
Change the color the user is shown as.
Possible values are:
.Dq Sy black ,
.Dq Sy blue ,
.Dq Sy cyan ,
.Dq Sy dark-gray ,
.Dq Sy gray ,
.Dq Sy green ,
.Dq Sy light-blue ,
.Dq Sy light-cyan ,
.Dq Sy light-green ,
.Dq Sy light-magenta ,
.Dq Sy light-red ,
.Dq Sy light-yellow ,
.Dq Sy magenta ,
.Dq Sy none ,
.Dq Sy red ,
.Dq Sy white ,
and
.Dq Sy yellow .
.El
.Ss Example 1: Override how @ada:example.com appears in chat
.Bd -literal -offset indent
[settings.users]
"@ada:example.com" = { name = "Ada Lovelace", color = "light-red" }
.Ed
.Sh STARTUP LAYOUT
The
.Sy layout
section allows configuring the initial set of tabs and windows to show when
starting the client.
.Bl -tag -width Ds
.It Sy style
Specifies what window layout to load when starting.
Valid values are
.Dq Sy restore
to restore the layout from the last time the client was exited,
.Dq Sy new
to open a single window (uses the value of
.Sy default_room
if set), or
.Dq Sy config
to open the layout described under
.Sy tabs .
.It Sy tabs
If
.Sy style
is set to
.Sy config ,
then this value will be used to open a set of tabs and windows at startup.
Each object can contain either a
.Sy window
key specifying a username, room identifier or room alias to show, or a
.Sy split
key specifying an array of window objects.
.El
.Ss Example 1: Show a single room every startup
.Bd -literal -offset indent
[settings]
default_room = "#iamb-users:0x.badd.cafe"
[layout]
style = "new"
.Ed
.Ss Example 2: Show a specific layout every startup
.Bd -literal -offset indent
[layout]
style = "config"
[[layout.tabs]]
window = "iamb://dms"
[[layout.tabs]]
window = "iamb://rooms"
[[layout.tabs]]
split = [
{ "window" = "#iamb-users:0x.badd.cafe" },
{ "window" = "#iamb-dev:0x.badd.cafe" }
]
.Ed
.Sh "CUSTOM KEYBINDINGS"
The
.Sy macros
subsections allow configuring custom keybindings.
Available subsections are:
.Bl -tag -width Ds
.It Sy insert , Sy i
Map the key sequences in this section in
.Sy Insert
mode.
.It Sy normal , Sy n
Map the key sequences in this section in
.Sy Normal
mode.
.It Sy visual , Sy v
Map the key sequences in this section in
.Sy Visual
mode.
.It Sy select
Map the key sequences in this section in
.Sy Select
mode.
.It Sy command , Sy c
Map the key sequences in this section in
.Sy Visual
mode.
.It Sy operator-pending
Map the key sequences in this section in
.Sy "Operator Pending"
mode.
.El
Multiple modes can be given together by separating their names with
.Dq Sy | .
.Ss Example 1: Use "jj" to exit Insert mode
.Bd -literal -offset indent
[macros.insert]
"jj" = "<Esc>"
.Ed
.Ss Example 2: Use "V" for switching between message bar and room history
.Bd -literal -offset indent
[macros."normal|visual"]
"V" = "<C-W>m"
.Ed
.Sh DIRECTORIES
Specifies the directories to save data in.
Configured as an object under the key
.Sy dirs .
.Bl -tag -width Ds
.It Sy cache
Specifies where to store assets and temporary data in.
(For example,
.Sy image_preview
and
.Sy logs
will also go in here by default.)
Defaults to
.Ev $XDG_CACHE_HOME/iamb .
.It Sy data
Specifies where to store persistent data in, such as E2EE room keys.
Defaults to
.Ev $XDG_DATA_HOME/iamb .
.It Sy downloads
Specifies where to store downloaded files.
Defaults to
.Ev $XDG_DOWNLOAD_DIR .
.It Sy image_previews
Specifies where to store automatically downloaded image previews.
Defaults to
.Ev ${cache}/image_preview_downloads .
.It Sy logs
Specifies where to store log files.
Defaults to
.Ev ${cache}/logs .
.El
.Sh FILES
.Bl -tag -width Ds
.It Pa ~/.config/iamb/config.toml
The TOML configuration file that
.Sy iamb
loads by default.
.It Pa ~/.config/iamb/config.json
A JSON configuration file that
.Sy iamb
will load if the TOML one is not found.
.It Pa /usr/share/iamb/config.example.toml
A sample configuration file with examples of how to set different values.
.El
.Sh "REPORTING BUGS"
Please report bugs in
.Sy iamb
or its manual pages at
.Lk https://github.com/ulyssa/iamb/issues
.Sh SEE ALSO
.Xr iamb 1
.Pp
Extended documentation is available online at
.Lk https://iamb.chat

53
docs/iamb.metainfo.xml Normal file
View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>chat.iamb.iamb</id>
<name>iamb</name>
<summary>A terminal Matrix client for Vim addicts</summary>
<url type="homepage">https://iamb.chat</url>
<releases>
<release version="0.0.11" date="2026-01-19"/>
<release version="0.0.10" date="2024-08-20"/>
<release version="0.0.9" date="2024-03-28"/>
</releases>
<developer id="dev.ulyssa">
<name>Ulyssa</name>
</developer>
<developer_name>Ulyssa</developer_name>
<metadata_license>CC-BY-SA-4.0</metadata_license>
<project_license>Apache-2.0</project_license>
<content_rating type="oars-1.1">
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<screenshots>
<screenshot type="default">
<image>https://iamb.chat/static/images/metainfo-screenshot.png</image>
<caption>Example screenshot of room and lists of rooms, spaces and members within iamb</caption>
</screenshot>
</screenshots>
<description>
<p>
iamb is a client for the Matrix communication protocol. It provides a
terminal user interface with familiar Vim keybindings, and includes
support for multiple profiles, threads, spaces, notifications,
reactions, custom keybindings, and more.
</p>
</description>
<launchable type="desktop-id">iamb.desktop</launchable>
<categories>
<category>Network</category>
<category>Chat</category>
</categories>
<provides>
<binary>iamb</binary>
</provides>
</component>

BIN
docs/iamb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

128
docs/iamb.svg Normal file
View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="iamb.svg"
inkscape:export-filename="iamb.png"
inkscape:export-xdpi="288"
inkscape:export-ydpi="288"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="4.3724198"
inkscape:cx="2.5157694"
inkscape:cy="43.11114"
inkscape:window-width="1850"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2">
<rect
x="69.359197"
y="2.6803692"
width="66.742953"
height="18.624167"
id="rect15628" />
<rect
x="2.8780095"
y="32.203989"
width="116.94288"
height="87.251209"
id="rect14838" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
id="rect111"
width="119.99836"
height="119.79127"
x="0.0058150524"
y="0.21117544"
ry="18.295183"
style="fill:#201127;fill-opacity:1;stroke-width:0.997728" />
<path
id="rect111-3"
style="fill:#1b1e34;fill-opacity:1;stroke-width:0.997728"
d="m 18.321605,-0.01480561 c -10.1355215,0 -18.29492247,8.15940011 -18.29492247,18.29492161 v 4.564453 H 119.99738 V 17.733241 C 119.70734,7.8552235 111.68056,-0.01480561 101.7298,-0.01480561 Z" />
<ellipse
style="fill:#c24b6e;fill-opacity:1"
id="path4855"
cx="105.25824"
cy="12.000000"
rx="5.9108677"
ry="5.9019933" />
<ellipse
style="fill:#ffeb99;fill-opacity:1"
id="path4855-6"
cx="91.251190"
cy="12.000000"
rx="5.9108677"
ry="5.9019933" />
<ellipse
style="fill:#6aaf9d;fill-opacity:1"
id="path4855-7"
cx="77.244141"
cy="12.000000"
rx="5.9108677"
ry="5.9019933" />
<g
aria-label="◡–"
transform="translate(-0.25103084,-17.617149)"
id="text14836"
style="font-size:77.3333px;line-height:1.25;font-family:monospace;-inkscape-font-specification:'monospace, Normal';text-align:center;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect14838);shape-padding:0.114105;display:inline">
<path
d="m 38.072257,96.572677 q -4.485331,0 -8.351996,-2.319999 -3.866665,-2.397332 -6.263997,-6.263997 -2.319999,-3.866665 -2.319999,-8.506662 h 3.247999 q 0,3.711998 1.855999,6.882663 1.933332,3.093332 5.026664,5.026664 3.170665,1.933333 6.80533,1.933333 3.711999,0 6.882664,-1.933333 3.170665,-1.933332 5.026664,-5.026664 1.933333,-3.170665 1.933333,-6.882663 h 3.247998 q 0,4.485331 -2.319999,8.429329 -2.319999,3.866665 -6.263997,6.263997 -3.866665,2.397332 -8.506663,2.397332 z"
style="display:inline;fill:#ec9a6d"
id="path809" />
<path
d="m 69.08294,84.895349 v -6.186663 h 30.93332 v 6.186663 z"
style="display:inline;fill:#ec9a6d"
id="path811" />
</g>
<g
aria-label="iamb"
transform="translate(-55.871719,2.2068568)"
id="text15626"
style="font-size:13.3333px;line-height:1.25;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect15628);display:inline">
<path
d="m 71.026037,7.5196777 h 3.066399 v 6.3606613 h 2.376296 v 0.930987 h -5.950506 v -0.930987 h 2.376296 V 8.4506649 h -1.868485 z m 1.868485,-2.8385345 h 1.197914 v 1.5169233 h -1.197914 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path800" />
<path
d="m 81.957,11.145971 h -0.397135 q -1.048174,0 -1.582027,0.371092 -0.527342,0.364583 -0.527342,1.093748 0,0.65755 0.397134,1.022133 0.397134,0.364582 1.100258,0.364582 0.98958,0 1.555985,-0.683592 0.566405,-0.690103 0.572916,-1.901037 v -0.266926 z m 2.324213,-0.494791 v 4.160146 H 83.076789 V 13.7306 q -0.384114,0.65104 -0.97005,0.963539 -0.579426,0.305989 -1.412757,0.305989 -1.113278,0 -1.777339,-0.624999 -0.664061,-0.631509 -0.664061,-1.686194 0,-1.217444 0.8138,-1.848953 0.82031,-0.631509 2.402338,-0.631509 h 1.608069 v -0.188802 q -0.0065,-0.8723932 -0.442708,-1.2630172 -0.436197,-0.3971345 -1.393225,-0.3971345 -0.611978,0 -1.236976,0.1757808 -0.624999,0.1757809 -1.217445,0.5143217 V 7.8517081 q 0.664061,-0.2539056 1.269528,-0.3776032 0.611977,-0.130208 1.184893,-0.130208 0.904945,0 1.542964,0.2669264 0.64453,0.2669264 1.041665,0.8007792 0.247395,0.3255201 0.351561,0.8072897 0.104167,0.4752592 0.104167,1.4322878 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path802" />
<path
d="m 89.815053,8.2618633 q 0.221354,-0.4687488 0.559894,-0.6901024 0.345052,-0.227864 0.826821,-0.227864 0.878904,0 1.236976,0.683592 0.364583,0.6770817 0.364583,2.5585871 v 4.22525 h -1.093748 v -4.173167 q 0,-1.5429644 -0.17578,-1.9140572 -0.169271,-0.3776033 -0.624999,-0.3776033 -0.520832,0 -0.716144,0.4036449 -0.188801,0.3971344 -0.188801,1.8880156 v 4.173167 h -1.093748 v -4.173167 q 0,-1.5624956 -0.188801,-1.927078 -0.182291,-0.3645825 -0.664061,-0.3645825 -0.475259,0 -0.664061,0.4036449 -0.182291,0.3971344 -0.182291,1.8880156 v 4.173167 H 86.123656 V 7.5196777 h 1.087237 v 0.6249984 q 0.214843,-0.390624 0.533853,-0.5924464 0.32552,-0.2083328 0.735675,-0.2083328 0.49479,0 0.82031,0.227864 0.332031,0.227864 0.514322,0.6901024 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path804" />
<path
d="m 99.417893,11.172012 q 0,-1.3932254 -0.442708,-2.102859 -0.442707,-0.7096337 -1.30859,-0.7096337 -0.872394,0 -1.321611,0.7161441 -0.449218,0.7096336 -0.449218,2.0963486 0,1.380205 0.449218,2.096349 0.449217,0.716144 1.321611,0.716144 0.865883,0 1.30859,-0.709633 0.442708,-0.709634 0.442708,-2.10286 z M 95.895766,8.4506649 q 0.286458,-0.5338528 0.787759,-0.8203104 0.507811,-0.2864576 1.171872,-0.2864576 1.3151,0 2.070307,1.0156224 0.755206,1.0091121 0.755206,2.7864517 0,1.80338 -0.761717,2.832024 -0.755206,1.022133 -2.076817,1.022133 -0.65104,0 -1.152341,-0.279948 -0.49479,-0.286457 -0.794269,-0.82682 v 0.917966 H 94.697852 V 4.6811432 h 1.197914 z"
style="font-family:'Bitstream Vera Sans Mono';-inkscape-font-specification:'Bitstream Vera Sans Mono, Normal';fill:#ec9a6d"
id="path806" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

116
flake.lock generated Normal file
View File

@@ -0,0 +1,116 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1759893430,
"narHash": "sha256-yAy4otLYm9iZ+NtQwTMEbqHwswSFUbhn7x826RR6djw=",
"owner": "ipetkov",
"repo": "crane",
"rev": "1979a2524cb8c801520bd94c38bb3d5692419d93",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1760510549,
"narHash": "sha256-NP+kmLMm7zSyv4Fufv+eSJXyqjLMUhUfPT6lXRlg/bU=",
"owner": "nix-community",
"repo": "fenix",
"rev": "ef7178cf086f267113b5c48fdeb6e510729c8214",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1760284886,
"narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1760457219,
"narHash": "sha256-WJOUGx42hrhmvvYcGkwea+BcJuQJLcns849OnewQqX4=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "8747cf81540bd1bbbab9ee2702f12c33aa887b46",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

111
flake.nix Normal file
View File

@@ -0,0 +1,111 @@
{
description = "iamb";
nixConfig.bash-prompt = "\[nix-develop\]$ ";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
crane.url = "github:ipetkov/crane";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
crane,
flake-utils,
fenix,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
rustToolchain = fenix.packages.${system}.fromToolchainFile {
file = ./rust-toolchain.toml;
# When the file changes, this hash must be updated.
sha256 = "sha256-Qxt8XAuaUR2OMdKbN4u8dBJOhSHxS+uS06Wl9+flVEk=";
};
# Nightly toolchain for rustfmt (pinned to current flake lock)
# Note that the github CI uses "current nightly" for formatting, it 's not pinned.
rustNightly = fenix.packages.${system}.latest;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
craneLibNightly = (crane.mkLib pkgs).overrideToolchain rustNightly.toolchain;
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
(craneLib.fileset.commonCargoSources ./.)
./src/windows/welcome.md
];
};
commonArgs = {
inherit src;
strictDeps = true;
pname = "iamb";
version = self.shortRev or self.dirtyShortRev;
};
# Build *just* the cargo dependencies, so we can reuse
# all of that work (e.g. via cachix) when running in CI
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Build the actual crate
iamb = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
checks = {
# Build the crate as part of `nix flake check`
inherit iamb;
iamb-clippy = craneLib.cargoClippy (commonArgs // {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
});
iamb-fmt = craneLibNightly.cargoFmt {
inherit src;
};
iamb-nextest = craneLib.cargoNextest (commonArgs // {
inherit cargoArtifacts;
partitions = 1;
partitionType = "count";
});
};
packages.default = iamb;
apps.default = flake-utils.lib.mkApp {
drv = iamb;
};
devShells.default = craneLib.devShell {
# Inherit inputs from checks
checks = self.checks.${system};
packages = with pkgs; [
cargo-tarpaulin
cargo-watch
sqlite
];
shellHook = ''
# Prepend nightly rustfmt to PATH.
export PATH="${rustNightly.rustfmt}/bin:$PATH"
'';
};
}
);
}

12
iamb.desktop Normal file
View File

@@ -0,0 +1,12 @@
[Desktop Entry]
Categories=Network;InstantMessaging;Chat;
Comment=A Matrix client for Vim addicts
Exec=iamb
GenericName=Matrix Client
Keywords=Matrix;matrix.org;chat;communications;talk;
Name=iamb
Icon=iamb
StartupNotify=false
Terminal=true
TryExec=iamb
Type=Application

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.88"
components = [ "clippy" ]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +1,89 @@
//! # Default Keybindings
//!
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
//! keys come from [modalkit::env::vim::keybindings].
use modalkit::{
editing::action::WindowAction,
editing::base::WordStyle,
actions::{InsertTextAction, MacroAction, WindowAction},
env::vim::keybindings::{InputStep, VimBindings},
env::vim::VimMode,
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings},
input::key::TerminalKey,
env::CommonKeyClass,
key::TerminalKey,
keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
prelude::*,
};
use crate::base::{IambAction, Keybindings};
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
use crate::config::{ApplicationSettings, Keys};
/// Find the boundaries for a Matrix username, room alias, or room ID.
///
/// Technically "[" and "]" should be here since IPv6 addresses are allowed
/// in the server name, but in practice that should be uncommon, and people
/// can just use `gf` and friends in Visual mode instead.
fn is_mxid_char(c: char) -> bool {
return c >= 'a' && c <= 'z' ||
c >= 'A' && c <= 'Z' ||
c >= '0' && c <= '9' ||
":-./@_#!".contains(c);
pub type IambStep = InputStep<IambInfo>;
fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent<TerminalKey, CommonKeyClass>) {
(EdgeRepeat::Once, EdgeEvent::Key(*key))
}
/// Initialize the default keybinding state.
pub fn setup_keybindings() -> Keybindings {
let mut ism = Keybindings::empty();
let vim = VimBindings::default()
.submit_on_enter()
.cursor_open(WordStyle::CharSet(is_mxid_char));
.cursor_open(MATRIX_ID_WORD.clone());
vim.setup(&mut ism);
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap());
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap());
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap());
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap());
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap());
let ctrl_w = "<C-W>".parse::<TerminalKey>().unwrap();
let ctrl_m = "<C-M>".parse::<TerminalKey>().unwrap();
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
let key_m_lc = "m".parse::<TerminalKey>().unwrap();
let key_z_lc = "z".parse::<TerminalKey>().unwrap();
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
let cwz = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, key_z_lc),
];
let cwcz = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, ctrl_z),
];
let zoom = InputStep::new().actions(vec![WindowAction::ZoomToggle.into()]);
let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
let zoom = IambStep::new()
.actions(vec![WindowAction::ZoomToggle.into()])
.goto(VimMode::Normal);
ism.add_mapping(VimMode::Normal, &cwz, &zoom);
ism.add_mapping(VimMode::Visual, &cwz, &zoom);
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
ism.add_mapping(VimMode::Visual, &cwcz, &zoom);
let cwm = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, key_m_lc),
];
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
let stoggle = InputStep::new().actions(vec![IambAction::ToggleScrollbackFocus.into()]);
let cwm = vec![once(&ctrl_w), once(&key_m_lc)];
let cwcm = vec![once(&ctrl_w), once(&ctrl_m)];
let stoggle = IambStep::new()
.actions(vec![IambAction::ToggleScrollbackFocus.into()])
.goto(VimMode::Normal);
ism.add_mapping(VimMode::Normal, &cwm, &stoggle);
ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
return ism;
let shift_enter = vec![once(&shift_enter)];
let newline = IambStep::new().actions(vec![InsertTextAction::Type(
Char::Single('\n').into(),
MoveDir1D::Previous,
1.into(),
)
.into()]);
ism.add_mapping(VimMode::Insert, &cwm, &newline);
ism.add_mapping(VimMode::Insert, &shift_enter, &newline);
ism
}
impl InputBindings<TerminalKey, IambStep> for ApplicationSettings {
fn setup(&self, bindings: &mut Keybindings) {
for (modes, keys) in &self.macros {
for (Keys(input, _), Keys(_, run)) in keys {
let act = MacroAction::Run(run.clone(), Count::Contextual);
let step = IambStep::new().actions(vec![act.into()]);
let input = input.iter().map(once).collect::<Vec<_>>();
for mode in &modes.0 {
bindings.add_mapping(*mode, &input, &step);
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,650 +0,0 @@
use std::borrow::Cow;
use std::cmp::{Ord, Ordering, PartialOrd};
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::convert::TryFrom;
use std::hash::{Hash, Hasher};
use std::str::Lines;
use chrono::{DateTime, NaiveDateTime, Utc};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use matrix_sdk::ruma::{
events::{
room::message::{MessageType, RoomMessageEventContent},
MessageLikeEvent,
},
MilliSecondsSinceUnixEpoch,
OwnedEventId,
OwnedUserId,
UInt,
};
use modalkit::tui::{
style::{Color, Modifier as StyleModifier, Style},
text::{Span, Spans, Text},
};
use modalkit::editing::{base::ViewportContext, cursor::Cursor};
use crate::base::{IambResult, RoomInfo};
pub type MessageEvent = MessageLikeEvent<RoomMessageEventContent>;
pub type MessageFetchResult = IambResult<(Option<String>, Vec<MessageEvent>)>;
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
pub type Messages = BTreeMap<MessageKey, Message>;
const COLORS: [Color; 13] = [
Color::Blue,
Color::Cyan,
Color::Green,
Color::LightBlue,
Color::LightGreen,
Color::LightCyan,
Color::LightMagenta,
Color::LightRed,
Color::LightYellow,
Color::Magenta,
Color::Red,
Color::Reset,
Color::Yellow,
];
const USER_GUTTER: usize = 30;
const TIME_GUTTER: usize = 12;
const MIN_MSG_LEN: usize = 30;
const USER_GUTTER_EMPTY: &str = " ";
const USER_GUTTER_EMPTY_SPAN: Span<'static> = Span {
content: Cow::Borrowed(USER_GUTTER_EMPTY),
style: Style {
fg: None,
bg: None,
add_modifier: StyleModifier::empty(),
sub_modifier: StyleModifier::empty(),
},
};
pub(crate) fn user_color(user: &str) -> Color {
let mut hasher = DefaultHasher::new();
user.hash(&mut hasher);
let color = hasher.finish() as usize % COLORS.len();
COLORS[color]
}
pub(crate) fn user_style(user: &str) -> Style {
Style::default().fg(user_color(user)).add_modifier(StyleModifier::BOLD)
}
struct WrappedLinesIterator<'a> {
iter: Lines<'a>,
curr: Option<&'a str>,
width: usize,
}
impl<'a> WrappedLinesIterator<'a> {
fn new(input: &'a str, width: usize) -> Self {
WrappedLinesIterator { iter: input.lines(), curr: None, width }
}
}
impl<'a> Iterator for WrappedLinesIterator<'a> {
type Item = (&'a str, usize);
fn next(&mut self) -> Option<Self::Item> {
if self.curr.is_none() {
self.curr = self.iter.next();
}
if let Some(s) = self.curr.take() {
let width = UnicodeWidthStr::width(s);
if width <= self.width {
return Some((s, width));
} else {
// Find where to split the line.
let mut width = 0;
let mut idx = 0;
for (i, g) in UnicodeSegmentation::grapheme_indices(s, true) {
let gw = UnicodeWidthStr::width(g);
idx = i;
if width + gw > self.width {
break;
}
width += gw;
}
self.curr = Some(&s[idx..]);
return Some((&s[..idx], width));
}
} else {
return None;
}
}
}
fn wrap(input: &str, width: usize) -> WrappedLinesIterator<'_> {
WrappedLinesIterator::new(input, width)
}
fn space(width: usize) -> String {
" ".repeat(width)
}
#[derive(thiserror::Error, Debug)]
pub enum TimeStampIntError {
#[error("Integer conversion error: {0}")]
IntError(#[from] std::num::TryFromIntError),
#[error("UInt conversion error: {0}")]
UIntError(<UInt as TryFrom<u64>>::Error),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MessageTimeStamp {
OriginServer(UInt),
LocalEcho,
}
impl MessageTimeStamp {
fn show(&self) -> Option<Span> {
match self {
MessageTimeStamp::OriginServer(ts) => {
let time = i64::from(*ts) / 1000;
let time = NaiveDateTime::from_timestamp_opt(time, 0)?;
let time = DateTime::<Utc>::from_utc(time, Utc);
let time = time.format("%T");
let time = format!(" [{}]", time);
Span::raw(time).into()
},
MessageTimeStamp::LocalEcho => None,
}
}
fn is_local_echo(&self) -> bool {
matches!(self, MessageTimeStamp::LocalEcho)
}
}
impl Ord for MessageTimeStamp {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(MessageTimeStamp::OriginServer(_), MessageTimeStamp::LocalEcho) => Ordering::Less,
(MessageTimeStamp::OriginServer(a), MessageTimeStamp::OriginServer(b)) => a.cmp(b),
(MessageTimeStamp::LocalEcho, MessageTimeStamp::OriginServer(_)) => Ordering::Greater,
(MessageTimeStamp::LocalEcho, MessageTimeStamp::LocalEcho) => Ordering::Equal,
}
}
}
impl PartialOrd for MessageTimeStamp {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.cmp(other).into()
}
}
impl From<MilliSecondsSinceUnixEpoch> for MessageTimeStamp {
fn from(millis: MilliSecondsSinceUnixEpoch) -> Self {
MessageTimeStamp::OriginServer(millis.0)
}
}
impl TryFrom<&MessageTimeStamp> for usize {
type Error = TimeStampIntError;
fn try_from(ts: &MessageTimeStamp) -> Result<Self, Self::Error> {
let n = match ts {
MessageTimeStamp::LocalEcho => 0,
MessageTimeStamp::OriginServer(u) => usize::try_from(u64::from(*u))?,
};
Ok(n)
}
}
impl TryFrom<usize> for MessageTimeStamp {
type Error = TimeStampIntError;
fn try_from(u: usize) -> Result<Self, Self::Error> {
if u == 0 {
Ok(MessageTimeStamp::LocalEcho)
} else {
let n = u64::try_from(u)?;
let n = UInt::try_from(n).map_err(TimeStampIntError::UIntError)?;
Ok(MessageTimeStamp::OriginServer(n))
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MessageCursor {
/// When timestamp is None, the corner is determined by moving backwards from
/// the most recently received message.
pub timestamp: Option<MessageKey>,
/// A row within the [Text] representation of a [Message].
pub text_row: usize,
}
impl MessageCursor {
pub fn new(timestamp: MessageKey, text_row: usize) -> Self {
MessageCursor { timestamp: Some(timestamp), text_row }
}
/// Get a cursor that refers to the most recent message.
pub fn latest() -> Self {
MessageCursor::default()
}
pub fn to_key<'a>(&'a self, info: &'a RoomInfo) -> Option<&'a MessageKey> {
if let Some(ref key) = self.timestamp {
Some(key)
} else {
Some(info.messages.last_key_value()?.0)
}
}
pub fn from_cursor(cursor: &Cursor, info: &RoomInfo) -> Option<Self> {
let ev_hash = u64::try_from(cursor.get_x()).ok()?;
let ev_term = OwnedEventId::try_from("$").ok()?;
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
let start = (ts_start, ev_term);
let mut mc = None;
for ((ts, event_id), _) in info.messages.range(start..) {
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
if hasher.finish() == ev_hash {
mc = Self::from((*ts, event_id.clone())).into();
break;
}
if mc.is_none() {
mc = Self::from((*ts, event_id.clone())).into();
}
if ts > &ts_start {
break;
}
}
return mc;
}
pub fn to_cursor(&self, info: &RoomInfo) -> Option<Cursor> {
let (ts, event_id) = self.to_key(info)?;
let y: usize = usize::try_from(ts).ok()?;
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
let x = usize::try_from(hasher.finish()).ok()?;
Cursor::new(y, x).into()
}
}
impl From<Option<MessageKey>> for MessageCursor {
fn from(key: Option<MessageKey>) -> Self {
MessageCursor { timestamp: key, text_row: 0 }
}
}
impl From<MessageKey> for MessageCursor {
fn from(key: MessageKey) -> Self {
MessageCursor { timestamp: Some(key), text_row: 0 }
}
}
impl Ord for MessageCursor {
fn cmp(&self, other: &Self) -> Ordering {
match (&self.timestamp, &other.timestamp) {
(None, None) => self.text_row.cmp(&other.text_row),
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(st), Some(ot)) => {
let pcmp = st.cmp(ot);
let tcmp = self.text_row.cmp(&other.text_row);
pcmp.then(tcmp)
},
}
}
}
impl PartialOrd for MessageCursor {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.cmp(other).into()
}
}
#[derive(Clone)]
pub enum MessageContent {
Original(Box<RoomMessageEventContent>),
Redacted,
}
impl AsRef<str> for MessageContent {
fn as_ref(&self) -> &str {
match self {
MessageContent::Original(ev) => {
match &ev.msgtype {
MessageType::Text(content) => {
return content.body.as_ref();
},
MessageType::Emote(content) => {
return content.body.as_ref();
},
MessageType::Notice(content) => {
return content.body.as_str();
},
MessageType::ServerNotice(_) => {
// XXX: implement
return "[server notice]";
},
MessageType::VerificationRequest(_) => {
// XXX: implement
return "[verification request]";
},
MessageType::Audio(..) => {
return "[audio]";
},
MessageType::File(..) => {
return "[file]";
},
MessageType::Image(..) => {
return "[image]";
},
MessageType::Video(..) => {
return "[video]";
},
_ => return "[unknown message type]",
}
},
MessageContent::Redacted => "[redacted]",
}
}
}
#[derive(Clone)]
pub struct Message {
pub content: MessageContent,
pub sender: OwnedUserId,
pub timestamp: MessageTimeStamp,
}
impl Message {
pub fn new(content: MessageContent, sender: OwnedUserId, timestamp: MessageTimeStamp) -> Self {
Message { content, sender, timestamp }
}
pub fn show(&self, selected: bool, vwctx: &ViewportContext<MessageCursor>) -> Text {
let width = vwctx.get_width();
let msg = self.as_ref();
let mut lines = vec![];
let mut style = Style::default();
if selected {
style = style.add_modifier(StyleModifier::REVERSED)
}
if self.timestamp.is_local_echo() {
style = style.add_modifier(StyleModifier::ITALIC);
}
if USER_GUTTER + TIME_GUTTER + MIN_MSG_LEN <= width {
let lw = width - USER_GUTTER - TIME_GUTTER;
for (i, (line, w)) in wrap(msg, lw).enumerate() {
let line = Span::styled(line, style);
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
if i == 0 {
let user = self.show_sender(true);
if let Some(time) = self.timestamp.show() {
lines.push(Spans(vec![user, line, trailing, time]))
} else {
lines.push(Spans(vec![user, line, trailing]))
}
} else {
let space = USER_GUTTER_EMPTY_SPAN;
lines.push(Spans(vec![space, line, trailing]))
}
}
} else if USER_GUTTER + MIN_MSG_LEN <= width {
let lw = width - USER_GUTTER;
for (i, (line, w)) in wrap(msg, lw).enumerate() {
let line = Span::styled(line, style);
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
let prefix = if i == 0 {
self.show_sender(true)
} else {
USER_GUTTER_EMPTY_SPAN
};
lines.push(Spans(vec![prefix, line, trailing]))
}
} else {
lines.push(Spans::from(self.show_sender(false)));
for (line, _) in wrap(msg, width.saturating_sub(2)) {
let line = format!(" {}", line);
let line = Span::styled(line, style);
lines.push(Spans(vec![line]))
}
}
return Text { lines };
}
fn show_sender(&self, align_right: bool) -> Span {
let sender = self.sender.to_string();
let style = user_style(sender.as_str());
let sender = if align_right {
format!("{: >width$} ", sender, width = 28)
} else {
format!("{: <width$} ", sender, width = 28)
};
Span::styled(sender, style)
}
}
impl From<MessageEvent> for Message {
fn from(event: MessageEvent) -> Self {
match event {
MessageLikeEvent::Original(ev) => {
let content = MessageContent::Original(ev.content.into());
Message::new(content, ev.sender, ev.origin_server_ts.into())
},
MessageLikeEvent::Redacted(ev) => {
Message::new(MessageContent::Redacted, ev.sender, ev.origin_server_ts.into())
},
}
}
}
impl AsRef<str> for Message {
fn as_ref(&self) -> &str {
self.content.as_ref()
}
}
impl ToString for Message {
fn to_string(&self) -> String {
self.as_ref().to_string()
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::tests::*;
#[test]
fn test_wrapped_lines_ascii() {
let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye";
let mut iter = wrap(s, 100);
assert_eq!(iter.next(), Some(("hello world!", 12)));
assert_eq!(iter.next(), Some(("abcdefghijklmnopqrstuvwxyz", 26)));
assert_eq!(iter.next(), Some(("goodbye", 7)));
assert_eq!(iter.next(), None);
let mut iter = wrap(s, 5);
assert_eq!(iter.next(), Some(("hello", 5)));
assert_eq!(iter.next(), Some((" worl", 5)));
assert_eq!(iter.next(), Some(("d!", 2)));
assert_eq!(iter.next(), Some(("abcde", 5)));
assert_eq!(iter.next(), Some(("fghij", 5)));
assert_eq!(iter.next(), Some(("klmno", 5)));
assert_eq!(iter.next(), Some(("pqrst", 5)));
assert_eq!(iter.next(), Some(("uvwxy", 5)));
assert_eq!(iter.next(), Some(("z", 1)));
assert_eq!(iter.next(), Some(("goodb", 5)));
assert_eq!(iter.next(), Some(("ye", 2)));
assert_eq!(iter.next(), None);
}
#[test]
fn test_wrapped_lines_unicode() {
let s = "";
let mut iter = wrap(s, 14);
assert_eq!(iter.next(), Some((s, 14)));
assert_eq!(iter.next(), None);
let mut iter = wrap(s, 5);
assert_eq!(iter.next(), Some(("", 4)));
assert_eq!(iter.next(), Some(("", 4)));
assert_eq!(iter.next(), Some(("", 4)));
assert_eq!(iter.next(), Some(("", 2)));
assert_eq!(iter.next(), None);
}
#[test]
fn test_mc_cmp() {
let mc1 = MessageCursor::from(MSG1_KEY.clone());
let mc2 = MessageCursor::from(MSG2_KEY.clone());
let mc3 = MessageCursor::from(MSG3_KEY.clone());
let mc4 = MessageCursor::from(MSG4_KEY.clone());
let mc5 = MessageCursor::from(MSG5_KEY.clone());
// Everything is equal to itself.
assert_eq!(mc1.cmp(&mc1), Ordering::Equal);
assert_eq!(mc2.cmp(&mc2), Ordering::Equal);
assert_eq!(mc3.cmp(&mc3), Ordering::Equal);
assert_eq!(mc4.cmp(&mc4), Ordering::Equal);
assert_eq!(mc5.cmp(&mc5), Ordering::Equal);
// Local echo is always greater than an origin server timestamp.
assert_eq!(mc1.cmp(&mc2), Ordering::Greater);
assert_eq!(mc1.cmp(&mc3), Ordering::Greater);
assert_eq!(mc1.cmp(&mc4), Ordering::Greater);
assert_eq!(mc1.cmp(&mc5), Ordering::Greater);
// mc2 is the smallest timestamp.
assert_eq!(mc2.cmp(&mc1), Ordering::Less);
assert_eq!(mc2.cmp(&mc3), Ordering::Less);
assert_eq!(mc2.cmp(&mc4), Ordering::Less);
assert_eq!(mc2.cmp(&mc5), Ordering::Less);
// mc3 should be less than mc4 because of its event ID.
assert_eq!(mc3.cmp(&mc1), Ordering::Less);
assert_eq!(mc3.cmp(&mc2), Ordering::Greater);
assert_eq!(mc3.cmp(&mc4), Ordering::Less);
assert_eq!(mc3.cmp(&mc5), Ordering::Less);
// mc4 should be greater than mc3 because of its event ID.
assert_eq!(mc4.cmp(&mc1), Ordering::Less);
assert_eq!(mc4.cmp(&mc2), Ordering::Greater);
assert_eq!(mc4.cmp(&mc3), Ordering::Greater);
assert_eq!(mc4.cmp(&mc5), Ordering::Less);
// mc5 is the greatest OriginServer timestamp.
assert_eq!(mc5.cmp(&mc1), Ordering::Less);
assert_eq!(mc5.cmp(&mc2), Ordering::Greater);
assert_eq!(mc5.cmp(&mc3), Ordering::Greater);
assert_eq!(mc5.cmp(&mc4), Ordering::Greater);
}
#[test]
fn test_mc_to_key() {
let info = mock_room();
let mc1 = MessageCursor::from(MSG1_KEY.clone());
let mc2 = MessageCursor::from(MSG2_KEY.clone());
let mc3 = MessageCursor::from(MSG3_KEY.clone());
let mc4 = MessageCursor::from(MSG4_KEY.clone());
let mc5 = MessageCursor::from(MSG5_KEY.clone());
let mc6 = MessageCursor::latest();
let k1 = mc1.to_key(&info).unwrap();
let k2 = mc2.to_key(&info).unwrap();
let k3 = mc3.to_key(&info).unwrap();
let k4 = mc4.to_key(&info).unwrap();
let k5 = mc5.to_key(&info).unwrap();
let k6 = mc6.to_key(&info).unwrap();
// These should all be equal to their MSGN_KEYs.
assert_eq!(k1, &MSG1_KEY.clone());
assert_eq!(k2, &MSG2_KEY.clone());
assert_eq!(k3, &MSG3_KEY.clone());
assert_eq!(k4, &MSG4_KEY.clone());
assert_eq!(k5, &MSG5_KEY.clone());
// MessageCursor::latest() turns into the largest key (our local echo message).
assert_eq!(k6, &MSG1_KEY.clone());
// MessageCursor::latest() fails to convert for a room w/o messages.
let info_empty = RoomInfo::default();
assert_eq!(mc6.to_key(&info_empty), None);
}
#[test]
fn test_mc_to_from_cursor() {
let info = mock_room();
let mc1 = MessageCursor::from(MSG1_KEY.clone());
let mc2 = MessageCursor::from(MSG2_KEY.clone());
let mc3 = MessageCursor::from(MSG3_KEY.clone());
let mc4 = MessageCursor::from(MSG4_KEY.clone());
let mc5 = MessageCursor::from(MSG5_KEY.clone());
let mc6 = MessageCursor::latest();
let identity = |mc: &MessageCursor| {
let c = mc.to_cursor(&info).unwrap();
MessageCursor::from_cursor(&c, &info).unwrap()
};
// These should all convert to a Cursor and back to the original value.
assert_eq!(identity(&mc1), mc1);
assert_eq!(identity(&mc2), mc2);
assert_eq!(identity(&mc3), mc3);
assert_eq!(identity(&mc4), mc4);
assert_eq!(identity(&mc5), mc5);
// MessageCursor::latest() should point at the most recent message after conversion.
assert_eq!(identity(&mc6), mc1);
}
}

376
src/message/compose.rs Normal file
View File

@@ -0,0 +1,376 @@
//! Code for converting composed messages into content to send to the homeserver.
use comrak::{markdown_to_html, ComrakOptions};
use nom::{
branch::alt,
bytes::complete::tag,
character::complete::space0,
combinator::value,
IResult,
};
use matrix_sdk::ruma::events::room::message::{
EmoteMessageEventContent,
MessageType,
RoomMessageEventContent,
TextMessageEventContent,
};
#[derive(Clone, Debug, Default)]
enum SlashCommand {
/// Send an emote message.
Emote,
/// Send a message as literal HTML.
Html,
/// Send a message without parsing any markup.
Plaintext,
/// Send a Markdown message (the default message markup).
#[default]
Markdown,
/// Send a message with confetti effects in clients that show them.
Confetti,
/// Send a message with fireworks effects in clients that show them.
Fireworks,
/// Send a message with heart effects in clients that show them.
Hearts,
/// Send a message with rainfall effects in clients that show them.
Rainfall,
/// Send a message with snowfall effects in clients that show them.
Snowfall,
/// Send a message with heart effects in clients that show them.
SpaceInvaders,
}
impl SlashCommand {
fn to_message(&self, input: &str) -> anyhow::Result<MessageType> {
let msgtype = match self {
SlashCommand::Emote => {
let msg = if let Some(html) = text_to_html(input) {
EmoteMessageEventContent::html(input, html)
} else {
EmoteMessageEventContent::plain(input)
};
MessageType::Emote(msg)
},
SlashCommand::Html => {
let msg = TextMessageEventContent::html(input, input);
MessageType::Text(msg)
},
SlashCommand::Plaintext => {
let msg = TextMessageEventContent::plain(input);
MessageType::Text(msg)
},
SlashCommand::Markdown => {
let msg = text_to_message_content(input.to_string());
MessageType::Text(msg)
},
SlashCommand::Confetti => {
MessageType::new("nic.custom.confetti", input.into(), Default::default())?
},
SlashCommand::Fireworks => {
MessageType::new("nic.custom.fireworks", input.into(), Default::default())?
},
SlashCommand::Hearts => {
MessageType::new("io.element.effect.hearts", input.into(), Default::default())?
},
SlashCommand::Rainfall => {
MessageType::new("io.element.effect.rainfall", input.into(), Default::default())?
},
SlashCommand::Snowfall => {
MessageType::new("io.element.effect.snowfall", input.into(), Default::default())?
},
SlashCommand::SpaceInvaders => {
MessageType::new(
"io.element.effects.space_invaders",
input.into(),
Default::default(),
)?
},
};
Ok(msgtype)
}
}
fn parse_slash_command_inner(input: &str) -> IResult<&str, SlashCommand> {
let (input, _) = space0(input)?;
let (input, slash) = alt((
value(SlashCommand::Emote, tag("/me ")),
value(SlashCommand::Html, tag("/h ")),
value(SlashCommand::Html, tag("/html ")),
value(SlashCommand::Plaintext, tag("/p ")),
value(SlashCommand::Plaintext, tag("/plain ")),
value(SlashCommand::Plaintext, tag("/plaintext ")),
value(SlashCommand::Markdown, tag("/md ")),
value(SlashCommand::Markdown, tag("/markdown ")),
value(SlashCommand::Confetti, tag("/confetti ")),
value(SlashCommand::Fireworks, tag("/fireworks ")),
value(SlashCommand::Hearts, tag("/hearts ")),
value(SlashCommand::Rainfall, tag("/rainfall ")),
value(SlashCommand::Snowfall, tag("/snowfall ")),
value(SlashCommand::SpaceInvaders, tag("/spaceinvaders ")),
))(input)?;
let (input, _) = space0(input)?;
Ok((input, slash))
}
fn parse_slash_command(input: &str) -> anyhow::Result<(&str, SlashCommand)> {
match parse_slash_command_inner(input) {
Ok(input) => Ok(input),
Err(e) => Err(anyhow::anyhow!("Failed to parse slash command: {e}")),
}
}
/// Check whether this character is not used for markup in Markdown.
///
/// Markdown uses just about every ASCII punctuation symbol in some way, especially
/// once autolinking is involved, so we really just check whether it's non-punctuation or
/// single/double quotations.
fn not_markdown_char(c: char) -> bool {
if !c.is_ascii_punctuation() {
return true;
}
matches!(c, '"' | '\'')
}
/// Check whether the input actually needs to be processed as Markdown.
fn not_markdown(input: &str) -> bool {
input.chars().all(not_markdown_char)
}
fn text_to_html(input: &str) -> Option<String> {
if not_markdown(input) {
return None;
}
let mut options = ComrakOptions::default();
options.extension.autolink = true;
options.extension.shortcodes = true;
options.extension.strikethrough = true;
options.render.hardbreaks = true;
markdown_to_html(input, &options).into()
}
fn text_to_message_content(input: String) -> TextMessageEventContent {
if let Some(html) = text_to_html(input.as_str()) {
TextMessageEventContent::html(input, html)
} else {
TextMessageEventContent::plain(input)
}
}
pub fn text_to_message(input: String) -> RoomMessageEventContent {
let msg = parse_slash_command(input.as_str())
.and_then(|(input, slash)| slash.to_message(input))
.unwrap_or_else(|_| MessageType::Text(text_to_message_content(input)));
RoomMessageEventContent::new(msg)
}
#[cfg(test)]
pub mod tests {
use super::*;
#[test]
fn test_markdown_autolink() {
let input = "http://example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
);
let input = "www.example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
);
let input = "See docs (they're at https://iamb.chat)\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
);
}
#[test]
fn test_markdown_message() {
let input = "**bold**\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
let input = "*emphasis*\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
let input = "`code`\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
let input = "```rust\nconst A: usize = 1;\n```\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
);
let input = ":heart:\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
let input = "para *1*\n\npara _2_\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p>para <em>1</em></p>\n<p>para <em>2</em></p>\n"
);
let input = "line 1\nline ~~2~~\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline <del>2</del></p>\n");
let input = "# Heading\n## Subheading\n\ntext\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
);
}
#[test]
fn test_markdown_headers() {
let input = "hello\n=====\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<h1>hello</h1>\n");
let input = "hello\n-----\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<h2>hello</h2>\n");
}
#[test]
fn test_markdown_lists() {
let input = "- A\n- B\n- C\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<ul>\n<li>A</li>\n<li>B</li>\n<li>C</li>\n</ul>\n"
);
let input = "1) A\n2) B\n3) C\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<ol>\n<li>A</li>\n<li>B</li>\n<li>C</li>\n</ol>\n"
);
}
#[test]
fn test_no_markdown_conversion_on_simple_text() {
let input = "para 1\n\npara 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
let input = "line 1\nline 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
let input = "isn't markdown\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
let input = "\"scare quotes\"\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert!(content.formatted.is_none());
}
#[test]
fn text_to_message_slash_commands() {
let MessageType::Text(content) = text_to_message("/html <b>bold</b>".into()).msgtype else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
let MessageType::Text(content) = text_to_message("/h <b>bold</b>".into()).msgtype else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
let MessageType::Text(content) = text_to_message("/plain <b>bold</b>".into()).msgtype
else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert!(content.formatted.is_none(), "{:?}", content.formatted);
let MessageType::Text(content) = text_to_message("/p <b>bold</b>".into()).msgtype else {
panic!("Expected MessageType::Text");
};
assert_eq!(content.body, "<b>bold</b>");
assert!(content.formatted.is_none(), "{:?}", content.formatted);
let MessageType::Emote(content) = text_to_message("/me *bold*".into()).msgtype else {
panic!("Expected MessageType::Emote");
};
assert_eq!(content.body, "*bold*");
assert_eq!(content.formatted.unwrap().body, "<p><em>bold</em></p>\n");
let content = text_to_message("/confetti hello".into()).msgtype;
assert_eq!(content.msgtype(), "nic.custom.confetti");
assert_eq!(content.body(), "hello");
let content = text_to_message("/fireworks hello".into()).msgtype;
assert_eq!(content.msgtype(), "nic.custom.fireworks");
assert_eq!(content.body(), "hello");
let content = text_to_message("/hearts hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effect.hearts");
assert_eq!(content.body(), "hello");
let content = text_to_message("/rainfall hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effect.rainfall");
assert_eq!(content.body(), "hello");
let content = text_to_message("/snowfall hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effect.snowfall");
assert_eq!(content.body(), "hello");
let content = text_to_message("/spaceinvaders hello".into()).msgtype;
assert_eq!(content.msgtype(), "io.element.effects.space_invaders");
assert_eq!(content.body(), "hello");
}
}

1549
src/message/html.rs Normal file

File diff suppressed because it is too large Load Diff

1512
src/message/mod.rs Normal file

File diff suppressed because it is too large Load Diff

318
src/message/printer.rs Normal file
View File

@@ -0,0 +1,318 @@
//! # Line Wrapping Logic
//!
//! The [TextPrinter] handles wrapping stylized text and inserting spaces for padding at the end of
//! lines to make concatenation work right (e.g., combining table cells after wrapping their
//! contents).
use std::borrow::Cow;
use ratatui::layout::Alignment;
use ratatui::style::Style;
use ratatui::text::{Line, Span, Text};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::config::{ApplicationSettings, TunableValues};
use crate::util::{
replace_emojis_in_line,
replace_emojis_in_span,
replace_emojis_in_str,
space_span,
take_width,
};
/// Wrap styled text for the current terminal width.
pub struct TextPrinter<'a> {
text: Text<'a>,
width: usize,
base_style: Style,
hide_reply: bool,
alignment: Alignment,
curr_spans: Vec<Span<'a>>,
curr_width: usize,
literal: bool,
pub(super) settings: &'a ApplicationSettings,
}
impl<'a> TextPrinter<'a> {
/// Create a new printer.
pub fn new(
width: usize,
base_style: Style,
hide_reply: bool,
settings: &'a ApplicationSettings,
) -> Self {
TextPrinter {
text: Text::default(),
width,
base_style,
hide_reply,
alignment: Alignment::Left,
curr_spans: vec![],
curr_width: 0,
literal: false,
settings,
}
}
/// Configure the alignment for each line.
pub fn align(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
/// Set whether newlines should be treated literally, or turned into spaces.
pub fn literal(mut self, literal: bool) -> Self {
self.literal = literal;
self
}
/// Indicates whether replies should be pushed to the printer.
pub fn hide_reply(&self) -> bool {
self.hide_reply
}
/// Indicates whether emojis should be replaced by shortcodes
pub fn emoji_shortcodes(&self) -> bool {
self.tunables().message_shortcode_display
}
pub fn settings(&self) -> &ApplicationSettings {
self.settings
}
pub fn tunables(&self) -> &TunableValues {
&self.settings.tunables
}
/// Indicates the current printer's width.
pub fn width(&self) -> usize {
self.width
}
/// Create a new printer with a smaller width.
pub fn sub(&self, indent: usize) -> Self {
TextPrinter {
text: Text::default(),
width: self.width.saturating_sub(indent),
base_style: self.base_style,
hide_reply: self.hide_reply,
alignment: self.alignment,
curr_spans: vec![],
curr_width: 0,
literal: self.literal,
settings: self.settings,
}
}
fn remaining(&self) -> usize {
self.width.saturating_sub(self.curr_width)
}
/// If there is any text on the current line, start a new one.
pub fn commit(&mut self) {
if self.curr_width > 0 {
self.push_break();
}
}
fn push(&mut self) {
self.curr_width = 0;
self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans)));
}
/// Start a new line.
pub fn push_break(&mut self) {
if self.curr_width == 0 && self.text.lines.is_empty() {
// Disallow leading breaks.
return;
}
let remaining = self.remaining();
if remaining > 0 {
match self.alignment {
Alignment::Left => {
let tspan = space_span(remaining, self.base_style);
self.curr_spans.push(tspan);
},
Alignment::Center => {
let trailing = remaining / 2;
let leading = remaining - trailing;
let tspan = space_span(trailing, self.base_style);
let lspan = space_span(leading, self.base_style);
self.curr_spans.push(tspan);
self.curr_spans.insert(0, lspan);
},
Alignment::Right => {
let lspan = space_span(remaining, self.base_style);
self.curr_spans.insert(0, lspan);
},
}
}
self.push();
}
fn push_str_wrapped<T>(&mut self, s: T, style: Style)
where
T: Into<Cow<'a, str>>,
{
let style = self.base_style.patch(style);
let mut cow = s.into();
loop {
let sw = UnicodeWidthStr::width(cow.as_ref());
if self.curr_width + sw <= self.width {
// The text fits within the current line.
self.curr_spans.push(Span::styled(cow, style));
self.curr_width += sw;
break;
}
// Take a leading portion of the text that fits in the line.
let ((s0, w), s1) = take_width(cow, self.remaining());
cow = s1;
self.curr_spans.push(Span::styled(s0, style));
self.curr_width += w;
self.commit();
}
if self.curr_width == self.width {
// If the last bit fills the full line, start a new one.
self.push();
}
}
/// Push a [Span] that isn't allowed to break across lines.
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
if self.emoji_shortcodes() {
replace_emojis_in_span(&mut span);
}
let sw = UnicodeWidthStr::width(span.content.as_ref());
if self.curr_width + sw > self.width {
// Span doesn't fit on this line, so start a new one.
self.commit();
}
self.curr_spans.push(span);
self.curr_width += sw;
}
/// Push text with a [Style].
pub fn push_str(&mut self, s: &'a str, style: Style) {
let style = self.base_style.patch(style);
if self.width == 0 {
return;
}
let tabstop = self.settings().tunables.tabstop;
for mut word in UnicodeSegmentation::split_word_bounds(s) {
if let "\n" | "\r\n" = word {
if self.literal {
self.commit();
continue;
}
// Render embedded newlines as spaces.
word = " ";
}
if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) {
// Drop leading whitespace.
continue;
}
let mut cow = if self.emoji_shortcodes() {
Cow::Owned(replace_emojis_in_str(word))
} else {
Cow::Borrowed(word)
};
if cow == "\t" {
let tablen = tabstop - (self.curr_width % tabstop);
cow = Cow::Owned(" ".repeat(tablen));
}
let sw = UnicodeWidthStr::width(cow.as_ref());
if sw > self.width {
self.push_str_wrapped(cow, style);
continue;
}
if self.curr_width + sw > self.width {
// Word doesn't fit on this line, so start a new one.
self.commit();
if !self.literal && cow.chars().all(char::is_whitespace) {
// Drop leading whitespace.
continue;
}
}
let span = Span::styled(cow, style);
self.curr_spans.push(span);
self.curr_width += sw;
}
if self.curr_width == self.width {
// If the last bit fills the full line, start a new one.
self.push();
}
}
/// Push a [Line] into the printer.
pub fn push_line(&mut self, mut line: Line<'a>) {
self.commit();
if self.emoji_shortcodes() {
replace_emojis_in_line(&mut line);
}
self.text.lines.push(line);
}
/// Push multiline [Text] into the printer.
pub fn push_text(&mut self, mut text: Text<'a>) {
self.commit();
if self.emoji_shortcodes() {
for line in &mut text.lines {
replace_emojis_in_line(line);
}
}
self.text.lines.extend(text.lines);
}
/// Render the contents of this printer as [Text].
pub fn finish(mut self) -> Text<'a> {
self.commit();
self.text
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::tests::mock_settings;
#[test]
fn test_push_nobreak() {
let settings = mock_settings();
let mut printer = TextPrinter::new(5, Style::default(), false, &settings);
printer.push_span_nobreak("hello world".into());
let text = printer.finish();
assert_eq!(text.lines.len(), 1);
assert_eq!(text.lines[0].spans.len(), 1);
assert_eq!(text.lines[0].spans[0].content, "hello world");
}
}

956
src/message/state.rs Normal file
View File

@@ -0,0 +1,956 @@
//! Code for displaying state events.
use std::borrow::Cow;
use std::str::FromStr;
use matrix_sdk::ruma::{
events::{
room::member::MembershipChange,
AnyFullStateEventContent,
AnySyncStateEvent,
FullStateEventContent,
},
OwnedRoomId,
UserId,
};
use super::html::{StyleTree, StyleTreeNode};
use ratatui::style::{Modifier as StyleModifier, Style};
fn bold(s: impl Into<Cow<'static, str>>) -> StyleTreeNode {
let bold = Style::default().add_modifier(StyleModifier::BOLD);
let text = StyleTreeNode::Text(s.into());
StyleTreeNode::Style(Box::new(text), bold)
}
pub fn body_cow_state(ev: &AnySyncStateEvent) -> Cow<'static, str> {
let event = match ev.content() {
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
content,
..
}) => {
let mut m = format!(
"* updated the room policy rule for {:?} to {:?}",
content.0.entity,
content.0.recommendation.as_str()
);
if !content.0.reason.is_empty() {
m.push_str(" (reason: ");
m.push_str(&content.0.reason);
m.push(')');
}
m
},
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
content,
..
}) => {
let mut m = format!(
"* updated the server policy rule for {:?} to {:?}",
content.0.entity,
content.0.recommendation.as_str()
);
if !content.0.reason.is_empty() {
m.push_str(" (reason: ");
m.push_str(&content.0.reason);
m.push(')');
}
m
},
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
content,
..
}) => {
let mut m = format!(
"* updated the user policy rule for {:?} to {:?}",
content.0.entity,
content.0.recommendation.as_str()
);
if !content.0.reason.is_empty() {
m.push_str(" (reason: ");
m.push_str(&content.0.reason);
m.push(')');
}
m
},
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
content, ..
}) => {
let mut m = String::from("* set the room aliases to: ");
for (i, alias) in content.aliases.iter().enumerate() {
if i != 0 {
m.push_str(", ");
}
m.push_str(alias.as_str());
}
m
},
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
content,
prev_content,
}) => {
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
match (prev_url, content.url) {
(None, Some(_)) => return Cow::Borrowed("* added a room avatar"),
(Some(old), Some(new)) => {
if old != &new {
return Cow::Borrowed("* replaced the room avatar");
}
return Cow::Borrowed("* updated the room avatar state");
},
(Some(_), None) => return Cow::Borrowed("* removed the room avatar"),
(None, None) => return Cow::Borrowed("* updated the room avatar state"),
}
},
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
content,
prev_content,
}) => {
let old_canon = prev_content.as_ref().and_then(|p| p.alias.as_ref());
let new_canon = content.alias.as_ref();
match (old_canon, new_canon) {
(None, Some(canon)) => {
format!("* updated the canonical alias for the room to: {canon}")
},
(Some(old), Some(new)) => {
if old != new {
format!("* updated the canonical alias for the room to: {new}")
} else {
return Cow::Borrowed("* removed the canonical alias for the room");
}
},
(Some(_), None) => {
return Cow::Borrowed("* removed the canonical alias for the room");
},
(None, None) => {
return Cow::Borrowed("* did not change the canonical alias");
},
}
},
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
content, ..
}) => {
if content.federate {
return Cow::Borrowed("* created a federated room");
} else {
return Cow::Borrowed("* created a non-federated room");
}
},
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
return Cow::Borrowed("* updated the encryption settings for the room");
},
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
content,
..
}) => {
format!("* set guest access for the room to {:?}", content.guest_access.as_str())
},
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
content,
..
}) => {
format!(
"* updated history visibility for the room to {:?}",
content.history_visibility.as_str()
)
},
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
content,
..
}) => {
format!("* update the join rules for the room to {:?}", content.join_rule.as_str())
},
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
content,
prev_content,
}) => {
let Ok(state_key) = UserId::parse(ev.state_key()) else {
return Cow::Owned(format!(
"* failed to calculate membership change for {:?}",
ev.state_key()
));
};
let prev_details = prev_content.as_ref().map(|p| p.details());
let change = content.membership_change(prev_details, ev.sender(), &state_key);
match change {
MembershipChange::None => {
format!("* did nothing to {state_key}")
},
MembershipChange::Error => {
format!("* failed to calculate membership change to {state_key}")
},
MembershipChange::Joined => {
return Cow::Borrowed("* joined the room");
},
MembershipChange::Left => {
return Cow::Borrowed("* left the room");
},
MembershipChange::Banned => {
format!("* banned {state_key} from the room")
},
MembershipChange::Unbanned => {
format!("* unbanned {state_key} from the room")
},
MembershipChange::Kicked => {
format!("* kicked {state_key} from the room")
},
MembershipChange::Invited => {
format!("* invited {state_key} to the room")
},
MembershipChange::KickedAndBanned => {
format!("* kicked and banned {state_key} from the room")
},
MembershipChange::InvitationAccepted => {
return Cow::Borrowed("* accepted an invitation to join the room");
},
MembershipChange::InvitationRejected => {
return Cow::Borrowed("* rejected an invitation to join the room");
},
MembershipChange::InvitationRevoked => {
format!("* revoked an invitation for {state_key} to join the room")
},
MembershipChange::Knocked => {
return Cow::Borrowed("* would like to join the room");
},
MembershipChange::KnockAccepted => {
format!("* accepted the room knock from {state_key}")
},
MembershipChange::KnockRetracted => {
return Cow::Borrowed("* retracted their room knock");
},
MembershipChange::KnockDenied => {
format!("* rejected the room knock from {state_key}")
},
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
match (displayname_change, avatar_url_change) {
(Some(change), avatar_change) => {
let mut m = match (change.old, change.new) {
(None, Some(new)) => {
format!("* set their display name to {new:?}")
},
(Some(old), Some(new)) => {
format!("* changed their display name from {old} to {new}")
},
(Some(_), None) => "* unset their display name".to_string(),
(None, None) => {
"* made an unknown change to their display name".to_string()
},
};
if avatar_change.is_some() {
m.push_str(" and changed their user avatar");
}
m
},
(None, Some(change)) => {
match (change.old, change.new) {
(None, Some(_)) => {
return Cow::Borrowed("* added a user avatar");
},
(Some(_), Some(_)) => {
return Cow::Borrowed("* changed their user avatar");
},
(Some(_), None) => {
return Cow::Borrowed("* removed their user avatar");
},
(None, None) => {
return Cow::Borrowed(
"* made an unknown change to their user avatar",
);
},
}
},
(None, None) => {
return Cow::Borrowed("* changed their user profile");
},
}
},
ev => {
format!("* made an unknown membership change to {state_key}: {ev:?}")
},
}
},
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
format!("* updated the room name to {:?}", content.name)
},
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
return Cow::Borrowed("* updated the pinned events for the room");
},
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
return Cow::Borrowed("* updated the power levels for the room");
},
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
return Cow::Borrowed("* updated the room's server ACLs");
},
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
content,
..
}) => {
format!("* sent a third-party invite to {:?}", content.display_name)
},
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
content,
..
}) => {
format!(
"* upgraded the room; replacement room is {}",
content.replacement_room.as_str()
)
},
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
content, ..
}) => {
format!("* set the room topic to {:?}", content.topic)
},
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
format!("* added a space child: {}", ev.state_key())
},
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
content, ..
}) => {
if content.canonical {
format!("* added a canonical parent space: {}", ev.state_key())
} else {
format!("* added a parent space: {}", ev.state_key())
}
},
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
return Cow::Borrowed("* shared beacon information");
},
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
return Cow::Borrowed("* updated membership for room call");
},
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
content, ..
}) => {
let mut m = String::from("* updated the list of service members in the room hints: ");
for (i, member) in content.service_members.iter().enumerate() {
if i != 0 {
m.push_str(", ");
}
m.push_str(member.as_str());
}
m
},
// Redacted variants of state events:
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated a room policy rule (redacted)");
},
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated a server policy rule (redacted)");
},
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated a user policy rule (redacted)");
},
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the room aliases for the room (redacted)");
},
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the room avatar (redacted)");
},
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the canonical alias for the room (redacted)");
},
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* created the room (redacted)");
},
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the encryption settings for the room (redacted)");
},
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed(
"* updated the guest access configuration for the room (redacted)",
);
},
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated history visilibity for the room (redacted)");
},
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the join rules for the room (redacted)");
},
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the room membership (redacted)");
},
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the room name (redacted)");
},
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the pinned events for the room (redacted)");
},
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the power levels for the room (redacted)");
},
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the room's server ACLs (redacted)");
},
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* sent a third-party invite (redacted)");
},
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* upgraded the room (redacted)");
},
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* updated the room topic (redacted)");
},
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* added a space child (redacted)");
},
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* added a parent space (redacted)");
},
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("* shared beacon information (redacted)");
},
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("Call membership changed");
},
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
return Cow::Borrowed("Member hints changed");
},
// Handle unknown events:
e => {
format!("* sent an unknown state event: {:?}", e.event_type())
},
};
return Cow::Owned(event);
}
pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree {
let children = match ev.content() {
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Original {
content,
..
}) => {
let prefix = StyleTreeNode::Text("* updated the room policy rule for ".into());
let entity = bold(format!("{:?}", content.0.entity));
let middle = StyleTreeNode::Text(" to ".into());
let rec =
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
let mut cs = vec![prefix, entity, middle, rec];
if !content.0.reason.is_empty() {
let reason = format!(" (reason: {})", content.0.reason);
cs.push(StyleTreeNode::Text(reason.into()));
}
cs
},
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Original {
content,
..
}) => {
let prefix = StyleTreeNode::Text("* updated the server policy rule for ".into());
let entity = bold(format!("{:?}", content.0.entity));
let middle = StyleTreeNode::Text(" to ".into());
let rec =
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
let mut cs = vec![prefix, entity, middle, rec];
if !content.0.reason.is_empty() {
let reason = format!(" (reason: {})", content.0.reason);
cs.push(StyleTreeNode::Text(reason.into()));
}
cs
},
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Original {
content,
..
}) => {
let prefix = StyleTreeNode::Text("* updated the user policy rule for ".into());
let entity = bold(format!("{:?}", content.0.entity));
let middle = StyleTreeNode::Text(" to ".into());
let rec =
StyleTreeNode::Text(format!("{:?}", content.0.recommendation.as_str()).into());
let mut cs = vec![prefix, entity, middle, rec];
if !content.0.reason.is_empty() {
let reason = format!(" (reason: {})", content.0.reason);
cs.push(StyleTreeNode::Text(reason.into()));
}
cs
},
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Original {
content, ..
}) => {
let prefix = StyleTreeNode::Text("* set the room aliases to: ".into());
let mut cs = vec![prefix];
for (i, alias) in content.aliases.iter().enumerate() {
if i != 0 {
cs.push(StyleTreeNode::Text(", ".into()));
}
cs.push(StyleTreeNode::RoomAlias(alias.clone()));
}
cs
},
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Original {
content,
prev_content,
}) => {
let prev_url = prev_content.as_ref().and_then(|p| p.url.as_ref());
let node = match (prev_url, content.url) {
(None, Some(_)) => StyleTreeNode::Text("* added a room avatar".into()),
(Some(old), Some(new)) => {
if old != &new {
StyleTreeNode::Text("* replaced the room avatar".into())
} else {
StyleTreeNode::Text("* updated the room avatar state".into())
}
},
(Some(_), None) => StyleTreeNode::Text("* removed the room avatar".into()),
(None, None) => StyleTreeNode::Text("* updated the room avatar state".into()),
};
vec![node]
},
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original {
content,
..
}) => {
if let Some(canon) = content.alias.as_ref() {
let canon = bold(canon.to_string());
let prefix =
StyleTreeNode::Text("* updated the canonical alias for the room to: ".into());
vec![prefix, canon]
} else {
vec![StyleTreeNode::Text(
"* removed the canonical alias for the room".into(),
)]
}
},
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Original {
content, ..
}) => {
if content.federate {
vec![StyleTreeNode::Text("* created a federated room".into())]
} else {
vec![StyleTreeNode::Text("* created a non-federated room".into())]
}
},
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Original { .. }) => {
vec![StyleTreeNode::Text(
"* updated the encryption settings for the room".into(),
)]
},
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original {
content,
..
}) => {
let access = bold(format!("{:?}", content.guest_access.as_str()));
let prefix = StyleTreeNode::Text("* set guest access for the room to ".into());
vec![prefix, access]
},
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original {
content,
..
}) => {
let prefix =
StyleTreeNode::Text("* updated history visibility for the room to ".into());
let vis = bold(format!("{:?}", content.history_visibility.as_str()));
vec![prefix, vis]
},
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Original {
content,
..
}) => {
let prefix = StyleTreeNode::Text("* update the join rules for the room to ".into());
let rule = bold(format!("{:?}", content.join_rule.as_str()));
vec![prefix, rule]
},
AnyFullStateEventContent::RoomMember(FullStateEventContent::Original {
content,
prev_content,
}) => {
let Ok(state_key) = UserId::parse(ev.state_key()) else {
let prefix =
StyleTreeNode::Text("* failed to calculate membership change for ".into());
let user_id = bold(format!("{:?}", ev.state_key()));
let children = vec![prefix, user_id];
return StyleTree { children };
};
let prev_details = prev_content.as_ref().map(|p| p.details());
let change = content.membership_change(prev_details, ev.sender(), &state_key);
let user_id = StyleTreeNode::UserId(state_key.clone());
match change {
MembershipChange::None => {
let prefix = StyleTreeNode::Text("* did nothing to ".into());
vec![prefix, user_id]
},
MembershipChange::Error => {
let prefix =
StyleTreeNode::Text("* failed to calculate membership change to ".into());
vec![prefix, user_id]
},
MembershipChange::Joined => {
vec![StyleTreeNode::Text("* joined the room".into())]
},
MembershipChange::Left => {
vec![StyleTreeNode::Text("* left the room".into())]
},
MembershipChange::Banned => {
let prefix = StyleTreeNode::Text("* banned ".into());
let suffix = StyleTreeNode::Text(" from the room".into());
vec![prefix, user_id, suffix]
},
MembershipChange::Unbanned => {
let prefix = StyleTreeNode::Text("* unbanned ".into());
let suffix = StyleTreeNode::Text(" from the room".into());
vec![prefix, user_id, suffix]
},
MembershipChange::Kicked => {
let prefix = StyleTreeNode::Text("* kicked ".into());
let suffix = StyleTreeNode::Text(" from the room".into());
vec![prefix, user_id, suffix]
},
MembershipChange::Invited => {
let prefix = StyleTreeNode::Text("* invited ".into());
let suffix = StyleTreeNode::Text(" to the room".into());
vec![prefix, user_id, suffix]
},
MembershipChange::KickedAndBanned => {
let prefix = StyleTreeNode::Text("* kicked and banned ".into());
let suffix = StyleTreeNode::Text(" from the room".into());
vec![prefix, user_id, suffix]
},
MembershipChange::InvitationAccepted => {
vec![StyleTreeNode::Text(
"* accepted an invitation to join the room".into(),
)]
},
MembershipChange::InvitationRejected => {
vec![StyleTreeNode::Text(
"* rejected an invitation to join the room".into(),
)]
},
MembershipChange::InvitationRevoked => {
let prefix = StyleTreeNode::Text("* revoked an invitation for ".into());
let suffix = StyleTreeNode::Text(" to join the room".into());
vec![prefix, user_id, suffix]
},
MembershipChange::Knocked => {
vec![StyleTreeNode::Text("* would like to join the room".into())]
},
MembershipChange::KnockAccepted => {
let prefix = StyleTreeNode::Text("* accepted the room knock from ".into());
vec![prefix, user_id]
},
MembershipChange::KnockRetracted => {
vec![StyleTreeNode::Text("* retracted their room knock".into())]
},
MembershipChange::KnockDenied => {
let prefix = StyleTreeNode::Text("* rejected the room knock from ".into());
vec![prefix, user_id]
},
MembershipChange::ProfileChanged { displayname_change, avatar_url_change } => {
match (displayname_change, avatar_url_change) {
(Some(change), avatar_change) => {
let mut m = match (change.old, change.new) {
(None, Some(new)) => {
vec![
StyleTreeNode::Text("* set their display name to ".into()),
StyleTreeNode::DisplayName(new.into(), state_key),
]
},
(Some(old), Some(new)) => {
vec![
StyleTreeNode::Text(
"* changed their display name from ".into(),
),
StyleTreeNode::DisplayName(old.into(), state_key.clone()),
StyleTreeNode::Text(" to ".into()),
StyleTreeNode::DisplayName(new.into(), state_key),
]
},
(Some(_), None) => {
vec![StyleTreeNode::Text("* unset their display name".into())]
},
(None, None) => {
vec![StyleTreeNode::Text(
"* made an unknown change to their display name".into(),
)]
},
};
if avatar_change.is_some() {
m.push(StyleTreeNode::Text(
" and changed their user avatar".into(),
));
}
m
},
(None, Some(change)) => {
let m = match (change.old, change.new) {
(None, Some(_)) => Cow::Borrowed("* added a user avatar"),
(Some(_), Some(_)) => Cow::Borrowed("* changed their user avatar"),
(Some(_), None) => Cow::Borrowed("* removed their user avatar"),
(None, None) => {
Cow::Borrowed("* made an unknown change to their user avatar")
},
};
vec![StyleTreeNode::Text(m)]
},
(None, None) => {
vec![StyleTreeNode::Text("* changed their user profile".into())]
},
}
},
ev => {
let prefix =
StyleTreeNode::Text("* made an unknown membership change to ".into());
let suffix = StyleTreeNode::Text(format!(": {ev:?}").into());
vec![prefix, user_id, suffix]
},
}
},
AnyFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => {
let prefix = StyleTreeNode::Text("* updated the room name to ".into());
let name = bold(format!("{:?}", content.name));
vec![prefix, name]
},
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { .. }) => {
vec![StyleTreeNode::Text(
"* updated the pinned events for the room".into(),
)]
},
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { .. }) => {
vec![StyleTreeNode::Text(
"* updated the power levels for the room".into(),
)]
},
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Original { .. }) => {
vec![StyleTreeNode::Text(
"* updated the room's server ACLs".into(),
)]
},
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Original {
content,
..
}) => {
let prefix = StyleTreeNode::Text("* sent a third-party invite to ".into());
let name = bold(format!("{:?}", content.display_name));
vec![prefix, name]
},
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Original {
content,
..
}) => {
let prefix = StyleTreeNode::Text("* upgraded the room; replacement room is ".into());
let room = StyleTreeNode::RoomId(content.replacement_room.clone());
vec![prefix, room]
},
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original {
content, ..
}) => {
let prefix = StyleTreeNode::Text("* set the room topic to ".into());
let topic = bold(format!("{:?}", content.topic));
vec![prefix, topic]
},
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Original { .. }) => {
let prefix = StyleTreeNode::Text("* added a space child: ".into());
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
StyleTreeNode::RoomId(room_id)
} else {
bold(ev.state_key().to_string())
};
vec![prefix, room_id]
},
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Original {
content, ..
}) => {
let prefix = if content.canonical {
StyleTreeNode::Text("* added a canonical parent space: ".into())
} else {
StyleTreeNode::Text("* added a parent space: ".into())
};
let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) {
StyleTreeNode::RoomId(room_id)
} else {
bold(ev.state_key().to_string())
};
vec![prefix, room_id]
},
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Original { .. }) => {
vec![StyleTreeNode::Text("* shared beacon information".into())]
},
AnyFullStateEventContent::CallMember(FullStateEventContent::Original { .. }) => {
vec![StyleTreeNode::Text(
"* updated membership for room call".into(),
)]
},
AnyFullStateEventContent::MemberHints(FullStateEventContent::Original {
content, ..
}) => {
let prefix = StyleTreeNode::Text(
"* updated the list of service members in the room hints: ".into(),
);
let mut cs = vec![prefix];
for (i, member) in content.service_members.iter().enumerate() {
if i != 0 {
cs.push(StyleTreeNode::Text(", ".into()));
}
cs.push(StyleTreeNode::UserId(member.clone()));
}
cs
},
// Redacted variants of state events:
AnyFullStateEventContent::PolicyRuleRoom(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated a room policy rule (redacted)".into(),
)]
},
AnyFullStateEventContent::PolicyRuleServer(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated a server policy rule (redacted)".into(),
)]
},
AnyFullStateEventContent::PolicyRuleUser(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated a user policy rule (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomAliases(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the room aliases for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomAvatar(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the room avatar (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the canonical alias for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomCreate(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text("* created the room (redacted)".into())]
},
AnyFullStateEventContent::RoomEncryption(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the encryption settings for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomGuestAccess(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the guest access configuration for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated history visilibity for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomJoinRules(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the join rules for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomMember(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the room membership (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomName(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the room name (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the pinned events for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomPowerLevels(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the power levels for the room (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomServerAcl(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the room's server ACLs (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomThirdPartyInvite(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* sent a third-party invite (redacted)".into(),
)]
},
AnyFullStateEventContent::RoomTombstone(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text("* upgraded the room (redacted)".into())]
},
AnyFullStateEventContent::RoomTopic(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* updated the room topic (redacted)".into(),
)]
},
AnyFullStateEventContent::SpaceChild(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* added a space child (redacted)".into(),
)]
},
AnyFullStateEventContent::SpaceParent(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* added a parent space (redacted)".into(),
)]
},
AnyFullStateEventContent::BeaconInfo(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text(
"* shared beacon information (redacted)".into(),
)]
},
AnyFullStateEventContent::CallMember(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text("Call membership changed".into())]
},
AnyFullStateEventContent::MemberHints(FullStateEventContent::Redacted(_)) => {
vec![StyleTreeNode::Text("Member hints changed".into())]
},
// Handle unknown events:
e => {
let prefix = StyleTreeNode::Text("* sent an unknown state event: ".into());
let event = bold(format!("{:?}", e.event_type()));
vec![prefix, event]
},
};
StyleTree { children }
}

324
src/notifications.rs Normal file
View File

@@ -0,0 +1,324 @@
use std::time::SystemTime;
use matrix_sdk::{
deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
room::Room as MatrixRoom,
ruma::{
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
serde::Raw,
MilliSecondsSinceUnixEpoch,
OwnedRoomId,
RoomId,
},
Client,
EncryptionState,
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
config::{ApplicationSettings, NotifyVia},
};
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
None => "iamb",
Some(iamb) => iamb,
};
/// Handle for an open notification that should be closed when the user views it.
pub struct NotificationHandle(
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
Option<notify_rust::NotificationHandle>,
);
impl Drop for NotificationHandle {
fn drop(&mut self) {
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
if let Some(handle) = self.0.take() {
handle.close();
}
}
}
pub async fn register_notifications(
client: &Client,
settings: &ApplicationSettings,
store: &AsyncProgramStore,
) {
if !settings.tunables.notifications.enabled {
return;
}
let notify_via = settings.tunables.notifications.via;
let show_message = settings.tunables.notifications.show_message;
let sound_hint = settings.tunables.notifications.sound_hint.clone();
let server_settings = client.notification_settings().await;
let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else {
return;
};
let store = store.clone();
client
.register_notification_handler(move |notification, room: MatrixRoom, client: Client| {
let store = store.clone();
let server_settings = server_settings.clone();
let sound_hint = sound_hint.clone();
async move {
let mode = global_or_room_mode(&server_settings, &room).await;
if mode == RoomNotificationMode::Mute {
return;
}
if is_visible_room(&store, room.room_id()).await {
return;
}
let room_id = room.room_id().to_owned();
match notification.event {
RawAnySyncOrStrippedTimelineEvent::Sync(e) => {
match parse_full_notification(e, room, show_message).await {
Ok((summary, body, server_ts)) => {
if server_ts < startup_ts {
return;
}
if is_missing_mention(&body, mode, &client) {
return;
}
send_notification(
&notify_via,
&summary,
body.as_deref(),
room_id,
&store,
sound_hint.as_deref(),
)
.await;
},
Err(err) => {
tracing::error!("Failed to extract notification data: {err}")
},
}
},
// Stripped events may be dropped silently because they're
// only relevant if we're not in a room, and we presumably
// don't want notifications for rooms we're not in.
RawAnySyncOrStrippedTimelineEvent::Stripped(_) => (),
}
}
})
.await;
}
async fn send_notification(
via: &NotifyVia,
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
store: &AsyncProgramStore,
sound_hint: Option<&str>,
) {
#[cfg(feature = "desktop")]
if via.desktop {
send_notification_desktop(summary, body, room_id, store, sound_hint).await;
}
#[cfg(not(feature = "desktop"))]
{
let _ = (summary, body, IAMB_XDG_NAME);
}
if via.bell {
send_notification_bell(store).await;
}
}
async fn send_notification_bell(store: &AsyncProgramStore) {
let mut locked = store.lock().await;
locked.application.ring_bell = true;
}
#[cfg(feature = "desktop")]
#[cfg_attr(target_os = "macos", allow(unused_variables))]
async fn send_notification_desktop(
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
_store: &AsyncProgramStore,
sound_hint: Option<&str>,
) {
let mut desktop_notification = notify_rust::Notification::new();
desktop_notification
.summary(summary)
.appname(IAMB_XDG_NAME)
.icon(IAMB_XDG_NAME)
.action("default", "default");
if let Some(sound_hint) = sound_hint {
desktop_notification.sound_name(sound_hint);
}
#[cfg(all(unix, not(target_os = "macos")))]
desktop_notification.urgency(notify_rust::Urgency::Normal);
if let Some(body) = body {
desktop_notification.body(body);
}
match desktop_notification.show() {
Err(err) => tracing::error!("Failed to send notification: {err}"),
Ok(handle) => {
#[cfg(all(unix, not(target_os = "macos")))]
_store
.lock()
.await
.application
.open_notifications
.entry(room_id)
.or_default()
.push(NotificationHandle(Some(handle)));
},
}
}
async fn global_or_room_mode(
settings: &NotificationSettings,
room: &MatrixRoom,
) -> RoomNotificationMode {
let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await;
if let Some(mode) = room_mode {
return mode;
}
let is_one_to_one = match room.is_direct().await {
Ok(true) => IsOneToOne::Yes,
_ => IsOneToOne::No,
};
let is_encrypted = match room.latest_encryption_state().await {
Ok(EncryptionState::Encrypted) => IsEncrypted::Yes,
_ => IsEncrypted::No,
};
settings
.get_default_room_notification_mode(is_encrypted, is_one_to_one)
.await
}
fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client: &Client) -> bool {
if let Some(body) = body {
if mode == RoomNotificationMode::MentionsAndKeywordsOnly {
let mentioned = match client.user_id() {
Some(user_id) => body.contains(user_id.localpart()),
_ => false,
};
return !mentioned;
}
}
false
}
fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
if let Some(draw_curr) = locked.application.draw_curr {
let info = locked.application.get_room_info(room_id.to_owned());
if let Some(draw_last) = info.draw_last {
return draw_last == draw_curr;
}
}
false
}
fn is_focused(locked: &ProgramStore) -> bool {
locked.application.focused
}
async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
let mut locked = store.lock().await;
is_focused(&locked) && is_open(&mut locked, room_id)
}
pub async fn parse_full_notification(
event: Raw<AnySyncTimelineEvent>,
room: MatrixRoom,
show_body: bool,
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
let event = event.deserialize().map_err(IambError::from)?;
let server_ts = event.origin_server_ts();
let sender_id = event.sender();
let sender = room.get_member_no_sync(sender_id).await.map_err(IambError::from)?;
let sender_name = sender
.as_ref()
.and_then(|m| m.display_name())
.unwrap_or_else(|| sender_id.localpart());
let summary = if let Some(room_name) = room.cached_display_name() {
if room.is_direct().await.map_err(IambError::from)? && sender_name == room_name.to_string()
{
sender_name.to_string()
} else {
format!("{sender_name} in {room_name}")
}
} else {
sender_name.to_string()
};
let body = if show_body {
event_notification_body(&event, sender_name).map(truncate)
} else {
None
};
return Ok((summary, body, server_ts));
}
pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option<String> {
let AnySyncTimelineEvent::MessageLike(event) = event else {
return None;
};
match event.original_content()? {
AnyMessageLikeEventContent::RoomMessage(message) => {
let body = match message.msgtype {
MessageType::Audio(_) => {
format!("{sender_name} sent an audio file.")
},
MessageType::Emote(content) => content.body,
MessageType::File(_) => {
format!("{sender_name} sent a file.")
},
MessageType::Image(_) => {
format!("{sender_name} sent an image.")
},
MessageType::Location(_) => {
format!("{sender_name} sent their location.")
},
MessageType::Notice(content) => content.body,
MessageType::ServerNotice(content) => content.body,
MessageType::Text(content) => content.body,
MessageType::Video(_) => {
format!("{sender_name} sent a video.")
},
MessageType::VerificationRequest(_) => {
format!("{sender_name} sent a verification request.")
},
_ => {
format!("[Unknown message type: {:?}]", &message.msgtype)
},
};
Some(body)
},
AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")),
_ => None,
}
}
fn truncate(s: String) -> String {
static MAX_LENGTH: usize = 5000;
if s.graphemes(true).count() > MAX_LENGTH {
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
truncated + "..."
} else {
s
}
}

175
src/preview.rs Normal file
View File

@@ -0,0 +1,175 @@
use std::{
fs::File,
io::{Read, Write},
path::{Path, PathBuf},
};
use matrix_sdk::{
media::{MediaFormat, MediaRequestParameters},
ruma::{
events::{
room::{
message::{MessageType, RoomMessageEventContent},
MediaSource,
},
MessageLikeEvent,
},
OwnedEventId,
OwnedRoomId,
},
Media,
};
use ratatui::layout::Rect;
use ratatui_image::Resize;
use crate::{
base::{AsyncProgramStore, ChatStore, IambError},
config::ImagePreviewSize,
message::ImageStatus,
};
pub fn source_from_event(
ev: &MessageLikeEvent<RoomMessageEventContent>,
) -> Option<(OwnedEventId, MediaSource)> {
if let MessageLikeEvent::Original(ev) = &ev {
if let MessageType::Image(c) = &ev.content.msgtype {
return Some((ev.event_id.clone(), c.source.clone()));
}
}
None
}
impl From<ImagePreviewSize> for Rect {
fn from(value: ImagePreviewSize) -> Self {
Rect::new(0, 0, value.width as _, value.height as _)
}
}
impl From<Rect> for ImagePreviewSize {
fn from(rect: Rect) -> Self {
ImagePreviewSize { width: rect.width as _, height: rect.height as _ }
}
}
/// Download and prepare the preview, and then lock the store to insert it.
pub fn spawn_insert_preview(
store: AsyncProgramStore,
room_id: OwnedRoomId,
event_id: OwnedEventId,
source: MediaSource,
media: Media,
cache_dir: PathBuf,
) {
tokio::spawn(async move {
let img = download_or_load(event_id.to_owned(), source, media, cache_dir)
.await
.map(std::io::Cursor::new)
.map(image::ImageReader::new)
.map_err(IambError::Matrix)
.and_then(|reader| reader.with_guessed_format().map_err(IambError::IOError))
.and_then(|reader| reader.decode().map_err(IambError::Image));
match img {
Err(err) => {
try_set_msg_preview_error(
&mut store.lock().await.application,
room_id,
event_id,
err,
);
},
Ok(img) => {
let mut locked = store.lock().await;
let ChatStore { rooms, picker, settings, .. } = &mut locked.application;
match picker
.as_mut()
.ok_or_else(|| IambError::Preview("Picker is empty".to_string()))
.and_then(|picker| {
Ok((
picker,
rooms
.get_or_default(room_id.clone())
.get_event_mut(&event_id)
.ok_or_else(|| {
IambError::Preview("Message not found".to_string())
})?,
settings.tunables.image_preview.clone().ok_or_else(|| {
IambError::Preview("image_preview settings not found".to_string())
})?,
))
})
.and_then(|(picker, msg, image_preview)| {
picker
.new_protocol(img, image_preview.size.into(), Resize::Fit(None))
.map_err(|err| IambError::Preview(format!("{err:?}")))
.map(|backend| (backend, msg))
}) {
Err(err) => {
try_set_msg_preview_error(&mut locked.application, room_id, event_id, err);
},
Ok((backend, msg)) => {
msg.image_preview = ImageStatus::Loaded(backend);
},
}
},
}
});
}
fn try_set_msg_preview_error(
application: &mut ChatStore,
room_id: OwnedRoomId,
event_id: OwnedEventId,
err: IambError,
) {
let rooms = &mut application.rooms;
match rooms
.get_or_default(room_id.clone())
.get_event_mut(&event_id)
.ok_or_else(|| IambError::Preview("Message not found".to_string()))
{
Ok(msg) => msg.image_preview = ImageStatus::Error(format!("{err:?}")),
Err(err) => {
tracing::error!(
"Failed to set error on msg.image_backend for event {}, room {}: {}",
event_id,
room_id,
err
)
},
}
}
async fn download_or_load(
event_id: OwnedEventId,
source: MediaSource,
media: Media,
mut cache_path: PathBuf,
) -> Result<Vec<u8>, matrix_sdk::Error> {
cache_path.push(Path::new(event_id.localpart()));
match File::open(&cache_path) {
Ok(mut f) => {
let mut buffer = Vec::new();
f.read_to_end(&mut buffer)?;
Ok(buffer)
},
Err(_) => {
media
.get_media_content(
&MediaRequestParameters { source, format: MediaFormat::File },
true,
)
.await
.and_then(|buffer| {
if let Err(err) =
File::create(&cache_path).and_then(|mut f| f.write_all(&buffer))
{
return Err(err.into());
}
Ok(buffer)
})
},
}
}

58
src/sled_export.rs Normal file
View File

@@ -0,0 +1,58 @@
//! # sled -> sqlite migration code
//!
//! Before the 0.0.9 release, iamb used matrix-sdk@0.6.2, which used [sled]
//! for storing information, including room keys. In matrix-sdk@0.7.0,
//! the SDK switched to using SQLite. This module takes care of opening
//! sled, exporting the inbound group sessions used for decryption,
//! and importing them into SQLite.
//!
//! This code will eventually be removed once people have been given enough
//! time to upgrade off of pre-0.0.9 versions.
//!
//! [sled]: https://docs.rs/sled/0.34.7/sled/index.html
use sled::{Config, IVec};
use std::path::Path;
use crate::base::IambError;
use matrix_sdk::crypto::olm::{ExportedRoomKey, InboundGroupSession, PickledInboundGroupSession};
#[derive(Debug, thiserror::Error)]
pub enum SledMigrationError {
#[error("sled failure: {0}")]
Sled(#[from] sled::Error),
#[error("deserialization failure: {0}")]
Deserialize(#[from] serde_json::Error),
}
fn group_session_from_slice(
(_, bytes): (IVec, IVec),
) -> Result<PickledInboundGroupSession, SledMigrationError> {
serde_json::from_slice(&bytes).map_err(SledMigrationError::from)
}
async fn export_room_keys_priv(
sled_dir: &Path,
) -> Result<Vec<ExportedRoomKey>, SledMigrationError> {
let path = sled_dir.join("matrix-sdk-state");
let store = Config::new().temporary(false).path(&path).open()?;
let inbound_groups = store.open_tree("inbound_group_sessions")?;
let mut exported = vec![];
let sessions = inbound_groups
.iter()
.map(|p| p.map_err(SledMigrationError::from).and_then(group_session_from_slice))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|p| InboundGroupSession::from_pickle(p).ok());
for session in sessions {
exported.push(session.export().await);
}
Ok(exported)
}
pub async fn export_room_keys(sled_dir: &Path) -> Result<Vec<ExportedRoomKey>, IambError> {
export_room_keys_priv(sled_dir).await.map_err(IambError::from)
}

View File

@@ -1,29 +1,44 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::PathBuf;
use matrix_sdk::ruma::{
event_id,
events::room::message::RoomMessageEventContent,
events::room::message::{OriginalRoomMessageEvent, RoomMessageEventContent},
server_name,
user_id,
EventId,
OwnedEventId,
OwnedRoomId,
OwnedUserId,
RoomId,
UInt,
};
use std::path::PathBuf;
use std::sync::mpsc::sync_channel;
use lazy_static::lazy_static;
use ratatui::style::{Color, Style};
use tokio::sync::mpsc::unbounded_channel;
use tracing::Level;
use url::Url;
use lazy_static::lazy_static;
use crate::{
base::{ChatStore, ProgramStore, RoomFetchStatus, RoomInfo},
config::{ApplicationSettings, DirectoryValues, ProfileConfig, TunableValues},
base::{ChatStore, EventLocation, ProgramStore, RoomInfo},
config::{
user_color,
user_style_from_color,
ApplicationSettings,
DirectoryValues,
Notifications,
NotifyVia,
ProfileConfig,
SortOverrides,
TunableValues,
UserColor,
UserDisplayStyle,
UserDisplayTunables,
},
message::{
Message,
MessageContent,
MessageEvent,
MessageKey,
MessageTimeStamp::{LocalEcho, OriginServer},
Messages,
@@ -31,65 +46,99 @@ use crate::{
worker::Requester,
};
const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
lazy_static! {
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
pub static ref TEST_ROOM1_ID: OwnedRoomId =
RoomId::new_v1(server_name!("example.com")).to_owned();
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref TEST_USER3: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref TEST_USER4: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref TEST_USER5: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref MSG1_KEY: MessageKey = (LocalEcho, EventId::new(server_name!("example.com")));
pub static ref MSG2_KEY: MessageKey =
(OriginServer(UInt::new(1).unwrap()), EventId::new(server_name!("example.com")));
pub static ref MSG3_KEY: MessageKey = (
OriginServer(UInt::new(2).unwrap()),
event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned()
);
pub static ref MSG4_KEY: MessageKey = (
OriginServer(UInt::new(2).unwrap()),
event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned()
);
pub static ref MSG5_KEY: MessageKey =
(OriginServer(UInt::new(8).unwrap()), EventId::new(server_name!("example.com")));
pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned();
pub static ref TEST_USER4: OwnedUserId = user_id!("@user4:example.com").to_owned();
pub static ref TEST_USER5: OwnedUserId = user_id!("@user5:example.com").to_owned();
pub static ref MSG1_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
pub static ref MSG2_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
pub static ref MSG3_EVID: OwnedEventId =
event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned();
pub static ref MSG4_EVID: OwnedEventId =
event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned();
pub static ref MSG5_EVID: OwnedEventId = EventId::new(server_name!("example.com"));
pub static ref MSG1_KEY: MessageKey = (LocalEcho, MSG1_EVID.clone());
pub static ref MSG2_KEY: MessageKey = (OriginServer(UInt::new(1).unwrap()), MSG2_EVID.clone());
pub static ref MSG3_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG3_EVID.clone());
pub static ref MSG4_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG4_EVID.clone());
pub static ref MSG5_KEY: MessageKey = (OriginServer(UInt::new(8).unwrap()), MSG5_EVID.clone());
}
pub fn user_style(user: &str) -> Style {
user_style_from_color(user_color(user))
}
pub fn mock_room1_message(
content: RoomMessageEventContent,
sender: OwnedUserId,
key: MessageKey,
) -> Message {
let origin_server_ts = key.0.as_millis().unwrap();
let event_id = key.1;
let event = OriginalRoomMessageEvent {
content,
event_id,
sender,
origin_server_ts,
room_id: TEST_ROOM1_ID.clone(),
unsigned: Default::default(),
};
event.into()
}
pub fn mock_message1() -> Message {
let content = RoomMessageEventContent::text_plain("writhe");
let content = MessageContent::Original(content.into());
let content = MessageEvent::Local(MSG1_EVID.clone(), content.into());
Message::new(content, TEST_USER1.clone(), MSG1_KEY.0)
}
pub fn mock_message2() -> Message {
let content = RoomMessageEventContent::text_plain("helium");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER2.clone(), MSG2_KEY.0)
mock_room1_message(content, TEST_USER2.clone(), MSG2_KEY.clone())
}
pub fn mock_message3() -> Message {
let content = RoomMessageEventContent::text_plain("this\nis\na\nmultiline\nmessage");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER2.clone(), MSG3_KEY.0)
mock_room1_message(content, TEST_USER2.clone(), MSG3_KEY.clone())
}
pub fn mock_message4() -> Message {
let content = RoomMessageEventContent::text_plain("help");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER1.clone(), MSG4_KEY.0)
mock_room1_message(content, TEST_USER1.clone(), MSG4_KEY.clone())
}
pub fn mock_message5() -> Message {
let content = RoomMessageEventContent::text_plain("character");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER2.clone(), MSG5_KEY.0)
mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone())
}
pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
let mut keys = HashMap::new();
keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone()));
keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone()));
keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone()));
keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone()));
keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone()));
keys
}
pub fn mock_messages() -> Messages {
let mut messages = BTreeMap::new();
let mut messages = Messages::main();
messages.insert(MSG1_KEY.clone(), mock_message1());
messages.insert(MSG2_KEY.clone(), mock_message2());
@@ -101,48 +150,105 @@ pub fn mock_messages() -> Messages {
}
pub fn mock_room() -> RoomInfo {
RoomInfo {
name: Some("Watercooler Discussion".into()),
messages: mock_messages(),
fetch_id: RoomFetchStatus::NotStarted,
fetch_last: None,
users_typing: None,
}
let mut room = RoomInfo::default();
room.name = Some("Watercooler Discussion".into());
room.keys = mock_keys();
*room.get_thread_mut(None) = mock_messages();
room
}
pub fn mock_dirs() -> DirectoryValues {
DirectoryValues {
cache: PathBuf::new(),
data: PathBuf::new(),
logs: PathBuf::new(),
downloads: PathBuf::new(),
downloads: None,
image_previews: PathBuf::new(),
}
}
pub fn mock_tunables() -> TunableValues {
TunableValues {
default_room: None,
log_level: Level::INFO,
message_shortcode_display: false,
normal_after_send: true,
reaction_display: true,
reaction_shortcode_display: false,
read_receipt_send: true,
read_receipt_display: true,
request_timeout: 120,
sort: SortOverrides::default().values(),
state_event_display: true,
typing_notice_send: true,
typing_notice_display: true,
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
color: Some(UserColor(Color::Black)),
name: Some("USER 5".into()),
})]
.into_iter()
.collect::<HashMap<_, _>>(),
open_command: None,
external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username,
message_user_color: false,
mouse: Default::default(),
notifications: Notifications {
enabled: false,
via: NotifyVia::default(),
show_message: true,
sound_hint: None,
},
image_preview: None,
user_gutter_width: 30,
tabstop: 4,
}
}
pub fn mock_settings() -> ApplicationSettings {
ApplicationSettings {
matrix_dir: PathBuf::new(),
layout_json: PathBuf::new(),
session_json: PathBuf::new(),
session_json_old: PathBuf::new(),
sled_dir: PathBuf::new(),
sqlite_dir: PathBuf::new(),
profile_name: "test".into(),
profile: ProfileConfig {
user_id: user_id!("@user:example.com").to_owned(),
url: Url::parse("https://example.com").unwrap(),
url: None,
settings: None,
dirs: None,
layout: None,
macros: None,
},
tunables: TunableValues { typing_notice: true, typing_notice_display: true },
tunables: mock_tunables(),
dirs: mock_dirs(),
layout: Default::default(),
macros: HashMap::default(),
}
}
pub fn mock_store() -> ProgramStore {
let (tx, _) = sync_channel(5);
let worker = Requester { tx };
pub async fn mock_store() -> ProgramStore {
let (tx, _) = unbounded_channel();
let homeserver = Url::parse("https://localhost").unwrap();
let client = matrix_sdk::Client::new(homeserver).await.unwrap();
let worker = Requester { tx, client };
let mut store = ChatStore::new(worker, mock_settings());
// Add presence information.
store.presences.get_or_default(TEST_USER1.clone());
store.presences.get_or_default(TEST_USER2.clone());
store.presences.get_or_default(TEST_USER3.clone());
store.presences.get_or_default(TEST_USER4.clone());
store.presences.get_or_default(TEST_USER5.clone());
let room_id = TEST_ROOM1_ID.clone();
let info = mock_room();
store.rooms.insert(room_id, info);
store.rooms.insert(room_id.clone(), info);
store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id);
ProgramStore::new(store)
}

214
src/util.rs Normal file
View File

@@ -0,0 +1,214 @@
//! # Utility functions
use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use ratatui::style::Style;
use ratatui::text::{Line, Span, Text};
pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) {
match cow {
Cow::Borrowed(s) => {
let s1 = Cow::Borrowed(&s[idx..]);
let s0 = Cow::Borrowed(&s[..idx]);
(s0, s1)
},
Cow::Owned(mut s) => {
let s1 = Cow::Owned(s.split_off(idx));
let s0 = Cow::Owned(s);
(s0, s1)
},
}
}
pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) {
// Find where to split the line.
let mut w = 0;
let idx = UnicodeSegmentation::grapheme_indices(s.as_ref(), true)
.find_map(|(i, g)| {
let gw = UnicodeWidthStr::width(g);
if w + gw > width {
Some(i)
} else {
w += gw;
None
}
})
.unwrap_or(s.len());
let (s0, s1) = split_cow(s, idx);
((s0, w), s1)
}
pub struct WrappedLinesIterator<'a> {
iter: std::vec::IntoIter<Cow<'a, str>>,
curr: Option<Cow<'a, str>>,
width: usize,
}
impl<'a> WrappedLinesIterator<'a> {
fn new<T>(input: T, width: usize) -> Self
where
T: Into<Cow<'a, str>>,
{
let width = width.max(2);
let cows: Vec<Cow<'a, str>> = match input.into() {
Cow::Borrowed(s) => s.lines().map(Cow::Borrowed).collect(),
Cow::Owned(s) => s.lines().map(ToOwned::to_owned).map(Cow::Owned).collect(),
};
WrappedLinesIterator { iter: cows.into_iter(), curr: None, width }
}
}
impl<'a> Iterator for WrappedLinesIterator<'a> {
type Item = (Cow<'a, str>, usize);
fn next(&mut self) -> Option<Self::Item> {
if self.curr.is_none() {
self.curr = self.iter.next();
}
if let Some(s) = self.curr.take() {
let width = UnicodeWidthStr::width(s.as_ref());
if width <= self.width {
return Some((s, width));
} else {
let (prefix, s1) = take_width(s, self.width);
self.curr = Some(s1);
return Some(prefix);
}
} else {
return None;
}
}
}
pub fn wrap<'a, T>(input: T, width: usize) -> WrappedLinesIterator<'a>
where
T: Into<Cow<'a, str>>,
{
WrappedLinesIterator::new(input, width)
}
pub fn wrapped_text<'a, T>(s: T, width: usize, style: Style) -> Text<'a>
where
T: Into<Cow<'a, str>>,
{
let mut text = Text::default();
for (line, w) in wrap(s, width) {
let space = space_span(width.saturating_sub(w), style);
let spans = Line::from(vec![Span::styled(line, style), space]);
text.lines.push(spans);
}
return text;
}
pub fn space(width: usize) -> String {
" ".repeat(width)
}
pub fn space_span(width: usize, style: Style) -> Span<'static> {
Span::styled(space(width), style)
}
pub fn space_text(width: usize, style: Style) -> Text<'static> {
space_span(width, style).into()
}
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
for (mut t, w) in texts.into_iter() {
for i in 0..height {
if let Some(line) = t.lines.get_mut(i) {
text.lines[i].spans.append(&mut line.spans);
} else {
text.lines[i].spans.push(space_span(w, style));
}
text.lines[i].spans.push(join.clone());
}
}
text
}
fn replace_emoji_in_grapheme(grapheme: &str) -> String {
emojis::get(grapheme)
.and_then(|emoji| emoji.shortcode())
.map(|shortcode| format!(":{shortcode}:"))
.unwrap_or_else(|| grapheme.to_owned())
}
pub fn replace_emojis_in_str(s: &str) -> String {
let graphemes = s.graphemes(true);
graphemes.map(replace_emoji_in_grapheme).collect()
}
pub fn replace_emojis_in_span(span: &mut Span) {
span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref()))
}
pub fn replace_emojis_in_line(line: &mut Line) {
for span in &mut line.spans {
replace_emojis_in_span(span);
}
}
#[cfg(test)]
pub mod tests {
use super::*;
#[test]
fn test_wrapped_lines_ascii() {
let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye";
let mut iter = wrap(s, 100);
assert_eq!(iter.next(), Some((Cow::Borrowed("hello world!"), 12)));
assert_eq!(iter.next(), Some((Cow::Borrowed("abcdefghijklmnopqrstuvwxyz"), 26)));
assert_eq!(iter.next(), Some((Cow::Borrowed("goodbye"), 7)));
assert_eq!(iter.next(), None);
let mut iter = wrap(s, 5);
assert_eq!(iter.next(), Some((Cow::Borrowed("hello"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed(" worl"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed("d!"), 2)));
assert_eq!(iter.next(), Some((Cow::Borrowed("abcde"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed("fghij"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed("klmno"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed("pqrst"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed("uvwxy"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed("z"), 1)));
assert_eq!(iter.next(), Some((Cow::Borrowed("goodb"), 5)));
assert_eq!(iter.next(), Some((Cow::Borrowed("ye"), 2)));
assert_eq!(iter.next(), None);
}
#[test]
fn test_wrapped_lines_unicode() {
let s = "";
let mut iter = wrap(s, 14);
assert_eq!(iter.next(), Some((Cow::Borrowed(s), 14)));
assert_eq!(iter.next(), None);
let mut iter = wrap(s, 5);
assert_eq!(iter.next(), Some((Cow::Borrowed(""), 4)));
assert_eq!(iter.next(), Some((Cow::Borrowed(""), 4)));
assert_eq!(iter.next(), Some((Cow::Borrowed(""), 4)));
assert_eq!(iter.next(), Some((Cow::Borrowed(""), 2)));
assert_eq!(iter.next(), None);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +1,79 @@
use matrix_sdk::room::Room as MatrixRoom;
use matrix_sdk::ruma::RoomId;
use matrix_sdk::DisplayName;
//! # Windows for Matrix rooms and spaces
use std::collections::HashSet;
use modalkit::tui::{
use matrix_sdk::{
notification_settings::RoomNotificationMode,
room::Room as MatrixRoom,
ruma::{
api::client::{
alias::{
create_alias::v3::Request as CreateAliasRequest,
delete_alias::v3::Request as DeleteAliasRequest,
},
error::ErrorKind as ClientApiErrorKind,
},
events::{
room::{
canonical_alias::RoomCanonicalAliasEventContent,
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
name::RoomNameEventContent,
topic::RoomTopicEventContent,
},
tag::{TagInfo, Tags},
},
OwnedEventId,
OwnedRoomAliasId,
OwnedUserId,
RoomId,
},
RoomDisplayName,
RoomState as MatrixRoomState,
};
use ratatui::{
buffer::Buffer,
layout::Rect,
layout::{Alignment, Rect},
style::{Modifier as StyleModifier, Style},
text::{Span, Spans},
widgets::StatefulWidget,
text::{Line, Span, Text},
widgets::{Paragraph, StatefulWidget, Widget},
};
use modalkit::{
editing::action::{
Action,
EditInfo,
EditResult,
Editable,
EditorAction,
Jumpable,
PromptAction,
Promptable,
Scrollable,
},
editing::base::{
Axis,
CloseFlags,
Count,
MoveDir1D,
OpenTarget,
PositionList,
ScrollStyle,
WordStyle,
},
input::InputContext,
widgets::{TermOffset, TerminalCursor, WindowOps},
use modalkit::actions::{
Action,
Editable,
EditorAction,
Jumpable,
PromptAction,
Promptable,
Scrollable,
};
use modalkit::errors::{EditResult, UIError};
use modalkit::prelude::*;
use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo};
use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps};
use crate::base::{
IambAction,
IambError,
IambId,
IambInfo,
IambResult,
MemberUpdateAction,
MessageAction,
ProgramAction,
ProgramContext,
ProgramStore,
RoomAction,
RoomField,
SendAction,
SpaceAction,
};
use self::chat::ChatState;
use self::space::{Space, SpaceState};
use std::convert::TryFrom;
mod chat;
mod scrollback;
mod space;
@@ -62,43 +87,259 @@ macro_rules! delegate {
};
}
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"mute" => RoomNotificationMode::Mute,
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
"all" => RoomNotificationMode::AllMessages,
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
};
Ok(mode)
}
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"invited" => HistoryVisibility::Invited,
"joined" => HistoryVisibility::Joined,
"shared" => HistoryVisibility::Shared,
"world" | "world_readable" => HistoryVisibility::WorldReadable,
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
};
Ok(mode)
}
/// State for a Matrix room or space.
///
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so
/// that operations like sending and accepting invites, opening the members window, etc., all work
/// similarly.
pub enum RoomState {
Chat(ChatState),
Space(SpaceState),
Chat(Box<ChatState>),
Space(Box<SpaceState>),
}
impl From<ChatState> for RoomState {
fn from(chat: ChatState) -> Self {
RoomState::Chat(chat)
RoomState::Chat(Box::new(chat))
}
}
impl From<SpaceState> for RoomState {
fn from(space: SpaceState) -> Self {
RoomState::Space(space)
RoomState::Space(Box::new(space))
}
}
impl RoomState {
pub fn new(room: MatrixRoom, name: DisplayName, store: &mut ProgramStore) -> Self {
pub fn new(
room: MatrixRoom,
thread: Option<OwnedEventId>,
name: RoomDisplayName,
tags: Option<Tags>,
store: &mut ProgramStore,
) -> Self {
let room_id = room.room_id().to_owned();
let info = store.application.get_room_info(room_id);
info.name = name.to_string().into();
info.tags = tags;
if room.is_space() {
SpaceState::new(room).into()
} else {
ChatState::new(room, store).into()
ChatState::new(room, thread, store).into()
}
}
pub fn room_command(
pub fn thread(&self) -> Option<&OwnedEventId> {
match self {
RoomState::Chat(chat) => chat.thread(),
RoomState::Space(_) => None,
}
}
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
match self {
RoomState::Chat(chat) => chat.refresh_room(store),
RoomState::Space(space) => space.refresh_room(store),
}
}
fn draw_invite(
&self,
invited: MatrixRoom,
area: Rect,
buf: &mut Buffer,
store: &mut ProgramStore,
) {
let inviter = store.application.worker.get_inviter(invited.clone());
let name = match invited.canonical_alias() {
Some(alias) => alias.to_string(),
None => format!("{:?}", store.application.get_room_title(self.id())),
};
let mut invited = vec![Span::from(format!("You have been invited to join {name}"))];
if let Ok(Some(inviter)) = &inviter {
let info = store.application.rooms.get_or_default(self.id().to_owned());
invited.push(Span::from(" by "));
invited.push(store.application.settings.get_user_span(inviter.user_id(), info));
}
let l1 = Line::from(invited);
let l2 = Line::from(
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
);
let text = Text::from(vec![l1, l2]);
Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
return;
}
pub async fn message_command(
&mut self,
act: MessageAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Chat(chat) => chat.message_command(act, ctx, store).await,
RoomState::Space(_) => Err(IambError::NoSelectedMessage.into()),
}
}
pub async fn space_command(
&mut self,
act: SpaceAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Space(space) => space.space_command(act, ctx, store).await,
RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()),
}
}
pub async fn send_command(
&mut self,
act: SendAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Chat(chat) => chat.send_command(act, ctx, store).await,
RoomState::Space(_) => Err(IambError::NoSelectedRoom.into()),
}
}
pub async fn room_command(
&mut self,
act: RoomAction,
_: ProgramContext,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
match act {
RoomAction::InviteAccept => {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
let details = room.invite_details().await.map_err(IambError::from)?;
let details = details.invitee.event().original_content();
let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default();
room.join().await.map_err(IambError::from)?;
if is_direct {
room.set_is_direct(true).await.map_err(IambError::from)?;
}
Ok(vec![])
} else {
Err(IambError::NotInvited.into())
}
},
RoomAction::InviteReject => {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
room.leave().await.map_err(IambError::from)?;
Ok(vec![])
} else {
Err(IambError::NotInvited.into())
}
},
RoomAction::InviteSend(user) => {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
room.invite_user_by_id(user.as_ref()).await.map_err(IambError::from)?;
Ok(vec![])
} else {
Err(IambError::NotJoined.into())
}
},
RoomAction::Leave(skip_confirm) => {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
if skip_confirm {
room.leave().await.map_err(IambError::from)?;
Ok(vec![])
} else {
let msg = "Do you really want to leave this room?";
let leave = IambAction::Room(RoomAction::Leave(true));
let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]);
let prompt = Box::new(prompt);
Err(UIError::NeedConfirm(prompt))
}
} else {
Err(IambError::NotJoined.into())
}
},
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
let Some(room) = store.application.worker.client.get_room(self.id()) else {
return Err(IambError::NotJoined.into());
};
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
let err = IambError::InvalidUserId(user);
return Err(err.into());
};
if !skip_confirm {
let msg = format!("Do you really want to {mua} {user} from this room?");
let act = RoomAction::MemberUpdate(mua, user, reason, true);
let act = IambAction::from(act);
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
}
match mua {
MemberUpdateAction::Ban => {
room.ban_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
MemberUpdateAction::Unban => {
room.unban_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
MemberUpdateAction::Kick => {
room.kick_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
}
Ok(vec![])
},
RoomAction::Members(mut cmd) => {
let width = Count::Exact(30);
let act =
@@ -107,20 +348,328 @@ impl RoomState {
width.into(),
);
Ok(vec![(act, cmd.context.take())])
Ok(vec![(act, cmd.context.clone())])
},
RoomAction::Set(field) => {
store.application.worker.set_room(self.id().to_owned(), field)?;
RoomAction::SetDirect(is_direct) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
room.set_is_direct(is_direct).await.map_err(IambError::from)?;
Ok(vec![])
},
RoomAction::Set(field, value) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
match field {
RoomField::History => {
let visibility = hist_visibility_mode(value)?;
let ev = RoomHistoryVisibilityEventContent::new(visibility);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Name => {
let ev = RoomNameEventContent::new(value);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Tag(tag) => {
let mut info = TagInfo::new();
info.order = Some(1.0);
let _ = room.set_tag(tag, info).await.map_err(IambError::from)?;
},
RoomField::Topic => {
let ev = RoomTopicEventContent::new(value);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::NotificationMode => {
let mode = notification_mode(value)?;
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
notifications
.set_room_notification_mode(self.id(), mode)
.await
.map_err(IambError::from)?;
},
RoomField::CanonicalAlias => {
let client = &mut store.application.worker.client;
let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else {
let err = IambError::InvalidRoomAlias(value);
return Err(err.into());
};
let mut alt_aliases =
room.alt_aliases().into_iter().collect::<HashSet<_>>();
let canonical_old = room.canonical_alias();
// If the room's alias is already that, ignore it
if canonical_old.as_ref() == Some(&orai) {
let msg = format!("The canonical room alias is already {orai}");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
}
// Try creating the room alias on the server.
let alias_create_req =
CreateAliasRequest::new(orai.clone(), room.room_id().into());
if let Err(e) = client.send(alias_create_req).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
return Err(IambError::from(e).into());
}
}
// Demote the previous one to an alt alias.
alt_aliases.extend(canonical_old);
// At this point the room alias definitely exists, and we can update the
// state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = Some(orai);
ev.alt_aliases = alt_aliases.into_iter().collect();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Alias(alias) => {
let client = &mut store.application.worker.client;
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
let err = IambError::InvalidRoomAlias(alias);
return Err(err.into());
};
let mut alt_aliases =
room.alt_aliases().into_iter().collect::<HashSet<_>>();
let canonical = room.canonical_alias();
if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) {
let msg = format!("The alias {orai} already maps to this room");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
} else {
alt_aliases.insert(orai.clone());
}
// If the room alias does not exist on the server, create it
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
if let Err(e) = client.send(alias_create_req).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
return Err(IambError::from(e).into());
}
}
// And add it to the aliases in the state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = canonical;
ev.alt_aliases = alt_aliases.into_iter().collect();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Aliases => {
// This never happens, aliases is only used for showing
},
RoomField::Id => {
// This never happens, id is only used for showing
},
}
Ok(vec![])
},
RoomAction::Unset(field) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
match field {
RoomField::History => {
let visibility = HistoryVisibility::Joined;
let ev = RoomHistoryVisibilityEventContent::new(visibility);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Name => {
let ev = RoomNameEventContent::new("".into());
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Tag(tag) => {
let _ = room.remove_tag(tag).await.map_err(IambError::from)?;
},
RoomField::Topic => {
let ev = RoomTopicEventContent::new("".into());
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::NotificationMode => {
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
notifications
.delete_user_defined_room_rules(self.id())
.await
.map_err(IambError::from)?;
},
RoomField::CanonicalAlias => {
let Some(alias_to_destroy) = room.canonical_alias() else {
let msg = "This room has no canonical alias to unset";
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
};
// Remove the canonical alias from the state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = None;
ev.alt_aliases = room.alt_aliases();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
// And then unmap it on the server.
let del_req = DeleteAliasRequest::new(alias_to_destroy);
let _ = store
.application
.worker
.client
.send(del_req)
.await
.map_err(IambError::from)?;
},
RoomField::Alias(alias) => {
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
let err = IambError::InvalidRoomAlias(alias);
return Err(err.into());
};
let alt_aliases = room.alt_aliases();
let canonical = room.canonical_alias();
if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) {
let msg = format!("The alias {orai:?} isn't mapped to this room");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
}
// Remove the alias from the state event if it's in it.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = canonical.filter(|canon| canon != &orai);
ev.alt_aliases = alt_aliases;
ev.alt_aliases.retain(|in_orai| in_orai != &orai);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
// And then unmap it on the server.
let del_req = DeleteAliasRequest::new(orai);
let _ = store
.application
.worker
.client
.send(del_req)
.await
.map_err(IambError::from)?;
},
RoomField::Aliases => {
// This will not happen, you cannot unset all aliases
},
RoomField::Id => {
// This never happens, id is only used for showing
},
}
Ok(vec![])
},
RoomAction::Show(field) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
let msg = match field {
RoomField::History => {
let visibility = room.history_visibility();
let visibility = visibility.as_ref().map(|v| v.as_str());
format!("Room history visibility: {}", visibility.unwrap_or("<unknown>"))
},
RoomField::Id => {
let id = room.room_id();
format!("Room identifier: {id}")
},
RoomField::Name => {
match room.name() {
None => "Room has no name".into(),
Some(name) => format!("Room name: {name:?}"),
}
},
RoomField::Topic => {
match room.topic() {
None => "Room has no topic".into(),
Some(topic) => format!("Room topic: {topic:?}"),
}
},
RoomField::NotificationMode => {
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
let mode =
notifications.get_user_defined_room_notification_mode(self.id()).await;
let level = match mode {
Some(RoomNotificationMode::Mute) => "mute",
Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords",
Some(RoomNotificationMode::AllMessages) => "all",
None => "default",
};
format!("Room notification level: {level:?}")
},
RoomField::Aliases => {
let aliases = room
.alt_aliases()
.iter()
.map(OwnedRoomAliasId::to_string)
.collect::<Vec<String>>();
if aliases.is_empty() {
"No alternative aliases in room".into()
} else {
format!("Alternative aliases: {}.", aliases.join(", "))
}
},
RoomField::CanonicalAlias => {
match room.canonical_alias() {
None => "No canonical alias for room".into(),
Some(can) => format!("Canonical alias: {can}"),
}
},
RoomField::Tag(_) => "Cannot currently show value for a tag".into(),
RoomField::Alias(_) => {
"Cannot show a single alias; use `:room aliases show` instead.".into()
},
};
let msg = InfoMessage::Pager(msg);
let act = Action::ShowInfoMessage(msg);
Ok(vec![(act, ctx)])
},
}
}
pub fn get_title(&self, store: &mut ProgramStore) -> Spans {
pub fn get_title(&self, store: &mut ProgramStore) -> Line<'_> {
let title = store.application.get_room_title(self.id());
let style = Style::default().add_modifier(StyleModifier::BOLD);
let mut spans = vec![Span::styled(title, style)];
let mut spans = vec![];
if let RoomState::Chat(chat) = self {
if chat.thread().is_some() {
spans.push("Thread in ".into());
}
}
spans.push(Span::styled(title, style));
match self.room().topic() {
Some(desc) if !desc.is_empty() => {
@@ -131,7 +680,7 @@ impl RoomState {
_ => {},
}
Spans(spans)
Line::from(spans)
}
pub fn focus_toggle(&mut self) {
@@ -209,6 +758,14 @@ impl TerminalCursor for RoomState {
impl WindowOps<IambInfo> for RoomState {
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
if self.room().state() == MatrixRoomState::Invited {
self.refresh_room(store);
}
if self.room().state() == MatrixRoomState::Invited {
self.draw_invite(self.room().clone(), area, buf, store);
}
match self {
RoomState::Chat(chat) => chat.draw(area, buf, focused, store),
RoomState::Space(space) => {
@@ -219,15 +776,35 @@ impl WindowOps<IambInfo> for RoomState {
fn dup(&self, store: &mut ProgramStore) -> Self {
match self {
RoomState::Chat(chat) => RoomState::Chat(chat.dup(store)),
RoomState::Space(space) => RoomState::Space(space.dup(store)),
RoomState::Chat(chat) => RoomState::Chat(Box::new(chat.dup(store))),
RoomState::Space(space) => RoomState::Space(Box::new(space.dup(store))),
}
}
fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool {
// XXX: what's the right closing behaviour for a room?
// Should write send a message?
true
fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool {
match self {
RoomState::Chat(chat) => chat.close(flags, store),
RoomState::Space(space) => space.close(flags, store),
}
}
fn write(
&mut self,
path: Option<&str>,
flags: WriteFlags,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Chat(chat) => chat.write(path, flags, store),
RoomState::Space(space) => space.write(path, flags, store),
}
}
fn get_completions(&self) -> Option<CompletionList> {
match self {
RoomState::Chat(chat) => chat.get_completions(),
RoomState::Space(space) => space.get_completions(),
}
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
@@ -244,3 +821,27 @@ impl WindowOps<IambInfo> for RoomState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_room_notification_level() {
let tests = vec![
("mute", RoomNotificationMode::Mute),
("mentions", RoomNotificationMode::MentionsAndKeywordsOnly),
("keywords", RoomNotificationMode::MentionsAndKeywordsOnly),
("all", RoomNotificationMode::AllMessages),
];
for (input, expect) in tests {
let res = notification_mode(input).unwrap();
assert_eq!(expect, res);
}
assert!(notification_mode("invalid").is_err());
assert!(notification_mode("not a level").is_err());
assert!(notification_mode("@user:example.com").is_err());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,69 @@
//! Window for Matrix spaces
use std::ops::{Deref, DerefMut};
use std::str::FromStr;
use std::time::{Duration, Instant};
use matrix_sdk::ruma::events::space::child::SpaceChildEventContent;
use matrix_sdk::ruma::events::StateEventType;
use matrix_sdk::ruma::OwnedSpaceChildOrder;
use matrix_sdk::{
room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId},
};
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use modalkit::{
widgets::list::{List, ListState},
widgets::{TermOffset, TerminalCursor, WindowOps},
use modalkit::prelude::{EditInfo, InfoMessage};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span, Text},
widgets::StatefulWidget,
};
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
use modalkit_ratatui::{
list::{List, ListState},
TermOffset,
TerminalCursor,
WindowOps,
};
use crate::windows::RoomItem;
use crate::base::{
IambBufferId,
IambError,
IambInfo,
IambResult,
ProgramContext,
ProgramStore,
RoomFocus,
SpaceAction,
};
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
/// State needed for rendering [Space].
pub struct SpaceState {
room_id: OwnedRoomId,
room: MatrixRoom,
list: ListState<RoomItem, IambInfo>,
last_fetch: Option<Instant>,
}
impl SpaceState {
pub fn new(room: MatrixRoom) -> Self {
let room_id = room.room_id().to_owned();
let content = IambBufferId::Room(room_id.clone(), RoomFocus::Scrollback);
let content = IambBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback);
let list = ListState::new(content, vec![]);
let last_fetch = None;
SpaceState { room_id, room, list }
SpaceState { room_id, room, list, last_fetch }
}
pub fn refresh_room(&mut self, store: &mut ProgramStore) {
if let Some(room) = store.application.worker.client.get_room(self.id()) {
self.room = room;
}
}
pub fn room(&self) -> &MatrixRoom {
@@ -44,6 +79,80 @@ impl SpaceState {
room_id: self.room_id.clone(),
room: self.room.clone(),
list: self.list.dup(store),
last_fetch: self.last_fetch,
}
}
pub async fn space_command(
&mut self,
act: SpaceAction,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match act {
SpaceAction::SetChild(child_id, order, suggested) => {
if !self
.room
.power_levels()
.await
.map_err(matrix_sdk::Error::from)
.map_err(IambError::from)?
.user_can_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
{
return Err(IambError::InsufficientPermission.into());
}
let via = self.room.route().await.map_err(IambError::from)?;
let mut ev = SpaceChildEventContent::new(via);
ev.order = order
.as_deref()
.map(OwnedSpaceChildOrder::from_str)
.transpose()
.map_err(IambError::InvalidSpaceChildOrder)?;
ev.suggested = suggested;
let _ = self
.room
.send_state_event_for_key(&child_id, ev)
.await
.map_err(IambError::from)?;
Ok(InfoMessage::from("Space updated").into())
},
SpaceAction::RemoveChild => {
let space = self.list.get().ok_or(IambError::NoSelectedRoomOrSpaceItem)?;
if !self
.room
.power_levels()
.await
.map_err(matrix_sdk::Error::from)
.map_err(IambError::from)?
.user_can_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
{
return Err(IambError::InsufficientPermission.into());
}
let ev = SpaceChildEventContent::new(vec![]);
let event_id = self
.room
.send_state_event_for_key(&space.room_id().to_owned(), ev)
.await
.map_err(IambError::from)?;
// Fix for element (see https://github.com/element-hq/element-web/issues/29606)
let _ = self
.room
.redact(&event_id.event_id, Some("workaround for element bug"), None)
.await
.map_err(IambError::from)?;
Ok(InfoMessage::from("Room removed").into())
},
}
}
}
@@ -68,6 +177,7 @@ impl DerefMut for SpaceState {
}
}
/// [StatefulWidget] for Matrix spaces.
pub struct Space<'a> {
focused: bool,
store: &'a mut ProgramStore,
@@ -84,28 +194,59 @@ impl<'a> Space<'a> {
}
}
impl<'a> StatefulWidget for Space<'a> {
impl StatefulWidget for Space<'_> {
type State = SpaceState;
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
let members = self.store.application.worker.space_members(state.room_id.clone()).unwrap();
let items = members
.into_iter()
.filter_map(|id| {
let (room, name) = self.store.application.worker.get_room(id.clone()).ok()?;
let mut empty_message = None;
let need_fetch = match state.last_fetch {
Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE,
None => true,
};
if id != state.room_id {
Some(RoomItem::new(room, name, self.store))
} else {
None
}
})
.collect();
if need_fetch {
let res = self.store.application.worker.space_members(state.room_id.clone());
state.list.set(items);
match res {
Ok(members) => {
let mut items = members
.into_iter()
.filter_map(|id| {
let (room, _, tags) =
self.store.application.worker.get_room(id.clone()).ok()?;
let room_info = std::sync::Arc::new((room, tags));
List::new(self.store)
.focus(self.focused)
.render(area, buffer, &mut state.list)
if id != state.room_id {
Some(RoomItem::new(room_info, self.store))
} else {
None
}
})
.collect::<Vec<_>>();
let fields = &self.store.application.settings.tunables.sort.rooms;
let collator = &mut self.store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
state.list.set(items);
state.last_fetch = Some(Instant::now());
},
Err(e) => {
let lines = vec![
Line::from("Unable to fetch space room hierarchy:"),
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
];
empty_message = Text::from(lines).into();
},
}
}
let mut list = List::new(self.store).focus(self.focused);
if let Some(text) = empty_message {
list = list.empty_message(text);
}
list.render(area, buffer, &mut state.list)
}
}

View File

@@ -12,6 +12,7 @@
- `:dms` will open a list of direct messages
- `:rooms` will open a list of joined rooms
- `:chats` will open a list containing both direct messages and rooms
- `:members` will open a list of members for the currently focused room or space
- `:spaces` will open a list of joined spaces
- `:join` can be used to switch to join a new room or start a direct message
@@ -36,10 +37,10 @@ The different subcommands are:
## Additional Configuration
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
`$CONFIG_DIR` is your system's per-user configuration directory.
You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
`$CONFIG_DIR` is your system's per-user configuration directory. For example,
this is typically `~/.config/iamb/config.toml` on systems that use the XDG
Base Directory Specification.
You can edit the following values in the file:
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
- `"cache"`, a directory for cached iamb
See the manual pages or <https://iamb.chat> for more details on how to
further configure or use iamb.

View File

@@ -1,16 +1,14 @@
//! Welcome Window
use std::ops::{Deref, DerefMut};
use modalkit::tui::{buffer::Buffer, layout::Rect};
use ratatui::{buffer::Buffer, layout::Rect};
use modalkit::{
widgets::textbox::TextBoxState,
widgets::WindowOps,
widgets::{TermOffset, TerminalCursor},
};
use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps};
use modalkit::editing::base::{CloseFlags, WordStyle};
use modalkit::editing::completion::CompletionList;
use modalkit::prelude::*;
use crate::base::{IambBufferId, IambInfo, ProgramStore};
use crate::base::{IambBufferId, IambInfo, IambResult, ProgramStore};
const WELCOME_TEXT: &str = include_str!("welcome.md");
@@ -63,6 +61,19 @@ impl WindowOps<IambInfo> for WelcomeState {
self.tbox.close(flags, store)
}
fn write(
&mut self,
path: Option<&str>,
flags: WriteFlags,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
self.tbox.write(path, flags, store)
}
fn get_completions(&self) -> Option<CompletionList> {
self.tbox.get_completions()
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
self.tbox.get_cursor_word(style)
}

File diff suppressed because it is too large Load Diff