80 Commits
latest ... 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
29 changed files with 6175 additions and 2145 deletions

View File

@@ -60,9 +60,9 @@ jobs:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.3
uses: mozilla-actions/sccache-action@v0.0.9
- name: 'Build: binary'
run: cargo build --release --locked --target ${{ env.TARGET }}
run: cargo +stable build --release --locked --target ${{ env.TARGET }}
- name: 'Upload: binary'
uses: actions/upload-artifact@v4
with:
@@ -73,8 +73,8 @@ jobs:
- name: 'Package: deb'
if: matrix.platform == 'ubuntu-latest'
run: |
cargo install --locked cargo-deb
cargo deb --no-strip --target ${{ env.TARGET }}
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
@@ -84,8 +84,8 @@ jobs:
- name: 'Package: rpm'
if: matrix.platform == 'ubuntu-latest'
run: |
cargo install --locked cargo-generate-rpm
cargo generate-rpm --target ${{ env.TARGET }}
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

View File

@@ -22,8 +22,8 @@ jobs:
uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust (1.70 w/ clippy)
uses: dtolnay/rust-toolchain@1.70
- name: Install Rust (1.83 w/ clippy)
uses: dtolnay/rust-toolchain@1.83
with:
components: clippy
- name: Install Rust (nightly w/ rustfmt)
@@ -34,7 +34,7 @@ jobs:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.3
uses: mozilla-actions/sccache-action@v0.0.9
- name: Check formatting
run: cargo +nightly fmt --all -- --check
- name: Check Clippy
@@ -45,3 +45,25 @@ jobs:
reporter: 'github-check'
- name: Run tests
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

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

4287
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.10"
version = "0.0.11"
edition = "2018"
authors = ["Ulyssa <git@ulyssa.dev>"]
repository = "https://github.com/ulyssa/iamb"
@@ -11,7 +11,7 @@ license = "Apache-2.0"
exclude = [".github", "CONTRIBUTING.md"]
keywords = ["matrix", "chat", "tui", "vim"]
categories = ["command-line-utilities"]
rust-version = "1.70"
rust-version = "1.88"
build = "build.rs"
[features]
@@ -34,10 +34,11 @@ clap = {version = "~4.3", features = ["derive"]}
css-color-parser = "0.1.2"
dirs = "4.0.0"
emojis = "0.5"
feruca = "0.10.1"
futures = "0.3"
gethostname = "0.4.1"
html5ever = "0.26.0"
image = "0.24.5"
image = "^0.25.6"
libc = "0.2"
markup5ever_rcdom = "0.2.0"
mime = "^0.3.16"
@@ -45,8 +46,8 @@ mime_guess = "^2.0.4"
nom = "7.0.0"
open = "3.2.0"
rand = "0.8.5"
ratatui = "0.26"
ratatui-image = { version = "1.0.0", features = ["serde"] }
ratatui = "0.29.0"
ratatui-image = { version = "~8.0.1", features = ["serde"] }
regex = "^1.5"
rpassword = "^7.2"
serde = "^1.0"
@@ -63,6 +64,8 @@ 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"
@@ -70,24 +73,24 @@ default-features = false
features = ["shortcodes"]
[dependencies.notify-rust]
version = "4.10.0"
version = "~4.10.0"
default-features = false
features = ["zbus", "serde"]
optional = true
[dependencies.modalkit]
version = "0.0.20"
version = "0.0.24"
default-features = false
#git = "https://github.com/ulyssa/modalkit"
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
[dependencies.modalkit-ratatui]
version = "0.0.20"
version = "0.0.24"
#git = "https://github.com/ulyssa/modalkit"
#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272"
#rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
[dependencies.matrix-sdk]
version = "0.7.1"
version = "0.14.0"
default-features = false
features = ["e2e-encryption", "sqlite", "sso-login"]

View File

@@ -51,9 +51,18 @@ 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.70.0 or above) and Cargo, and then run:
Install Rust (1.83.0 or above) and Cargo, and then run:
```
cargo install --locked iamb
@@ -80,9 +89,27 @@ On FreeBSD a package is available from the official repositories. To install it
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 availabe in Homebrew's
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
repository. To install it simply run:
```
@@ -127,3 +154,4 @@ iamb is released under the [Apache License, Version 2.0].
[crates-io-iamb]: https://crates.io/crates/iamb
[iamb.chat]: https://iamb.chat
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
[rustup]: https://rustup.rs/

View File

@@ -54,7 +54,7 @@ version and quit.
View a list of joined rooms and direct messages.
.It Sy ":dms"
View a list of direct messages.
.It Sy ":logout"
.It Sy ":logout [user id]"
Log out of
.Nm .
.It Sy ":rooms"
@@ -63,8 +63,12 @@ View a list of joined rooms.
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"
@@ -77,39 +81,56 @@ 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"
Download an attachment from the selected message.
.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 ":open"
Download and then open an attachment, or open a link in a message.
.It Sy ":react [shortcode]"
React to the selected message with an Emoji.
.It Sy ":redact [reason]"
Redact the selected message.
.It Sy ":reply"
Reply to the selected message.
.It Sy ":unreads clear"
Mark all unread rooms as read.
.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 ":upload"
.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"
Create a new room.
.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"
@@ -117,7 +138,7 @@ 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.
Join a room or open it if you are already joined.
.It Sy ":leave"
Leave the currently focused room.
.It Sy ":members"
@@ -126,6 +147,10 @@ View a list of members of the currently focused room.
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
@@ -153,12 +178,16 @@ Remove a tag from the currently focused room.
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]"
@@ -173,6 +202,18 @@ Unban a user from this room with an optional 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]"

View File

@@ -173,6 +173,9 @@ 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
@@ -208,6 +211,9 @@ 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.
@@ -231,6 +237,10 @@ Possible values are
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)
@@ -269,6 +279,8 @@ 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
@@ -332,9 +344,29 @@ 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 ther server first
.Ss Example 1: Group room members by their server first
.Bd -literal -offset indent
[settings.sort]
members = ["server", "localpart"]

View File

@@ -1,12 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>iamb</id>
<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>
@@ -14,6 +16,7 @@
<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>
@@ -23,8 +26,8 @@
<screenshots>
<screenshot type="default">
<image>https://iamb.chat/static/images/iamb-demo.gif</image>
<caption>Example conversation within iamb</caption>
<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>
@@ -37,7 +40,6 @@
</p>
</description>
<icon type="remote">https://iamb.chat/images/iamb.svg</icon>
<launchable type="desktop-id">iamb.desktop</launchable>
<categories>

124
flake.lock generated
View File

@@ -1,33 +1,51 @@
{
"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": 1709126324,
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@@ -38,11 +56,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1709703039,
"narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=",
"lastModified": 1760284886,
"narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d",
"rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43",
"type": "github"
},
"original": {
@@ -52,45 +70,28 @@
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1706487304,
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
"nixpkgs": "nixpkgs"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1709863839,
"narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a",
"lastModified": 1760457219,
"narHash": "sha256-WJOUGx42hrhmvvYcGkwea+BcJuQJLcns849OnewQqX4=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "8747cf81540bd1bbbab9ee2702f12c33aa887b46",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
@@ -108,21 +109,6 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"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",

125
flake.nix
View File

@@ -5,40 +5,107 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
crane.url = "github:ipetkov/crane";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
outputs =
{
self,
nixpkgs,
crane,
flake-utils,
fenix,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rustNightly = pkgs.rust-bin.nightly."2024-03-08".default;
in
with pkgs;
{
packages.default = rustPlatform.buildRustPackage {
pname = "iamb";
version = self.shortRev or self.dirtyShortRev;
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
(with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa]);
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=";
};
devShell = mkShell {
buildInputs = [
(rustNightly.override {
extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ];
})
pkg-config
cargo-tarpaulin
cargo-watch
# 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"
'';
};
}
);
}

3
rust-toolchain.toml Normal file
View File

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

View File

@@ -12,6 +12,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use emojis::Emoji;
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use matrix_sdk::ruma::room_version_rules::RedactionRules;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
@@ -47,6 +49,7 @@ use matrix_sdk::{
},
room::redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent},
tag::{TagName, Tags},
AnySyncStateEvent,
MessageLikeEvent,
},
presence::PresenceState,
@@ -55,7 +58,6 @@ use matrix_sdk::{
OwnedRoomId,
OwnedUserId,
RoomId,
RoomVersionId,
UserId,
},
RoomState as MatrixRoomState,
@@ -72,7 +74,7 @@ use modalkit::{
ApplicationStore,
ApplicationWindowId,
},
completion::{complete_path, CompletionMap},
completion::{complete_path, Completer, CompletionMap},
context::EditContext,
cursor::Cursor,
rope::EditRope,
@@ -90,6 +92,7 @@ use modalkit::{
use crate::config::ImagePreviewProtocolValues;
use crate::message::ImageStatus;
use crate::notifications::NotificationHandle;
use crate::preview::{source_from_event, spawn_insert_preview};
use crate::{
message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages},
@@ -166,6 +169,9 @@ pub enum MessageAction {
/// Reply to a message.
Reply,
/// Go to the message the hovered message replied to.
Replied,
/// Unreact to a message.
///
/// If no specific Emoji to remove to is specified, then all reactions from the user on the
@@ -177,6 +183,19 @@ pub enum MessageAction {
Unreact(Option<String>, bool),
}
/// An action taken in the currently selected space.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SpaceAction {
/// Add a room or update metadata.
///
/// The [`Option<String>`] argument is the order parameter.
/// The [`bool`] argument indicates whether the room is suggested.
SetChild(OwnedRoomId, Option<String>, bool),
/// Remove the selected room.
RemoveChild,
}
/// The type of room being created.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CreateRoomType {
@@ -243,6 +262,9 @@ pub enum SortFieldRoom {
/// Sort rooms by the timestamps of their most recent messages.
Recent,
/// Sort rooms by whether they are invites.
Invite,
}
/// Fields that users can be sorted by.
@@ -277,7 +299,7 @@ impl<'de> Deserialize<'de> for SortColumn<SortFieldRoom> {
/// [serde] visitor for deserializing [SortColumn] for rooms and spaces.
struct SortRoomVisitor;
impl<'de> Visitor<'de> for SortRoomVisitor {
impl Visitor<'_> for SortRoomVisitor {
type Value = SortColumn<SortFieldRoom>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -307,6 +329,7 @@ impl<'de> Visitor<'de> for SortRoomVisitor {
"name" => SortFieldRoom::Name,
"alias" => SortFieldRoom::Alias,
"id" => SortFieldRoom::RoomId,
"invite" => SortFieldRoom::Invite,
_ => {
let msg = format!("Unknown sort field: {value:?}");
return Err(E::custom(msg));
@@ -329,7 +352,7 @@ impl<'de> Deserialize<'de> for SortColumn<SortFieldUser> {
/// [serde] visitor for deserializing [SortColumn] for users.
struct SortUserVisitor;
impl<'de> Visitor<'de> for SortUserVisitor {
impl Visitor<'_> for SortUserVisitor {
type Value = SortColumn<SortFieldUser>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -375,6 +398,9 @@ pub enum RoomField {
/// The room name.
Name,
/// The room id.
Id,
/// A room tag.
Tag(TagName),
@@ -468,6 +494,8 @@ pub enum HomeserverAction {
/// Create a new room with an optional localpart.
CreateRoom(Option<String>, CreateRoomType, CreateRoomFlags),
Logout(String, bool),
/// Forget all left rooms
Forget,
}
/// An action performed against the user's room keys.
@@ -493,6 +521,9 @@ pub enum IambAction {
/// Perform an action on the currently selected message.
Message(MessageAction),
/// Perform an action on the current space.
Space(SpaceAction),
/// Open a URL.
OpenLink(String),
@@ -534,6 +565,12 @@ impl From<MessageAction> for IambAction {
}
}
impl From<SpaceAction> for IambAction {
fn from(act: SpaceAction) -> Self {
IambAction::Space(act)
}
}
impl From<RoomAction> for IambAction {
fn from(act: RoomAction) -> Self {
IambAction::Room(act)
@@ -553,6 +590,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Break,
IambAction::Keys(..) => SequenceStatus::Break,
IambAction::Message(..) => SequenceStatus::Break,
IambAction::Space(..) => SequenceStatus::Break,
IambAction::Room(..) => SequenceStatus::Break,
IambAction::OpenLink(..) => SequenceStatus::Break,
IambAction::Send(..) => SequenceStatus::Break,
@@ -568,6 +606,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Atom,
IambAction::Keys(..) => SequenceStatus::Atom,
IambAction::Message(..) => SequenceStatus::Atom,
IambAction::Space(..) => SequenceStatus::Atom,
IambAction::OpenLink(..) => SequenceStatus::Atom,
IambAction::Room(..) => SequenceStatus::Atom,
IambAction::Send(..) => SequenceStatus::Atom,
@@ -583,6 +622,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Ignore,
IambAction::Keys(..) => SequenceStatus::Ignore,
IambAction::Message(..) => SequenceStatus::Ignore,
IambAction::Space(..) => SequenceStatus::Ignore,
IambAction::Room(..) => SequenceStatus::Ignore,
IambAction::OpenLink(..) => SequenceStatus::Ignore,
IambAction::Send(..) => SequenceStatus::Ignore,
@@ -597,6 +637,7 @@ impl ApplicationAction for IambAction {
IambAction::ClearUnreads => false,
IambAction::Homeserver(..) => false,
IambAction::Message(..) => false,
IambAction::Space(..) => false,
IambAction::Room(..) => false,
IambAction::Keys(..) => false,
IambAction::Send(..) => false,
@@ -614,6 +655,12 @@ impl From<RoomAction> for ProgramAction {
}
}
impl From<SpaceAction> for ProgramAction {
fn from(act: SpaceAction) -> Self {
IambAction::from(act).into()
}
}
impl From<IambAction> for ProgramAction {
fn from(act: IambAction) -> Self {
Action::Application(act)
@@ -709,10 +756,22 @@ pub enum IambError {
#[error("Current window is not a room or space")]
NoSelectedRoomOrSpace,
/// A failure due to not having a room or space item selected in a list.
#[error("No room or space currently selected in list")]
NoSelectedRoomOrSpaceItem,
/// A failure due to not having a room selected.
#[error("Current window is not a room")]
NoSelectedRoom,
/// A failure due to not having a space selected.
#[error("Current window is not a space")]
NoSelectedSpace,
/// A failure due to not having sufficient permission to perform an action in a room.
#[error("You do not have the permission to do that")]
InsufficientPermission,
/// A failure due to not having an outstanding room invitation.
#[error("You do not have a current invitation to this room")]
NotInvited,
@@ -729,6 +788,10 @@ pub enum IambError {
#[error("Invalid room alias id: {0}")]
InvalidRoomAliasId(#[from] matrix_sdk::ruma::IdParseError),
/// An invalid space child order was specified.
#[error("Invalid space child order: {0}")]
InvalidSpaceChildOrder(matrix_sdk::ruma::IdParseError),
/// A failure occurred during verification.
#[error("Verification request error: {0}")]
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
@@ -785,6 +848,9 @@ pub enum EventLocation {
/// The [EventId] belongs to a reaction to the given event.
Reaction(OwnedEventId),
/// The [EventId] belongs to a state event in the main timeline of the room.
State(MessageKey),
}
impl EventLocation {
@@ -814,7 +880,6 @@ impl UnreadInfo {
}
/// Information about room's the user's joined.
#[derive(Default)]
pub struct RoomInfo {
/// The display name for this room.
pub name: Option<String>,
@@ -829,15 +894,13 @@ pub struct RoomInfo {
messages: Messages,
/// A map of read markers to display on different events.
pub event_receipts: HashMap<OwnedEventId, HashSet<OwnedUserId>>,
pub event_receipts: HashMap<ReceiptThread, HashMap<OwnedEventId, HashSet<OwnedUserId>>>,
/// A map of the most recent read marker for each user.
///
/// Every receipt in this map should also have an entry in [`event_receipts`],
/// Every receipt in this map should also have an entry in [`event_receipts`](`Self::event_receipts`),
/// however not every user has an entry. If a user's most recent receipt is
/// older than the oldest loaded event, that user will not be included.
pub user_receipts: HashMap<OwnedUserId, OwnedEventId>,
pub user_receipts: HashMap<ReceiptThread, HashMap<OwnedUserId, OwnedEventId>>,
/// A map of message identifiers to a map of reaction events.
pub reactions: HashMap<OwnedEventId, MessageReactions>,
@@ -863,6 +926,28 @@ pub struct RoomInfo {
pub draw_last: Option<Instant>,
}
impl Default for RoomInfo {
fn default() -> Self {
Self {
messages: Messages::new(ReceiptThread::Main),
name: Default::default(),
tags: Default::default(),
keys: Default::default(),
event_receipts: Default::default(),
user_receipts: Default::default(),
reactions: Default::default(),
threads: Default::default(),
fetching: Default::default(),
fetch_id: Default::default(),
fetch_last: Default::default(),
users_typing: Default::default(),
display_names: Default::default(),
draw_last: Default::default(),
}
}
}
impl RoomInfo {
pub fn get_thread(&self, root: Option<&EventId>) -> Option<&Messages> {
if let Some(thread_root) = root {
@@ -874,7 +959,9 @@ impl RoomInfo {
pub fn get_thread_mut(&mut self, root: Option<OwnedEventId>) -> &mut Messages {
if let Some(thread_root) = root {
self.threads.entry(thread_root).or_default()
self.threads
.entry(thread_root.clone())
.or_insert_with(|| Messages::thread(thread_root))
} else {
&mut self.messages
}
@@ -945,24 +1032,30 @@ impl RoomInfo {
self.messages.get_mut(self.keys.get(event_id)?.to_message_key()?)
}
pub fn redact(&mut self, ev: OriginalSyncRoomRedactionEvent, room_version: &RoomVersionId) {
pub fn redact(&mut self, ev: OriginalSyncRoomRedactionEvent, rules: &RedactionRules) {
let Some(redacts) = &ev.redacts else {
return;
};
match self.keys.get(redacts) {
None => return,
Some(EventLocation::State(key)) => {
if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, rules);
}
},
Some(EventLocation::Message(None, key)) => {
if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, room_version);
msg.redact(ev, rules);
}
},
Some(EventLocation::Message(Some(root), key)) => {
if let Some(thread) = self.threads.get_mut(root) {
if let Some(msg) = thread.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, room_version);
msg.redact(ev, rules);
}
}
},
@@ -1008,7 +1101,9 @@ impl RoomInfo {
};
let source = if let Some(thread) = thread {
self.threads.entry(thread.clone()).or_default()
self.threads
.entry(thread.clone())
.or_insert_with(|| Messages::thread(thread.clone()))
} else {
&mut self.messages
};
@@ -1025,6 +1120,7 @@ impl RoomInfo {
content.apply_replacement(new_msgtype);
},
MessageEvent::Redacted(_) |
MessageEvent::State(_) |
MessageEvent::EncryptedOriginal(_) |
MessageEvent::EncryptedRedacted(_) => {
return;
@@ -1034,16 +1130,52 @@ impl RoomInfo {
msg.html = msg.event.html();
}
pub fn insert_any_state(&mut self, msg: AnySyncStateEvent) {
let event_id = msg.event_id().to_owned();
let key = (msg.origin_server_ts().into(), event_id.clone());
let loc = EventLocation::State(key.clone());
self.keys.insert(event_id, loc);
self.messages.insert_message(key, msg);
}
/// Indicates whether this room has unread messages.
pub fn unreads(&self, settings: &ApplicationSettings) -> UnreadInfo {
let last_message = self.messages.last_key_value();
let last_receipt = self.get_receipt(&settings.profile.user_id);
let last_receipt = self
.user_receipts
.get(&ReceiptThread::Main)
.and_then(|receipts| receipts.get(&settings.profile.user_id));
let last_receipt = last_receipt.as_ref().and_then(|event_id| {
match &self.keys.get(*event_id)? {
EventLocation::Message(_, key) | EventLocation::State(key) => Some(key),
EventLocation::Reaction(_) => None,
}
});
let last_unthreaded = self
.user_receipts
.get(&ReceiptThread::Unthreaded)
.and_then(|receipts| receipts.get(&settings.profile.user_id));
let last_unthreaded = last_unthreaded.as_ref().and_then(|event_id| {
match &self.keys.get(*event_id)? {
EventLocation::Message(_, key) | EventLocation::State(key) => Some(key),
EventLocation::Reaction(_) => None,
}
});
let last_receipt = std::cmp::max(last_receipt, last_unthreaded);
match (last_message, last_receipt) {
(Some(((ts, recent), _)), Some(last_read)) => {
UnreadInfo { unread: last_read != recent, latest: Some(*ts) }
(Some(((ts, _), _)), Some((read_ts, _))) => {
UnreadInfo { unread: ts > read_ts, latest: Some(*ts) }
},
(Some(((ts, _), _)), None) => {
// If we've never loaded/generated a room's receipt (example,
// a newly joined but never viewed room), show it as unread.
UnreadInfo { unread: true, latest: Some(*ts) }
},
(Some(((ts, _), _)), None) => UnreadInfo { unread: false, latest: Some(*ts) },
(None, _) => UnreadInfo::default(),
}
}
@@ -1071,7 +1203,10 @@ impl RoomInfo {
let event_id = msg.event_id().to_owned();
let key = (msg.origin_server_ts().into(), event_id.clone());
let replies = self.threads.entry(thread_root.clone()).or_default();
let replies = self
.threads
.entry(thread_root.clone())
.or_insert_with(|| Messages::thread(thread_root.clone()));
let loc = EventLocation::Message(Some(thread_root), key.clone());
self.keys.insert(event_id, loc);
replies.insert_message(key, msg);
@@ -1131,40 +1266,73 @@ impl RoomInfo {
/// Indicates whether we've recently fetched scrollback for this room.
pub fn recently_fetched(&self) -> bool {
self.fetch_last.map_or(false, |i| i.elapsed() < ROOM_FETCH_DEBOUNCE)
self.fetch_last.is_some_and(|i| i.elapsed() < ROOM_FETCH_DEBOUNCE)
}
fn clear_receipt(&mut self, user_id: &OwnedUserId) -> Option<()> {
let old_event_id = self.user_receipts.get(user_id)?;
let old_receipts = self.event_receipts.get_mut(old_event_id)?;
fn clear_receipt(&mut self, thread: &ReceiptThread, user_id: &OwnedUserId) -> Option<()> {
let old_event_id =
self.user_receipts.get(thread).and_then(|receipts| receipts.get(user_id))?;
let old_thread = self.event_receipts.get_mut(thread)?;
let old_receipts = old_thread.get_mut(old_event_id)?;
old_receipts.remove(user_id);
if old_receipts.is_empty() {
self.event_receipts.remove(old_event_id);
old_thread.remove(old_event_id);
}
if old_thread.is_empty() {
self.event_receipts.remove(thread);
}
None
}
pub fn set_receipt(&mut self, user_id: OwnedUserId, event_id: OwnedEventId) {
self.clear_receipt(&user_id);
pub fn set_receipt(
&mut self,
thread: ReceiptThread,
user_id: OwnedUserId,
event_id: OwnedEventId,
) {
self.clear_receipt(&thread, &user_id);
self.event_receipts
.entry(thread.clone())
.or_default()
.entry(event_id.clone())
.or_default()
.insert(user_id.clone());
self.user_receipts.insert(user_id, event_id);
self.user_receipts.entry(thread).or_default().insert(user_id, event_id);
}
pub fn fully_read(&mut self, user_id: OwnedUserId) {
pub fn fully_read(&mut self, user_id: &UserId) {
let Some(((_, event_id), _)) = self.messages.last_key_value() else {
return;
};
self.set_receipt(user_id, event_id.clone());
self.set_receipt(ReceiptThread::Main, user_id.to_owned(), event_id.clone());
let newest = self
.threads
.iter()
.filter_map(|(thread_id, messages)| {
let thread = ReceiptThread::Thread(thread_id.to_owned());
messages
.last_key_value()
.map(|((_, event_id), _)| (thread, event_id.to_owned()))
})
.collect::<Vec<_>>();
for (thread, event_id) in newest.into_iter() {
self.set_receipt(thread, user_id.to_owned(), event_id.clone());
}
}
pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> {
self.user_receipts.get(user_id)
pub fn receipts<'a>(
&'a self,
user_id: &'a UserId,
) -> impl Iterator<Item = (&'a ReceiptThread, &'a OwnedEventId)> + 'a {
self.user_receipts
.iter()
.filter_map(move |(t, rs)| rs.get(user_id).map(|r| (t, r)))
}
fn get_typers(&self) -> &[OwnedUserId] {
@@ -1223,7 +1391,9 @@ impl RoomInfo {
}
if !settings.tunables.typing_notice_display {
return area;
// still keep one line blank, so `render_jump_to_recent` doesn't immediately hide the
// last line in scrollback
return Rect::new(area.x, area.y, area.width, area.height - 1);
}
let top = Rect::new(area.x, area.y, area.width, area.height - 1);
@@ -1268,7 +1438,7 @@ fn emoji_map() -> CompletionMap<String, &'static Emoji> {
#[cfg(unix)]
fn picker_from_termios(protocol_type: Option<ProtocolType>) -> Option<Picker> {
let mut picker = match Picker::from_termios() {
let mut picker = match Picker::from_query_stdio() {
Ok(picker) => picker,
Err(e) => {
tracing::error!("Failed to setup image previews: {e}");
@@ -1277,9 +1447,7 @@ fn picker_from_termios(protocol_type: Option<ProtocolType>) -> Option<Picker> {
};
if let Some(protocol_type) = protocol_type {
picker.protocol_type = protocol_type;
} else {
picker.guess_protocol();
picker.set_protocol_type(protocol_type);
}
Some(picker)
@@ -1302,8 +1470,8 @@ fn picker_from_settings(settings: &ApplicationSettings) -> Option<Picker> {
}) = image_preview_protocol
{
// User forced type and font_size: use that.
let mut picker = Picker::new(font_size);
picker.protocol_type = protocol_type;
let mut picker = Picker::from_fontsize(font_size);
picker.set_protocol_type(protocol_type);
Some(picker)
} else {
// Guess, but use type if forced.
@@ -1338,14 +1506,19 @@ impl SyncInfo {
}
}
bitflags::bitflags! {
/// Load-needs
#[derive(Debug, Default, PartialEq)]
pub struct Need: u32 {
const EMPTY = 0b00000000;
const MESSAGES = 0b00000001;
const MEMBERS = 0b00000010;
}
static MESSAGE_NEED_TTL: u8 = 30;
#[derive(Debug, PartialEq)]
/// Load messages until the event is loaded or `ttl` loads are exceeded
pub struct MessageNeed {
pub event_id: OwnedEventId,
pub ttl: u8,
}
#[derive(Default, Debug, PartialEq)]
pub struct Need {
pub members: bool,
pub messages: Option<Vec<MessageNeed>>,
}
/// Things that need loading for different rooms.
@@ -1355,9 +1528,31 @@ pub struct RoomNeeds {
}
impl RoomNeeds {
/// Mark a room for needing something to be loaded.
pub fn insert(&mut self, room_id: OwnedRoomId, need: Need) {
self.needs.entry(room_id).or_default().insert(need);
/// Mark a room for needing to load members.
pub fn need_members(&mut self, room_id: OwnedRoomId) {
self.needs.entry(room_id).or_default().members = true;
}
/// Mark a room for needing to load messages.
pub fn need_messages(&mut self, room_id: OwnedRoomId) {
self.needs.entry(room_id).or_default().messages.get_or_insert_default();
}
/// Mark a room for needing to load messages until the given message is loaded or a retry limit
/// is exceeded.
pub fn need_message(&mut self, room_id: OwnedRoomId, event_id: OwnedEventId) {
let messages = &mut self.needs.entry(room_id).or_default().messages.get_or_insert_default();
messages.push(MessageNeed { event_id, ttl: MESSAGE_NEED_TTL });
}
pub fn need_messages_all(&mut self, room_id: OwnedRoomId, message_needs: Vec<MessageNeed>) {
self.needs
.entry(room_id)
.or_default()
.messages
.get_or_insert_default()
.extend(message_needs);
}
pub fn rooms(&self) -> usize {
@@ -1417,6 +1612,12 @@ pub struct ChatStore {
/// Whether the application is currently focused
pub focused: bool,
/// Collator for locale-aware text sorting.
pub collator: feruca::Collator,
/// Notifications that should be dismissed when the user opens the room.
pub open_notifications: HashMap<OwnedRoomId, Vec<NotificationHandle>>,
}
impl ChatStore {
@@ -1431,6 +1632,7 @@ impl ChatStore {
cmds: crate::commands::setup_commands(),
emojis: emoji_map(),
collator: Default::default(),
names: Default::default(),
rooms: Default::default(),
presences: Default::default(),
@@ -1440,6 +1642,7 @@ impl ChatStore {
draw_curr: None,
ring_bell: false,
focused: true,
open_notifications: Default::default(),
}
}
@@ -1560,7 +1763,7 @@ impl<'de> Deserialize<'de> for IambId {
/// [serde] visitor for deserializing [IambId].
struct IambIdVisitor;
impl<'de> Visitor<'de> for IambIdVisitor {
impl Visitor<'_> for IambIdVisitor {
type Value = IambId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -1697,6 +1900,13 @@ impl RoomFocus {
pub fn is_msgbar(&self) -> bool {
matches!(self, RoomFocus::MessageBar)
}
pub fn toggle(&mut self) {
*self = match self {
RoomFocus::MessageBar => RoomFocus::Scrollback,
RoomFocus::Scrollback => RoomFocus::MessageBar,
};
}
}
/// Identifiers used to track where a mark was placed.
@@ -1765,11 +1975,20 @@ impl ApplicationInfo for IambInfo {
type WindowId = IambId;
type ContentId = IambBufferId;
fn content_of_command(ct: CommandType) -> IambBufferId {
IambBufferId::Command(ct)
}
}
pub struct IambCompleter;
impl Completer<IambInfo> for IambCompleter {
fn complete(
&mut self,
text: &EditRope,
cursor: &mut Cursor,
content: &IambBufferId,
store: &mut ProgramStore,
store: &mut ChatStore,
) -> Vec<String> {
match content {
IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store),
@@ -1787,21 +2006,16 @@ impl ApplicationInfo for IambInfo {
IambBufferId::UnreadList => vec![],
}
}
fn content_of_command(ct: CommandType) -> IambBufferId {
IambBufferId::Command(ct)
}
}
/// Tab completion for user IDs.
fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);
store
.application
.presences
.complete(id.as_ref())
.into_iter()
@@ -1810,7 +2024,7 @@ fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) ->
}
/// Tab completion within the message bar.
fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
@@ -1819,13 +2033,12 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
match id.chars().next() {
// Complete room aliases.
Some('#') => {
return store.application.names.complete(id.as_ref());
return store.names.complete(id.as_ref());
},
// Complete room identifiers.
Some('!') => {
return store
.application
.rooms
.complete(id.as_ref())
.into_iter()
@@ -1835,8 +2048,8 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
// Complete Emoji shortcodes.
Some(':') => {
let list = store.application.emojis.complete(&id[1..]);
let iter = list.into_iter().take(200).map(|s| format!(":{}:", s));
let list = store.emojis.complete(&id[1..]);
let iter = list.into_iter().take(200).map(|s| format!(":{s}:"));
return iter.collect();
},
@@ -1844,7 +2057,6 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
// Complete usernames for @ and empty strings.
Some('@') | None => {
return store
.application
.presences
.complete(id.as_ref())
.into_iter()
@@ -1858,28 +2070,23 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
}
/// Tab completion for Matrix identifiers (usernames, room aliases, etc.)
fn complete_matrix_names(
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);
let list = store.application.names.complete(id.as_ref());
let list = store.names.complete(id.as_ref());
if !list.is_empty() {
return list;
}
let list = store.application.presences.complete(id.as_ref());
let list = store.presences.complete(id.as_ref());
if !list.is_empty() {
return list.into_iter().map(|i| i.to_string()).collect();
}
store
.application
.rooms
.complete(id.as_ref())
.into_iter()
@@ -1888,12 +2095,12 @@ fn complete_matrix_names(
}
/// Tab completion for Emoji shortcode names.
fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let sc = text.get_prefix_word_mut(cursor, &WordStyle::Little);
let sc = sc.unwrap_or_else(EditRope::empty);
let sc = Cow::from(&sc);
store.application.emojis.complete(sc.as_ref())
store.emojis.complete(sc.as_ref())
}
/// Tab completion for command names.
@@ -1901,11 +2108,11 @@ fn complete_cmdname(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
store: &ChatStore,
) -> Vec<String> {
// Complete command name and set cursor position.
let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little);
store.application.cmds.complete_name(desc.command.as_str())
store.cmds.complete_name(desc.command.as_str())
}
/// Tab completion for command arguments.
@@ -1913,9 +2120,9 @@ fn complete_cmdarg(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
store: &ChatStore,
) -> Vec<String> {
let cmd = match store.application.cmds.get(desc.command.as_str()) {
let cmd = match store.cmds.get(desc.command.as_str()) {
Ok(cmd) => cmd,
Err(_) => return vec![],
};
@@ -1938,12 +2145,7 @@ fn complete_cmdarg(
}
/// Tab completion for commands.
fn complete_cmd(
cmd: &str,
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
fn complete_cmd(cmd: &str, text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
match CommandDescription::from_str(cmd) {
Ok(desc) => {
if desc.arg.untrimmed.is_empty() {
@@ -1960,7 +2162,7 @@ fn complete_cmd(
}
/// Tab completion for the command bar.
fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let eo = text.cursor_to_offset(cursor);
let slice = text.slice(..eo);
let cow = Cow::from(&slice);
@@ -1993,7 +2195,7 @@ pub mod tests {
));
for i in 0..3 {
let event_id = format!("$house_{}", i);
let event_id = format!("$house_{i}");
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
@@ -2012,7 +2214,7 @@ pub mod tests {
));
for i in 0..2 {
let event_id = format!("$smile_{}", i);
let event_id = format!("$smile_{i}");
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
@@ -2026,7 +2228,7 @@ pub mod tests {
}
for i in 2..4 {
let event_id = format!("$smile_{}", i);
let event_id = format!("$smile_{i}");
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
@@ -2128,18 +2330,19 @@ pub mod tests {
let mut need_load = RoomNeeds::default();
need_load.insert(room_id.clone(), Need::MESSAGES);
need_load.insert(room_id.clone(), Need::MEMBERS);
need_load.need_messages(room_id.clone());
need_load.need_members(room_id.clone());
assert_eq!(need_load.into_iter().collect::<Vec<(OwnedRoomId, Need)>>(), vec![(
room_id,
Need::MESSAGES | Need::MEMBERS,
Need { members: true, messages: Some(Vec::new()) }
)],);
}
#[tokio::test]
async fn test_complete_msgbar() {
let store = mock_store().await;
let store = store.application;
let text = EditRope::from("going for a walk :walk ");
let mut cursor = Cursor::new(0, 22);
@@ -2163,6 +2366,7 @@ pub mod tests {
#[tokio::test]
async fn test_complete_cmdbar() {
let store = mock_store().await;
let store = store.application;
let users = vec![
"@user1:example.com",
"@user2:example.com",

View File

@@ -2,9 +2,9 @@
//!
//! The command-bar commands are set up here, and iamb-specific commands are defined here. See
//! [modalkit::env::vim::command] for additional Vim commands we pull in.
use std::convert::TryFrom;
use std::{convert::TryFrom, str::FromStr as _};
use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId};
use matrix_sdk::ruma::{events::tag::TagName, OwnedRoomId, OwnedUserId};
use modalkit::{
commands::{CommandError, CommandResult, CommandStep},
@@ -27,6 +27,7 @@ use crate::base::{
RoomAction,
RoomField,
SendAction,
SpaceAction,
VerifyAction,
};
@@ -199,6 +200,17 @@ fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Ok(step);
}
fn iamb_forget(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let forget = IambAction::Homeserver(HomeserverAction::Forget);
let step = CommandStep::Continue(forget.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
@@ -274,6 +286,17 @@ fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Ok(step);
}
fn iamb_replied(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let ract = IambAction::from(MessageAction::Replied);
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
@@ -475,10 +498,18 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room topic show
("topic", "show", None) => RoomAction::Show(RoomField::Topic).into(),
("topic", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room tag set <tag-name>
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room tag unset <tag-name>
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
// :room notify set <notification-level>
("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(),
("notify", "set", None) => return Result::Err(CommandError::InvalidArgument),
@@ -491,10 +522,6 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(),
("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room tag unset <tag-name>
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
// :room aliases show
("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(),
("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
@@ -531,6 +558,91 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument)
},
// :room id show
("id", "show", None) => RoomAction::Show(RoomField::Id).into(),
("id", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
_ => return Result::Err(CommandError::InvalidArgument),
};
let step = CommandStep::Continue(act.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_space(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.options()?;
if args.len() < 2 {
return Err(CommandError::InvalidArgument);
}
let OptionType::Positional(field) = args.remove(0) else {
return Err(CommandError::InvalidArgument);
};
let OptionType::Positional(action) = args.remove(0) else {
return Err(CommandError::InvalidArgument);
};
let act: IambAction = match (field.as_str(), action.as_str()) {
// :space child remove
("child", "remove") => {
if !(args.is_empty()) {
return Err(CommandError::InvalidArgument);
}
SpaceAction::RemoveChild.into()
},
// :space child set <child>
("child", "set") => {
let mut order = None;
let mut suggested = false;
let mut raw_child = None;
for arg in args {
match arg {
OptionType::Flag(name, Some(arg)) => {
match name.as_str() {
"order" => {
if order.is_some() {
let msg = "Multiple ++order arguments are not allowed";
let err = CommandError::Error(msg.into());
return Err(err);
} else {
order = Some(arg);
}
},
_ => return Err(CommandError::InvalidArgument),
}
},
OptionType::Flag(name, None) => {
match name.as_str() {
"suggested" => suggested = true,
_ => return Err(CommandError::InvalidArgument),
}
},
OptionType::Positional(arg) => {
if raw_child.is_some() {
let msg = "Multiple room arguments are not allowed";
let err = CommandError::Error(msg.into());
return Err(err);
}
raw_child = Some(arg);
},
}
}
let child = if let Some(child) = raw_child {
OwnedRoomId::from_str(&child)
.map_err(|_| CommandError::Error("Invalid room id specified".into()))?
} else {
let msg = "Must specify a room to add";
return Err(CommandError::Error(msg.into()));
};
SpaceAction::SetChild(child, order, suggested).into()
},
_ => return Result::Err(CommandError::InvalidArgument),
};
@@ -641,6 +753,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![],
f: iamb_leave,
});
cmds.add_command(ProgramCommand {
name: "forget".into(),
aliases: vec![],
f: iamb_forget,
});
cmds.add_command(ProgramCommand {
name: "members".into(),
aliases: vec![],
@@ -661,12 +778,22 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![],
f: iamb_reply,
});
cmds.add_command(ProgramCommand {
name: "replied".into(),
aliases: vec![],
f: iamb_replied,
});
cmds.add_command(ProgramCommand {
name: "rooms".into(),
aliases: vec![],
f: iamb_rooms,
});
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room });
cmds.add_command(ProgramCommand {
name: "space".into(),
aliases: vec![],
f: iamb_space,
});
cmds.add_command(ProgramCommand {
name: "spaces".into(),
aliases: vec![],
@@ -721,7 +848,7 @@ pub fn setup_commands() -> ProgramCommands {
#[cfg(test)]
mod tests {
use super::*;
use matrix_sdk::ruma::user_id;
use matrix_sdk::ruma::{room_id, user_id};
use modalkit::actions::WindowAction;
use modalkit::editing::context::EditContext;
@@ -1047,22 +1174,119 @@ mod tests {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = format!("room notify set mute");
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
let cmd = "room notify set mute";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::NotificationMode, "mute".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = format!("room notify unset");
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
let cmd = "room notify unset";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = format!("room notify show");
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap();
let cmd = "room notify show";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Show(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
}
#[test]
fn test_cmd_room_id_show() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room id show", ctx.clone()).unwrap();
let act = RoomAction::Show(RoomField::Id);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room id show foo", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_space_child() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = "space";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space ++foo bar baz";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child foo";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_space_child_set() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = "space child set !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = SpaceAction::SetChild(room_id!("!roomid:example.org").to_owned(), None, false);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "space child set ++order=abcd ++suggested !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = SpaceAction::SetChild(
room_id!("!roomid:example.org").to_owned(),
Some("abcd".into()),
true,
);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "space child set ++order=abcd ++order=1234 !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(
res,
Err(CommandError::Error("Multiple ++order arguments are not allowed".into()))
);
let cmd = "space child set !roomid:example.org !otherroom:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::Error("Multiple room arguments are not allowed".into())));
let cmd = "space child set ++foo=abcd !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child set ++foo !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child ++order=abcd ++suggested set !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child set foo";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::Error("Invalid room id specified".into())));
let cmd = "space child set";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::Error("Must specify a room to add".into())));
}
#[test]
fn test_cmd_space_child_remove() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = "space child remove";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = SpaceAction::RemoveChild;
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "space child remove foo";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_invite() {
let mut cmds = setup_commands();

View File

@@ -1,16 +1,17 @@
//! # Logic for loading and validating application configuration
use std::borrow::Cow;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fmt;
use std::fs::File;
use std::hash::{Hash, Hasher};
use std::io::{BufReader, BufWriter};
use std::io::{BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process;
use clap::Parser;
use matrix_sdk::matrix_auth::MatrixSession;
use matrix_sdk::authentication::matrix::MatrixSession;
use matrix_sdk::ruma::{OwnedDeviceId, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId};
use ratatui::style::{Color, Modifier as StyleModifier, Style};
use ratatui::text::Span;
@@ -45,8 +46,9 @@ const DEFAULT_MEMBERS_SORT: [SortColumn<SortFieldUser>; 2] = [
SortColumn(SortFieldUser::UserId, SortOrder::Ascending),
];
const DEFAULT_ROOM_SORT: [SortColumn<SortFieldRoom>; 4] = [
const DEFAULT_ROOM_SORT: [SortColumn<SortFieldRoom>; 5] = [
SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending),
SortColumn(SortFieldRoom::Invite, SortOrder::Ascending),
SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending),
SortColumn(SortFieldRoom::Unread, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
@@ -97,14 +99,14 @@ fn validate_profile_name(name: &str) -> bool {
let mut chars = name.chars();
if !chars.next().map_or(false, |c| c.is_ascii_alphanumeric()) {
if !chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) {
return false;
}
name.chars().all(is_profile_char)
}
fn validate_profile_names(names: &HashMap<String, ProfileConfig>) {
fn validate_profile_names(names: &BTreeMap<String, ProfileConfig>) {
for name in names.keys() {
if validate_profile_name(name.as_str()) {
continue;
@@ -151,7 +153,7 @@ pub enum ConfigError {
pub struct Keys(pub Vec<TerminalKey>, pub String);
pub struct KeysVisitor;
impl<'de> Visitor<'de> for KeysVisitor {
impl Visitor<'_> for KeysVisitor {
type Value = Keys;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -182,7 +184,7 @@ impl<'de> Deserialize<'de> for Keys {
pub struct VimModes(pub Vec<VimMode>);
pub struct VimModesVisitor;
impl<'de> Visitor<'de> for VimModesVisitor {
impl Visitor<'_> for VimModesVisitor {
type Value = VimModes;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -232,7 +234,7 @@ impl From<LogLevel> for Level {
}
}
impl<'de> Visitor<'de> for LogLevelVisitor {
impl Visitor<'_> for LogLevelVisitor {
type Value = LogLevel;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -267,7 +269,7 @@ impl<'de> Deserialize<'de> for LogLevel {
pub struct UserColor(pub Color);
pub struct UserColorVisitor;
impl<'de> Visitor<'de> for UserColorVisitor {
impl Visitor<'_> for UserColorVisitor {
type Value = UserColor;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@@ -321,7 +323,7 @@ pub struct Session {
impl From<Session> for MatrixSession {
fn from(session: Session) -> Self {
MatrixSession {
tokens: matrix_sdk::matrix_auth::MatrixSessionTokens {
tokens: matrix_sdk::authentication::SessionTokens {
access_token: session.access_token,
refresh_token: session.refresh_token,
},
@@ -352,29 +354,31 @@ pub struct UserDisplayTunables {
pub type UserOverrides = HashMap<OwnedUserId, UserDisplayTunables>;
fn merge_sorts(a: SortOverrides, b: SortOverrides) -> SortOverrides {
fn merge_sorts(profile: SortOverrides, global: SortOverrides) -> SortOverrides {
SortOverrides {
chats: b.chats.or(a.chats),
dms: b.dms.or(a.dms),
rooms: b.rooms.or(a.rooms),
spaces: b.spaces.or(a.spaces),
members: b.members.or(a.members),
chats: profile.chats.or(global.chats),
dms: profile.dms.or(global.dms),
rooms: profile.rooms.or(global.rooms),
spaces: profile.spaces.or(global.spaces),
members: profile.members.or(global.members),
}
}
fn merge_maps<K, V>(a: Option<HashMap<K, V>>, b: Option<HashMap<K, V>>) -> Option<HashMap<K, V>>
fn merge_maps<K, V>(
profile: Option<HashMap<K, V>>,
global: Option<HashMap<K, V>>,
) -> Option<HashMap<K, V>>
where
K: Eq + Hash,
{
match (a, b) {
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(Some(mut a), Some(b)) => {
for (k, v) in b {
a.insert(k, v);
match (global, profile) {
(Some(m), None) | (None, Some(m)) => Some(m),
(Some(mut global), Some(profile)) => {
for (k, v) in profile {
global.insert(k, v);
}
Some(a)
Some(global)
},
(None, None) => None,
}
@@ -398,26 +402,79 @@ pub enum UserDisplayStyle {
DisplayName,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum NotifyVia {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct NotifyVia {
/// Deliver notifications via terminal bell.
Bell,
pub bell: bool,
/// Deliver notifications via desktop mechanism.
#[cfg(feature = "desktop")]
Desktop,
pub desktop: bool,
}
pub struct NotifyViaVisitor;
impl Default for NotifyVia {
fn default() -> Self {
#[cfg(not(feature = "desktop"))]
return NotifyVia::Bell;
#[cfg(feature = "desktop")]
return NotifyVia::Desktop;
Self {
bell: cfg!(not(feature = "desktop")),
#[cfg(feature = "desktop")]
desktop: true,
}
}
}
impl Visitor<'_> for NotifyViaVisitor {
type Value = NotifyVia;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid notify destination (e.g. \"bell\" or \"desktop\")")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: SerdeError,
{
let mut via = NotifyVia {
bell: false,
#[cfg(feature = "desktop")]
desktop: false,
};
for value in value.split('|') {
match value.to_ascii_lowercase().as_str() {
"bell" => {
via.bell = true;
},
#[cfg(feature = "desktop")]
"desktop" => {
via.desktop = true;
},
#[cfg(not(feature = "desktop"))]
"desktop" => {
return Err(E::custom("desktop notification support was compiled out"))
},
_ => return Err(E::custom("could not parse into a notify destination")),
};
}
Ok(via)
}
}
impl<'de> Deserialize<'de> for NotifyVia {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(NotifyViaVisitor)
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct Mouse {
#[serde(default)]
pub enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct Notifications {
#[serde(default)]
@@ -426,6 +483,8 @@ pub struct Notifications {
pub via: NotifyVia,
#[serde(default = "default_true")]
pub show_message: bool,
#[serde(default)]
pub sound_hint: Option<String>,
}
#[derive(Clone)]
@@ -501,12 +560,14 @@ impl SortOverrides {
pub struct TunableValues {
pub log_level: Level,
pub message_shortcode_display: bool,
pub normal_after_send: bool,
pub reaction_display: bool,
pub reaction_shortcode_display: bool,
pub read_receipt_send: bool,
pub read_receipt_display: bool,
pub request_timeout: u64,
pub sort: SortValues,
pub state_event_display: bool,
pub typing_notice_send: bool,
pub typing_notice_display: bool,
pub users: UserOverrides,
@@ -514,16 +575,19 @@ pub struct TunableValues {
pub message_user_color: bool,
pub default_room: Option<String>,
pub open_command: Option<Vec<String>>,
pub mouse: Mouse,
pub notifications: Notifications,
pub image_preview: Option<ImagePreviewValues>,
pub user_gutter_width: usize,
pub external_edit_file_suffix: String,
pub tabstop: usize,
}
#[derive(Clone, Default, Deserialize)]
pub struct Tunables {
pub log_level: Option<LogLevel>,
pub message_shortcode_display: Option<bool>,
pub normal_after_send: Option<bool>,
pub reaction_display: Option<bool>,
pub reaction_shortcode_display: Option<bool>,
pub read_receipt_send: Option<bool>,
@@ -531,6 +595,7 @@ pub struct Tunables {
pub request_timeout: Option<u64>,
#[serde(default)]
pub sort: SortOverrides,
pub state_event_display: Option<bool>,
pub typing_notice_send: Option<bool>,
pub typing_notice_display: Option<bool>,
pub users: Option<UserOverrides>,
@@ -538,10 +603,12 @@ pub struct Tunables {
pub message_user_color: Option<bool>,
pub default_room: Option<String>,
pub open_command: Option<Vec<String>>,
pub mouse: Option<Mouse>,
pub notifications: Option<Notifications>,
pub image_preview: Option<ImagePreview>,
pub user_gutter_width: Option<usize>,
pub external_edit_file_suffix: Option<String>,
pub tabstop: Option<usize>,
}
impl Tunables {
@@ -551,6 +618,7 @@ impl Tunables {
message_shortcode_display: self
.message_shortcode_display
.or(other.message_shortcode_display),
normal_after_send: self.normal_after_send.or(other.normal_after_send),
reaction_display: self.reaction_display.or(other.reaction_display),
reaction_shortcode_display: self
.reaction_shortcode_display
@@ -559,6 +627,7 @@ impl Tunables {
read_receipt_display: self.read_receipt_display.or(other.read_receipt_display),
request_timeout: self.request_timeout.or(other.request_timeout),
sort: merge_sorts(self.sort, other.sort),
state_event_display: self.state_event_display.or(other.state_event_display),
typing_notice_send: self.typing_notice_send.or(other.typing_notice_send),
typing_notice_display: self.typing_notice_display.or(other.typing_notice_display),
users: merge_maps(self.users, other.users),
@@ -566,12 +635,14 @@ impl Tunables {
message_user_color: self.message_user_color.or(other.message_user_color),
default_room: self.default_room.or(other.default_room),
open_command: self.open_command.or(other.open_command),
mouse: self.mouse.or(other.mouse),
notifications: self.notifications.or(other.notifications),
image_preview: self.image_preview.or(other.image_preview),
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
external_edit_file_suffix: self
.external_edit_file_suffix
.or(other.external_edit_file_suffix),
tabstop: self.tabstop.or(other.tabstop),
}
}
@@ -579,12 +650,14 @@ impl Tunables {
TunableValues {
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
message_shortcode_display: self.message_shortcode_display.unwrap_or(false),
normal_after_send: self.normal_after_send.unwrap_or(false),
reaction_display: self.reaction_display.unwrap_or(true),
reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false),
read_receipt_send: self.read_receipt_send.unwrap_or(true),
read_receipt_display: self.read_receipt_display.unwrap_or(true),
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT),
sort: self.sort.values(),
state_event_display: self.state_event_display.unwrap_or(true),
typing_notice_send: self.typing_notice_send.unwrap_or(true),
typing_notice_display: self.typing_notice_display.unwrap_or(true),
users: self.users.unwrap_or_default(),
@@ -592,12 +665,14 @@ impl Tunables {
message_user_color: self.message_user_color.unwrap_or(false),
default_room: self.default_room,
open_command: self.open_command,
mouse: self.mouse.unwrap_or_default(),
notifications: self.notifications.unwrap_or_default(),
image_preview: self.image_preview.map(ImagePreview::values),
user_gutter_width: self.user_gutter_width.unwrap_or(30),
external_edit_file_suffix: self
.external_edit_file_suffix
.unwrap_or_else(|| ".md".to_string()),
tabstop: self.tabstop.unwrap_or(4),
}
}
}
@@ -632,11 +707,11 @@ impl DirectoryValues {
#[derive(Clone, Default, Deserialize)]
pub struct Directories {
pub cache: Option<PathBuf>,
pub data: Option<PathBuf>,
pub logs: Option<PathBuf>,
pub downloads: Option<PathBuf>,
pub image_previews: Option<PathBuf>,
pub cache: Option<String>,
pub data: Option<String>,
pub logs: Option<String>,
pub downloads: Option<String>,
pub image_previews: Option<String>,
}
impl Directories {
@@ -653,6 +728,11 @@ impl Directories {
fn values(self) -> DirectoryValues {
let cache = self
.cache
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.or_else(|| {
let mut dir = dirs::cache_dir()?;
dir.push("iamb");
@@ -662,6 +742,11 @@ impl Directories {
let data = self
.data
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.or_else(|| {
let mut dir = dirs::data_dir()?;
dir.push("iamb");
@@ -669,19 +754,40 @@ impl Directories {
})
.expect("no dirs.data value configured!");
let logs = self.logs.unwrap_or_else(|| {
let mut dir = cache.clone();
dir.push("logs");
dir
});
let logs = self
.logs
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.unwrap_or_else(|| {
let mut dir = cache.clone();
dir.push("logs");
dir
});
let downloads = self.downloads.or_else(dirs::download_dir);
let downloads = self
.downloads
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.or_else(dirs::download_dir);
let image_previews = self.image_previews.unwrap_or_else(|| {
let mut dir = cache.clone();
dir.push("image_preview_downloads");
dir
});
let image_previews = self
.image_previews
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.unwrap_or_else(|| {
let mut dir = cache.clone();
dir.push("image_preview_downloads");
dir
});
DirectoryValues { cache, data, logs, downloads, image_previews }
}
@@ -729,7 +835,7 @@ pub struct ProfileConfig {
#[derive(Clone, Deserialize)]
pub struct IambConfig {
pub profiles: HashMap<String, ProfileConfig>,
pub profiles: BTreeMap<String, ProfileConfig>,
pub default_profile: Option<String>,
pub settings: Option<Tunables>,
pub dirs: Option<Directories>,
@@ -769,14 +875,22 @@ pub struct ApplicationSettings {
}
impl ApplicationSettings {
fn get_xdg_config_home() -> Option<PathBuf> {
env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from)
}
pub fn load(cli: Iamb) -> Result<Self, Box<dyn std::error::Error>> {
let mut config_dir = cli.config_directory.or_else(dirs::config_dir).unwrap_or_else(|| {
usage!(
"No user configuration directory found;\
please specify one via -C.\n\n
For more information try '--help'"
);
});
let mut config_dir = cli
.config_directory
.or_else(Self::get_xdg_config_home)
.or_else(dirs::config_dir)
.unwrap_or_else(|| {
usage!(
"No user configuration directory found;\
please specify one via -C.\n\n
For more information try '--help'"
);
});
config_dir.push("iamb");
let config_json = config_dir.join("config.json");
@@ -816,14 +930,33 @@ impl ApplicationSettings {
} else if profiles.len() == 1 {
profiles.into_iter().next().unwrap()
} else {
usage!(
"No profile specified. \
Please use -P or add \"default_profile\" to your configuration.\n\n\
For more information try '--help'",
);
loop {
println!("\nNo profile specified. Available profiles:");
profiles.keys().enumerate().for_each(|(i, name)| println!("{i}: {name}"));
print!("Select a number or 'q' to quit: ");
let _ = std::io::stdout().flush();
let mut input = String::new();
let _ = std::io::stdin().read_line(&mut input);
if input.trim() == "q" {
usage!(
"No profile specified. \
Please use -P or add \"default_profile\" to your configuration.\n\n\
For more information try '--help'",
);
}
if let Ok(i) = input.trim().parse::<usize>() {
if i < profiles.len() {
break profiles.into_iter().nth(i).unwrap();
}
}
println!("\nInvalid index.");
}
};
let macros = merge_maps(macros, profile.macros.take()).unwrap_or_default();
let macros = merge_maps(profile.macros.take(), macros).unwrap_or_default();
let layout = profile.layout.take().or(layout).unwrap_or_default();
let tunables = global.unwrap_or_default();
@@ -898,7 +1031,7 @@ impl ApplicationSettings {
Ok(())
}
pub fn get_user_char_span<'a>(&self, user_id: &'a UserId) -> Span<'a> {
pub fn get_user_char_span(&self, user_id: &UserId) -> Span<'_> {
let (color, c) = self
.tunables
.users
@@ -1022,10 +1155,10 @@ mod tests {
assert_eq!(res, Some(b.clone()));
let res = merge_maps(Some(b.clone()), Some(c.clone()));
assert_eq!(res, Some(c.clone()));
assert_eq!(res, Some(b.clone()));
let res = merge_maps(Some(c.clone()), Some(b.clone()));
assert_eq!(res, Some(b.clone()));
assert_eq!(res, Some(c.clone()));
}
#[test]
@@ -1189,6 +1322,29 @@ mod tests {
assert_eq!(run, &exp);
}
#[test]
fn test_parse_notify_via() {
assert_eq!(NotifyVia { bell: false, desktop: true }, NotifyVia::default());
assert_eq!(
NotifyVia { bell: false, desktop: true },
serde_json::from_str(r#""desktop""#).unwrap()
);
assert_eq!(
NotifyVia { bell: true, desktop: false },
serde_json::from_str(r#""bell""#).unwrap()
);
assert_eq!(
NotifyVia { bell: true, desktop: true },
serde_json::from_str(r#""bell|desktop""#).unwrap()
);
assert_eq!(
NotifyVia { bell: true, desktop: true },
serde_json::from_str(r#""desktop|bell""#).unwrap()
);
assert!(serde_json::from_str::<NotifyVia>(r#""other""#).is_err());
assert!(serde_json::from_str::<NotifyVia>(r#""""#).is_err());
}
#[test]
fn test_load_example_config_toml() {
let path = PathBuf::from("config.example.toml");

View File

@@ -44,11 +44,14 @@ use modalkit::crossterm::{
read,
DisableBracketedPaste,
DisableFocusChange,
DisableMouseCapture,
EnableBracketedPaste,
EnableFocusChange,
EnableMouseCapture,
Event,
KeyEventKind,
KeyboardEnhancementFlags,
MouseEventKind,
PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
@@ -59,7 +62,7 @@ use modalkit::crossterm::{
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
style::{Color, Style},
style::{Color, Modifier, Style},
text::Span,
widgets::Paragraph,
Terminal,
@@ -86,6 +89,7 @@ use crate::{
ChatStore,
HomeserverAction,
IambAction,
IambCompleter,
IambError,
IambId,
IambInfo,
@@ -310,7 +314,7 @@ impl Application {
}
term.draw(|f| {
let area = f.size();
let area = f.area();
let modestr = bindings.show_mode();
let cursor = bindings.get_cursor_indicator();
@@ -324,6 +328,9 @@ impl Application {
.show_dialog(dialogstr)
.show_mode(modestr)
.borders(true)
.border_style(Style::default().add_modifier(Modifier::DIM))
.tab_style(Style::default().add_modifier(Modifier::DIM))
.tab_style_focused(Style::default().remove_modifier(Modifier::DIM))
.focus(focused);
f.render_stateful_widget(screen, area, sstate);
@@ -339,7 +346,7 @@ impl Application {
let inner = Rect::new(cx, cy, 1, 1);
f.render_widget(para, inner)
}
f.set_cursor(cx, cy);
f.set_cursor_position((cx, cy));
}
})?;
@@ -364,8 +371,30 @@ impl Application {
return Ok(ke.into());
},
Event::Mouse(_) => {
// Do nothing for now.
Event::Mouse(me) => {
let dir = match me.kind {
MouseEventKind::ScrollUp => MoveDir2D::Up,
MouseEventKind::ScrollDown => MoveDir2D::Down,
MouseEventKind::ScrollLeft => MoveDir2D::Left,
MouseEventKind::ScrollRight => MoveDir2D::Right,
_ => continue,
};
let size = ScrollSize::Cell;
let style = ScrollStyle::Direction2D(dir, size, 1.into());
let ctx = ProgramContext::default();
let mut store = self.store.lock().await;
match self.screen.scroll(&style, &ctx, store.deref_mut()) {
Ok(None) => {},
Ok(Some(info)) => {
drop(store);
self.handle_info(info);
},
Err(e) => {
self.screen.push_error(e);
},
}
},
Event::FocusGained => {
let mut store = self.store.lock().await;
@@ -504,7 +533,7 @@ impl Application {
},
// Unimplemented.
Action::KeywordLookup => {
Action::KeywordLookup(_) => {
// XXX: implement
None
},
@@ -532,9 +561,12 @@ impl Application {
IambAction::ClearUnreads => {
let user_id = &store.application.settings.profile.user_id;
// Clear any notifications we displayed:
store.application.open_notifications.clear();
for room_id in store.application.sync_info.chats() {
if let Some(room) = store.application.rooms.get_mut(room_id) {
room.fully_read(user_id.clone());
room.fully_read(user_id);
}
}
@@ -557,6 +589,9 @@ impl Application {
IambAction::Message(act) => {
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
},
IambAction::Space(act) => {
self.screen.current_window_mut()?.space_command(act, ctx, store).await?
},
IambAction::Room(act) => {
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
self.action_prepend(acts);
@@ -564,6 +599,9 @@ impl Application {
None
},
IambAction::Send(act) => {
if store.application.settings.tunables.normal_after_send {
self.bindings.reset_mode();
}
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
},
@@ -625,6 +663,13 @@ impl Application {
Err(UIError::NeedConfirm(prompt))
},
HomeserverAction::Forget => {
let client = &store.application.worker.client;
for room in client.left_rooms() {
room.forget().await.map_err(IambError::from)?;
}
Ok(vec![])
},
}
}
@@ -847,7 +892,7 @@ async fn check_import_keys(
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
Ok(encrypted) => encrypted,
Err(e) => {
format!("* Failed to encrypt room keys during export: {e}");
println!("* Failed to encrypt room keys during export: {e}");
process::exit(2);
},
};
@@ -929,8 +974,8 @@ async fn login_normal(
}
/// Set up the terminal for drawing the TUI, and getting additional info.
fn setup_tty(title: &str, enable_enhanced_keys: bool) -> std::io::Result<()> {
let title = format!("iamb ({})", title);
fn setup_tty(settings: &ApplicationSettings, enable_enhanced_keys: bool) -> std::io::Result<()> {
let title = format!("iamb ({})", settings.profile.user_id.as_str());
// Enable raw mode and enter the alternate screen.
crossterm::terminal::enable_raw_mode()?;
@@ -944,15 +989,23 @@ fn setup_tty(title: &str, enable_enhanced_keys: bool) -> std::io::Result<()> {
)?;
}
if settings.tunables.mouse.enabled {
crossterm::execute!(stdout(), EnableMouseCapture)?;
}
crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title))
}
// Do our best to reverse what we did in setup_tty() when we exit or crash.
fn restore_tty(enable_enhanced_keys: bool) {
fn restore_tty(enable_enhanced_keys: bool, enable_mouse: bool) {
if enable_enhanced_keys {
let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags);
}
if enable_mouse {
let _ = crossterm::queue!(stdout(), DisableMouseCapture);
}
let _ = crossterm::execute!(
stdout(),
DisableBracketedPaste,
@@ -975,7 +1028,9 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
// Set up the async worker thread and global store.
let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
let store = ChatStore::new(worker.clone(), settings.clone());
let store = Store::new(store);
let mut store = Store::new(store);
store.completer = Box::new(IambCompleter);
let store = Arc::new(AsyncMutex::new(store));
worker.init(store.clone());
@@ -988,7 +1043,7 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
match res {
Err(UIError::Application(IambError::Matrix(e))) => {
if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() {
print_exit("Server did not recognize our API token; did you log out from this session elsewhere?")
print_exit(format!("Server did not recognize our API token; did you log out from this session elsewhere?\nTry deleting `{}` to force a clean login.", settings.session_json.display()))
} else {
print_exit(e)
}
@@ -1006,11 +1061,12 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
false
},
};
setup_tty(settings.profile.user_id.as_str(), enable_enhanced_keys)?;
setup_tty(&settings, enable_enhanced_keys)?;
let orig_hook = std::panic::take_hook();
let enable_mouse = settings.tunables.mouse.enabled;
std::panic::set_hook(Box::new(move |panic_info| {
restore_tty(enable_enhanced_keys);
restore_tty(enable_enhanced_keys, enable_mouse);
orig_hook(panic_info);
process::exit(1);
}));
@@ -1020,7 +1076,7 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
application.run().await?;
// Clean up the terminal on exit.
restore_tty(enable_enhanced_keys);
restore_tty(enable_enhanced_keys, enable_mouse);
Ok(())
}

View File

@@ -10,10 +10,12 @@
//!
//! This isn't as important for iamb, since it isn't a browser environment, but we do still map
//! input onto an enum of the safe list of tags to keep it easy to understand and process.
use std::borrow::Cow;
use std::ops::Deref;
use css_color_parser::Color as CssColor;
use markup5ever_rcdom::{Handle, NodeData, RcDom};
use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId};
use unicode_segmentation::UnicodeSegmentation;
use url::Url;
@@ -34,10 +36,13 @@ use ratatui::{
};
use crate::{
config::ApplicationSettings,
message::printer::TextPrinter,
util::{join_cell_text, space_text},
};
const QUOTE_COLOR: Color = Color::Indexed(236);
/// Generate bullet points from a [ListStyle].
pub struct BulletIterator {
style: ListStyle,
@@ -148,7 +153,12 @@ impl Table {
}
}
fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text {
fn to_text<'a>(
&'a self,
width: usize,
style: Style,
settings: &'a ApplicationSettings,
) -> Text<'a> {
let mut text = Text::default();
let columns = self.columns();
let cell_total = width.saturating_sub(columns).saturating_sub(1);
@@ -167,7 +177,7 @@ impl Table {
if let Some(caption) = &self.caption {
let subw = width.saturating_sub(6);
let mut printer =
TextPrinter::new(subw, style, true, emoji_shortcodes).align(Alignment::Center);
TextPrinter::new(subw, style, true, settings).align(Alignment::Center);
caption.print(&mut printer, style);
for mut line in printer.finish().lines {
@@ -214,7 +224,7 @@ impl Table {
CellType::Data => style,
};
cell.to_text(*w, style, emoji_shortcodes)
cell.to_text(*w, style, settings)
} else {
space_text(*w, style)
};
@@ -271,13 +281,22 @@ pub enum StyleTreeNode {
Ruler,
Style(Box<StyleTreeNode>, Style),
Table(Table),
Text(String),
Text(Cow<'static, str>),
Sequence(StyleTreeChildren),
RoomAlias(OwnedRoomAliasId),
RoomId(OwnedRoomId),
UserId(OwnedUserId),
DisplayName(String, OwnedUserId),
}
impl StyleTreeNode {
pub fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text {
let mut printer = TextPrinter::new(width, style, true, emoji_shortcodes);
pub fn to_text<'a>(
&'a self,
width: usize,
style: Style,
settings: &'a ApplicationSettings,
) -> Text<'a> {
let mut printer = TextPrinter::new(width, style, true, settings);
self.print(&mut printer, style);
printer.finish()
}
@@ -312,6 +331,12 @@ impl StyleTreeNode {
StyleTreeNode::Ruler => {},
StyleTreeNode::Text(_) => {},
StyleTreeNode::Break => {},
// TODO: eventually these should turn into internal links:
StyleTreeNode::UserId(_) => {},
StyleTreeNode::RoomId(_) => {},
StyleTreeNode::RoomAlias(_) => {},
StyleTreeNode::DisplayName(_, _) => {},
}
}
@@ -328,11 +353,14 @@ impl StyleTreeNode {
printer.push_span_nobreak(span);
},
StyleTreeNode::Blockquote(child) => {
let mut subp = printer.sub(4);
let mut subp = printer.sub(3);
child.print(&mut subp, style);
for mut line in subp.finish() {
line.spans.insert(0, Span::styled(" ", style));
line.spans.insert(0, Span::styled(" ", style));
line.spans
.insert(0, Span::styled(line::THICK_VERTICAL, style.fg(QUOTE_COLOR)));
line.spans.insert(0, Span::styled(" ", style));
printer.push_line(line);
}
},
@@ -430,14 +458,14 @@ impl StyleTreeNode {
}
},
StyleTreeNode::Table(table) => {
let text = table.to_text(width, style, printer.emoji_shortcodes());
let text = table.to_text(width, style, printer.settings);
printer.push_text(text);
},
StyleTreeNode::Break => {
printer.push_break();
},
StyleTreeNode::Text(s) => {
printer.push_str(s.as_str(), style);
printer.push_str(s.as_ref(), style);
},
StyleTreeNode::Style(child, patch) => child.print(printer, style.patch(*patch)),
@@ -446,13 +474,30 @@ impl StyleTreeNode {
child.print(printer, style);
}
},
StyleTreeNode::UserId(user_id) => {
let style = printer.settings().get_user_style(user_id);
printer.push_str(user_id.as_str(), style);
},
StyleTreeNode::DisplayName(display_name, user_id) => {
let style = printer.settings().get_user_style(user_id);
printer.push_str(display_name.as_str(), style);
},
StyleTreeNode::RoomId(room_id) => {
let bold = style.add_modifier(StyleModifier::BOLD);
printer.push_str(room_id.as_str(), bold);
},
StyleTreeNode::RoomAlias(alias) => {
let bold = style.add_modifier(StyleModifier::BOLD);
printer.push_str(alias.as_str(), bold);
},
}
}
}
/// A processed HTML document.
pub struct StyleTree {
children: StyleTreeChildren,
pub(super) children: StyleTreeChildren,
}
impl StyleTree {
@@ -466,14 +511,14 @@ impl StyleTree {
return links;
}
pub fn to_text(
&self,
pub fn to_text<'a>(
&'a self,
width: usize,
style: Style,
hide_reply: bool,
emoji_shortcodes: bool,
) -> Text<'_> {
let mut printer = TextPrinter::new(width, style, hide_reply, emoji_shortcodes);
settings: &'a ApplicationSettings,
) -> Text<'a> {
let mut printer = TextPrinter::new(width, style, hide_reply, settings);
for child in self.children.iter() {
child.print(&mut printer, style);
@@ -484,11 +529,11 @@ impl StyleTree {
}
pub struct TreeGenState {
link_num: u8,
pub link_num: u8,
}
impl TreeGenState {
fn next_link_char(&mut self) -> Option<char> {
pub fn next_link_char(&mut self) -> Option<char> {
let num = self.link_num;
if num < 62 {
@@ -661,7 +706,7 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
let tree = match &node.data {
NodeData::Document => *c2t(node.children.borrow().as_slice(), state),
NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string()),
NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string().into()),
NodeData::Element { name, attrs, .. } => {
match name.local.as_ref() {
// Message that this one replies to.
@@ -708,7 +753,7 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
StyleTreeNode::Style(c, s)
},
"del" | "strike" => {
"del" | "s" | "strike" => {
let c = c2t(&node.children.borrow(), state);
let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT);
@@ -775,7 +820,8 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
*c2t(&node.children.borrow(), state)
},
_ => return vec![],
// Treat unknown tags as plain text.
_ => *c2t(&node.children.borrow(), state),
}
},
@@ -811,17 +857,19 @@ pub fn parse_matrix_html(s: &str) -> StyleTree {
#[cfg(test)]
pub mod tests {
use super::*;
use crate::tests::mock_settings;
use crate::util::space_span;
use pretty_assertions::assert_eq;
use unicode_width::UnicodeWidthStr;
#[test]
fn test_header() {
let settings = mock_settings();
let bold = Style::default().add_modifier(StyleModifier::BOLD);
let s = "<h1>Header 1</h1>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold),
Span::styled(" ", bold),
@@ -833,7 +881,7 @@ pub mod tests {
let s = "<h2>Header 2</h2>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold),
Span::styled("#", bold),
@@ -846,7 +894,7 @@ pub mod tests {
let s = "<h3>Header 3</h3>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold),
Span::styled("#", bold),
@@ -860,7 +908,7 @@ pub mod tests {
let s = "<h4>Header 4</h4>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold),
Span::styled("#", bold),
@@ -875,7 +923,7 @@ pub mod tests {
let s = "<h5>Header 5</h5>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold),
Span::styled("#", bold),
@@ -891,7 +939,7 @@ pub mod tests {
let s = "<h6>Header 6</h6>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold),
Span::styled("#", bold),
@@ -909,6 +957,7 @@ pub mod tests {
#[test]
fn test_style() {
let settings = mock_settings();
let def = Style::default();
let bold = def.add_modifier(StyleModifier::BOLD);
let italic = def.add_modifier(StyleModifier::ITALIC);
@@ -918,7 +967,7 @@ pub mod tests {
let s = "<b>Bold!</b>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Bold", bold),
Span::styled("!", bold),
@@ -927,7 +976,7 @@ pub mod tests {
let s = "<strong>Bold!</strong>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Bold", bold),
Span::styled("!", bold),
@@ -936,7 +985,7 @@ pub mod tests {
let s = "<i>Italic!</i>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Italic", italic),
Span::styled("!", italic),
@@ -945,7 +994,7 @@ pub mod tests {
let s = "<em>Italic!</em>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Italic", italic),
Span::styled("!", italic),
@@ -954,7 +1003,7 @@ pub mod tests {
let s = "<del>Strikethrough!</del>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Strikethrough", strike),
Span::styled("!", strike),
@@ -963,7 +1012,7 @@ pub mod tests {
let s = "<strike>Strikethrough!</strike>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Strikethrough", strike),
Span::styled("!", strike),
@@ -972,7 +1021,7 @@ pub mod tests {
let s = "<u>Underline!</u>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Underline", underl),
Span::styled("!", underl),
@@ -981,7 +1030,7 @@ pub mod tests {
let s = "<font color=\"#ff0000\">Red!</u>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Red", red),
Span::styled("!", red),
@@ -990,7 +1039,7 @@ pub mod tests {
let s = "<font color=\"red\">Red!</u>";
let tree = parse_matrix_html(s);
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &settings);
assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Red", red),
Span::styled("!", red),
@@ -1000,9 +1049,10 @@ pub mod tests {
#[test]
fn test_paragraph() {
let settings = mock_settings();
let s = "<p>Hello world!</p><p>Content</p><p>Goodbye world!</p>";
let tree = parse_matrix_html(s);
let text = tree.to_text(10, Style::default(), false, false);
let text = tree.to_text(10, Style::default(), false, &settings);
assert_eq!(text.lines.len(), 7);
assert_eq!(
text.lines[0],
@@ -1027,25 +1077,42 @@ pub mod tests {
#[test]
fn test_blockquote() {
let settings = mock_settings();
let s = "<blockquote>Hello world!</blockquote>";
let tree = parse_matrix_html(s);
let text = tree.to_text(10, Style::default(), false, false);
let text = tree.to_text(10, Style::default(), false, &settings);
let style = Style::new().fg(QUOTE_COLOR);
assert_eq!(text.lines.len(), 2);
assert_eq!(
text.lines[0],
Line::from(vec![Span::raw(" "), Span::raw("Hello"), Span::raw(" ")])
Line::from(vec![
Span::raw(" "),
Span::styled(line::THICK_VERTICAL, style),
Span::raw(" "),
Span::raw("Hello"),
Span::raw(" "),
Span::raw(" "),
])
);
assert_eq!(
text.lines[1],
Line::from(vec![Span::raw(" "), Span::raw("world"), Span::raw("!")])
Line::from(vec![
Span::raw(" "),
Span::styled(line::THICK_VERTICAL, style),
Span::raw(" "),
Span::raw("world"),
Span::raw("!"),
Span::raw(" "),
])
);
}
#[test]
fn test_list_unordered() {
let settings = mock_settings();
let s = "<ul><li>List Item 1</li><li>List Item 2</li><li>List Item 3</li></ul>";
let tree = parse_matrix_html(s);
let text = tree.to_text(8, Style::default(), false, false);
let text = tree.to_text(8, Style::default(), false, &settings);
assert_eq!(text.lines.len(), 6);
assert_eq!(
text.lines[0],
@@ -1105,9 +1172,10 @@ pub mod tests {
#[test]
fn test_list_ordered() {
let settings = mock_settings();
let s = "<ol><li>List Item 1</li><li>List Item 2</li><li>List Item 3</li></ol>";
let tree = parse_matrix_html(s);
let text = tree.to_text(9, Style::default(), false, false);
let text = tree.to_text(9, Style::default(), false, &settings);
assert_eq!(text.lines.len(), 6);
assert_eq!(
text.lines[0],
@@ -1167,6 +1235,7 @@ pub mod tests {
#[test]
fn test_table() {
let settings = mock_settings();
let s = "<table>\
<thead>\
<tr><th>Column 1</th><th>Column 2</th><th>Column 3</th></tr>
@@ -1177,7 +1246,7 @@ pub mod tests {
<tr><td>a</td><td>b</td><td>c</td></tr>\
</tbody></table>";
let tree = parse_matrix_html(s);
let text = tree.to_text(15, Style::default(), false, false);
let text = tree.to_text(15, Style::default(), false, &settings);
let bold = Style::default().add_modifier(StyleModifier::BOLD);
assert_eq!(text.lines.len(), 11);
@@ -1267,10 +1336,11 @@ pub mod tests {
#[test]
fn test_matrix_reply() {
let settings = mock_settings();
let s = "<mx-reply>This was replied to</mx-reply>This is the reply";
let tree = parse_matrix_html(s);
let text = tree.to_text(10, Style::default(), false, false);
let text = tree.to_text(10, Style::default(), false, &settings);
assert_eq!(text.lines.len(), 4);
assert_eq!(
text.lines[0],
@@ -1307,7 +1377,7 @@ pub mod tests {
);
let tree = parse_matrix_html(s);
let text = tree.to_text(10, Style::default(), true, false);
let text = tree.to_text(10, Style::default(), true, &settings);
assert_eq!(text.lines.len(), 2);
assert_eq!(
text.lines[0],
@@ -1332,9 +1402,10 @@ pub mod tests {
#[test]
fn test_self_closing() {
let settings = mock_settings();
let s = "Hello<br>World<br>Goodbye";
let tree = parse_matrix_html(s);
let text = tree.to_text(7, Style::default(), true, false);
let text = tree.to_text(7, Style::default(), true, &settings);
assert_eq!(text.lines.len(), 3);
assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),]));
assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),]));
@@ -1343,9 +1414,10 @@ pub mod tests {
#[test]
fn test_embedded_newline() {
let settings = mock_settings();
let s = "<p>Hello\nWorld</p>";
let tree = parse_matrix_html(s);
let text = tree.to_text(15, Style::default(), true, false);
let text = tree.to_text(15, Style::default(), true, &settings);
assert_eq!(text.lines.len(), 1);
assert_eq!(
text.lines[0],
@@ -1360,16 +1432,18 @@ pub mod tests {
#[test]
fn test_pre_tag() {
let settings = mock_settings();
let s = concat!(
"<pre><code class=\"language-rust\">",
"fn hello() -&gt; usize {\n",
" \t// weired\n",
" return 5;\n",
"}\n",
"</code></pre>\n"
);
let tree = parse_matrix_html(s);
let text = tree.to_text(25, Style::default(), true, false);
assert_eq!(text.lines.len(), 5);
let text = tree.to_text(25, Style::default(), true, &settings);
assert_eq!(text.lines.len(), 6);
assert_eq!(
text.lines[0],
Line::from(vec![
@@ -1400,6 +1474,20 @@ pub mod tests {
);
assert_eq!(
text.lines[2],
Line::from(vec![
Span::raw(line::VERTICAL),
Span::raw(" "),
Span::raw(" "),
Span::raw("/"),
Span::raw("/"),
Span::raw(" "),
Span::raw("weired"),
Span::raw(" "),
Span::raw(line::VERTICAL)
])
);
assert_eq!(
text.lines[3],
Line::from(vec![
Span::raw(line::VERTICAL),
Span::raw(" "),
@@ -1412,7 +1500,7 @@ pub mod tests {
])
);
assert_eq!(
text.lines[3],
text.lines[4],
Line::from(vec![
Span::raw(line::VERTICAL),
Span::raw("}"),
@@ -1421,7 +1509,7 @@ pub mod tests {
])
);
assert_eq!(
text.lines[4],
text.lines[5],
Line::from(vec![
Span::raw(line::BOTTOM_LEFT),
Span::raw(line::HORIZONTAL.repeat(23)),
@@ -1432,6 +1520,11 @@ pub mod tests {
#[test]
fn test_emoji_shortcodes() {
let mut enabled = mock_settings();
enabled.tunables.message_shortcode_display = true;
let mut disabled = mock_settings();
disabled.tunables.message_shortcode_display = false;
for shortcode in ["exploding_head", "polar_bear", "canada"] {
let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str();
let emoji_width = UnicodeWidthStr::width(emoji);
@@ -1440,13 +1533,13 @@ pub mod tests {
let s = format!("<p>{emoji}</p>");
let tree = parse_matrix_html(s.as_str());
// Test with emojis_shortcodes set to false
let text = tree.to_text(20, Style::default(), false, false);
let text = tree.to_text(20, Style::default(), false, &disabled);
assert_eq!(text.lines, vec![Line::from(vec![
Span::raw(emoji),
space_span(20 - emoji_width, Style::default()),
]),]);
// Test with emojis_shortcodes set to true
let text = tree.to_text(20, Style::default(), false, true);
let text = tree.to_text(20, Style::default(), false, &enabled);
assert_eq!(text.lines, vec![Line::from(vec![
Span::raw(replacement.as_str()),
space_span(20 - replacement_width, Style::default()),

View File

@@ -2,15 +2,16 @@
use std::borrow::Cow;
use std::cmp::{Ord, Ordering, PartialOrd};
use std::collections::hash_map::DefaultHasher;
use std::collections::hash_set;
use std::collections::BTreeMap;
use std::convert::{TryFrom, TryInto};
use std::fmt::{self, Display};
use std::hash::{Hash, Hasher};
use std::ops::{Deref, DerefMut};
use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone};
use chrono::{DateTime, Local as LocalTz};
use humansize::{format_size, DECIMAL};
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use matrix_sdk::ruma::room_version_rules::RedactionRules;
use serde_json::json;
use unicode_width::UnicodeWidthStr;
@@ -35,6 +36,7 @@ use matrix_sdk::ruma::{
},
redaction::SyncRoomRedactionEvent,
},
AnySyncStateEvent,
RedactContent,
RedactedUnsigned,
},
@@ -42,7 +44,6 @@ use matrix_sdk::ruma::{
MilliSecondsSinceUnixEpoch,
OwnedEventId,
OwnedUserId,
RoomVersionId,
UInt,
};
@@ -67,13 +68,17 @@ use crate::{
mod compose;
mod html;
mod printer;
mod state;
pub use self::compose::text_to_message;
use self::state::{body_cow_state, html_state};
pub use html::TreeGenState;
type ProtocolPreview<'a> = (&'a Protocol, u16, u16);
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
#[derive(Default)]
pub struct Messages(BTreeMap<MessageKey, Message>);
pub struct Messages(BTreeMap<MessageKey, Message>, pub ReceiptThread);
impl Deref for Messages {
type Target = BTreeMap<MessageKey, Message>;
@@ -90,6 +95,18 @@ impl DerefMut for Messages {
}
impl Messages {
pub fn new(thread: ReceiptThread) -> Self {
Self(Default::default(), thread)
}
pub fn main() -> Self {
Self::new(ReceiptThread::Main)
}
pub fn thread(root: OwnedEventId) -> Self {
Self::new(ReceiptThread::Thread(root))
}
pub fn insert_message(&mut self, key: MessageKey, msg: impl Into<Message>) {
let event_id = key.1.clone();
let msg = msg.into();
@@ -155,12 +172,15 @@ fn placeholder_frame(
image_preview_size: &ImagePreviewSize,
) -> Option<String> {
let ImagePreviewSize { width, height } = image_preview_size;
if outer_width < *width || (*width < 2 || *height < 2) {
let width = usize::min(*width, outer_width);
if width < 2 || *height < 2 {
return None;
}
let mut placeholder = "\u{230c}".to_string();
placeholder.push_str(&" ".repeat(width - 2));
placeholder.push_str("\u{230d}\n");
placeholder.push('\u{230d}');
placeholder.push_str(&"\n".repeat((height - 1) / 2));
if *height > 2 {
if let Some(text) = text {
if text.width() <= width - 2 {
@@ -170,7 +190,7 @@ fn placeholder_frame(
}
}
placeholder.push_str(&"\n".repeat(height - 2));
placeholder.push_str(&"\n".repeat(height / 2));
placeholder.push('\u{230e}');
placeholder.push_str(&" ".repeat(width - 2));
placeholder.push_str("\u{230f}\n");
@@ -180,9 +200,8 @@ fn placeholder_frame(
#[inline]
fn millis_to_datetime(ms: UInt) -> DateTime<LocalTz> {
let time = i64::from(ms) / 1000;
let time = NaiveDateTime::from_timestamp_opt(time, 0).unwrap_or_default();
LocalTz.from_utc_datetime(&time)
let time = DateTime::from_timestamp(time, 0).unwrap_or_default();
time.into()
}
#[derive(thiserror::Error, Debug)]
@@ -215,13 +234,13 @@ impl MessageTimeStamp {
dt1.date_naive() == dt2.date_naive()
}
fn show_date(&self) -> Option<Span> {
fn show_date(&self) -> Option<Span<'_>> {
let time = self.as_datetime().format("%A, %B %d %Y").to_string();
Span::styled(time, BOLD_STYLE).into()
}
fn show_time(&self) -> Option<Span> {
fn show_time(&self) -> Option<Span<'_>> {
match self {
MessageTimeStamp::OriginServer(ms) => {
let time = millis_to_datetime(*ms).format("%T");
@@ -426,6 +445,7 @@ pub enum MessageEvent {
EncryptedRedacted(Box<RedactedRoomEncryptedEvent>),
Original(Box<OriginalRoomMessageEvent>),
Redacted(Box<RedactedRoomMessageEvent>),
State(Box<AnySyncStateEvent>),
Local(OwnedEventId, Box<RoomMessageEventContent>),
}
@@ -436,6 +456,7 @@ impl MessageEvent {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
MessageEvent::Original(ev) => ev.event_id.as_ref(),
MessageEvent::Redacted(ev) => ev.event_id.as_ref(),
MessageEvent::State(ev) => ev.event_id(),
MessageEvent::Local(event_id, _) => event_id.as_ref(),
}
}
@@ -446,6 +467,7 @@ impl MessageEvent {
MessageEvent::Original(ev) => Some(&ev.content),
MessageEvent::EncryptedRedacted(_) => None,
MessageEvent::Redacted(_) => None,
MessageEvent::State(_) => None,
MessageEvent::Local(_, content) => Some(content),
}
}
@@ -463,6 +485,7 @@ impl MessageEvent {
MessageEvent::Original(ev) => body_cow_content(&ev.content),
MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned),
MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned),
MessageEvent::State(ev) => body_cow_state(ev),
MessageEvent::Local(_, content) => body_cow_content(content),
}
}
@@ -473,6 +496,7 @@ impl MessageEvent {
MessageEvent::EncryptedRedacted(_) => return None,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
MessageEvent::State(ev) => return Some(html_state(ev)),
MessageEvent::Local(_, content) => content,
};
@@ -487,15 +511,16 @@ impl MessageEvent {
}
}
fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
fn redact(&mut self, redaction: SyncRoomRedactionEvent, rules: &RedactionRules) {
match self {
MessageEvent::EncryptedOriginal(_) => return,
MessageEvent::EncryptedRedacted(_) => return,
MessageEvent::Redacted(_) => return,
MessageEvent::State(_) => return,
MessageEvent::Local(_, _) => return,
MessageEvent::Original(ev) => {
let redacted = RedactedRoomMessageEvent {
content: ev.content.clone().redact(version),
content: ev.content.clone().redact(rules),
event_id: ev.event_id.clone(),
sender: ev.sender.clone(),
origin_server_ts: ev.origin_server_ts,
@@ -549,27 +574,18 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
MessageType::Video(content) => {
display_file_to_text!(Video, content);
},
_ => {
match content.msgtype() {
// Just show the body text for the special Element messages.
"nic.custom.confetti" |
"nic.custom.fireworks" |
"io.element.effect.hearts" |
"io.element.effect.rainfall" |
"io.element.effect.snowfall" |
"io.element.effects.space_invaders" => content.body(),
other => {
return Cow::Owned(format!("[Unknown message type: {other:?}]"));
},
}
},
_ => content.body(),
};
Cow::Borrowed(s)
}
fn body_cow_reason(unsigned: &RedactedUnsigned) -> Cow<'_, str> {
let reason = unsigned.redacted_because.content.reason.as_ref();
let reason = unsigned
.redacted_because
.deserialize()
.ok()
.and_then(|ev| ev.content.reason);
if let Some(r) = reason {
Cow::Owned(format!("[Redacted: {r:?}]"))
@@ -623,8 +639,8 @@ struct MessageFormatter<'a> {
/// The date the message was sent.
date: Option<Span<'a>>,
/// Iterator over the users who have read up to this message.
read: Option<hash_set::Iter<'a, OwnedUserId>>,
/// The users who have read up to this message.
read: Vec<OwnedUserId>,
}
impl<'a> MessageFormatter<'a> {
@@ -657,13 +673,11 @@ impl<'a> MessageFormatter<'a> {
line.push(time);
// Show read receipts.
let user_char =
|user: &'a OwnedUserId| -> Span<'a> { settings.get_user_char_span(user) };
let mut read = self.read.iter_mut().flatten();
let user_char = |user: OwnedUserId| -> Span { settings.get_user_char_span(&user) };
let a = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
let b = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
let c = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
let a = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
let b = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
let c = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
line.push(Span::raw(" "));
line.push(c);
@@ -716,39 +730,55 @@ impl<'a> MessageFormatter<'a> {
style: Style,
text: &mut Text<'a>,
info: &'a RoomInfo,
) {
settings: &'a ApplicationSettings,
) -> Option<ProtocolPreview<'a>> {
let reply_style = if settings.tunables.message_user_color {
style.patch(settings.get_user_color(&msg.sender))
} else {
style
};
let width = self.width();
let w = width.saturating_sub(2);
let shortcodes = self.settings.tunables.message_shortcode_display;
let (mut replied, _) = msg.show_msg(w, style, true, shortcodes);
let (mut replied, proto) = msg.show_msg(w, reply_style, true, settings);
let mut sender = msg.sender_span(info, self.settings);
let sender_width = UnicodeWidthStr::width(sender.content.as_ref());
let trailing = w.saturating_sub(sender_width + 1);
sender.style = sender.style.patch(style);
sender.style = sender.style.patch(reply_style);
self.push_spans(
Line::from(vec![
Span::styled(" ", style),
Span::styled(THICK_VERTICAL, style),
sender,
Span::styled(":", style),
space_span(trailing, style),
Span::styled(":", reply_style),
space_span(trailing, reply_style),
]),
style,
text,
);
// Determine the image offset of the reply header, taking into account the formatting
let proto = proto.map(|p| {
let y_off = text.lines.len() as u16;
// Adjust x_off by 2 to account for the vertical line and indent
let x_off = self.cols.user_gutter_width(settings) + 2;
(p, x_off, y_off)
});
for line in replied.lines.iter_mut() {
line.spans.insert(0, Span::styled(THICK_VERTICAL, style));
line.spans.insert(0, Span::styled(" ", style));
}
self.push_text(replied, style, text);
self.push_text(replied, reply_style, text);
proto
}
fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) {
let mut emojis = printer::TextPrinter::new(self.width(), style, false, false);
let mut emojis = printer::TextPrinter::new(self.width(), style, false, self.settings);
let mut reactions = 0;
for (key, count) in counts {
@@ -797,7 +827,7 @@ impl<'a> MessageFormatter<'a> {
let plural = len != 1;
let style = Style::default();
let mut threaded =
printer::TextPrinter::new(self.width(), style, false, false).literal(true);
printer::TextPrinter::new(self.width(), style, false, self.settings).literal(true);
let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD));
threaded.push_str(" \u{2937} ", style);
threaded.push_span_nobreak(len);
@@ -814,7 +844,7 @@ impl<'a> MessageFormatter<'a> {
pub enum ImageStatus {
None,
Downloading(ImagePreviewSize),
Loaded(Box<dyn Protocol>),
Loaded(Protocol),
Error(String),
}
@@ -849,6 +879,7 @@ impl Message {
MessageEvent::Local(_, content) => content,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
MessageEvent::State(_) => return None,
};
match &content.relates_to {
@@ -869,6 +900,7 @@ impl Message {
MessageEvent::Local(_, content) => content,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
MessageEvent::State(_) => return None,
};
match &content.relates_to {
@@ -922,7 +954,13 @@ impl Message {
let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER;
let user = self.show_sender(prev, true, info, settings);
let time = self.timestamp.show_time();
let read = info.event_receipts.get(self.event.event_id()).map(|read| read.iter());
let read = info
.event_receipts
.values()
.filter_map(|receipts| receipts.get(self.event.event_id()))
.flat_map(|read| read.iter())
.map(|user_id| user_id.to_owned())
.collect();
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width {
@@ -930,7 +968,7 @@ impl Message {
let fill = width - user_gutter - TIME_GUTTER;
let user = self.show_sender(prev, true, info, settings);
let time = self.timestamp.show_time();
let read = None;
let read = Vec::new();
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else if user_gutter + MIN_MSG_LEN <= width {
@@ -938,7 +976,7 @@ impl Message {
let fill = width - user_gutter;
let user = self.show_sender(prev, true, info, settings);
let time = None;
let read = None;
let read = Vec::new();
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else {
@@ -946,7 +984,7 @@ impl Message {
let fill = width.saturating_sub(2);
let user = self.show_sender(prev, false, info, settings);
let time = None;
let read = None;
let read = Vec::new();
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
}
@@ -962,7 +1000,7 @@ impl Message {
vwctx: &ViewportContext<MessageCursor>,
info: &'a RoomInfo,
settings: &'a ApplicationSettings,
) -> (Text<'a>, Option<(&dyn Protocol, u16, u16)>) {
) -> (Text<'a>, [Option<ProtocolPreview<'a>>; 2]) {
let width = vwctx.get_width();
let style = self.get_render_style(selected, settings);
@@ -975,24 +1013,20 @@ impl Message {
.reply_to()
.or_else(|| self.thread_root())
.and_then(|e| info.get_event(&e));
if let Some(r) = &reply {
fmt.push_in_reply(r, style, &mut text, info);
}
let proto_reply = reply.as_ref().and_then(|r| {
// Format the reply header, push it into the `Text` buffer, and get any image.
fmt.push_in_reply(r, style, &mut text, info, settings)
});
// Now show the message contents, and the inlined reply if we couldn't find it above.
let (msg, proto) = self.show_msg(
width,
style,
reply.is_some(),
settings.tunables.message_shortcode_display,
);
let (msg, proto) = self.show_msg(width, style, reply.is_some(), settings);
// Given our text so far, determine the image offset.
let proto = proto.map(|p| {
let proto_main = proto.map(|p| {
let y_off = text.lines.len() as u16;
let x_off = fmt.cols.user_gutter_width(settings);
// Adjust y_off by 1 if a date was printed before the message to account for the extra line.
// Adjust y_off by 1 if a date was printed before the message to account for
// the extra line we're going to print.
let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off };
(p, x_off, y_off)
});
@@ -1013,7 +1047,7 @@ impl Message {
fmt.push_thread_reply_count(thread.len(), &mut text);
}
(text, proto)
(text, [proto_main, proto_reply])
}
pub fn show<'a>(
@@ -1027,18 +1061,18 @@ impl Message {
self.show_with_preview(prev, selected, vwctx, info, settings).0
}
fn show_msg(
&self,
fn show_msg<'a>(
&'a self,
width: usize,
style: Style,
hide_reply: bool,
emoji_shortcodes: bool,
) -> (Text, Option<&dyn Protocol>) {
settings: &'a ApplicationSettings,
) -> (Text<'a>, Option<&'a Protocol>) {
if let Some(html) = &self.html {
(html.to_text(width, style, hide_reply, emoji_shortcodes), None)
(html.to_text(width, style, hide_reply, settings), None)
} else {
let mut msg = self.event.body();
if emoji_shortcodes {
if settings.tunables.message_shortcode_display {
msg = Cow::Owned(replace_emojis_in_str(msg.as_ref()));
}
@@ -1053,8 +1087,8 @@ impl Message {
placeholder_frame(Some("Downloading..."), width, image_preview_size)
},
ImageStatus::Loaded(backend) => {
proto = Some(backend.as_ref());
placeholder_frame(None, width, &backend.rect().into())
proto = Some(backend);
placeholder_frame(Some("No Space..."), width, &backend.area().into())
},
ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")),
};
@@ -1097,17 +1131,19 @@ impl Message {
let padding = user_gutter - 2 - width;
let sender = if align_right {
space(padding) + &truncated + " "
format!("{}{} ", space(padding), truncated)
} else {
truncated.into_owned() + &space(padding) + " "
format!("{}{} ", truncated, space(padding))
};
Span::styled(sender, style).into()
}
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
self.event.redact(redaction, version);
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, rules: &RedactionRules) {
self.event.redact(redaction, rules);
self.html = None;
self.downloaded = false;
self.image_preview = ImageStatus::None;
}
}
@@ -1153,6 +1189,16 @@ impl From<RoomMessageEvent> for Message {
}
}
impl From<AnySyncStateEvent> for Message {
fn from(event: AnySyncStateEvent) -> Self {
let timestamp = event.origin_server_ts().into();
let user_id = event.sender().to_owned();
let event = MessageEvent::State(event.into());
Message::new(event, user_id, timestamp)
}
}
impl Display for Message {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.event.body())
@@ -1251,7 +1297,7 @@ pub mod tests {
assert_eq!(k6, &MSG1_KEY.clone());
// MessageCursor::latest() fails to convert for a room w/o messages.
let messages_empty = Messages::default();
let messages_empty = Messages::new(ReceiptThread::Main);
assert_eq!(mc6.to_key(&messages_empty), None);
}
@@ -1300,7 +1346,17 @@ pub mod tests {
)
);
assert_eq!(placeholder_frame(None, 2, &ImagePreviewSize { width: 4, height: 4 }), None);
assert_eq!(
placeholder_frame(None, 2, &ImagePreviewSize { width: 4, height: 4 }),
pretty_frame_test(
r#"
⌌⌍
⌎⌏
"#
)
);
assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 1, height: 4 }), None);
assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 4, height: 1 }), None);
@@ -1313,6 +1369,33 @@ pub mod tests {
OK
⌎ ⌏
"#
)
);
assert_eq!(
placeholder_frame(Some("OK"), 6, &ImagePreviewSize { width: 6, height: 6 }),
pretty_frame_test(
r#"
⌌ ⌍
OK
⌎ ⌏
"#
)
);
assert_eq!(
placeholder_frame(Some("OK"), 6, &ImagePreviewSize { width: 6, height: 7 }),
pretty_frame_test(
r#"
⌌ ⌍
OK
⌎ ⌏
"#
)
);

View File

@@ -11,6 +11,7 @@ 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,
@@ -25,28 +26,34 @@ pub struct TextPrinter<'a> {
width: usize,
base_style: Style,
hide_reply: bool,
emoji_shortcodes: 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, emoji_shortcodes: bool) -> Self {
pub fn new(
width: usize,
base_style: Style,
hide_reply: bool,
settings: &'a ApplicationSettings,
) -> Self {
TextPrinter {
text: Text::default(),
width,
base_style,
hide_reply,
emoji_shortcodes,
alignment: Alignment::Left,
curr_spans: vec![],
curr_width: 0,
literal: false,
settings,
}
}
@@ -69,7 +76,15 @@ impl<'a> TextPrinter<'a> {
/// Indicates whether emojis should be replaced by shortcodes
pub fn emoji_shortcodes(&self) -> bool {
self.emoji_shortcodes
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.
@@ -84,12 +99,12 @@ impl<'a> TextPrinter<'a> {
width: self.width.saturating_sub(indent),
base_style: self.base_style,
hide_reply: self.hide_reply,
emoji_shortcodes: self.emoji_shortcodes,
alignment: self.alignment,
curr_spans: vec![],
curr_width: 0,
literal: self.literal,
settings: self.settings,
}
}
@@ -179,7 +194,7 @@ impl<'a> TextPrinter<'a> {
/// 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 {
if self.emoji_shortcodes() {
replace_emojis_in_span(&mut span);
}
let sw = UnicodeWidthStr::width(span.content.as_ref());
@@ -201,6 +216,8 @@ impl<'a> TextPrinter<'a> {
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 {
@@ -217,11 +234,17 @@ impl<'a> TextPrinter<'a> {
continue;
}
let cow = if self.emoji_shortcodes {
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 {
@@ -253,7 +276,7 @@ impl<'a> TextPrinter<'a> {
/// Push a [Line] into the printer.
pub fn push_line(&mut self, mut line: Line<'a>) {
self.commit();
if self.emoji_shortcodes {
if self.emoji_shortcodes() {
replace_emojis_in_line(&mut line);
}
self.text.lines.push(line);
@@ -262,7 +285,7 @@ impl<'a> TextPrinter<'a> {
/// Push multiline [Text] into the printer.
pub fn push_text(&mut self, mut text: Text<'a>) {
self.commit();
if self.emoji_shortcodes {
if self.emoji_shortcodes() {
for line in &mut text.lines {
replace_emojis_in_line(line);
}
@@ -280,10 +303,12 @@ impl<'a> TextPrinter<'a> {
#[cfg(test)]
pub mod tests {
use super::*;
use crate::tests::mock_settings;
#[test]
fn test_push_nobreak() {
let mut printer = TextPrinter::new(5, Style::default(), false, false);
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);

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

View File

@@ -1,15 +1,18 @@
use std::time::SystemTime;
use matrix_sdk::{
deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
room::Room as MatrixRoom,
ruma::{
api::client::push::get_notifications::v3::Notification,
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
serde::Raw,
MilliSecondsSinceUnixEpoch,
OwnedRoomId,
RoomId,
},
Client,
EncryptionState,
};
use unicode_segmentation::UnicodeSegmentation;
@@ -23,6 +26,21 @@ const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
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,
@@ -33,6 +51,7 @@ pub async fn register_notifications(
}
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;
@@ -43,6 +62,7 @@ pub async fn register_notifications(
.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 {
@@ -53,51 +73,111 @@ pub async fn register_notifications(
return;
}
match parse_notification(notification, room, show_message).await {
Ok((summary, body, server_ts)) => {
if server_ts < startup_ts {
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;
}
if is_missing_mention(&body, mode, &client) {
return;
}
match notify_via {
#[cfg(feature = "desktop")]
NotifyVia::Desktop => send_notification_desktop(summary, body),
NotifyVia::Bell => send_notification_bell(&store).await,
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}")
},
}
},
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")]
fn send_notification_desktop(summary: String, body: Option<String>) {
#[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)
.summary(summary)
.appname(IAMB_XDG_NAME)
.icon(IAMB_XDG_NAME)
.action("default", "default");
if let Some(body) = body {
desktop_notification.body(&body);
if let Some(sound_hint) = sound_hint {
desktop_notification.sound_name(sound_hint);
}
if let Err(err) = desktop_notification.show() {
tracing::error!("Failed to send notification: {err}")
#[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)));
},
}
}
@@ -113,8 +193,8 @@ async fn global_or_room_mode(
Ok(true) => IsOneToOne::Yes,
_ => IsOneToOne::No,
};
let is_encrypted = match room.is_encrypted().await {
Ok(true) => IsEncrypted::Yes,
let is_encrypted = match room.latest_encryption_state().await {
Ok(EncryptionState::Encrypted) => IsEncrypted::Yes,
_ => IsEncrypted::No,
};
settings
@@ -155,12 +235,12 @@ async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
is_focused(&locked) && is_open(&mut locked, room_id)
}
pub async fn parse_notification(
notification: Notification,
pub async fn parse_full_notification(
event: Raw<AnySyncTimelineEvent>,
room: MatrixRoom,
show_body: bool,
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
let event = notification.event.deserialize().map_err(IambError::from)?;
let event = event.deserialize().map_err(IambError::from)?;
let server_ts = event.origin_server_ts();
@@ -172,19 +252,19 @@ pub async fn parse_notification(
.and_then(|m| m.display_name())
.unwrap_or_else(|| sender_id.localpart());
let summary = if let Ok(room_name) = room.display_name().await {
format!("{sender_name} in {room_name}")
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,
room.is_direct().await.map_err(IambError::from)?,
)
.map(truncate)
event_notification_body(&event, sender_name).map(truncate)
} else {
None
};
@@ -192,11 +272,7 @@ pub async fn parse_notification(
return Ok((summary, body, server_ts));
}
pub fn event_notification_body(
event: &AnySyncTimelineEvent,
sender_name: &str,
is_direct: bool,
) -> Option<String> {
pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option<String> {
let AnySyncTimelineEvent::MessageLike(event) = event else {
return None;
};
@@ -207,10 +283,7 @@ pub fn event_notification_body(
MessageType::Audio(_) => {
format!("{sender_name} sent an audio file.")
},
MessageType::Emote(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::Emote(content) => content.body,
MessageType::File(_) => {
format!("{sender_name} sent a file.")
},
@@ -220,22 +293,9 @@ pub fn event_notification_body(
MessageType::Location(_) => {
format!("{sender_name} sent their location.")
},
MessageType::Notice(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::ServerNotice(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::Text(content) => {
if is_direct {
content.body
} else {
let message = &content.body;
format!("{sender_name}: {message}")
}
},
MessageType::Notice(content) => content.body,
MessageType::ServerNotice(content) => content.body,
MessageType::Text(content) => content.body,
MessageType::Video(_) => {
format!("{sender_name} sent a video.")
},
@@ -254,7 +314,7 @@ pub fn event_notification_body(
}
fn truncate(s: String) -> String {
static MAX_LENGTH: usize = 100;
static MAX_LENGTH: usize = 5000;
if s.graphemes(true).count() > MAX_LENGTH {
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
truncated + "..."

View File

@@ -5,7 +5,7 @@ use std::{
};
use matrix_sdk::{
media::{MediaFormat, MediaRequest},
media::{MediaFormat, MediaRequestParameters},
ruma::{
events::{
room::{
@@ -63,7 +63,7 @@ pub fn spawn_insert_preview(
let img = download_or_load(event_id.to_owned(), source, media, cache_dir)
.await
.map(std::io::Cursor::new)
.map(image::io::Reader::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));
@@ -157,7 +157,10 @@ async fn download_or_load(
},
Err(_) => {
media
.get_media_content(&MediaRequest { source, format: MediaFormat::File }, true)
.get_media_content(
&MediaRequestParameters { source, format: MediaFormat::File },
true,
)
.await
.and_then(|buffer| {
if let Err(err) =

View File

@@ -49,7 +49,8 @@ use crate::{
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!("@user3:example.com").to_owned();
@@ -137,7 +138,7 @@ pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
}
pub fn mock_messages() -> Messages {
let mut messages = Messages::default();
let mut messages = Messages::main();
messages.insert(MSG1_KEY.clone(), mock_message1());
messages.insert(MSG2_KEY.clone(), mock_message2());
@@ -171,12 +172,14 @@ pub fn mock_tunables() -> 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 {
@@ -189,13 +192,16 @@ pub fn mock_tunables() -> TunableValues {
external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username,
message_user_color: false,
mouse: Default::default(),
notifications: Notifications {
enabled: false,
via: NotifyVia::Desktop,
via: NotifyVia::default(),
show_message: true,
sound_hint: None,
},
image_preview: None,
user_gutter_width: 30,
tabstop: 4,
}
}

View File

@@ -5,7 +5,7 @@
//!
//! Additionally, some of the iamb commands delegate behaviour to the current UI element. For
//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState],
//! where we have the message bar and room ID easily accesible and resetable.
//! where we have the message bar and room ID easily accessible and resettable.
use std::cmp::{Ord, Ordering, PartialOrd};
use std::fmt::{self, Display};
use std::ops::Deref;
@@ -23,6 +23,7 @@ use matrix_sdk::{
RoomAliasId,
RoomId,
},
RoomState as MatrixRoomState,
};
use ratatui::{
@@ -65,7 +66,6 @@ use crate::base::{
IambInfo,
IambResult,
MessageAction,
Need,
ProgramAction,
ProgramContext,
ProgramStore,
@@ -75,11 +75,13 @@ use crate::base::{
SortFieldRoom,
SortFieldUser,
SortOrder,
SpaceAction,
UnreadInfo,
};
use self::{room::RoomState, welcome::WelcomeState};
use crate::message::MessageTimeStamp;
use feruca::Collator;
pub mod room;
pub mod welcome;
@@ -94,12 +96,12 @@ fn bold_style() -> Style {
}
#[inline]
fn bold_span(s: &str) -> Span {
fn bold_span(s: &str) -> Span<'_> {
Span::styled(s, bold_style())
}
#[inline]
fn bold_spans(s: &str) -> Line {
fn bold_spans(s: &str) -> Line<'_> {
bold_span(s).into()
}
@@ -113,12 +115,12 @@ fn selected_style(selected: bool) -> Style {
}
#[inline]
fn selected_span(s: &str, selected: bool) -> Span {
fn selected_span(s: &str, selected: bool) -> Span<'_> {
Span::styled(s, selected_style(selected))
}
#[inline]
fn selected_text(s: &str, selected: bool) -> Text {
fn selected_text(s: &str, selected: bool) -> Text<'_> {
Text::from(selected_span(s, selected))
}
@@ -168,7 +170,12 @@ fn user_cmp(a: &MemberItem, b: &MemberItem, field: &SortFieldUser) -> Ordering {
}
}
fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
fn room_cmp<T: RoomLikeItem>(
a: &T,
b: &T,
field: &SortFieldRoom,
collator: &mut Collator,
) -> Ordering {
match field {
SortFieldRoom::Favorite => {
let fava = a.has_tag(TagName::Favorite);
@@ -184,7 +191,7 @@ fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
// If a has LowPriority and b doesn't, it should sort later in room list.
lowa.cmp(&lowb)
},
SortFieldRoom::Name => a.name().cmp(b.name()),
SortFieldRoom::Name => collator.collate(a.name(), b.name()),
SortFieldRoom::Alias => some_cmp(a.alias(), b.alias(), Ord::cmp),
SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()),
SortFieldRoom::Unread => {
@@ -195,6 +202,10 @@ fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
// sort larger timestamps towards the top.
some_cmp(a.recent_ts(), b.recent_ts(), |a, b| b.cmp(a))
},
SortFieldRoom::Invite => {
// sort invites before other rooms.
b.is_invite().cmp(&a.is_invite())
},
}
}
@@ -203,9 +214,10 @@ fn room_fields_cmp<T: RoomLikeItem>(
a: &T,
b: &T,
fields: &[SortColumn<SortFieldRoom>],
collator: &mut Collator,
) -> Ordering {
for SortColumn(field, order) in fields {
match (room_cmp(a, b, field), order) {
match (room_cmp(a, b, field, collator), order) {
(Ordering::Equal, _) => continue,
(o, SortOrder::Ascending) => return o,
(o, SortOrder::Descending) => return o.reverse(),
@@ -213,7 +225,7 @@ fn room_fields_cmp<T: RoomLikeItem>(
}
// Break ties on ascending room id.
room_cmp(a, b, &SortFieldRoom::RoomId)
room_cmp(a, b, &SortFieldRoom::RoomId, collator)
}
fn user_fields_cmp(
@@ -273,6 +285,7 @@ trait RoomLikeItem {
fn recent_ts(&self) -> Option<&MessageTimeStamp>;
fn alias(&self) -> Option<&RoomAliasId>;
fn name(&self) -> &str;
fn is_invite(&self) -> bool;
}
#[inline]
@@ -354,6 +367,19 @@ impl IambWindow {
}
}
pub async fn space_command(
&mut self,
act: SpaceAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
if let IambWindow::Room(w) = self {
w.space_command(act, ctx, store).await
} else {
return Err(IambError::NoSelectedRoom.into());
}
}
pub async fn room_command(
&mut self,
act: RoomAction,
@@ -496,7 +522,8 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room_info| DirectItem::new(room_info, store))
.collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.dms;
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
state.set(items);
@@ -541,7 +568,8 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room_info| RoomItem::new(room_info, store))
.collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.rooms;
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
state.set(items);
@@ -572,7 +600,8 @@ impl WindowOps<IambInfo> for IambWindow {
items.extend(dms);
let fields = &store.application.settings.tunables.sort.chats;
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
state.set(items);
@@ -605,12 +634,13 @@ impl WindowOps<IambInfo> for IambWindow {
items.extend(dms);
let fields = &store.application.settings.tunables.sort.chats;
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
state.set(items);
List::new(store)
.empty_message("You do not have rooms or dms yet")
.empty_message("You do not have any unreads yet")
.empty_alignment(Alignment::Center)
.focus(focused)
.render(area, buf, state);
@@ -625,7 +655,8 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room| SpaceItem::new(room, store))
.collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.spaces;
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
state.set(items);
@@ -711,7 +742,7 @@ impl Window<IambInfo> for IambWindow {
}
}
fn get_tab_title(&self, store: &mut ProgramStore) -> Line {
fn get_tab_title(&self, store: &mut ProgramStore) -> Line<'_> {
match self {
IambWindow::DirectList(_) => bold_spans("Direct Messages"),
IambWindow::RoomList(_) => bold_spans("Rooms"),
@@ -739,7 +770,7 @@ impl Window<IambInfo> for IambWindow {
}
}
fn get_win_title(&self, store: &mut ProgramStore) -> Line {
fn get_win_title(&self, store: &mut ProgramStore) -> Line<'_> {
match self {
IambWindow::DirectList(_) => bold_spans("Direct Messages"),
IambWindow::RoomList(_) => bold_spans("Rooms"),
@@ -769,7 +800,7 @@ impl Window<IambInfo> for IambWindow {
let (room, name, tags) = store.application.worker.get_room(room_id)?;
let room = RoomState::new(room, thread, name, tags, store);
store.application.need_load.insert(room.id().to_owned(), Need::MEMBERS);
store.application.need_load.need_members(room.id().to_owned());
return Ok(room.into());
},
IambId::DirectList => {
@@ -831,7 +862,7 @@ impl Window<IambInfo> for IambWindow {
let (room, name, tags) = store.application.worker.get_room(room_id)?;
let room = RoomState::new(room, None, name, tags, store);
store.application.need_load.insert(room.id().to_owned(), Need::MEMBERS);
store.application.need_load.need_members(room.id().to_owned());
Ok(room.into())
}
}
@@ -914,6 +945,10 @@ impl RoomLikeItem for GenericChatItem {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for GenericChatItem {
@@ -923,7 +958,12 @@ impl Display for GenericChatItem {
}
impl ListItem<IambInfo> for GenericChatItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let unread = self.unread.is_unread();
let style = selected_style(selected);
let (name, mut labels) = name_and_labels(&self.name, unread, style);
@@ -1024,16 +1064,25 @@ impl RoomLikeItem for RoomItem {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for RoomItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, ":verify request {}", self.name)
write!(f, "{}", self.name)
}
}
impl ListItem<IambInfo> for RoomItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let unread = self.unread.is_unread();
let style = selected_style(selected);
let (name, mut labels) = name_and_labels(&self.name, unread, style);
@@ -1124,6 +1173,10 @@ impl RoomLikeItem for DirectItem {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for DirectItem {
@@ -1133,7 +1186,12 @@ impl Display for DirectItem {
}
impl ListItem<IambInfo> for DirectItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let unread = self.unread.is_unread();
let style = selected_style(selected);
let (name, mut labels) = name_and_labels(&self.name, unread, style);
@@ -1223,16 +1281,25 @@ impl RoomLikeItem for SpaceItem {
// XXX: this needs to check whether the space contains rooms with unread messages
false
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for SpaceItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, ":verify request {}", self.room_id())
write!(f, "{}", self.name)
}
}
impl ListItem<IambInfo> for SpaceItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
selected_text(self.name.as_str(), selected)
}
@@ -1363,7 +1430,12 @@ impl Display for VerifyItem {
}
impl ListItem<IambInfo> for VerifyItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let mut lines = vec![];
let bold = Style::default().add_modifier(StyleModifier::BOLD);
@@ -1473,7 +1545,7 @@ impl ListItem<IambInfo> for MemberItem {
selected: bool,
_: &ViewportContext<ListCursor>,
store: &mut ProgramStore,
) -> Text {
) -> Text<'_> {
let info = store.application.rooms.get_or_default(self.room_id.clone());
let user_id = self.member.user_id();
@@ -1517,6 +1589,10 @@ impl ListItem<IambInfo> for MemberItem {
fn get_word(&self) -> Option<String> {
self.member.user_id().to_string().into()
}
fn matches(&self, needle: &regex::Regex) -> bool {
needle.is_match(self.member.name()) || needle.is_match(self.member.user_id().as_str())
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for MemberItem {
@@ -1556,6 +1632,7 @@ mod tests {
alias: Option<OwnedRoomAliasId>,
name: &'static str,
unread: UnreadInfo,
invite: bool,
}
impl RoomLikeItem for &TestRoomItem {
@@ -1582,46 +1659,55 @@ mod tests {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.invite
}
}
#[test]
fn test_sort_rooms() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com");
let room1 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![TagName::Favorite],
alias: Some(room_alias_id!("#room1:example.com").to_owned()),
name: "Z",
unread: UnreadInfo::default(),
invite: false,
};
let room2 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: Some(room_alias_id!("#a:example.com").to_owned()),
name: "Unnamed Room",
unread: UnreadInfo::default(),
invite: false,
};
let room3 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Cool Room",
unread: UnreadInfo::default(),
invite: false,
};
// Sort by Name ascending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Ascending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room3, &room2, &room1]);
// Sort by Name descending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Descending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room1, &room2, &room3]);
// Sort by Favorite and Alias before Name to show order matters.
@@ -1631,7 +1717,7 @@ mod tests {
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room1, &room2, &room3]);
// Now flip order of Favorite with Descending
@@ -1641,24 +1727,27 @@ mod tests {
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room2, &room3, &room1]);
}
#[test]
fn test_sort_room_recents() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com");
let room1 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Room 1",
unread: UnreadInfo { unread: false, latest: None },
invite: false,
};
let room2 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Room 2",
@@ -1666,10 +1755,11 @@ mod tests {
unread: false,
latest: Some(MessageTimeStamp::OriginServer(40u32.into())),
},
invite: false,
};
let room3 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Room 3",
@@ -1677,18 +1767,71 @@ mod tests {
unread: false,
latest: Some(MessageTimeStamp::OriginServer(20u32.into())),
},
invite: false,
};
// Sort by Recent ascending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Ascending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room2, &room3, &room1]);
// Sort by Recent descending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Descending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room1, &room3, &room2]);
}
#[test]
fn test_sort_room_invites() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com");
let room1 = TestRoomItem {
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 1",
unread: UnreadInfo::default(),
invite: false,
};
let room2 = TestRoomItem {
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 2",
unread: UnreadInfo::default(),
invite: false,
};
let room3 = TestRoomItem {
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "New Fancy Room",
unread: UnreadInfo::default(),
invite: true,
};
// Sort invites first
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[
SortColumn(SortFieldRoom::Invite, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room3, &room1, &room2]);
// Sort invites after
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[
SortColumn(SortFieldRoom::Invite, SortOrder::Descending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room1, &room2, &room3]);
}
}

View File

@@ -7,14 +7,16 @@ use std::path::{Path, PathBuf};
use edit::edit_with_builder as external_edit;
use edit::Builder;
use matrix_sdk::EncryptionState;
use modalkit::editing::store::RegisterError;
use ratatui::style::{Color, Style};
use std::process::Command;
use tokio;
use url::Url;
use matrix_sdk::{
attachment::AttachmentConfig,
media::{MediaFormat, MediaRequest},
media::{MediaFormat, MediaRequestParameters},
room::Room as MatrixRoom,
ruma::{
events::reaction::ReactionEventContent,
@@ -86,7 +88,14 @@ use crate::base::{
SendAction,
};
use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
use crate::message::{
text_to_message,
Message,
MessageEvent,
MessageKey,
MessageTimeStamp,
TreeGenState,
};
use crate::worker::Requester;
use super::scrollback::{Scrollback, ScrollbackState};
@@ -213,12 +222,10 @@ impl ChatState {
};
let (source, msg_filename) = match &ev.content.msgtype {
MessageType::Audio(c) => (c.source.clone(), c.body.as_str()),
MessageType::File(c) => {
(c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str()))
},
MessageType::Image(c) => (c.source.clone(), c.body.as_str()),
MessageType::Video(c) => (c.source.clone(), c.body.as_str()),
MessageType::Audio(c) => (c.source.clone(), c.filename()),
MessageType::File(c) => (c.source.clone(), c.filename()),
MessageType::Image(c) => (c.source.clone(), c.filename()),
MessageType::Video(c) => (c.source.clone(), c.filename()),
_ => {
if !flags.contains(DownloadFlags::OPEN) {
return Err(IambError::NoAttachment.into());
@@ -226,10 +233,14 @@ impl ChatState {
let links = if let Some(html) = &msg.html {
html.get_links()
} else if let Ok(url) = Url::parse(&msg.event.body()) {
vec![('0', url)]
} else {
vec![]
linkify::LinkFinder::new()
.links(&msg.event.body())
.filter_map(|u| Url::parse(u.as_str()).ok())
.scan(TreeGenState { link_num: 0 }, |state, u| {
state.next_link_char().map(|c| (c, u))
})
.collect()
};
if links.is_empty() {
@@ -252,7 +263,7 @@ impl ChatState {
};
if filename.is_dir() {
filename.push(msg_filename);
filename.push(msg_filename.replace(std::path::MAIN_SEPARATOR_STR, "_"));
}
if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
@@ -262,9 +273,9 @@ impl ChatState {
let mut filename_incr = filename.clone();
for n in 1..=1000 {
if let Some(ext) = ext.and_then(OsStr::to_str) {
filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext));
filename_incr.set_file_name(format!("{stem}-{n}.{ext}"));
} else {
filename_incr.set_file_name(format!("{}-{}", stem, n));
filename_incr.set_file_name(format!("{stem}-{n}"));
}
if !filename_incr.exists() {
@@ -276,7 +287,7 @@ impl ChatState {
}
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
let req = MediaRequest { source, format: MediaFormat::File };
let req = MediaRequestParameters { source, format: MediaFormat::File };
let bytes =
media.get_media_content(&req, true).await.map_err(IambError::from)?;
@@ -380,6 +391,7 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => {
let msg = "Cannot react to a redacted message";
let err = UIError::Failure(msg.into());
@@ -389,7 +401,7 @@ impl ChatState {
};
if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) {
let msg = format!("Youve already reacted to this message with {}", emoji);
let msg = format!("Youve already reacted to this message with {emoji}");
let err = UIError::Failure(msg);
return Err(err);
@@ -417,6 +429,7 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => {
let msg = "Cannot redact already redacted message";
let err = UIError::Failure(msg.into());
@@ -437,6 +450,21 @@ impl ChatState {
Ok(None)
},
MessageAction::Replied => {
let Some(reply) = msg.reply_to() else {
let msg = "Selected message is not a reply";
return Err(UIError::Failure(msg.into()));
};
let Some(key) = info.get_message_key(&reply) else {
store.application.need_load.need_message(self.room_id.clone(), reply);
let msg = "Replied to message will be loaded in the background";
return Err(UIError::Failure(msg.into()));
};
self.scrollback.goto_message(key.clone());
Ok(None)
},
MessageAction::Unreact(reaction, literal) => {
let emoji = match reaction {
reaction if literal => reaction,
@@ -464,6 +492,7 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => {
let msg = "Cannot unreact to a redacted message";
let err = UIError::Failure(msg.into());
@@ -596,17 +625,16 @@ impl ChatState {
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
let bytes = Vec::<u8>::new();
let mut buff = std::io::Cursor::new(bytes);
dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?;
dynimage.write_to(&mut buff, image::ImageFormat::Png)?;
Ok(buff.into_inner())
})
.map_err(IambError::from)?;
})?;
let mime = mime::IMAGE_PNG;
let name = "Clipboard.png";
let config = AttachmentConfig::new();
let resp = room
.send_attachment(name.as_ref(), &mime, bytes, config)
.send_attachment(name, &mime, bytes, config)
.await
.map_err(IambError::from)?;
@@ -635,10 +663,7 @@ impl ChatState {
}
pub fn focus_toggle(&mut self) {
self.focus = match self.focus {
RoomFocus::Scrollback => RoomFocus::MessageBar,
RoomFocus::MessageBar => RoomFocus::Scrollback,
};
self.focus.toggle();
}
pub fn room(&self) -> &MatrixRoom {
@@ -649,6 +674,14 @@ impl ChatState {
&self.room_id
}
pub fn auto_toggle_focus(
&mut self,
act: &EditorAction,
ctx: &ProgramContext,
) -> Option<EditorAction> {
auto_toggle_focus(&mut self.focus, act, ctx, &self.scrollback, &mut self.tbox)
}
pub fn typing_notice(
&self,
act: &EditorAction,
@@ -751,8 +784,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
// Check whether we should automatically switch between the message bar
// or message scrollback, and use an adjusted action if we do so.
let adjusted = self.auto_toggle_focus(act, ctx);
let act = adjusted.as_ref().unwrap_or(act);
// Send typing notice if needed.
self.typing_notice(act, ctx, store);
// And now we can finally run the editor command.
match delegate!(self, w => w.editor_command(act, ctx, store)) {
res @ Ok(_) => res,
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
@@ -849,16 +889,16 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
fn recall(
&mut self,
filter: &RecallFilter,
dir: &MoveDir1D,
count: &Count,
prefixed: bool,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let count = ctx.resolve(count);
let rope = self.tbox.get();
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
let text = self.sent.recall(&rope, &mut self.sent_scrollback, filter, *dir, count);
if let Some(text) = text {
self.tbox.set_text(text);
@@ -882,9 +922,7 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
match act {
PromptAction::Submit => self.submit(ctx, store),
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
PromptAction::Recall(dir, count, prefixed) => {
self.recall(dir, count, *prefixed, ctx, store)
},
PromptAction::Recall(filter, dir, count) => self.recall(filter, dir, count, ctx, store),
}
}
}
@@ -906,7 +944,7 @@ impl<'a> Chat<'a> {
}
}
impl<'a> StatefulWidget for Chat<'a> {
impl StatefulWidget for Chat<'_> {
type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
@@ -954,7 +992,16 @@ impl<'a> StatefulWidget for Chat<'a> {
Paragraph::new(desc_spans).render(descarea, buf);
}
let prompt = if self.focused { "> " } else { " " };
let prompt = match (self.focused, state.room().encryption_state()) {
(false, _) => Span::raw(" "),
(_, EncryptionState::Encrypted) => {
Span::styled("\u{1F512}\u{FE0E} ", Style::new().fg(Color::LightGreen))
},
(_, EncryptionState::NotEncrypted) => {
Span::styled("\u{1F513}\u{FE0E} ", Style::new().fg(Color::Red))
},
(_, EncryptionState::Unknown) => Span::styled("> ", Style::new().fg(Color::Red)),
};
let tbox = TextBox::new().prompt(prompt);
tbox.render(textarea, buf, &mut state.tbox);
@@ -990,3 +1037,158 @@ fn cmd(open_command: &Vec<String>) -> Option<Command> {
}
None
}
pub fn auto_toggle_focus(
focus: &mut RoomFocus,
act: &EditorAction,
ctx: &ProgramContext,
scrollback: &ScrollbackState,
tbox: &mut TextBoxState<IambInfo>,
) -> Option<EditorAction> {
let is_insert = ctx.get_insert_style().is_some();
match (focus, act) {
(f @ RoomFocus::Scrollback, _) if is_insert => {
// Insert mode commands should switch focus.
f.toggle();
None
},
(f @ RoomFocus::Scrollback, EditorAction::InsertText(_)) => {
// Pasting or otherwise inserting text should switch.
f.toggle();
None
},
(
f @ RoomFocus::Scrollback,
EditorAction::Edit(
op,
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Next), count),
),
) if ctx.resolve(op).is_motion() => {
let count = ctx.resolve(count);
if count > 0 && scrollback.is_latest() {
// Trying to move down a line when already at the end of room history should
// switch.
f.toggle();
// And decrement the count for the action.
let count = count.saturating_sub(1).into();
let target = EditTarget::Motion(mov.clone(), count);
let dec = EditorAction::Edit(op.clone(), target);
Some(dec)
} else {
None
}
},
(
f @ RoomFocus::MessageBar,
EditorAction::Edit(
op,
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Previous), count),
),
) if !is_insert && ctx.resolve(op).is_motion() => {
let count = ctx.resolve(count);
if count > 0 && tbox.get_cursor().y == 0 {
// Trying to move up a line when already at the top of the msgbar should
// switch as long as we're not in Insert mode.
f.toggle();
// And decrement the count for the action.
let count = count.saturating_sub(1).into();
let target = EditTarget::Motion(mov.clone(), count);
let dec = EditorAction::Edit(op.clone(), target);
Some(dec)
} else {
None
}
},
(RoomFocus::Scrollback, _) | (RoomFocus::MessageBar, _) => {
// Do not switch.
None
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use modalkit::actions::{EditAction, InsertTextAction};
use crate::tests::{mock_store, TEST_ROOM1_ID};
macro_rules! move_line {
($dir: expr, $count: expr) => {
EditorAction::Edit(
EditAction::Motion.into(),
EditTarget::Motion(MoveType::Line($dir), $count.into()),
)
};
}
#[tokio::test]
async fn test_auto_focus() {
let mut store = mock_store().await;
let ctx = ProgramContext::default();
let room_id = TEST_ROOM1_ID.clone();
let scrollback = ScrollbackState::new(room_id.clone(), None);
let id = IambBufferId::Room(room_id, None, RoomFocus::MessageBar);
let ebuf = store.load_buffer(id);
let mut tbox = TextBoxState::new(ebuf);
// Start out focused on the scrollback.
let mut focused = RoomFocus::Scrollback;
// Inserting text toggles:
let act = EditorAction::InsertText(InsertTextAction::Type(
Char::from('a').into(),
MoveDir1D::Next,
1.into(),
));
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert!(res.is_none());
// Going down in message bar doesn't toggle:
let act = move_line!(MoveDir1D::Next, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert!(res.is_none());
// But going up will:
let act = move_line!(MoveDir1D::Previous, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::Scrollback);
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 0)));
// Going up in scrollback doesn't toggle:
let act = move_line!(MoveDir1D::Previous, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::Scrollback);
assert_eq!(res, None);
// And then go back down:
let act = move_line!(MoveDir1D::Next, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 0)));
// Go up 2 will go up 1 in scrollback:
let act = move_line!(MoveDir1D::Previous, 2);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::Scrollback);
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 1)));
// Go down 3 will go down 2 in messagebar:
let act = move_line!(MoveDir1D::Next, 3);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 2)));
}
}

View File

@@ -26,7 +26,7 @@ use matrix_sdk::{
OwnedUserId,
RoomId,
},
DisplayName,
RoomDisplayName,
RoomState as MatrixRoomState,
};
@@ -66,6 +66,7 @@ use crate::base::{
RoomAction,
RoomField,
SendAction,
SpaceAction,
};
use self::chat::ChatState;
@@ -119,19 +120,19 @@ fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility
/// 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))
}
}
@@ -139,7 +140,7 @@ impl RoomState {
pub fn new(
room: MatrixRoom,
thread: Option<OwnedEventId>,
name: DisplayName,
name: RoomDisplayName,
tags: Option<Tags>,
store: &mut ProgramStore,
) -> Self {
@@ -214,6 +215,18 @@ impl RoomState {
}
}
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,
@@ -406,7 +419,7 @@ impl RoomState {
// 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, None).await {
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 {
@@ -447,7 +460,7 @@ impl RoomState {
// 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, None).await {
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 {
@@ -464,6 +477,9 @@ impl RoomState {
RoomField::Aliases => {
// This never happens, aliases is only used for showing
},
RoomField::Id => {
// This never happens, id is only used for showing
},
}
Ok(vec![])
@@ -519,7 +535,7 @@ impl RoomState {
.application
.worker
.client
.send(del_req, None)
.send(del_req)
.await
.map_err(IambError::from)?;
},
@@ -552,13 +568,16 @@ impl RoomState {
.application
.worker
.client
.send(del_req, None)
.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![])
@@ -572,7 +591,12 @@ impl RoomState {
let msg = match field {
RoomField::History => {
let visibility = room.history_visibility();
format!("Room history visibility: {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() {
@@ -634,7 +658,7 @@ impl RoomState {
}
}
pub fn get_title(&self, store: &mut ProgramStore) -> Line {
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![];
@@ -752,8 +776,8 @@ 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))),
}
}

View File

@@ -47,7 +47,6 @@ use crate::{
IambId,
IambInfo,
IambResult,
Need,
ProgramContext,
ProgramStore,
RoomFetchStatus,
@@ -79,14 +78,20 @@ fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
}
fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
nth_key_before(pos, n, thread).into()
let key = nth_key_before(pos, n, thread);
if matches!(thread.last_key_value(), Some((last, _)) if &key == last) {
MessageCursor::latest()
} else {
MessageCursor::from(key)
}
}
fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> Option<MessageKey> {
let mut end = &pos;
let iter = thread.range(&pos..).enumerate();
let mut iter = thread.range(&pos..).enumerate();
for (i, (key, _)) in iter {
for (i, (key, _)) in iter.by_ref() {
end = key;
if i >= n {
@@ -94,11 +99,12 @@ fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
}
}
end.clone()
// Avoid returning the key if it's at the end.
iter.next().map(|_| end.clone())
}
fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor {
nth_key_after(pos, n, thread).into()
nth_key_after(pos, n, thread).map(MessageCursor::from).unwrap_or_default()
}
fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> {
@@ -150,10 +156,20 @@ impl ScrollbackState {
}
}
pub fn is_latest(&self) -> bool {
self.cursor.timestamp.is_none()
}
pub fn goto_latest(&mut self) {
self.cursor = MessageCursor::latest();
}
pub fn goto_message(&mut self, target: MessageKey) {
let mut cursor = MessageCursor::new(target, 0);
std::mem::swap(&mut cursor, &mut self.cursor);
self.jumped.push(cursor);
}
/// Set the dimensions and placement within the terminal window for this list.
pub fn set_term_info(&mut self, area: Rect) {
self.viewctx.dimensions = (area.width as usize, area.height as usize);
@@ -678,10 +694,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
if needs_load {
store
.application
.need_load
.insert(self.room_id.clone(), Need::MESSAGES);
store.application.need_load.need_messages(self.room_id.clone());
}
mc
},
@@ -757,10 +770,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
if needs_load {
store
.application
.need_load
.insert(self.room_id.to_owned(), Need::MESSAGES);
store.application.need_load.need_messages(self.room_id.to_owned());
}
mc.map(|c| self._range_to(c))
@@ -829,8 +839,8 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
fn complete(
&mut self,
_: &CompletionStyle,
_: &CompletionType,
_: &CompletionSelection,
_: &CompletionDisplay,
_: &ProgramContext,
_: &mut ProgramStore,
@@ -1284,7 +1294,7 @@ impl<'a> Scrollback<'a> {
}
}
impl<'a> StatefulWidget for Scrollback<'a> {
impl StatefulWidget for Scrollback<'_> {
type State = ScrollbackState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
@@ -1317,10 +1327,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
k
} else {
if state.need_more_messages(info) {
self.store
.application
.need_load
.insert(state.room_id.to_owned(), Need::MESSAGES);
self.store.application.need_load.need_messages(state.room_id.to_owned());
}
return;
};
@@ -1340,7 +1347,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
for (key, item) in thread.range(&corner_key..) {
let sel = key == cursor_key;
let (txt, mut msg_preview) =
let (txt, [mut msg_preview, mut reply_preview]) =
item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings);
let incomplete_ok = !full || !sel;
@@ -1357,11 +1364,17 @@ impl<'a> StatefulWidget for Scrollback<'a> {
continue;
}
// Only take the preview into the matching row number.
// `reply` and `msg` previews are on rows,
// so an `or` works to pick the one that matches (if any)
let line_preview = match msg_preview {
// Only take the preview into the matching row number.
Some((_, _, y)) if y as usize == row => msg_preview.take(),
_ => None,
};
}
.or(match reply_preview {
Some((_, _, y)) if y as usize == row => reply_preview.take(),
_ => None,
});
lines.push((key, row, line, line_preview));
sawit |= sel;
@@ -1396,7 +1409,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
// line.
for (x, y, backend) in image_previews {
let image_widget = Image::new(backend);
let mut rect = backend.rect();
let mut rect = backend.area();
rect.x = x;
rect.y = y;
// Don't render outside of scrollback area
@@ -1411,17 +1424,14 @@ impl<'a> StatefulWidget for Scrollback<'a> {
{
// If the cursor is at the last message, then update the read marker.
if let Some((k, _)) = thread.last_key_value() {
info.set_receipt(settings.profile.user_id.clone(), k.1.clone());
info.set_receipt(thread.1.clone(), settings.profile.user_id.clone(), k.1.clone());
}
}
// Check whether we should load older messages for this room.
if state.need_more_messages(info) {
// If the top of the screen is the older message, load more.
self.store
.application
.need_load
.insert(state.room_id.to_owned(), Need::MESSAGES);
self.store.application.need_load.need_messages(state.room_id.to_owned());
}
info.draw_last = self.store.application.draw_curr;
@@ -1431,7 +1441,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::*;
use crate::{base::Need, tests::*};
#[tokio::test]
async fn test_search_messages() {
@@ -1476,7 +1486,7 @@ mod tests {
std::mem::take(&mut store.application.need_load)
.into_iter()
.collect::<Vec<(OwnedRoomId, Need)>>(),
vec![(room_id.clone(), Need::MESSAGES)]
vec![(room_id.clone(), Need { messages: Some(Vec::new()), members: false })]
);
// Search forward twice to MSG1.
@@ -1518,8 +1528,9 @@ mod tests {
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG5_KEY.clone().into());
// And one more becomes "latest" cursor:
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
assert_eq!(scrollback.cursor, MessageCursor::latest());
}
#[tokio::test]
@@ -1553,7 +1564,7 @@ mod tests {
// MSG1: | XXXday, Month NN 20XX |
// | @user1:example.com writhe |
// |------------------------------------------------------------|
let area = Rect::new(0, 0, 60, 4);
let area = Rect::new(0, 0, 60, 5);
let mut buffer = Buffer::empty(area);
scrollback.draw(area, &mut buffer, true, &mut store);

View File

@@ -1,12 +1,17 @@
//! 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::prelude::{EditInfo, InfoMessage};
use ratatui::{
buffer::Buffer,
layout::Rect,
@@ -22,9 +27,18 @@ use modalkit_ratatui::{
WindowOps,
};
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
use crate::base::{
IambBufferId,
IambError,
IambInfo,
IambResult,
ProgramContext,
ProgramStore,
RoomFocus,
SpaceAction,
};
use crate::windows::{room_fields_cmp, RoomItem};
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
@@ -68,6 +82,79 @@ impl SpaceState {
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())
},
}
}
}
impl TerminalCursor for SpaceState {
@@ -107,7 +194,7 @@ 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) {
@@ -137,7 +224,8 @@ impl<'a> StatefulWidget for Space<'a> {
})
.collect::<Vec<_>>();
let fields = &self.store.application.settings.tunables.sort.rooms;
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
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());

View File

@@ -20,11 +20,12 @@ use tracing::{error, warn};
use url::Url;
use matrix_sdk::{
authentication::matrix::MatrixSession,
config::{RequestConfig, SyncSettings},
deserialized_responses::DisplayName,
encryption::verification::{SasVerification, Verification},
encryption::{BackupDownloadStrategy, EncryptionSettings},
event_handler::Ctx,
matrix_auth::MatrixSession,
reqwest,
room::{Messages, MessagesOptions, Room as MatrixRoom, RoomMember},
ruma::{
@@ -58,6 +59,7 @@ use matrix_sdk::{
typing::SyncTypingEvent,
AnyInitialStateEvent,
AnyMessageLikeEvent,
AnySyncStateEvent,
AnyTimelineEvent,
EmptyStateKey,
InitialStateEvent,
@@ -78,15 +80,15 @@ use matrix_sdk::{
},
Client,
ClientBuildError,
DisplayName,
Error as MatrixError,
RoomDisplayName,
RoomMemberships,
};
use modalkit::errors::UIError;
use modalkit::prelude::{EditInfo, InfoMessage};
use crate::base::Need;
use crate::base::MessageNeed;
use crate::notifications::register_notifications;
use crate::{
base::{
@@ -114,8 +116,7 @@ const IAMB_DEVICE_NAME: &str = "iamb";
const IAMB_USER_AGENT: &str = "iamb";
const MIN_MSG_LOAD: u32 = 50;
type MessageFetchResult =
IambResult<(Option<String>, Vec<(AnyMessageLikeEvent, Vec<OwnedUserId>)>)>;
type MessageFetchResult = IambResult<(Option<String>, Vec<(AnyTimelineEvent, Vec<OwnedUserId>)>)>;
fn initial_devname() -> String {
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
@@ -209,13 +210,13 @@ async fn update_event_receipts(info: &mut RoomInfo, room: &MatrixRoom, event_id:
};
for (user_id, _) in receipts {
info.set_receipt(user_id, event_id.to_owned());
info.set_receipt(ReceiptThread::Main, user_id, event_id.to_owned());
}
}
#[derive(Debug)]
enum Plan {
Messages(OwnedRoomId, Option<String>),
Messages(OwnedRoomId, Option<String>, Vec<MessageNeed>),
Members(OwnedRoomId),
}
@@ -224,8 +225,8 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec<Plan> {
let ChatStore { need_load, rooms, .. } = &mut locked.application;
let mut plan = Vec::with_capacity(need_load.rooms() * 2);
for (room_id, mut need) in std::mem::take(need_load).into_iter() {
if need.contains(Need::MESSAGES) {
for (room_id, need) in std::mem::take(need_load).into_iter() {
if let Some(message_need) = need.messages {
let info = rooms.get_or_default(room_id.clone());
if !info.recently_fetched() && !info.fetching {
@@ -238,16 +239,11 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec<Plan> {
RoomFetchStatus::NotStarted => None,
};
plan.push(Plan::Messages(room_id.to_owned(), fetch_id));
need.remove(Need::MESSAGES);
plan.push(Plan::Messages(room_id.to_owned(), fetch_id, message_need));
}
}
if need.contains(Need::MEMBERS) {
if need.members {
plan.push(Plan::Members(room_id.to_owned()));
need.remove(Need::MEMBERS);
}
if !need.is_empty() {
need_load.insert(room_id, need);
}
}
@@ -257,14 +253,14 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec<Plan> {
async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permits: &Semaphore) {
let permit = permits.acquire().await;
match plan {
Plan::Messages(room_id, fetch_id) => {
Plan::Messages(room_id, fetch_id, message_need) => {
let limit = MIN_MSG_LOAD;
let client = client.clone();
let store_clone = store.clone();
let res = load_older_one(&client, &room_id, fetch_id, limit).await;
let mut locked = store.lock().await;
load_insert(room_id, res, locked.deref_mut(), store_clone);
load_insert(room_id, res, locked.deref_mut(), store_clone, message_need);
},
Plan::Members(room_id) => {
let res = members_load(client, &room_id).await;
@@ -282,6 +278,9 @@ async fn load_older_one(
limit: u32,
) -> MessageFetchResult {
if let Some(room) = client.get_room(room_id) {
// Update cached encryption state. This is a noop if the state is already cached.
let _ = room.request_encryption_state().await;
let mut opts = match &fetch_id {
Some(id) => MessagesOptions::backward().from(id.as_str()),
None => MessagesOptions::backward(),
@@ -293,10 +292,8 @@ async fn load_older_one(
let mut msgs = vec![];
for ev in chunk.into_iter() {
let msg = match ev.event.deserialize() {
Ok(AnyTimelineEvent::MessageLike(msg)) => msg,
Ok(AnyTimelineEvent::State(_)) => continue,
Err(_) => continue,
let Ok(msg) = ev.into_raw().deserialize() else {
continue;
};
let event_id = msg.event_id();
@@ -311,6 +308,7 @@ async fn load_older_one(
},
};
let msg = msg.into_full_event(room_id.to_owned());
msgs.push((msg, receipts));
}
@@ -325,6 +323,7 @@ fn load_insert(
res: MessageFetchResult,
locked: &mut ProgramStore,
store: AsyncProgramStore,
message_needs: Vec<MessageNeed>,
) {
let ChatStore { presences, rooms, worker, picker, settings, .. } = &mut locked.application;
let info = rooms.get_or_default(room_id.clone());
@@ -338,37 +337,57 @@ fn load_insert(
let _ = presences.get_or_default(sender);
for user_id in receipts {
info.set_receipt(user_id, msg.event_id().to_owned());
info.set_receipt(ReceiptThread::Main, user_id, msg.event_id().to_owned());
}
match msg {
AnyMessageLikeEvent::RoomEncrypted(msg) => {
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomEncrypted(msg)) => {
info.insert_encrypted(msg);
},
AnyMessageLikeEvent::RoomMessage(msg) => {
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(msg)) => {
info.insert_with_preview(
room_id.clone(),
store.clone(),
*picker,
picker.clone(),
msg,
settings,
client.media(),
);
},
AnyMessageLikeEvent::Reaction(ev) => {
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::Reaction(ev)) => {
info.insert_reaction(ev);
},
_ => continue,
AnyTimelineEvent::MessageLike(_) => {
continue;
},
AnyTimelineEvent::State(msg) => {
if settings.tunables.state_event_display {
info.insert_any_state(msg.into());
}
},
}
}
info.fetch_id = fetch_id.map_or(RoomFetchStatus::Done, RoomFetchStatus::HaveMore);
// check if more are needed
let needs: Vec<_> = message_needs
.into_iter()
.filter(|need| !info.keys.contains_key(&need.event_id) && need.ttl > 0)
.map(|mut need| {
need.ttl -= 1;
need
})
.collect();
if !needs.is_empty() {
locked.application.need_load.need_messages_all(room_id, needs);
}
},
Err(e) => {
warn!(room_id = room_id.as_str(), err = e.to_string(), "Failed to load older messages");
// Wait and try again.
locked.application.need_load.insert(room_id, Need::MESSAGES);
locked.application.need_load.need_messages_all(room_id, message_needs);
},
}
}
@@ -440,7 +459,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
let mut dms = vec![];
for room in client.invited_rooms().into_iter() {
let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string();
let name = room.cached_display_name().unwrap_or(RoomDisplayName::Empty).to_string();
let tags = room.tags().await.unwrap_or_default();
names.push((room.room_id().to_owned(), name));
@@ -455,7 +474,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
}
for room in client.joined_rooms().into_iter() {
let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string();
let name = room.cached_display_name().unwrap_or(RoomDisplayName::Empty).to_string();
let tags = room.tags().await.unwrap_or_default();
names.push((room.room_id().to_owned(), name));
@@ -490,31 +509,36 @@ async fn refresh_rooms_forever(client: &Client, store: &AsyncProgramStore) {
async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
let mut interval = tokio::time::interval(Duration::from_secs(2));
let mut sent = HashMap::<OwnedRoomId, OwnedEventId>::default();
let mut sent: HashMap<OwnedRoomId, HashMap<ReceiptThread, OwnedEventId>> = Default::default();
loop {
interval.tick().await;
let locked = store.lock().await;
let user_id = &locked.application.settings.profile.user_id;
let updates = client
.joined_rooms()
.into_iter()
.filter_map(|room| {
let room_id = room.room_id().to_owned();
let info = locked.application.rooms.get(&room_id)?;
let new_receipt = info.get_receipt(user_id)?;
let old_receipt = sent.get(&room_id);
if Some(new_receipt) != old_receipt {
Some((room_id, new_receipt.clone()))
} else {
None
let mut locked = store.lock().await;
let ChatStore { settings, open_notifications, rooms, .. } = &mut locked.application;
let user_id = &settings.profile.user_id;
let mut updates = Vec::new();
for room in client.joined_rooms() {
let room_id = room.room_id();
let Some(info) = rooms.get(room_id) else {
continue;
};
let changed = info.receipts(user_id).filter_map(|(thread, new_receipt)| {
let old_receipt = sent.get(room_id).and_then(|ts| ts.get(thread));
let changed = Some(new_receipt) != old_receipt;
if changed {
open_notifications.remove(room_id);
}
})
.collect::<Vec<_>>();
changed.then(|| (room_id.to_owned(), thread.to_owned(), new_receipt.to_owned()))
});
updates.extend(changed);
}
drop(locked);
for (room_id, new_receipt) in updates {
for (room_id, thread, new_receipt) in updates {
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
let Some(room) = client.get_room(&room_id) else {
@@ -522,15 +546,11 @@ async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
};
match room
.send_single_receipt(
ReceiptType::Read,
ReceiptThread::Unthreaded,
new_receipt.clone(),
)
.send_single_receipt(ReceiptType::Read, thread.to_owned(), new_receipt.clone())
.await
{
Ok(()) => {
sent.insert(room_id, new_receipt);
sent.entry(room_id).or_default().insert(thread, new_receipt);
},
Err(e) => tracing::warn!(?room_id, "Failed to set read receipt: {e}"),
}
@@ -549,7 +569,7 @@ pub async fn do_first_sync(client: &Client, store: &AsyncProgramStore) -> Result
let mut filter = FilterDefinition::default();
filter.room = room_ev;
let settings = SyncSettings::new().filter(filter.into());
let settings = SyncSettings::new().filter(filter.into()).timeout(Duration::from_secs(0));
client.sync_once(settings).await?;
@@ -562,12 +582,12 @@ pub async fn do_first_sync(client: &Client, store: &AsyncProgramStore) -> Result
for room in sync_info.rooms.iter() {
let room_id = room.as_ref().0.room_id().to_owned();
need_load.insert(room_id, Need::MESSAGES);
need_load.need_messages(room_id);
}
for room in sync_info.dms.iter() {
let room_id = room.as_ref().0.room_id().to_owned();
need_load.insert(room_id, Need::MESSAGES);
need_load.need_messages(room_id);
}
Ok(())
@@ -603,7 +623,7 @@ fn oneshot<T>() -> (ClientReply<T>, ClientResponse<T>) {
return (reply, response);
}
pub type FetchedRoom = (MatrixRoom, DisplayName, Option<Tags>);
pub type FetchedRoom = (MatrixRoom, RoomDisplayName, Option<Tags>);
pub enum WorkerTask {
Init(AsyncProgramStore, ClientReply<()>),
@@ -700,7 +720,7 @@ async fn create_client_inner(
.build()
.unwrap();
let req_config = RequestConfig::new().timeout(req_timeout).retry_timeout(req_timeout);
let req_config = RequestConfig::new().timeout(req_timeout).max_retry_time(req_timeout);
// Set up the Matrix client for the selected profile.
let builder = Client::builder()
@@ -1001,7 +1021,7 @@ impl ClientWorker {
info.insert_with_preview(
room_id.to_owned(),
store.clone(),
*picker,
picker.clone(),
full_ev,
settings,
client.media(),
@@ -1043,14 +1063,32 @@ impl ClientWorker {
let Some(receipts) = receipts.get(&ReceiptType::Read) else {
continue;
};
for user_id in receipts.keys() {
info.set_receipt(user_id.to_owned(), event_id.clone());
for (user_id, rcpt) in receipts.iter() {
info.set_receipt(
rcpt.thread.clone(),
user_id.to_owned(),
event_id.clone(),
);
}
}
}
},
);
if self.settings.tunables.state_event_display {
let _ = self.client.add_event_handler(
|ev: AnySyncStateEvent, room: MatrixRoom, store: Ctx<AsyncProgramStore>| {
async move {
let room_id = room.room_id();
let mut locked = store.lock().await;
let info = locked.application.get_room_info(room_id.to_owned());
info.insert_any_state(ev);
}
},
);
}
let _ = self.client.add_event_handler(
|ev: OriginalSyncRoomRedactionEvent,
room: MatrixRoom,
@@ -1058,11 +1096,15 @@ impl ClientWorker {
async move {
let room_id = room.room_id();
let room_info = room.clone_info();
let room_version = room_info.room_version().unwrap_or(&RoomVersionId::V1);
let rules = &room_info
.room_version()
.and_then(RoomVersionId::rules)
.unwrap_or(RoomVersionId::V1.rules().unwrap())
.redaction;
let mut locked = store.lock().await;
let info = locked.application.get_room_info(room_id.to_owned());
info.redact(ev, room_version);
info.redact(ev, rules);
}
},
);
@@ -1076,11 +1118,12 @@ impl ClientWorker {
let room_id = room.room_id();
let user_id = ev.state_key;
let ambiguous_name =
ev.content.displayname.as_deref().unwrap_or_else(|| user_id.localpart());
let ambiguous_name = DisplayName::new(
ev.content.displayname.as_deref().unwrap_or_else(|| user_id.as_str()),
);
let ambiguous = client
.store()
.get_users_with_display_name(room_id, ambiguous_name)
.state_store()
.get_users_with_display_name(room_id, &ambiguous_name)
.await
.map(|users| users.len() > 1)
.unwrap_or_default();
@@ -1217,7 +1260,7 @@ impl ClientWorker {
let settings = self.settings.clone();
async move {
while !client.logged_in() {
while !client.is_active() {
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -1309,7 +1352,7 @@ impl ClientWorker {
// Remove the session.json file.
std::fs::remove_file(&self.settings.session_json)?;
Ok(Some(InfoMessage::from("Sucessfully logged out")))
Ok(Some(InfoMessage::from("Successfully logged out")))
}
async fn direct_message(&mut self, user: OwnedUserId) -> IambResult<OwnedRoomId> {
@@ -1346,7 +1389,7 @@ impl ClientWorker {
async fn get_room(&mut self, room_id: OwnedRoomId) -> IambResult<FetchedRoom> {
if let Some(room) = self.client.get_room(&room_id) {
let name = room.display_name().await.map_err(IambError::from)?;
let name = room.cached_display_name().ok_or_else(|| IambError::UnknownRoom(room_id))?;
let tags = room.tags().await.map_err(IambError::from)?;
Ok((room, name, tags))
@@ -1389,9 +1432,9 @@ impl ClientWorker {
req.limit = Some(1000u32.into());
req.max_depth = Some(1u32.into());
let resp = self.client.send(req, None).await.map_err(IambError::from)?;
let resp = self.client.send(req).await.map_err(IambError::from)?;
let rooms = resp.rooms.into_iter().map(|chunk| chunk.room_id).collect();
let rooms = resp.rooms.into_iter().map(|chunk| chunk.summary.room_id).collect();
Ok(rooms)
}