Compare commits

...

56 commits
latest ... main

Author SHA1 Message Date
1475d9ce50
feat: add test for new username_display config options 2025-07-25 02:07:41 +08:00
5029c6ee3b
feat: add regex option for username_display 2025-07-25 02:07:40 +08: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
28 changed files with 5115 additions and 1788 deletions

View file

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

View file

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

3600
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -53,7 +53,7 @@ user_id = "@user:example.com"
## Installation (via `crates.io`) ## 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 cargo install --locked iamb
@ -80,9 +80,27 @@ On FreeBSD a package is available from the official repositories. To install it
pkg install iamb 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 ### 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: repository. To install it simply run:
``` ```

View file

@ -54,7 +54,7 @@ version and quit.
View a list of joined rooms and direct messages. View a list of joined rooms and direct messages.
.It Sy ":dms" .It Sy ":dms"
View a list of direct messages. View a list of direct messages.
.It Sy ":logout" .It Sy ":logout [user id]"
Log out of Log out of
.Nm . .Nm .
.It Sy ":rooms" .It Sy ":rooms"
@ -63,6 +63,8 @@ View a list of joined rooms.
View a list of joined spaces. View a list of joined spaces.
.It Sy ":unreads" .It Sy ":unreads"
View a list of unread rooms. View a list of unread rooms.
.It Sy ":unreads clear"
Mark all rooms as read.
.It Sy ":welcome" .It Sy ":welcome"
View the startup Welcome window. View the startup Welcome window.
.El .El
@ -77,39 +79,54 @@ Import and decrypt keys from
.Pa path . .Pa path .
.It Sy ":verify" .It Sy ":verify"
View a list of ongoing E2EE verifications. 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 .El
.Sh "MESSAGE COMMANDS" .Sh "MESSAGE COMMANDS"
.Bl -tag -width Ds .Bl -tag -width Ds
.It Sy ":download" .It Sy ":download [path]"
Download an attachment from the selected message. 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" .It Sy ":edit"
Edit the selected message. Edit the selected message.
.It Sy ":editor" .It Sy ":editor"
Open an external Open an external
.Ev $EDITOR .Ev $EDITOR
to compose a message. to compose a message.
.It Sy ":open"
Download and then open an attachment, or open a link in a message.
.It Sy ":react [shortcode]" .It Sy ":react [shortcode]"
React to the selected message with an Emoji. 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]" .It Sy ":unreact [shortcode]"
Remove your reaction from the selected message. Remove your reaction from the selected message.
When no arguments are given, remove all of your reactions from the 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 ":upload [path]"
Upload an attachment and send it to the currently selected room. Upload an attachment and send it to the currently selected room.
.El .El
.Sh "ROOM COMMANDS" .Sh "ROOM COMMANDS"
.Bl -tag -width Ds .Bl -tag -width Ds
.It Sy ":create" .It Sy ":create [arguments]"
Create a new room. Create a new room. Arguments can be
.Dq ++alias=[alias] ,
.Dq ++public ,
.Dq ++space ,
and
.Dq ++encrypted .
.It Sy ":invite accept" .It Sy ":invite accept"
Accept an invitation to the currently focused room. Accept an invitation to the currently focused room.
.It Sy ":invite reject" .It Sy ":invite reject"
@ -117,7 +134,7 @@ Reject an invitation to the currently focused room.
.It Sy ":invite send [user]" .It Sy ":invite send [user]"
Send an invitation to a user to join the currently focused room. Send an invitation to a user to join the currently focused room.
.It Sy ":join [room]" .It Sy ":join [room]"
Join a room. Join a room or open it if you are already joined.
.It Sy ":leave" .It Sy ":leave"
Leave the currently focused room. Leave the currently focused room.
.It Sy ":members" .It Sy ":members"
@ -126,6 +143,10 @@ View a list of members of the currently focused room.
Set the name of the currently focused room. Set the name of the currently focused room.
.It Sy ":room name unset" .It Sy ":room name unset"
Unset the name of the currently focused room. 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]" .It Sy ":room notify set [level]"
Set a notification level for the currently focused room. Set a notification level for the currently focused room.
Valid levels are Valid levels are
@ -153,12 +174,16 @@ Remove a tag from the currently focused room.
Set the topic of the currently focused room. Set the topic of the currently focused room.
.It Sy ":room topic unset" .It Sy ":room topic unset"
Unset the topic of the currently focused room. 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]" .It Sy ":room alias set [alias]"
Create and point the given alias to the room. Create and point the given alias to the room.
.It Sy ":room alias unset [alias]" .It Sy ":room alias unset [alias]"
Delete the provided alias from the room's alternative alias list. Delete the provided alias from the room's alternative alias list.
.It Sy ":room alias show" .It Sy ":room alias show"
Show alternative aliases to the room, if any are set. 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]" .It Sy ":room canon set [alias]"
Set the room's canonical alias to the one provided, and make the previous one an alternative 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]" .It Sy ":room canon unset [alias]"
@ -173,6 +198,18 @@ Unban a user from this room with an optional reason.
Kick a user from this room with an optional reason. Kick a user from this room with an optional reason.
.El .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" .Sh "WINDOW COMMANDS"
.Bl -tag -width Ds .Bl -tag -width Ds
.It Sy ":horizontal [cmd]" .It Sy ":horizontal [cmd]"

View file

@ -173,6 +173,9 @@ respective shortcodes.
.It Sy message_user_color .It Sy message_user_color
Defines whether or not the message body is colored like the username. 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 .It Sy notifications
When this subsection is present, you can enable and configure push notifications. When this subsection is present, you can enable and configure push notifications.
See See
@ -208,6 +211,9 @@ See
.Sx "SORTING LISTS" .Sx "SORTING LISTS"
for more details. for more details.
.It Sy state_event_display
Defines whether the state events like joined or left are shown.
.It Sy typing_notice_send .It Sy typing_notice_send
Defines whether or not the typing state is sent. 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. Specify the width of the column where usernames are displayed in a room.
Usernames that are too long are truncated. Usernames that are too long are truncated.
Defaults to 30. Defaults to 30.
.It Sy tabstop
Number of spaces that a <Tab> counts for.
Defaults to 4.
.El .El
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support) .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 Setting this field to
.Dq Sy bell .Dq Sy bell
will use the terminal bell instead. will use the terminal bell instead.
Both can be used via
.Dq Sy desktop|bell .
.It Sy show_message .It Sy show_message
controls whether to show the message in the desktop notification, and defaults to controls whether to show the message in the desktop notification, and defaults to
@ -332,9 +344,29 @@ window.
Defaults to Defaults to
.Sy ["power",\ "id"] . .Sy ["power",\ "id"] .
.El .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 .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 .Bd -literal -offset indent
[settings.sort] [settings.sort]
members = ["server", "localpart"] members = ["server", "localpart"]

View file

@ -1,12 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<component type="console-application"> <component type="console-application">
<id>iamb</id> <id>chat.iamb.iamb</id>
<name>iamb</name> <name>iamb</name>
<summary>A terminal Matrix client for Vim addicts</summary> <summary>A terminal Matrix client for Vim addicts</summary>
<url type="homepage">https://iamb.chat</url> <url type="homepage">https://iamb.chat</url>
<releases> <releases>
<release version="0.0.10" date="2024-08-20"/>
<release version="0.0.9" date="2024-03-28"/> <release version="0.0.9" date="2024-03-28"/>
</releases> </releases>
@ -14,6 +15,7 @@
<name>Ulyssa</name> <name>Ulyssa</name>
</developer> </developer>
<developer_name>Ulyssa</developer_name>
<metadata_license>CC-BY-SA-4.0</metadata_license> <metadata_license>CC-BY-SA-4.0</metadata_license>
<project_license>Apache-2.0</project_license> <project_license>Apache-2.0</project_license>
@ -23,8 +25,8 @@
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image>https://iamb.chat/static/images/iamb-demo.gif</image> <image>https://iamb.chat/static/images/metainfo-screenshot.png</image>
<caption>Example conversation within iamb</caption> <caption>Example screenshot of room and lists of rooms, spaces and members within iamb</caption>
</screenshot> </screenshot>
</screenshots> </screenshots>
@ -37,7 +39,6 @@
</p> </p>
</description> </description>
<icon type="remote">https://iamb.chat/images/iamb.svg</icon>
<launchable type="desktop-id">iamb.desktop</launchable> <launchable type="desktop-id">iamb.desktop</launchable>
<categories> <categories>

58
flake.lock generated
View file

@ -5,29 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1709126324, "lastModified": 1731533236,
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "d465f4819400de7c8d874d50b982301f28a84605", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"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",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -38,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1709703039, "lastModified": 1736883708,
"narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=", "narHash": "sha256-uQ+NQ0/xYU0N1CnXsa2zghgNaOPxWpMJXSUJJ9W7140=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d", "rev": "eb62e6aa39ea67e0b8018ba8ea077efe65807dc8",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -54,11 +36,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1706487304, "lastModified": 1736320768,
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=", "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac", "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -77,15 +59,14 @@
}, },
"rust-overlay": { "rust-overlay": {
"inputs": { "inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1709863839, "lastModified": 1736994333,
"narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=", "narHash": "sha256-v4Jrok5yXsZ6dwj2+2uo5cSyUi9fBTurHqHvNHLT1XA=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a", "rev": "848db855cb9e88785996e961951659570fc58814",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -108,21 +89,6 @@
"repo": "default", "repo": "default",
"type": "github" "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", "root": "root",

View file

@ -14,7 +14,7 @@
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly. # We only need the nightly overlay in the devShell because .rs files are formatted with nightly.
overlays = [ (import rust-overlay) ]; overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; }; pkgs = import nixpkgs { inherit system overlays; };
rustNightly = pkgs.rust-bin.nightly."2024-03-08".default; rustNightly = pkgs.rust-bin.nightly."2024-12-12".default;
in in
with pkgs; with pkgs;
{ {
@ -27,7 +27,7 @@
}; };
nativeBuildInputs = [ pkg-config ]; nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
(with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa]); (with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa ]);
}; };
devShell = mkShell { devShell = mkShell {
@ -38,6 +38,7 @@
pkg-config pkg-config
cargo-tarpaulin cargo-tarpaulin
cargo-watch cargo-watch
sqlite
]; ];
}; };
}); });

3
rust-toolchain.toml Normal file
View file

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

View file

@ -12,6 +12,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use emojis::Emoji; use emojis::Emoji;
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Rect}, layout::{Alignment, Rect},
@ -47,6 +48,7 @@ use matrix_sdk::{
}, },
room::redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent}, room::redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent},
tag::{TagName, Tags}, tag::{TagName, Tags},
AnySyncStateEvent,
MessageLikeEvent, MessageLikeEvent,
}, },
presence::PresenceState, presence::PresenceState,
@ -72,7 +74,7 @@ use modalkit::{
ApplicationStore, ApplicationStore,
ApplicationWindowId, ApplicationWindowId,
}, },
completion::{complete_path, CompletionMap}, completion::{complete_path, Completer, CompletionMap},
context::EditContext, context::EditContext,
cursor::Cursor, cursor::Cursor,
rope::EditRope, rope::EditRope,
@ -90,6 +92,7 @@ use modalkit::{
use crate::config::ImagePreviewProtocolValues; use crate::config::ImagePreviewProtocolValues;
use crate::message::ImageStatus; use crate::message::ImageStatus;
use crate::notifications::NotificationHandle;
use crate::preview::{source_from_event, spawn_insert_preview}; use crate::preview::{source_from_event, spawn_insert_preview};
use crate::{ use crate::{
message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages}, message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages},
@ -177,6 +180,19 @@ pub enum MessageAction {
Unreact(Option<String>, bool), 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. /// The type of room being created.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum CreateRoomType { pub enum CreateRoomType {
@ -243,6 +259,9 @@ pub enum SortFieldRoom {
/// Sort rooms by the timestamps of their most recent messages. /// Sort rooms by the timestamps of their most recent messages.
Recent, Recent,
/// Sort rooms by whether they are invites.
Invite,
} }
/// Fields that users can be sorted by. /// Fields that users can be sorted by.
@ -277,7 +296,7 @@ impl<'de> Deserialize<'de> for SortColumn<SortFieldRoom> {
/// [serde] visitor for deserializing [SortColumn] for rooms and spaces. /// [serde] visitor for deserializing [SortColumn] for rooms and spaces.
struct SortRoomVisitor; struct SortRoomVisitor;
impl<'de> Visitor<'de> for SortRoomVisitor { impl Visitor<'_> for SortRoomVisitor {
type Value = SortColumn<SortFieldRoom>; type Value = SortColumn<SortFieldRoom>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -307,6 +326,7 @@ impl<'de> Visitor<'de> for SortRoomVisitor {
"name" => SortFieldRoom::Name, "name" => SortFieldRoom::Name,
"alias" => SortFieldRoom::Alias, "alias" => SortFieldRoom::Alias,
"id" => SortFieldRoom::RoomId, "id" => SortFieldRoom::RoomId,
"invite" => SortFieldRoom::Invite,
_ => { _ => {
let msg = format!("Unknown sort field: {value:?}"); let msg = format!("Unknown sort field: {value:?}");
return Err(E::custom(msg)); return Err(E::custom(msg));
@ -329,7 +349,7 @@ impl<'de> Deserialize<'de> for SortColumn<SortFieldUser> {
/// [serde] visitor for deserializing [SortColumn] for users. /// [serde] visitor for deserializing [SortColumn] for users.
struct SortUserVisitor; struct SortUserVisitor;
impl<'de> Visitor<'de> for SortUserVisitor { impl Visitor<'_> for SortUserVisitor {
type Value = SortColumn<SortFieldUser>; type Value = SortColumn<SortFieldUser>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -375,6 +395,9 @@ pub enum RoomField {
/// The room name. /// The room name.
Name, Name,
/// The room id.
Id,
/// A room tag. /// A room tag.
Tag(TagName), Tag(TagName),
@ -493,6 +516,9 @@ pub enum IambAction {
/// Perform an action on the currently selected message. /// Perform an action on the currently selected message.
Message(MessageAction), Message(MessageAction),
/// Perform an action on the current space.
Space(SpaceAction),
/// Open a URL. /// Open a URL.
OpenLink(String), OpenLink(String),
@ -534,6 +560,12 @@ impl From<MessageAction> for IambAction {
} }
} }
impl From<SpaceAction> for IambAction {
fn from(act: SpaceAction) -> Self {
IambAction::Space(act)
}
}
impl From<RoomAction> for IambAction { impl From<RoomAction> for IambAction {
fn from(act: RoomAction) -> Self { fn from(act: RoomAction) -> Self {
IambAction::Room(act) IambAction::Room(act)
@ -553,6 +585,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Break, IambAction::Homeserver(..) => SequenceStatus::Break,
IambAction::Keys(..) => SequenceStatus::Break, IambAction::Keys(..) => SequenceStatus::Break,
IambAction::Message(..) => SequenceStatus::Break, IambAction::Message(..) => SequenceStatus::Break,
IambAction::Space(..) => SequenceStatus::Break,
IambAction::Room(..) => SequenceStatus::Break, IambAction::Room(..) => SequenceStatus::Break,
IambAction::OpenLink(..) => SequenceStatus::Break, IambAction::OpenLink(..) => SequenceStatus::Break,
IambAction::Send(..) => SequenceStatus::Break, IambAction::Send(..) => SequenceStatus::Break,
@ -568,6 +601,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Atom, IambAction::Homeserver(..) => SequenceStatus::Atom,
IambAction::Keys(..) => SequenceStatus::Atom, IambAction::Keys(..) => SequenceStatus::Atom,
IambAction::Message(..) => SequenceStatus::Atom, IambAction::Message(..) => SequenceStatus::Atom,
IambAction::Space(..) => SequenceStatus::Atom,
IambAction::OpenLink(..) => SequenceStatus::Atom, IambAction::OpenLink(..) => SequenceStatus::Atom,
IambAction::Room(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom,
IambAction::Send(..) => SequenceStatus::Atom, IambAction::Send(..) => SequenceStatus::Atom,
@ -583,6 +617,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Ignore, IambAction::Homeserver(..) => SequenceStatus::Ignore,
IambAction::Keys(..) => SequenceStatus::Ignore, IambAction::Keys(..) => SequenceStatus::Ignore,
IambAction::Message(..) => SequenceStatus::Ignore, IambAction::Message(..) => SequenceStatus::Ignore,
IambAction::Space(..) => SequenceStatus::Ignore,
IambAction::Room(..) => SequenceStatus::Ignore, IambAction::Room(..) => SequenceStatus::Ignore,
IambAction::OpenLink(..) => SequenceStatus::Ignore, IambAction::OpenLink(..) => SequenceStatus::Ignore,
IambAction::Send(..) => SequenceStatus::Ignore, IambAction::Send(..) => SequenceStatus::Ignore,
@ -597,6 +632,7 @@ impl ApplicationAction for IambAction {
IambAction::ClearUnreads => false, IambAction::ClearUnreads => false,
IambAction::Homeserver(..) => false, IambAction::Homeserver(..) => false,
IambAction::Message(..) => false, IambAction::Message(..) => false,
IambAction::Space(..) => false,
IambAction::Room(..) => false, IambAction::Room(..) => false,
IambAction::Keys(..) => false, IambAction::Keys(..) => false,
IambAction::Send(..) => false, IambAction::Send(..) => false,
@ -614,6 +650,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 { impl From<IambAction> for ProgramAction {
fn from(act: IambAction) -> Self { fn from(act: IambAction) -> Self {
Action::Application(act) Action::Application(act)
@ -709,10 +751,22 @@ pub enum IambError {
#[error("Current window is not a room or space")] #[error("Current window is not a room or space")]
NoSelectedRoomOrSpace, 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. /// A failure due to not having a room selected.
#[error("Current window is not a room")] #[error("Current window is not a room")]
NoSelectedRoom, 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. /// A failure due to not having an outstanding room invitation.
#[error("You do not have a current invitation to this room")] #[error("You do not have a current invitation to this room")]
NotInvited, NotInvited,
@ -785,6 +839,9 @@ pub enum EventLocation {
/// The [EventId] belongs to a reaction to the given event. /// The [EventId] belongs to a reaction to the given event.
Reaction(OwnedEventId), Reaction(OwnedEventId),
/// The [EventId] belongs to a state event in the main timeline of the room.
State(MessageKey),
} }
impl EventLocation { impl EventLocation {
@ -814,7 +871,6 @@ impl UnreadInfo {
} }
/// Information about room's the user's joined. /// Information about room's the user's joined.
#[derive(Default)]
pub struct RoomInfo { pub struct RoomInfo {
/// The display name for this room. /// The display name for this room.
pub name: Option<String>, pub name: Option<String>,
@ -829,15 +885,13 @@ pub struct RoomInfo {
messages: Messages, messages: Messages,
/// A map of read markers to display on different events. /// 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. /// 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 /// 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. /// 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. /// A map of message identifiers to a map of reaction events.
pub reactions: HashMap<OwnedEventId, MessageReactions>, pub reactions: HashMap<OwnedEventId, MessageReactions>,
@ -863,6 +917,28 @@ pub struct RoomInfo {
pub draw_last: Option<Instant>, 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 { impl RoomInfo {
pub fn get_thread(&self, root: Option<&EventId>) -> Option<&Messages> { pub fn get_thread(&self, root: Option<&EventId>) -> Option<&Messages> {
if let Some(thread_root) = root { if let Some(thread_root) = root {
@ -874,7 +950,9 @@ impl RoomInfo {
pub fn get_thread_mut(&mut self, root: Option<OwnedEventId>) -> &mut Messages { pub fn get_thread_mut(&mut self, root: Option<OwnedEventId>) -> &mut Messages {
if let Some(thread_root) = root { 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 { } else {
&mut self.messages &mut self.messages
} }
@ -952,6 +1030,12 @@ impl RoomInfo {
match self.keys.get(redacts) { match self.keys.get(redacts) {
None => return, None => return,
Some(EventLocation::State(key)) => {
if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, room_version);
}
},
Some(EventLocation::Message(None, key)) => { Some(EventLocation::Message(None, key)) => {
if let Some(msg) = self.messages.get_mut(key) { if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev); let ev = SyncRoomRedactionEvent::Original(ev);
@ -1008,7 +1092,9 @@ impl RoomInfo {
}; };
let source = if let Some(thread) = thread { 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 { } else {
&mut self.messages &mut self.messages
}; };
@ -1025,6 +1111,7 @@ impl RoomInfo {
content.apply_replacement(new_msgtype); content.apply_replacement(new_msgtype);
}, },
MessageEvent::Redacted(_) | MessageEvent::Redacted(_) |
MessageEvent::State(_) |
MessageEvent::EncryptedOriginal(_) | MessageEvent::EncryptedOriginal(_) |
MessageEvent::EncryptedRedacted(_) => { MessageEvent::EncryptedRedacted(_) => {
return; return;
@ -1034,16 +1121,32 @@ impl RoomInfo {
msg.html = msg.event.html(); 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. /// Indicates whether this room has unread messages.
pub fn unreads(&self, settings: &ApplicationSettings) -> UnreadInfo { pub fn unreads(&self, settings: &ApplicationSettings) -> UnreadInfo {
let last_message = self.messages.last_key_value(); 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));
match (last_message, last_receipt) { match (last_message, last_receipt) {
(Some(((ts, recent), _)), Some(last_read)) => { (Some(((ts, recent), _)), Some(last_read)) => {
UnreadInfo { unread: last_read != recent, latest: Some(*ts) } UnreadInfo { unread: last_read != recent, latest: Some(*ts) }
}, },
(Some(((ts, _), _)), None) => UnreadInfo { unread: false, 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) }
},
(None, _) => UnreadInfo::default(), (None, _) => UnreadInfo::default(),
} }
} }
@ -1071,7 +1174,10 @@ impl RoomInfo {
let event_id = msg.event_id().to_owned(); let event_id = msg.event_id().to_owned();
let key = (msg.origin_server_ts().into(), event_id.clone()); 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()); let loc = EventLocation::Message(Some(thread_root), key.clone());
self.keys.insert(event_id, loc); self.keys.insert(event_id, loc);
replies.insert_message(key, msg); replies.insert_message(key, msg);
@ -1131,40 +1237,73 @@ impl RoomInfo {
/// Indicates whether we've recently fetched scrollback for this room. /// Indicates whether we've recently fetched scrollback for this room.
pub fn recently_fetched(&self) -> bool { 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<()> { fn clear_receipt(&mut self, thread: &ReceiptThread, user_id: &OwnedUserId) -> Option<()> {
let old_event_id = self.user_receipts.get(user_id)?; let old_event_id =
let old_receipts = self.event_receipts.get_mut(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); old_receipts.remove(user_id);
if old_receipts.is_empty() { 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 None
} }
pub fn set_receipt(&mut self, user_id: OwnedUserId, event_id: OwnedEventId) { pub fn set_receipt(
self.clear_receipt(&user_id); &mut self,
thread: ReceiptThread,
user_id: OwnedUserId,
event_id: OwnedEventId,
) {
self.clear_receipt(&thread, &user_id);
self.event_receipts self.event_receipts
.entry(thread.clone())
.or_default()
.entry(event_id.clone()) .entry(event_id.clone())
.or_default() .or_default()
.insert(user_id.clone()); .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 { let Some(((_, event_id), _)) = self.messages.last_key_value() else {
return; 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> { pub fn receipts<'a>(
self.user_receipts.get(user_id) &'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] { fn get_typers(&self) -> &[OwnedUserId] {
@ -1223,7 +1362,9 @@ impl RoomInfo {
} }
if !settings.tunables.typing_notice_display { 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); let top = Rect::new(area.x, area.y, area.width, area.height - 1);
@ -1268,7 +1409,7 @@ fn emoji_map() -> CompletionMap<String, &'static Emoji> {
#[cfg(unix)] #[cfg(unix)]
fn picker_from_termios(protocol_type: Option<ProtocolType>) -> Option<Picker> { 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, Ok(picker) => picker,
Err(e) => { Err(e) => {
tracing::error!("Failed to setup image previews: {e}"); tracing::error!("Failed to setup image previews: {e}");
@ -1277,9 +1418,7 @@ fn picker_from_termios(protocol_type: Option<ProtocolType>) -> Option<Picker> {
}; };
if let Some(protocol_type) = protocol_type { if let Some(protocol_type) = protocol_type {
picker.protocol_type = protocol_type; picker.set_protocol_type(protocol_type);
} else {
picker.guess_protocol();
} }
Some(picker) Some(picker)
@ -1302,8 +1441,8 @@ fn picker_from_settings(settings: &ApplicationSettings) -> Option<Picker> {
}) = image_preview_protocol }) = image_preview_protocol
{ {
// User forced type and font_size: use that. // User forced type and font_size: use that.
let mut picker = Picker::new(font_size); let mut picker = Picker::from_fontsize(font_size);
picker.protocol_type = protocol_type; picker.set_protocol_type(protocol_type);
Some(picker) Some(picker)
} else { } else {
// Guess, but use type if forced. // Guess, but use type if forced.
@ -1417,6 +1556,12 @@ pub struct ChatStore {
/// Whether the application is currently focused /// Whether the application is currently focused
pub focused: bool, 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 { impl ChatStore {
@ -1431,6 +1576,7 @@ impl ChatStore {
cmds: crate::commands::setup_commands(), cmds: crate::commands::setup_commands(),
emojis: emoji_map(), emojis: emoji_map(),
collator: Default::default(),
names: Default::default(), names: Default::default(),
rooms: Default::default(), rooms: Default::default(),
presences: Default::default(), presences: Default::default(),
@ -1440,6 +1586,7 @@ impl ChatStore {
draw_curr: None, draw_curr: None,
ring_bell: false, ring_bell: false,
focused: true, focused: true,
open_notifications: Default::default(),
} }
} }
@ -1560,7 +1707,7 @@ impl<'de> Deserialize<'de> for IambId {
/// [serde] visitor for deserializing [IambId]. /// [serde] visitor for deserializing [IambId].
struct IambIdVisitor; struct IambIdVisitor;
impl<'de> Visitor<'de> for IambIdVisitor { impl Visitor<'_> for IambIdVisitor {
type Value = IambId; type Value = IambId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -1697,6 +1844,13 @@ impl RoomFocus {
pub fn is_msgbar(&self) -> bool { pub fn is_msgbar(&self) -> bool {
matches!(self, RoomFocus::MessageBar) 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. /// Identifiers used to track where a mark was placed.
@ -1765,11 +1919,20 @@ impl ApplicationInfo for IambInfo {
type WindowId = IambId; type WindowId = IambId;
type ContentId = IambBufferId; type ContentId = IambBufferId;
fn content_of_command(ct: CommandType) -> IambBufferId {
IambBufferId::Command(ct)
}
}
pub struct IambCompleter;
impl Completer<IambInfo> for IambCompleter {
fn complete( fn complete(
&mut self,
text: &EditRope, text: &EditRope,
cursor: &mut Cursor, cursor: &mut Cursor,
content: &IambBufferId, content: &IambBufferId,
store: &mut ProgramStore, store: &mut ChatStore,
) -> Vec<String> { ) -> Vec<String> {
match content { match content {
IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store), IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store),
@ -1787,21 +1950,16 @@ impl ApplicationInfo for IambInfo {
IambBufferId::UnreadList => vec![], IambBufferId::UnreadList => vec![],
} }
} }
fn content_of_command(ct: CommandType) -> IambBufferId {
IambBufferId::Command(ct)
}
} }
/// Tab completion for user IDs. /// 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 let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD) .get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty); .unwrap_or_else(EditRope::empty);
let id = Cow::from(&id); let id = Cow::from(&id);
store store
.application
.presences .presences
.complete(id.as_ref()) .complete(id.as_ref())
.into_iter() .into_iter()
@ -1810,7 +1968,7 @@ fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) ->
} }
/// Tab completion within the message bar. /// 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 let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD) .get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty); .unwrap_or_else(EditRope::empty);
@ -1819,13 +1977,12 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
match id.chars().next() { match id.chars().next() {
// Complete room aliases. // Complete room aliases.
Some('#') => { Some('#') => {
return store.application.names.complete(id.as_ref()); return store.names.complete(id.as_ref());
}, },
// Complete room identifiers. // Complete room identifiers.
Some('!') => { Some('!') => {
return store return store
.application
.rooms .rooms
.complete(id.as_ref()) .complete(id.as_ref())
.into_iter() .into_iter()
@ -1835,7 +1992,7 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
// Complete Emoji shortcodes. // Complete Emoji shortcodes.
Some(':') => { Some(':') => {
let list = store.application.emojis.complete(&id[1..]); let list = store.emojis.complete(&id[1..]);
let iter = list.into_iter().take(200).map(|s| format!(":{}:", s)); let iter = list.into_iter().take(200).map(|s| format!(":{}:", s));
return iter.collect(); return iter.collect();
@ -1844,7 +2001,6 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
// Complete usernames for @ and empty strings. // Complete usernames for @ and empty strings.
Some('@') | None => { Some('@') | None => {
return store return store
.application
.presences .presences
.complete(id.as_ref()) .complete(id.as_ref())
.into_iter() .into_iter()
@ -1858,28 +2014,23 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -
} }
/// Tab completion for Matrix identifiers (usernames, room aliases, etc.) /// Tab completion for Matrix identifiers (usernames, room aliases, etc.)
fn complete_matrix_names( fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
let id = text let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD) .get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty); .unwrap_or_else(EditRope::empty);
let id = Cow::from(&id); 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() { if !list.is_empty() {
return list; return list;
} }
let list = store.application.presences.complete(id.as_ref()); let list = store.presences.complete(id.as_ref());
if !list.is_empty() { if !list.is_empty() {
return list.into_iter().map(|i| i.to_string()).collect(); return list.into_iter().map(|i| i.to_string()).collect();
} }
store store
.application
.rooms .rooms
.complete(id.as_ref()) .complete(id.as_ref())
.into_iter() .into_iter()
@ -1888,12 +2039,12 @@ fn complete_matrix_names(
} }
/// Tab completion for Emoji shortcode 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 = text.get_prefix_word_mut(cursor, &WordStyle::Little);
let sc = sc.unwrap_or_else(EditRope::empty); let sc = sc.unwrap_or_else(EditRope::empty);
let sc = Cow::from(&sc); let sc = Cow::from(&sc);
store.application.emojis.complete(sc.as_ref()) store.emojis.complete(sc.as_ref())
} }
/// Tab completion for command names. /// Tab completion for command names.
@ -1901,11 +2052,11 @@ fn complete_cmdname(
desc: CommandDescription, desc: CommandDescription,
text: &EditRope, text: &EditRope,
cursor: &mut Cursor, cursor: &mut Cursor,
store: &ProgramStore, store: &ChatStore,
) -> Vec<String> { ) -> Vec<String> {
// Complete command name and set cursor position. // Complete command name and set cursor position.
let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little); 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. /// Tab completion for command arguments.
@ -1913,9 +2064,9 @@ fn complete_cmdarg(
desc: CommandDescription, desc: CommandDescription,
text: &EditRope, text: &EditRope,
cursor: &mut Cursor, cursor: &mut Cursor,
store: &ProgramStore, store: &ChatStore,
) -> Vec<String> { ) -> 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, Ok(cmd) => cmd,
Err(_) => return vec![], Err(_) => return vec![],
}; };
@ -1938,12 +2089,7 @@ fn complete_cmdarg(
} }
/// Tab completion for commands. /// Tab completion for commands.
fn complete_cmd( fn complete_cmd(cmd: &str, text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
cmd: &str,
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
match CommandDescription::from_str(cmd) { match CommandDescription::from_str(cmd) {
Ok(desc) => { Ok(desc) => {
if desc.arg.untrimmed.is_empty() { if desc.arg.untrimmed.is_empty() {
@ -1960,7 +2106,7 @@ fn complete_cmd(
} }
/// Tab completion for the command bar. /// 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 eo = text.cursor_to_offset(cursor);
let slice = text.slice(..eo); let slice = text.slice(..eo);
let cow = Cow::from(&slice); let cow = Cow::from(&slice);
@ -2140,6 +2286,7 @@ pub mod tests {
#[tokio::test] #[tokio::test]
async fn test_complete_msgbar() { async fn test_complete_msgbar() {
let store = mock_store().await; let store = mock_store().await;
let store = store.application;
let text = EditRope::from("going for a walk :walk "); let text = EditRope::from("going for a walk :walk ");
let mut cursor = Cursor::new(0, 22); let mut cursor = Cursor::new(0, 22);
@ -2163,6 +2310,7 @@ pub mod tests {
#[tokio::test] #[tokio::test]
async fn test_complete_cmdbar() { async fn test_complete_cmdbar() {
let store = mock_store().await; let store = mock_store().await;
let store = store.application;
let users = vec![ let users = vec![
"@user1:example.com", "@user1:example.com",
"@user2: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 //! 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. //! [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::{ use modalkit::{
commands::{CommandError, CommandResult, CommandStep}, commands::{CommandError, CommandResult, CommandStep},
@ -27,6 +27,7 @@ use crate::base::{
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
SpaceAction,
VerifyAction, VerifyAction,
}; };
@ -475,10 +476,18 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(), ("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(),
("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument), ("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> // :room tag set <tag-name>
("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(), ("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(),
("tag", "set", None) => return Result::Err(CommandError::InvalidArgument), ("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> // :room notify set <notification-level>
("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(), ("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(),
("notify", "set", None) => return Result::Err(CommandError::InvalidArgument), ("notify", "set", None) => return Result::Err(CommandError::InvalidArgument),
@ -491,10 +500,6 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(), ("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(),
("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), ("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 // :room aliases show
("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(), ("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(),
("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), ("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
@ -531,6 +536,91 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument) 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), _ => return Result::Err(CommandError::InvalidArgument),
}; };
@ -667,6 +757,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
f: iamb_rooms, f: iamb_rooms,
}); });
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room }); 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 { cmds.add_command(ProgramCommand {
name: "spaces".into(), name: "spaces".into(),
aliases: vec![], aliases: vec![],
@ -721,7 +816,7 @@ pub fn setup_commands() -> ProgramCommands {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use matrix_sdk::ruma::user_id; use matrix_sdk::ruma::{room_id, user_id};
use modalkit::actions::WindowAction; use modalkit::actions::WindowAction;
use modalkit::editing::context::EditContext; use modalkit::editing::context::EditContext;
@ -1047,22 +1142,119 @@ mod tests {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
let ctx = EditContext::default(); let ctx = EditContext::default();
let cmd = format!("room notify set mute"); let cmd = "room notify set mute";
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::NotificationMode, "mute".into()); let act = RoomAction::Set(RoomField::NotificationMode, "mute".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = format!("room notify unset"); let cmd = "room notify unset";
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::NotificationMode); let act = RoomAction::Unset(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]); assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = format!("room notify show"); let cmd = "room notify show";
let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Show(RoomField::NotificationMode); let act = RoomAction::Show(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]); 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] #[test]
fn test_cmd_invite() { fn test_cmd_invite() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();

View file

@ -1,16 +1,17 @@
//! # Logic for loading and validating application configuration //! # Logic for loading and validating application configuration
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fmt; use std::fmt;
use std::fs::File; use std::fs::File;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::io::{BufReader, BufWriter}; use std::io::{BufReader, BufWriter, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process; use std::process;
use clap::Parser; 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 matrix_sdk::ruma::{OwnedDeviceId, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId};
use ratatui::style::{Color, Modifier as StyleModifier, Style}; use ratatui::style::{Color, Modifier as StyleModifier, Style};
use ratatui::text::Span; use ratatui::text::Span;
@ -45,8 +46,9 @@ const DEFAULT_MEMBERS_SORT: [SortColumn<SortFieldUser>; 2] = [
SortColumn(SortFieldUser::UserId, SortOrder::Ascending), 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::Favorite, SortOrder::Ascending),
SortColumn(SortFieldRoom::Invite, SortOrder::Ascending),
SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending), SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending),
SortColumn(SortFieldRoom::Unread, SortOrder::Ascending), SortColumn(SortFieldRoom::Unread, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending), SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
@ -97,14 +99,14 @@ fn validate_profile_name(name: &str) -> bool {
let mut chars = name.chars(); 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; return false;
} }
name.chars().all(is_profile_char) 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() { for name in names.keys() {
if validate_profile_name(name.as_str()) { if validate_profile_name(name.as_str()) {
continue; continue;
@ -151,7 +153,7 @@ pub enum ConfigError {
pub struct Keys(pub Vec<TerminalKey>, pub String); pub struct Keys(pub Vec<TerminalKey>, pub String);
pub struct KeysVisitor; pub struct KeysVisitor;
impl<'de> Visitor<'de> for KeysVisitor { impl Visitor<'_> for KeysVisitor {
type Value = Keys; type Value = Keys;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 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 VimModes(pub Vec<VimMode>);
pub struct VimModesVisitor; pub struct VimModesVisitor;
impl<'de> Visitor<'de> for VimModesVisitor { impl Visitor<'_> for VimModesVisitor {
type Value = VimModes; type Value = VimModes;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 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; type Value = LogLevel;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 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 UserColor(pub Color);
pub struct UserColorVisitor; pub struct UserColorVisitor;
impl<'de> Visitor<'de> for UserColorVisitor { impl Visitor<'_> for UserColorVisitor {
type Value = UserColor; type Value = UserColor;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -321,7 +323,7 @@ pub struct Session {
impl From<Session> for MatrixSession { impl From<Session> for MatrixSession {
fn from(session: Session) -> Self { fn from(session: Session) -> Self {
MatrixSession { MatrixSession {
tokens: matrix_sdk::matrix_auth::MatrixSessionTokens { tokens: matrix_sdk::authentication::matrix::MatrixSessionTokens {
access_token: session.access_token, access_token: session.access_token,
refresh_token: session.refresh_token, refresh_token: session.refresh_token,
}, },
@ -352,29 +354,31 @@ pub struct UserDisplayTunables {
pub type UserOverrides = HashMap<OwnedUserId, UserDisplayTunables>; pub type UserOverrides = HashMap<OwnedUserId, UserDisplayTunables>;
fn merge_sorts(a: SortOverrides, b: SortOverrides) -> SortOverrides { fn merge_sorts(profile: SortOverrides, global: SortOverrides) -> SortOverrides {
SortOverrides { SortOverrides {
chats: b.chats.or(a.chats), chats: profile.chats.or(global.chats),
dms: b.dms.or(a.dms), dms: profile.dms.or(global.dms),
rooms: b.rooms.or(a.rooms), rooms: profile.rooms.or(global.rooms),
spaces: b.spaces.or(a.spaces), spaces: profile.spaces.or(global.spaces),
members: b.members.or(a.members), 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 where
K: Eq + Hash, K: Eq + Hash,
{ {
match (a, b) { match (global, profile) {
(Some(a), None) => Some(a), (Some(m), None) | (None, Some(m)) => Some(m),
(None, Some(b)) => Some(b), (Some(mut global), Some(profile)) => {
(Some(mut a), Some(b)) => { for (k, v) in profile {
for (k, v) in b { global.insert(k, v);
a.insert(k, v);
} }
Some(a) Some(global)
}, },
(None, None) => None, (None, None) => None,
} }
@ -396,28 +400,84 @@ pub enum UserDisplayStyle {
// it can wind up being the Matrix username if there are display name collisions in the room, // it can wind up being the Matrix username if there are display name collisions in the room,
// in order to avoid any confusion. // in order to avoid any confusion.
DisplayName, DisplayName,
// Acts like Username, except when the username matches given regex, then acts like DisplayName
Regex,
} }
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")] pub struct NotifyVia {
pub enum NotifyVia {
/// Deliver notifications via terminal bell. /// Deliver notifications via terminal bell.
Bell, pub bell: bool,
/// Deliver notifications via desktop mechanism. /// Deliver notifications via desktop mechanism.
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
Desktop, pub desktop: bool,
} }
pub struct NotifyViaVisitor;
impl Default for NotifyVia { impl Default for NotifyVia {
fn default() -> Self { fn default() -> Self {
#[cfg(not(feature = "desktop"))] Self {
return NotifyVia::Bell; bell: cfg!(not(feature = "desktop")),
#[cfg(feature = "desktop")]
#[cfg(feature = "desktop")] desktop: true,
return NotifyVia::Desktop; }
} }
} }
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)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct Notifications { pub struct Notifications {
#[serde(default)] #[serde(default)]
@ -501,29 +561,35 @@ impl SortOverrides {
pub struct TunableValues { pub struct TunableValues {
pub log_level: Level, pub log_level: Level,
pub message_shortcode_display: bool, pub message_shortcode_display: bool,
pub normal_after_send: bool,
pub reaction_display: bool, pub reaction_display: bool,
pub reaction_shortcode_display: bool, pub reaction_shortcode_display: bool,
pub read_receipt_send: bool, pub read_receipt_send: bool,
pub read_receipt_display: bool, pub read_receipt_display: bool,
pub request_timeout: u64, pub request_timeout: u64,
pub sort: SortValues, pub sort: SortValues,
pub state_event_display: bool,
pub typing_notice_send: bool, pub typing_notice_send: bool,
pub typing_notice_display: bool, pub typing_notice_display: bool,
pub users: UserOverrides, pub users: UserOverrides,
pub username_display: UserDisplayStyle, pub username_display: UserDisplayStyle,
pub username_display_regex: Option<String>,
pub message_user_color: bool, pub message_user_color: bool,
pub default_room: Option<String>, pub default_room: Option<String>,
pub open_command: Option<Vec<String>>, pub open_command: Option<Vec<String>>,
pub mouse: Mouse,
pub notifications: Notifications, pub notifications: Notifications,
pub image_preview: Option<ImagePreviewValues>, pub image_preview: Option<ImagePreviewValues>,
pub user_gutter_width: usize, pub user_gutter_width: usize,
pub external_edit_file_suffix: String, pub external_edit_file_suffix: String,
pub tabstop: usize,
} }
#[derive(Clone, Default, Deserialize)] #[derive(Clone, Default, Deserialize)]
pub struct Tunables { pub struct Tunables {
pub log_level: Option<LogLevel>, pub log_level: Option<LogLevel>,
pub message_shortcode_display: Option<bool>, pub message_shortcode_display: Option<bool>,
pub normal_after_send: Option<bool>,
pub reaction_display: Option<bool>, pub reaction_display: Option<bool>,
pub reaction_shortcode_display: Option<bool>, pub reaction_shortcode_display: Option<bool>,
pub read_receipt_send: Option<bool>, pub read_receipt_send: Option<bool>,
@ -531,17 +597,21 @@ pub struct Tunables {
pub request_timeout: Option<u64>, pub request_timeout: Option<u64>,
#[serde(default)] #[serde(default)]
pub sort: SortOverrides, pub sort: SortOverrides,
pub state_event_display: Option<bool>,
pub typing_notice_send: Option<bool>, pub typing_notice_send: Option<bool>,
pub typing_notice_display: Option<bool>, pub typing_notice_display: Option<bool>,
pub users: Option<UserOverrides>, pub users: Option<UserOverrides>,
pub username_display: Option<UserDisplayStyle>, pub username_display: Option<UserDisplayStyle>,
pub username_display_regex: Option<String>,
pub message_user_color: Option<bool>, pub message_user_color: Option<bool>,
pub default_room: Option<String>, pub default_room: Option<String>,
pub open_command: Option<Vec<String>>, pub open_command: Option<Vec<String>>,
pub mouse: Option<Mouse>,
pub notifications: Option<Notifications>, pub notifications: Option<Notifications>,
pub image_preview: Option<ImagePreview>, pub image_preview: Option<ImagePreview>,
pub user_gutter_width: Option<usize>, pub user_gutter_width: Option<usize>,
pub external_edit_file_suffix: Option<String>, pub external_edit_file_suffix: Option<String>,
pub tabstop: Option<usize>,
} }
impl Tunables { impl Tunables {
@ -551,6 +621,7 @@ impl Tunables {
message_shortcode_display: self message_shortcode_display: self
.message_shortcode_display .message_shortcode_display
.or(other.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_display: self.reaction_display.or(other.reaction_display),
reaction_shortcode_display: self reaction_shortcode_display: self
.reaction_shortcode_display .reaction_shortcode_display
@ -559,19 +630,23 @@ impl Tunables {
read_receipt_display: self.read_receipt_display.or(other.read_receipt_display), read_receipt_display: self.read_receipt_display.or(other.read_receipt_display),
request_timeout: self.request_timeout.or(other.request_timeout), request_timeout: self.request_timeout.or(other.request_timeout),
sort: merge_sorts(self.sort, other.sort), 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_send: self.typing_notice_send.or(other.typing_notice_send),
typing_notice_display: self.typing_notice_display.or(other.typing_notice_display), typing_notice_display: self.typing_notice_display.or(other.typing_notice_display),
users: merge_maps(self.users, other.users), users: merge_maps(self.users, other.users),
username_display: self.username_display.or(other.username_display), username_display: self.username_display.or(other.username_display),
username_display_regex: self.username_display_regex.or(other.username_display_regex),
message_user_color: self.message_user_color.or(other.message_user_color), message_user_color: self.message_user_color.or(other.message_user_color),
default_room: self.default_room.or(other.default_room), default_room: self.default_room.or(other.default_room),
open_command: self.open_command.or(other.open_command), open_command: self.open_command.or(other.open_command),
mouse: self.mouse.or(other.mouse),
notifications: self.notifications.or(other.notifications), notifications: self.notifications.or(other.notifications),
image_preview: self.image_preview.or(other.image_preview), image_preview: self.image_preview.or(other.image_preview),
user_gutter_width: self.user_gutter_width.or(other.user_gutter_width), user_gutter_width: self.user_gutter_width.or(other.user_gutter_width),
external_edit_file_suffix: self external_edit_file_suffix: self
.external_edit_file_suffix .external_edit_file_suffix
.or(other.external_edit_file_suffix), .or(other.external_edit_file_suffix),
tabstop: self.tabstop.or(other.tabstop),
} }
} }
@ -579,25 +654,30 @@ impl Tunables {
TunableValues { TunableValues {
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO), log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
message_shortcode_display: self.message_shortcode_display.unwrap_or(false), 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_display: self.reaction_display.unwrap_or(true),
reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false), reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false),
read_receipt_send: self.read_receipt_send.unwrap_or(true), read_receipt_send: self.read_receipt_send.unwrap_or(true),
read_receipt_display: self.read_receipt_display.unwrap_or(true), read_receipt_display: self.read_receipt_display.unwrap_or(true),
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT), request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT),
sort: self.sort.values(), 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_send: self.typing_notice_send.unwrap_or(true),
typing_notice_display: self.typing_notice_display.unwrap_or(true), typing_notice_display: self.typing_notice_display.unwrap_or(true),
users: self.users.unwrap_or_default(), users: self.users.unwrap_or_default(),
username_display: self.username_display.unwrap_or_default(), username_display: self.username_display.unwrap_or_default(),
username_display_regex: self.username_display_regex,
message_user_color: self.message_user_color.unwrap_or(false), message_user_color: self.message_user_color.unwrap_or(false),
default_room: self.default_room, default_room: self.default_room,
open_command: self.open_command, open_command: self.open_command,
mouse: self.mouse.unwrap_or_default(),
notifications: self.notifications.unwrap_or_default(), notifications: self.notifications.unwrap_or_default(),
image_preview: self.image_preview.map(ImagePreview::values), image_preview: self.image_preview.map(ImagePreview::values),
user_gutter_width: self.user_gutter_width.unwrap_or(30), user_gutter_width: self.user_gutter_width.unwrap_or(30),
external_edit_file_suffix: self external_edit_file_suffix: self
.external_edit_file_suffix .external_edit_file_suffix
.unwrap_or_else(|| ".md".to_string()), .unwrap_or_else(|| ".md".to_string()),
tabstop: self.tabstop.unwrap_or(4),
} }
} }
} }
@ -729,7 +809,7 @@ pub struct ProfileConfig {
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
pub struct IambConfig { pub struct IambConfig {
pub profiles: HashMap<String, ProfileConfig>, pub profiles: BTreeMap<String, ProfileConfig>,
pub default_profile: Option<String>, pub default_profile: Option<String>,
pub settings: Option<Tunables>, pub settings: Option<Tunables>,
pub dirs: Option<Directories>, pub dirs: Option<Directories>,
@ -769,14 +849,22 @@ pub struct ApplicationSettings {
} }
impl 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>> { 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(|| { let mut config_dir = cli
usage!( .config_directory
"No user configuration directory found;\ .or_else(Self::get_xdg_config_home)
please specify one via -C.\n\n .or_else(dirs::config_dir)
For more information try '--help'" .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"); config_dir.push("iamb");
let config_json = config_dir.join("config.json"); let config_json = config_dir.join("config.json");
@ -816,14 +904,36 @@ impl ApplicationSettings {
} else if profiles.len() == 1 { } else if profiles.len() == 1 {
profiles.into_iter().next().unwrap() profiles.into_iter().next().unwrap()
} else { } else {
usage!( loop {
"No profile specified. \ println!("\nNo profile specified. Available profiles:");
Please use -P or add \"default_profile\" to your configuration.\n\n\ profiles
For more information try '--help'", .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 layout = profile.layout.take().or(layout).unwrap_or_default();
let tunables = global.unwrap_or_default(); let tunables = global.unwrap_or_default();
@ -898,7 +1008,7 @@ impl ApplicationSettings {
Ok(()) 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 let (color, c) = self
.tunables .tunables
.users .users
@ -958,6 +1068,20 @@ impl ApplicationSettings {
Cow::Borrowed(user_id.as_str()) Cow::Borrowed(user_id.as_str())
} }
}, },
(None, UserDisplayStyle::Regex) => {
let re = regex::Regex::new(
&self.tunables.username_display_regex.clone().unwrap_or("*".into()),
)
.unwrap();
if !re.is_match(user_id.as_str()) {
Cow::Borrowed(user_id.as_str())
} else if let Some(display) = info.display_names.get(user_id) {
Cow::Borrowed(display.as_str())
} else {
Cow::Borrowed(user_id.as_str())
}
},
}; };
Span::styled(name, style) Span::styled(name, style)
@ -1022,10 +1146,10 @@ mod tests {
assert_eq!(res, Some(b.clone())); assert_eq!(res, Some(b.clone()));
let res = merge_maps(Some(b.clone()), Some(c.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())); let res = merge_maps(Some(c.clone()), Some(b.clone()));
assert_eq!(res, Some(b.clone())); assert_eq!(res, Some(c.clone()));
} }
#[test] #[test]
@ -1074,6 +1198,13 @@ mod tests {
let res: Tunables = let res: Tunables =
serde_json::from_str("{\"username_display\": \"displayname\"}").unwrap(); serde_json::from_str("{\"username_display\": \"displayname\"}").unwrap();
assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName)); assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName));
let res: Tunables = serde_json::from_str(
"{\"username_display\": \"regex\",\n\"username_display_regex\": \"foo\"}",
)
.unwrap();
assert_eq!(res.username_display, Some(UserDisplayStyle::Regex));
assert_eq!(res.username_display_regex.unwrap_or("FAILED".into()), "foo".to_string());
} }
#[test] #[test]
@ -1189,6 +1320,29 @@ mod tests {
assert_eq!(run, &exp); 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] #[test]
fn test_load_example_config_toml() { fn test_load_example_config_toml() {
let path = PathBuf::from("config.example.toml"); let path = PathBuf::from("config.example.toml");

View file

@ -44,11 +44,14 @@ use modalkit::crossterm::{
read, read,
DisableBracketedPaste, DisableBracketedPaste,
DisableFocusChange, DisableFocusChange,
DisableMouseCapture,
EnableBracketedPaste, EnableBracketedPaste,
EnableFocusChange, EnableFocusChange,
EnableMouseCapture,
Event, Event,
KeyEventKind, KeyEventKind,
KeyboardEnhancementFlags, KeyboardEnhancementFlags,
MouseEventKind,
PopKeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
}, },
@ -59,7 +62,7 @@ use modalkit::crossterm::{
use ratatui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Modifier, Style},
text::Span, text::Span,
widgets::Paragraph, widgets::Paragraph,
Terminal, Terminal,
@ -86,6 +89,7 @@ use crate::{
ChatStore, ChatStore,
HomeserverAction, HomeserverAction,
IambAction, IambAction,
IambCompleter,
IambError, IambError,
IambId, IambId,
IambInfo, IambInfo,
@ -310,7 +314,7 @@ impl Application {
} }
term.draw(|f| { term.draw(|f| {
let area = f.size(); let area = f.area();
let modestr = bindings.show_mode(); let modestr = bindings.show_mode();
let cursor = bindings.get_cursor_indicator(); let cursor = bindings.get_cursor_indicator();
@ -324,6 +328,9 @@ impl Application {
.show_dialog(dialogstr) .show_dialog(dialogstr)
.show_mode(modestr) .show_mode(modestr)
.borders(true) .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); .focus(focused);
f.render_stateful_widget(screen, area, sstate); f.render_stateful_widget(screen, area, sstate);
@ -339,7 +346,7 @@ impl Application {
let inner = Rect::new(cx, cy, 1, 1); let inner = Rect::new(cx, cy, 1, 1);
f.render_widget(para, inner) 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()); return Ok(ke.into());
}, },
Event::Mouse(_) => { Event::Mouse(me) => {
// Do nothing for now. 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 => { Event::FocusGained => {
let mut store = self.store.lock().await; let mut store = self.store.lock().await;
@ -504,7 +533,7 @@ impl Application {
}, },
// Unimplemented. // Unimplemented.
Action::KeywordLookup => { Action::KeywordLookup(_) => {
// XXX: implement // XXX: implement
None None
}, },
@ -532,9 +561,12 @@ impl Application {
IambAction::ClearUnreads => { IambAction::ClearUnreads => {
let user_id = &store.application.settings.profile.user_id; 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() { for room_id in store.application.sync_info.chats() {
if let Some(room) = store.application.rooms.get_mut(room_id) { 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) => { IambAction::Message(act) => {
self.screen.current_window_mut()?.message_command(act, ctx, store).await? 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) => { IambAction::Room(act) => {
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?; let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
self.action_prepend(acts); self.action_prepend(acts);
@ -564,6 +599,9 @@ impl Application {
None None
}, },
IambAction::Send(act) => { 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? self.screen.current_window_mut()?.send_command(act, ctx, store).await?
}, },
@ -847,7 +885,7 @@ async fn check_import_keys(
let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) { let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) {
Ok(encrypted) => encrypted, Ok(encrypted) => encrypted,
Err(e) => { Err(e) => {
format!("* Failed to encrypt room keys during export: {e}"); println!("* Failed to encrypt room keys during export: {e}");
process::exit(2); process::exit(2);
}, },
}; };
@ -929,8 +967,8 @@ async fn login_normal(
} }
/// Set up the terminal for drawing the TUI, and getting additional info. /// Set up the terminal for drawing the TUI, and getting additional info.
fn setup_tty(title: &str, enable_enhanced_keys: bool) -> std::io::Result<()> { fn setup_tty(settings: &ApplicationSettings, enable_enhanced_keys: bool) -> std::io::Result<()> {
let title = format!("iamb ({})", title); let title = format!("iamb ({})", settings.profile.user_id.as_str());
// Enable raw mode and enter the alternate screen. // Enable raw mode and enter the alternate screen.
crossterm::terminal::enable_raw_mode()?; crossterm::terminal::enable_raw_mode()?;
@ -944,15 +982,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)) crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title))
} }
// Do our best to reverse what we did in setup_tty() when we exit or crash. // 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 { if enable_enhanced_keys {
let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags); let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags);
} }
if enable_mouse {
let _ = crossterm::queue!(stdout(), DisableMouseCapture);
}
let _ = crossterm::execute!( let _ = crossterm::execute!(
stdout(), stdout(),
DisableBracketedPaste, DisableBracketedPaste,
@ -975,7 +1021,9 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
// Set up the async worker thread and global store. // Set up the async worker thread and global store.
let worker = ClientWorker::spawn(client.clone(), settings.clone()).await; let worker = ClientWorker::spawn(client.clone(), settings.clone()).await;
let store = ChatStore::new(worker.clone(), settings.clone()); 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)); let store = Arc::new(AsyncMutex::new(store));
worker.init(store.clone()); worker.init(store.clone());
@ -1006,11 +1054,12 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
false 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 orig_hook = std::panic::take_hook();
let enable_mouse = settings.tunables.mouse.enabled;
std::panic::set_hook(Box::new(move |panic_info| { 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); orig_hook(panic_info);
process::exit(1); process::exit(1);
})); }));
@ -1020,7 +1069,7 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
application.run().await?; application.run().await?;
// Clean up the terminal on exit. // Clean up the terminal on exit.
restore_tty(enable_enhanced_keys); restore_tty(enable_enhanced_keys, enable_mouse);
Ok(()) 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 //! 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. //! 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 std::ops::Deref;
use css_color_parser::Color as CssColor; use css_color_parser::Color as CssColor;
use markup5ever_rcdom::{Handle, NodeData, RcDom}; use markup5ever_rcdom::{Handle, NodeData, RcDom};
use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use url::Url; use url::Url;
@ -34,10 +36,13 @@ use ratatui::{
}; };
use crate::{ use crate::{
config::ApplicationSettings,
message::printer::TextPrinter, message::printer::TextPrinter,
util::{join_cell_text, space_text}, util::{join_cell_text, space_text},
}; };
const QUOTE_COLOR: Color = Color::Indexed(236);
/// Generate bullet points from a [ListStyle]. /// Generate bullet points from a [ListStyle].
pub struct BulletIterator { pub struct BulletIterator {
style: ListStyle, 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 mut text = Text::default();
let columns = self.columns(); let columns = self.columns();
let cell_total = width.saturating_sub(columns).saturating_sub(1); let cell_total = width.saturating_sub(columns).saturating_sub(1);
@ -167,7 +177,7 @@ impl Table {
if let Some(caption) = &self.caption { if let Some(caption) = &self.caption {
let subw = width.saturating_sub(6); let subw = width.saturating_sub(6);
let mut printer = 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); caption.print(&mut printer, style);
for mut line in printer.finish().lines { for mut line in printer.finish().lines {
@ -214,7 +224,7 @@ impl Table {
CellType::Data => style, CellType::Data => style,
}; };
cell.to_text(*w, style, emoji_shortcodes) cell.to_text(*w, style, settings)
} else { } else {
space_text(*w, style) space_text(*w, style)
}; };
@ -271,13 +281,22 @@ pub enum StyleTreeNode {
Ruler, Ruler,
Style(Box<StyleTreeNode>, Style), Style(Box<StyleTreeNode>, Style),
Table(Table), Table(Table),
Text(String), Text(Cow<'static, str>),
Sequence(StyleTreeChildren), Sequence(StyleTreeChildren),
RoomAlias(OwnedRoomAliasId),
RoomId(OwnedRoomId),
UserId(OwnedUserId),
DisplayName(String, OwnedUserId),
} }
impl StyleTreeNode { impl StyleTreeNode {
pub fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text { pub fn to_text<'a>(
let mut printer = TextPrinter::new(width, style, true, emoji_shortcodes); &'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); self.print(&mut printer, style);
printer.finish() printer.finish()
} }
@ -312,6 +331,12 @@ impl StyleTreeNode {
StyleTreeNode::Ruler => {}, StyleTreeNode::Ruler => {},
StyleTreeNode::Text(_) => {}, StyleTreeNode::Text(_) => {},
StyleTreeNode::Break => {}, 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); printer.push_span_nobreak(span);
}, },
StyleTreeNode::Blockquote(child) => { StyleTreeNode::Blockquote(child) => {
let mut subp = printer.sub(4); let mut subp = printer.sub(3);
child.print(&mut subp, style); child.print(&mut subp, style);
for mut line in subp.finish() { 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); printer.push_line(line);
} }
}, },
@ -430,14 +458,14 @@ impl StyleTreeNode {
} }
}, },
StyleTreeNode::Table(table) => { 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); printer.push_text(text);
}, },
StyleTreeNode::Break => { StyleTreeNode::Break => {
printer.push_break(); printer.push_break();
}, },
StyleTreeNode::Text(s) => { 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)), StyleTreeNode::Style(child, patch) => child.print(printer, style.patch(*patch)),
@ -446,13 +474,30 @@ impl StyleTreeNode {
child.print(printer, style); 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. /// A processed HTML document.
pub struct StyleTree { pub struct StyleTree {
children: StyleTreeChildren, pub(super) children: StyleTreeChildren,
} }
impl StyleTree { impl StyleTree {
@ -466,14 +511,14 @@ impl StyleTree {
return links; return links;
} }
pub fn to_text( pub fn to_text<'a>(
&self, &'a self,
width: usize, width: usize,
style: Style, style: Style,
hide_reply: bool, hide_reply: bool,
emoji_shortcodes: bool, settings: &'a ApplicationSettings,
) -> Text<'_> { ) -> Text<'a> {
let mut printer = TextPrinter::new(width, style, hide_reply, emoji_shortcodes); let mut printer = TextPrinter::new(width, style, hide_reply, settings);
for child in self.children.iter() { for child in self.children.iter() {
child.print(&mut printer, style); child.print(&mut printer, style);
@ -484,11 +529,11 @@ impl StyleTree {
} }
pub struct TreeGenState { pub struct TreeGenState {
link_num: u8, pub link_num: u8,
} }
impl TreeGenState { impl TreeGenState {
fn next_link_char(&mut self) -> Option<char> { pub fn next_link_char(&mut self) -> Option<char> {
let num = self.link_num; let num = self.link_num;
if num < 62 { if num < 62 {
@ -661,7 +706,7 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
let tree = match &node.data { let tree = match &node.data {
NodeData::Document => *c2t(node.children.borrow().as_slice(), state), 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, .. } => { NodeData::Element { name, attrs, .. } => {
match name.local.as_ref() { match name.local.as_ref() {
// Message that this one replies to. // Message that this one replies to.
@ -708,7 +753,7 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
StyleTreeNode::Style(c, s) StyleTreeNode::Style(c, s)
}, },
"del" | "strike" => { "del" | "s" | "strike" => {
let c = c2t(&node.children.borrow(), state); let c = c2t(&node.children.borrow(), state);
let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT); let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT);
@ -811,17 +856,19 @@ pub fn parse_matrix_html(s: &str) -> StyleTree {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use super::*; use super::*;
use crate::tests::mock_settings;
use crate::util::space_span; use crate::util::space_span;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
#[test] #[test]
fn test_header() { fn test_header() {
let settings = mock_settings();
let bold = Style::default().add_modifier(StyleModifier::BOLD); let bold = Style::default().add_modifier(StyleModifier::BOLD);
let s = "<h1>Header 1</h1>"; let s = "<h1>Header 1</h1>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold), Span::styled("#", bold),
Span::styled(" ", bold), Span::styled(" ", bold),
@ -833,7 +880,7 @@ pub mod tests {
let s = "<h2>Header 2</h2>"; let s = "<h2>Header 2</h2>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold), Span::styled("#", bold),
Span::styled("#", bold), Span::styled("#", bold),
@ -846,7 +893,7 @@ pub mod tests {
let s = "<h3>Header 3</h3>"; let s = "<h3>Header 3</h3>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold), Span::styled("#", bold),
Span::styled("#", bold), Span::styled("#", bold),
@ -860,7 +907,7 @@ pub mod tests {
let s = "<h4>Header 4</h4>"; let s = "<h4>Header 4</h4>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold), Span::styled("#", bold),
Span::styled("#", bold), Span::styled("#", bold),
@ -875,7 +922,7 @@ pub mod tests {
let s = "<h5>Header 5</h5>"; let s = "<h5>Header 5</h5>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold), Span::styled("#", bold),
Span::styled("#", bold), Span::styled("#", bold),
@ -891,7 +938,7 @@ pub mod tests {
let s = "<h6>Header 6</h6>"; let s = "<h6>Header 6</h6>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("#", bold), Span::styled("#", bold),
Span::styled("#", bold), Span::styled("#", bold),
@ -909,6 +956,7 @@ pub mod tests {
#[test] #[test]
fn test_style() { fn test_style() {
let settings = mock_settings();
let def = Style::default(); let def = Style::default();
let bold = def.add_modifier(StyleModifier::BOLD); let bold = def.add_modifier(StyleModifier::BOLD);
let italic = def.add_modifier(StyleModifier::ITALIC); let italic = def.add_modifier(StyleModifier::ITALIC);
@ -918,7 +966,7 @@ pub mod tests {
let s = "<b>Bold!</b>"; let s = "<b>Bold!</b>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Bold", bold), Span::styled("Bold", bold),
Span::styled("!", bold), Span::styled("!", bold),
@ -927,7 +975,7 @@ pub mod tests {
let s = "<strong>Bold!</strong>"; let s = "<strong>Bold!</strong>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Bold", bold), Span::styled("Bold", bold),
Span::styled("!", bold), Span::styled("!", bold),
@ -936,7 +984,7 @@ pub mod tests {
let s = "<i>Italic!</i>"; let s = "<i>Italic!</i>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Italic", italic), Span::styled("Italic", italic),
Span::styled("!", italic), Span::styled("!", italic),
@ -945,7 +993,7 @@ pub mod tests {
let s = "<em>Italic!</em>"; let s = "<em>Italic!</em>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Italic", italic), Span::styled("Italic", italic),
Span::styled("!", italic), Span::styled("!", italic),
@ -954,7 +1002,7 @@ pub mod tests {
let s = "<del>Strikethrough!</del>"; let s = "<del>Strikethrough!</del>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Strikethrough", strike), Span::styled("Strikethrough", strike),
Span::styled("!", strike), Span::styled("!", strike),
@ -963,7 +1011,7 @@ pub mod tests {
let s = "<strike>Strikethrough!</strike>"; let s = "<strike>Strikethrough!</strike>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Strikethrough", strike), Span::styled("Strikethrough", strike),
Span::styled("!", strike), Span::styled("!", strike),
@ -972,7 +1020,7 @@ pub mod tests {
let s = "<u>Underline!</u>"; let s = "<u>Underline!</u>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Underline", underl), Span::styled("Underline", underl),
Span::styled("!", underl), Span::styled("!", underl),
@ -981,7 +1029,7 @@ pub mod tests {
let s = "<font color=\"#ff0000\">Red!</u>"; let s = "<font color=\"#ff0000\">Red!</u>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Red", red), Span::styled("Red", red),
Span::styled("!", red), Span::styled("!", red),
@ -990,7 +1038,7 @@ pub mod tests {
let s = "<font color=\"red\">Red!</u>"; let s = "<font color=\"red\">Red!</u>";
let tree = parse_matrix_html(s); 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::styled("Red", red), Span::styled("Red", red),
Span::styled("!", red), Span::styled("!", red),
@ -1000,9 +1048,10 @@ pub mod tests {
#[test] #[test]
fn test_paragraph() { fn test_paragraph() {
let settings = mock_settings();
let s = "<p>Hello world!</p><p>Content</p><p>Goodbye world!</p>"; let s = "<p>Hello world!</p><p>Content</p><p>Goodbye world!</p>";
let tree = parse_matrix_html(s); 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.len(), 7);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
@ -1027,25 +1076,42 @@ pub mod tests {
#[test] #[test]
fn test_blockquote() { fn test_blockquote() {
let settings = mock_settings();
let s = "<blockquote>Hello world!</blockquote>"; let s = "<blockquote>Hello world!</blockquote>";
let tree = parse_matrix_html(s); 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.len(), 2);
assert_eq!( assert_eq!(
text.lines[0], 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!( assert_eq!(
text.lines[1], 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] #[test]
fn test_list_unordered() { 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 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 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.len(), 6);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
@ -1105,9 +1171,10 @@ pub mod tests {
#[test] #[test]
fn test_list_ordered() { 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 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 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.len(), 6);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
@ -1167,6 +1234,7 @@ pub mod tests {
#[test] #[test]
fn test_table() { fn test_table() {
let settings = mock_settings();
let s = "<table>\ let s = "<table>\
<thead>\ <thead>\
<tr><th>Column 1</th><th>Column 2</th><th>Column 3</th></tr> <tr><th>Column 1</th><th>Column 2</th><th>Column 3</th></tr>
@ -1177,7 +1245,7 @@ pub mod tests {
<tr><td>a</td><td>b</td><td>c</td></tr>\ <tr><td>a</td><td>b</td><td>c</td></tr>\
</tbody></table>"; </tbody></table>";
let tree = parse_matrix_html(s); 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); let bold = Style::default().add_modifier(StyleModifier::BOLD);
assert_eq!(text.lines.len(), 11); assert_eq!(text.lines.len(), 11);
@ -1267,10 +1335,11 @@ pub mod tests {
#[test] #[test]
fn test_matrix_reply() { fn test_matrix_reply() {
let settings = mock_settings();
let s = "<mx-reply>This was replied to</mx-reply>This is the reply"; let s = "<mx-reply>This was replied to</mx-reply>This is the reply";
let tree = parse_matrix_html(s); 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.len(), 4);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
@ -1307,7 +1376,7 @@ pub mod tests {
); );
let tree = parse_matrix_html(s); 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.len(), 2);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
@ -1332,9 +1401,10 @@ pub mod tests {
#[test] #[test]
fn test_self_closing() { fn test_self_closing() {
let settings = mock_settings();
let s = "Hello<br>World<br>Goodbye"; let s = "Hello<br>World<br>Goodbye";
let tree = parse_matrix_html(s); 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.len(), 3);
assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),])); 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(" "),])); assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),]));
@ -1343,9 +1413,10 @@ pub mod tests {
#[test] #[test]
fn test_embedded_newline() { fn test_embedded_newline() {
let settings = mock_settings();
let s = "<p>Hello\nWorld</p>"; let s = "<p>Hello\nWorld</p>";
let tree = parse_matrix_html(s); 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.len(), 1);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
@ -1360,16 +1431,18 @@ pub mod tests {
#[test] #[test]
fn test_pre_tag() { fn test_pre_tag() {
let settings = mock_settings();
let s = concat!( let s = concat!(
"<pre><code class=\"language-rust\">", "<pre><code class=\"language-rust\">",
"fn hello() -&gt; usize {\n", "fn hello() -&gt; usize {\n",
" \t// weired\n",
" return 5;\n", " return 5;\n",
"}\n", "}\n",
"</code></pre>\n" "</code></pre>\n"
); );
let tree = parse_matrix_html(s); let tree = parse_matrix_html(s);
let text = tree.to_text(25, Style::default(), true, false); let text = tree.to_text(25, Style::default(), true, &settings);
assert_eq!(text.lines.len(), 5); assert_eq!(text.lines.len(), 6);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
Line::from(vec![ Line::from(vec![
@ -1400,6 +1473,20 @@ pub mod tests {
); );
assert_eq!( assert_eq!(
text.lines[2], 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![ Line::from(vec![
Span::raw(line::VERTICAL), Span::raw(line::VERTICAL),
Span::raw(" "), Span::raw(" "),
@ -1412,7 +1499,7 @@ pub mod tests {
]) ])
); );
assert_eq!( assert_eq!(
text.lines[3], text.lines[4],
Line::from(vec![ Line::from(vec![
Span::raw(line::VERTICAL), Span::raw(line::VERTICAL),
Span::raw("}"), Span::raw("}"),
@ -1421,7 +1508,7 @@ pub mod tests {
]) ])
); );
assert_eq!( assert_eq!(
text.lines[4], text.lines[5],
Line::from(vec![ Line::from(vec![
Span::raw(line::BOTTOM_LEFT), Span::raw(line::BOTTOM_LEFT),
Span::raw(line::HORIZONTAL.repeat(23)), Span::raw(line::HORIZONTAL.repeat(23)),
@ -1432,6 +1519,11 @@ pub mod tests {
#[test] #[test]
fn test_emoji_shortcodes() { 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"] { for shortcode in ["exploding_head", "polar_bear", "canada"] {
let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str(); let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str();
let emoji_width = UnicodeWidthStr::width(emoji); let emoji_width = UnicodeWidthStr::width(emoji);
@ -1440,13 +1532,13 @@ pub mod tests {
let s = format!("<p>{emoji}</p>"); let s = format!("<p>{emoji}</p>");
let tree = parse_matrix_html(s.as_str()); let tree = parse_matrix_html(s.as_str());
// Test with emojis_shortcodes set to false // 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::raw(emoji), Span::raw(emoji),
space_span(20 - emoji_width, Style::default()), space_span(20 - emoji_width, Style::default()),
]),]); ]),]);
// Test with emojis_shortcodes set to true // 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![ assert_eq!(text.lines, vec![Line::from(vec![
Span::raw(replacement.as_str()), Span::raw(replacement.as_str()),
space_span(20 - replacement_width, Style::default()), space_span(20 - replacement_width, Style::default()),

View file

@ -2,15 +2,15 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::cmp::{Ord, Ordering, PartialOrd}; use std::cmp::{Ord, Ordering, PartialOrd};
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::hash_set;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::{TryFrom, TryInto}; use std::convert::{TryFrom, TryInto};
use std::fmt::{self, Display}; use std::fmt::{self, Display};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::ops::{Deref, DerefMut}; 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 humansize::{format_size, DECIMAL};
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use serde_json::json; use serde_json::json;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@ -35,6 +35,7 @@ use matrix_sdk::ruma::{
}, },
redaction::SyncRoomRedactionEvent, redaction::SyncRoomRedactionEvent,
}, },
AnySyncStateEvent,
RedactContent, RedactContent,
RedactedUnsigned, RedactedUnsigned,
}, },
@ -67,13 +68,17 @@ use crate::{
mod compose; mod compose;
mod html; mod html;
mod printer; mod printer;
mod state;
pub use self::compose::text_to_message; 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); pub type MessageKey = (MessageTimeStamp, OwnedEventId);
#[derive(Default)] pub struct Messages(BTreeMap<MessageKey, Message>, pub ReceiptThread);
pub struct Messages(BTreeMap<MessageKey, Message>);
impl Deref for Messages { impl Deref for Messages {
type Target = BTreeMap<MessageKey, Message>; type Target = BTreeMap<MessageKey, Message>;
@ -90,6 +95,18 @@ impl DerefMut for Messages {
} }
impl 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>) { pub fn insert_message(&mut self, key: MessageKey, msg: impl Into<Message>) {
let event_id = key.1.clone(); let event_id = key.1.clone();
let msg = msg.into(); let msg = msg.into();
@ -160,7 +177,9 @@ fn placeholder_frame(
} }
let mut placeholder = "\u{230c}".to_string(); let mut placeholder = "\u{230c}".to_string();
placeholder.push_str(&" ".repeat(width - 2)); 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 *height > 2 {
if let Some(text) = text { if let Some(text) = text {
if text.width() <= width - 2 { if text.width() <= width - 2 {
@ -170,7 +189,7 @@ fn placeholder_frame(
} }
} }
placeholder.push_str(&"\n".repeat(height - 2)); placeholder.push_str(&"\n".repeat(height / 2));
placeholder.push('\u{230e}'); placeholder.push('\u{230e}');
placeholder.push_str(&" ".repeat(width - 2)); placeholder.push_str(&" ".repeat(width - 2));
placeholder.push_str("\u{230f}\n"); placeholder.push_str("\u{230f}\n");
@ -180,9 +199,8 @@ fn placeholder_frame(
#[inline] #[inline]
fn millis_to_datetime(ms: UInt) -> DateTime<LocalTz> { fn millis_to_datetime(ms: UInt) -> DateTime<LocalTz> {
let time = i64::from(ms) / 1000; let time = i64::from(ms) / 1000;
let time = NaiveDateTime::from_timestamp_opt(time, 0).unwrap_or_default(); let time = DateTime::from_timestamp(time, 0).unwrap_or_default();
time.into()
LocalTz.from_utc_datetime(&time)
} }
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
@ -426,6 +444,7 @@ pub enum MessageEvent {
EncryptedRedacted(Box<RedactedRoomEncryptedEvent>), EncryptedRedacted(Box<RedactedRoomEncryptedEvent>),
Original(Box<OriginalRoomMessageEvent>), Original(Box<OriginalRoomMessageEvent>),
Redacted(Box<RedactedRoomMessageEvent>), Redacted(Box<RedactedRoomMessageEvent>),
State(Box<AnySyncStateEvent>),
Local(OwnedEventId, Box<RoomMessageEventContent>), Local(OwnedEventId, Box<RoomMessageEventContent>),
} }
@ -436,6 +455,7 @@ impl MessageEvent {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(), MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
MessageEvent::Original(ev) => ev.event_id.as_ref(), MessageEvent::Original(ev) => ev.event_id.as_ref(),
MessageEvent::Redacted(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(), MessageEvent::Local(event_id, _) => event_id.as_ref(),
} }
} }
@ -446,6 +466,7 @@ impl MessageEvent {
MessageEvent::Original(ev) => Some(&ev.content), MessageEvent::Original(ev) => Some(&ev.content),
MessageEvent::EncryptedRedacted(_) => None, MessageEvent::EncryptedRedacted(_) => None,
MessageEvent::Redacted(_) => None, MessageEvent::Redacted(_) => None,
MessageEvent::State(_) => None,
MessageEvent::Local(_, content) => Some(content), MessageEvent::Local(_, content) => Some(content),
} }
} }
@ -463,6 +484,7 @@ impl MessageEvent {
MessageEvent::Original(ev) => body_cow_content(&ev.content), MessageEvent::Original(ev) => body_cow_content(&ev.content),
MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned), MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned),
MessageEvent::Redacted(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), MessageEvent::Local(_, content) => body_cow_content(content),
} }
} }
@ -473,6 +495,7 @@ impl MessageEvent {
MessageEvent::EncryptedRedacted(_) => return None, MessageEvent::EncryptedRedacted(_) => return None,
MessageEvent::Original(ev) => &ev.content, MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None, MessageEvent::Redacted(_) => return None,
MessageEvent::State(ev) => return Some(html_state(ev)),
MessageEvent::Local(_, content) => content, MessageEvent::Local(_, content) => content,
}; };
@ -492,6 +515,7 @@ impl MessageEvent {
MessageEvent::EncryptedOriginal(_) => return, MessageEvent::EncryptedOriginal(_) => return,
MessageEvent::EncryptedRedacted(_) => return, MessageEvent::EncryptedRedacted(_) => return,
MessageEvent::Redacted(_) => return, MessageEvent::Redacted(_) => return,
MessageEvent::State(_) => return,
MessageEvent::Local(_, _) => return, MessageEvent::Local(_, _) => return,
MessageEvent::Original(ev) => { MessageEvent::Original(ev) => {
let redacted = RedactedRoomMessageEvent { let redacted = RedactedRoomMessageEvent {
@ -623,8 +647,8 @@ struct MessageFormatter<'a> {
/// The date the message was sent. /// The date the message was sent.
date: Option<Span<'a>>, date: Option<Span<'a>>,
/// Iterator over the users who have read up to this message. /// The users who have read up to this message.
read: Option<hash_set::Iter<'a, OwnedUserId>>, read: Vec<OwnedUserId>,
} }
impl<'a> MessageFormatter<'a> { impl<'a> MessageFormatter<'a> {
@ -657,13 +681,11 @@ impl<'a> MessageFormatter<'a> {
line.push(time); line.push(time);
// Show read receipts. // Show read receipts.
let user_char = let user_char = |user: OwnedUserId| -> Span { settings.get_user_char_span(&user) };
|user: &'a OwnedUserId| -> Span<'a> { settings.get_user_char_span(user) };
let mut read = self.read.iter_mut().flatten();
let a = 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 = read.next().map(user_char).unwrap_or_else(|| Span::raw(" ")); let b = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
let c = read.next().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(Span::raw(" "));
line.push(c); line.push(c);
@ -716,11 +738,11 @@ impl<'a> MessageFormatter<'a> {
style: Style, style: Style,
text: &mut Text<'a>, text: &mut Text<'a>,
info: &'a RoomInfo, info: &'a RoomInfo,
) { settings: &'a ApplicationSettings,
) -> Option<ProtocolPreview<'a>> {
let width = self.width(); let width = self.width();
let w = width.saturating_sub(2); let w = width.saturating_sub(2);
let shortcodes = self.settings.tunables.message_shortcode_display; let (mut replied, proto) = msg.show_msg(w, style, true, settings);
let (mut replied, _) = msg.show_msg(w, style, true, shortcodes);
let mut sender = msg.sender_span(info, self.settings); let mut sender = msg.sender_span(info, self.settings);
let sender_width = UnicodeWidthStr::width(sender.content.as_ref()); let sender_width = UnicodeWidthStr::width(sender.content.as_ref());
let trailing = w.saturating_sub(sender_width + 1); let trailing = w.saturating_sub(sender_width + 1);
@ -739,16 +761,26 @@ impl<'a> MessageFormatter<'a> {
text, 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() { for line in replied.lines.iter_mut() {
line.spans.insert(0, Span::styled(THICK_VERTICAL, style)); line.spans.insert(0, Span::styled(THICK_VERTICAL, style));
line.spans.insert(0, Span::styled(" ", style)); line.spans.insert(0, Span::styled(" ", style));
} }
self.push_text(replied, style, text); self.push_text(replied, style, text);
proto
} }
fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) { 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; let mut reactions = 0;
for (key, count) in counts { for (key, count) in counts {
@ -797,7 +829,7 @@ impl<'a> MessageFormatter<'a> {
let plural = len != 1; let plural = len != 1;
let style = Style::default(); let style = Style::default();
let mut threaded = 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)); let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD));
threaded.push_str(" \u{2937} ", style); threaded.push_str(" \u{2937} ", style);
threaded.push_span_nobreak(len); threaded.push_span_nobreak(len);
@ -814,7 +846,7 @@ impl<'a> MessageFormatter<'a> {
pub enum ImageStatus { pub enum ImageStatus {
None, None,
Downloading(ImagePreviewSize), Downloading(ImagePreviewSize),
Loaded(Box<dyn Protocol>), Loaded(Protocol),
Error(String), Error(String),
} }
@ -849,6 +881,7 @@ impl Message {
MessageEvent::Local(_, content) => content, MessageEvent::Local(_, content) => content,
MessageEvent::Original(ev) => &ev.content, MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None, MessageEvent::Redacted(_) => return None,
MessageEvent::State(_) => return None,
}; };
match &content.relates_to { match &content.relates_to {
@ -869,6 +902,7 @@ impl Message {
MessageEvent::Local(_, content) => content, MessageEvent::Local(_, content) => content,
MessageEvent::Original(ev) => &ev.content, MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None, MessageEvent::Redacted(_) => return None,
MessageEvent::State(_) => return None,
}; };
match &content.relates_to { match &content.relates_to {
@ -922,7 +956,13 @@ impl Message {
let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER; let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER;
let user = self.show_sender(prev, true, info, settings); let user = self.show_sender(prev, true, info, settings);
let time = self.timestamp.show_time(); 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 } MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width { } else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width {
@ -930,7 +970,7 @@ impl Message {
let fill = width - user_gutter - TIME_GUTTER; let fill = width - user_gutter - TIME_GUTTER;
let user = self.show_sender(prev, true, info, settings); let user = self.show_sender(prev, true, info, settings);
let time = self.timestamp.show_time(); let time = self.timestamp.show_time();
let read = None; let read = Vec::new();
MessageFormatter { settings, cols, orig, fill, user, date, time, read } MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else if user_gutter + MIN_MSG_LEN <= width { } else if user_gutter + MIN_MSG_LEN <= width {
@ -938,7 +978,7 @@ impl Message {
let fill = width - user_gutter; let fill = width - user_gutter;
let user = self.show_sender(prev, true, info, settings); let user = self.show_sender(prev, true, info, settings);
let time = None; let time = None;
let read = None; let read = Vec::new();
MessageFormatter { settings, cols, orig, fill, user, date, time, read } MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else { } else {
@ -946,7 +986,7 @@ impl Message {
let fill = width.saturating_sub(2); let fill = width.saturating_sub(2);
let user = self.show_sender(prev, false, info, settings); let user = self.show_sender(prev, false, info, settings);
let time = None; let time = None;
let read = None; let read = Vec::new();
MessageFormatter { settings, cols, orig, fill, user, date, time, read } MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} }
@ -962,7 +1002,7 @@ impl Message {
vwctx: &ViewportContext<MessageCursor>, vwctx: &ViewportContext<MessageCursor>,
info: &'a RoomInfo, info: &'a RoomInfo,
settings: &'a ApplicationSettings, settings: &'a ApplicationSettings,
) -> (Text<'a>, Option<(&dyn Protocol, u16, u16)>) { ) -> (Text<'a>, [Option<ProtocolPreview<'a>>; 2]) {
let width = vwctx.get_width(); let width = vwctx.get_width();
let style = self.get_render_style(selected, settings); let style = self.get_render_style(selected, settings);
@ -975,24 +1015,20 @@ impl Message {
.reply_to() .reply_to()
.or_else(|| self.thread_root()) .or_else(|| self.thread_root())
.and_then(|e| info.get_event(&e)); .and_then(|e| info.get_event(&e));
let proto_reply = reply.as_ref().and_then(|r| {
if let Some(r) = &reply { // Format the reply header, push it into the `Text` buffer, and get any image.
fmt.push_in_reply(r, style, &mut text, info); 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. // Now show the message contents, and the inlined reply if we couldn't find it above.
let (msg, proto) = self.show_msg( let (msg, proto) = self.show_msg(width, style, reply.is_some(), settings);
width,
style,
reply.is_some(),
settings.tunables.message_shortcode_display,
);
// Given our text so far, determine the image offset. // 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 y_off = text.lines.len() as u16;
let x_off = fmt.cols.user_gutter_width(settings); 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 }; let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off };
(p, x_off, y_off) (p, x_off, y_off)
}); });
@ -1013,7 +1049,7 @@ impl Message {
fmt.push_thread_reply_count(thread.len(), &mut text); fmt.push_thread_reply_count(thread.len(), &mut text);
} }
(text, proto) (text, [proto_main, proto_reply])
} }
pub fn show<'a>( pub fn show<'a>(
@ -1027,18 +1063,18 @@ impl Message {
self.show_with_preview(prev, selected, vwctx, info, settings).0 self.show_with_preview(prev, selected, vwctx, info, settings).0
} }
fn show_msg( fn show_msg<'a>(
&self, &'a self,
width: usize, width: usize,
style: Style, style: Style,
hide_reply: bool, hide_reply: bool,
emoji_shortcodes: bool, settings: &'a ApplicationSettings,
) -> (Text, Option<&dyn Protocol>) { ) -> (Text<'a>, Option<&'a Protocol>) {
if let Some(html) = &self.html { 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 { } else {
let mut msg = self.event.body(); 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())); msg = Cow::Owned(replace_emojis_in_str(msg.as_ref()));
} }
@ -1053,8 +1089,8 @@ impl Message {
placeholder_frame(Some("Downloading..."), width, image_preview_size) placeholder_frame(Some("Downloading..."), width, image_preview_size)
}, },
ImageStatus::Loaded(backend) => { ImageStatus::Loaded(backend) => {
proto = Some(backend.as_ref()); proto = Some(backend);
placeholder_frame(None, width, &backend.rect().into()) placeholder_frame(Some("Cut off..."), width, &backend.area().into())
}, },
ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")), ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")),
}; };
@ -1097,9 +1133,9 @@ impl Message {
let padding = user_gutter - 2 - width; let padding = user_gutter - 2 - width;
let sender = if align_right { let sender = if align_right {
space(padding) + &truncated + " " format!("{}{} ", space(padding), truncated)
} else { } else {
truncated.into_owned() + &space(padding) + " " format!("{}{} ", truncated, space(padding))
}; };
Span::styled(sender, style).into() Span::styled(sender, style).into()
@ -1108,6 +1144,8 @@ impl Message {
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) { pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
self.event.redact(redaction, version); self.event.redact(redaction, version);
self.html = None; self.html = None;
self.downloaded = false;
self.image_preview = ImageStatus::None;
} }
} }
@ -1153,6 +1191,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 { impl Display for Message {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.event.body()) write!(f, "{}", self.event.body())
@ -1251,7 +1299,7 @@ pub mod tests {
assert_eq!(k6, &MSG1_KEY.clone()); assert_eq!(k6, &MSG1_KEY.clone());
// MessageCursor::latest() fails to convert for a room w/o messages. // 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); assert_eq!(mc6.to_key(&messages_empty), None);
} }
@ -1313,6 +1361,33 @@ pub mod tests {
OK 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_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::config::{ApplicationSettings, TunableValues};
use crate::util::{ use crate::util::{
replace_emojis_in_line, replace_emojis_in_line,
replace_emojis_in_span, replace_emojis_in_span,
@ -25,28 +26,34 @@ pub struct TextPrinter<'a> {
width: usize, width: usize,
base_style: Style, base_style: Style,
hide_reply: bool, hide_reply: bool,
emoji_shortcodes: bool,
alignment: Alignment, alignment: Alignment,
curr_spans: Vec<Span<'a>>, curr_spans: Vec<Span<'a>>,
curr_width: usize, curr_width: usize,
literal: bool, literal: bool,
pub(super) settings: &'a ApplicationSettings,
} }
impl<'a> TextPrinter<'a> { impl<'a> TextPrinter<'a> {
/// Create a new printer. /// 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 { TextPrinter {
text: Text::default(), text: Text::default(),
width, width,
base_style, base_style,
hide_reply, hide_reply,
emoji_shortcodes,
alignment: Alignment::Left, alignment: Alignment::Left,
curr_spans: vec![], curr_spans: vec![],
curr_width: 0, curr_width: 0,
literal: false, literal: false,
settings,
} }
} }
@ -69,7 +76,15 @@ impl<'a> TextPrinter<'a> {
/// Indicates whether emojis should be replaced by shortcodes /// Indicates whether emojis should be replaced by shortcodes
pub fn emoji_shortcodes(&self) -> bool { 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. /// Indicates the current printer's width.
@ -84,12 +99,12 @@ impl<'a> TextPrinter<'a> {
width: self.width.saturating_sub(indent), width: self.width.saturating_sub(indent),
base_style: self.base_style, base_style: self.base_style,
hide_reply: self.hide_reply, hide_reply: self.hide_reply,
emoji_shortcodes: self.emoji_shortcodes,
alignment: self.alignment, alignment: self.alignment,
curr_spans: vec![], curr_spans: vec![],
curr_width: 0, curr_width: 0,
literal: self.literal, 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. /// Push a [Span] that isn't allowed to break across lines.
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) { 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); replace_emojis_in_span(&mut span);
} }
let sw = UnicodeWidthStr::width(span.content.as_ref()); let sw = UnicodeWidthStr::width(span.content.as_ref());
@ -201,6 +216,8 @@ impl<'a> TextPrinter<'a> {
return; return;
} }
let tabstop = self.settings().tunables.tabstop;
for mut word in UnicodeSegmentation::split_word_bounds(s) { for mut word in UnicodeSegmentation::split_word_bounds(s) {
if let "\n" | "\r\n" = word { if let "\n" | "\r\n" = word {
if self.literal { if self.literal {
@ -217,11 +234,17 @@ impl<'a> TextPrinter<'a> {
continue; continue;
} }
let cow = if self.emoji_shortcodes { let mut cow = if self.emoji_shortcodes() {
Cow::Owned(replace_emojis_in_str(word)) Cow::Owned(replace_emojis_in_str(word))
} else { } else {
Cow::Borrowed(word) 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()); let sw = UnicodeWidthStr::width(cow.as_ref());
if sw > self.width { if sw > self.width {
@ -253,7 +276,7 @@ impl<'a> TextPrinter<'a> {
/// Push a [Line] into the printer. /// Push a [Line] into the printer.
pub fn push_line(&mut self, mut line: Line<'a>) { pub fn push_line(&mut self, mut line: Line<'a>) {
self.commit(); self.commit();
if self.emoji_shortcodes { if self.emoji_shortcodes() {
replace_emojis_in_line(&mut line); replace_emojis_in_line(&mut line);
} }
self.text.lines.push(line); self.text.lines.push(line);
@ -262,7 +285,7 @@ impl<'a> TextPrinter<'a> {
/// Push multiline [Text] into the printer. /// Push multiline [Text] into the printer.
pub fn push_text(&mut self, mut text: Text<'a>) { pub fn push_text(&mut self, mut text: Text<'a>) {
self.commit(); self.commit();
if self.emoji_shortcodes { if self.emoji_shortcodes() {
for line in &mut text.lines { for line in &mut text.lines {
replace_emojis_in_line(line); replace_emojis_in_line(line);
} }
@ -280,10 +303,12 @@ impl<'a> TextPrinter<'a> {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use super::*; use super::*;
use crate::tests::mock_settings;
#[test] #[test]
fn test_push_nobreak() { 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()); printer.push_span_nobreak("hello world".into());
let text = printer.finish(); let text = printer.finish();
assert_eq!(text.lines.len(), 1); 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 {} from the room", state_key)
},
MembershipChange::Unbanned => {
format!("* unbanned {} from the room", state_key)
},
MembershipChange::Kicked => {
format!("* kicked {} from the room", state_key)
},
MembershipChange::Invited => {
format!("* invited {} to the room", state_key)
},
MembershipChange::KickedAndBanned => {
format!("* kicked and banned {} from the room", state_key)
},
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 {} to join the room", state_key)
},
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,12 +1,14 @@
use std::time::SystemTime; use std::time::SystemTime;
use matrix_sdk::{ use matrix_sdk::{
deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode}, notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
room::Room as MatrixRoom, room::Room as MatrixRoom,
ruma::{ ruma::{
api::client::push::get_notifications::v3::Notification,
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent}, events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
serde::Raw,
MilliSecondsSinceUnixEpoch, MilliSecondsSinceUnixEpoch,
OwnedRoomId,
RoomId, RoomId,
}, },
Client, Client,
@ -23,6 +25,21 @@ const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
Some(iamb) => iamb, Some(iamb) => iamb,
}; };
/// Handle for an open notification that should be closed when the user views it.
pub struct NotificationHandle(
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
Option<notify_rust::NotificationHandle>,
);
impl Drop for NotificationHandle {
fn drop(&mut self) {
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
if let Some(handle) = self.0.take() {
handle.close();
}
}
}
pub async fn register_notifications( pub async fn register_notifications(
client: &Client, client: &Client,
settings: &ApplicationSettings, settings: &ApplicationSettings,
@ -53,51 +70,103 @@ pub async fn register_notifications(
return; return;
} }
match parse_notification(notification, room, show_message).await { let room_id = room.room_id().to_owned();
Ok((summary, body, server_ts)) => { match notification.event {
if server_ts < startup_ts { RawAnySyncOrStrippedTimelineEvent::Sync(e) => {
return; 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) { if is_missing_mention(&body, mode, &client) {
return; return;
} }
match notify_via { send_notification(
#[cfg(feature = "desktop")] &notify_via,
NotifyVia::Desktop => send_notification_desktop(summary, body), &summary,
NotifyVia::Bell => send_notification_bell(&store).await, body.as_deref(),
room_id,
&store,
)
.await;
},
Err(err) => {
tracing::error!("Failed to extract notification data: {err}")
},
} }
}, },
Err(err) => { // Stripped events may be dropped silently because they're
tracing::error!("Failed to extract notification data: {err}") // 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; .await;
} }
async fn send_notification(
via: &NotifyVia,
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
store: &AsyncProgramStore,
) {
#[cfg(feature = "desktop")]
if via.desktop {
send_notification_desktop(summary, body, room_id, store).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) { async fn send_notification_bell(store: &AsyncProgramStore) {
let mut locked = store.lock().await; let mut locked = store.lock().await;
locked.application.ring_bell = true; locked.application.ring_bell = true;
} }
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
fn send_notification_desktop(summary: String, body: Option<String>) { async fn send_notification_desktop(
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
_store: &AsyncProgramStore,
) {
let mut desktop_notification = notify_rust::Notification::new(); let mut desktop_notification = notify_rust::Notification::new();
desktop_notification desktop_notification
.summary(&summary) .summary(summary)
.appname(IAMB_XDG_NAME) .appname(IAMB_XDG_NAME)
.icon(IAMB_XDG_NAME) .icon(IAMB_XDG_NAME)
.action("default", "default"); .action("default", "default");
#[cfg(all(unix, not(target_os = "macos")))]
desktop_notification.urgency(notify_rust::Urgency::Normal);
if let Some(body) = body { if let Some(body) = body {
desktop_notification.body(&body); desktop_notification.body(body);
} }
if let Err(err) = desktop_notification.show() { match desktop_notification.show() {
tracing::error!("Failed to send notification: {err}") 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)));
},
} }
} }
@ -155,12 +224,12 @@ async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
is_focused(&locked) && is_open(&mut locked, room_id) is_focused(&locked) && is_open(&mut locked, room_id)
} }
pub async fn parse_notification( pub async fn parse_full_notification(
notification: Notification, event: Raw<AnySyncTimelineEvent>,
room: MatrixRoom, room: MatrixRoom,
show_body: bool, show_body: bool,
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> { ) -> 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(); let server_ts = event.origin_server_ts();
@ -172,19 +241,19 @@ pub async fn parse_notification(
.and_then(|m| m.display_name()) .and_then(|m| m.display_name())
.unwrap_or_else(|| sender_id.localpart()); .unwrap_or_else(|| sender_id.localpart());
let summary = if let Ok(room_name) = room.display_name().await { let summary = if let Some(room_name) = room.cached_display_name() {
format!("{sender_name} in {room_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 { } else {
sender_name.to_string() sender_name.to_string()
}; };
let body = if show_body { let body = if show_body {
event_notification_body( event_notification_body(&event, sender_name).map(truncate)
&event,
sender_name,
room.is_direct().await.map_err(IambError::from)?,
)
.map(truncate)
} else { } else {
None None
}; };
@ -192,11 +261,7 @@ pub async fn parse_notification(
return Ok((summary, body, server_ts)); return Ok((summary, body, server_ts));
} }
pub fn event_notification_body( pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option<String> {
event: &AnySyncTimelineEvent,
sender_name: &str,
is_direct: bool,
) -> Option<String> {
let AnySyncTimelineEvent::MessageLike(event) = event else { let AnySyncTimelineEvent::MessageLike(event) = event else {
return None; return None;
}; };
@ -207,10 +272,7 @@ pub fn event_notification_body(
MessageType::Audio(_) => { MessageType::Audio(_) => {
format!("{sender_name} sent an audio file.") format!("{sender_name} sent an audio file.")
}, },
MessageType::Emote(content) => { MessageType::Emote(content) => content.body,
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::File(_) => { MessageType::File(_) => {
format!("{sender_name} sent a file.") format!("{sender_name} sent a file.")
}, },
@ -220,22 +282,9 @@ pub fn event_notification_body(
MessageType::Location(_) => { MessageType::Location(_) => {
format!("{sender_name} sent their location.") format!("{sender_name} sent their location.")
}, },
MessageType::Notice(content) => { MessageType::Notice(content) => content.body,
let message = &content.body; MessageType::ServerNotice(content) => content.body,
format!("{sender_name}: {message}") MessageType::Text(content) => content.body,
},
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::Video(_) => { MessageType::Video(_) => {
format!("{sender_name} sent a video.") format!("{sender_name} sent a video.")
}, },
@ -254,7 +303,7 @@ pub fn event_notification_body(
} }
fn truncate(s: String) -> String { fn truncate(s: String) -> String {
static MAX_LENGTH: usize = 100; static MAX_LENGTH: usize = 5000;
if s.graphemes(true).count() > MAX_LENGTH { if s.graphemes(true).count() > MAX_LENGTH {
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect(); let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
truncated + "..." truncated + "..."

View file

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

View file

@ -137,7 +137,7 @@ pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
} }
pub fn mock_messages() -> Messages { pub fn mock_messages() -> Messages {
let mut messages = Messages::default(); let mut messages = Messages::main();
messages.insert(MSG1_KEY.clone(), mock_message1()); messages.insert(MSG1_KEY.clone(), mock_message1());
messages.insert(MSG2_KEY.clone(), mock_message2()); messages.insert(MSG2_KEY.clone(), mock_message2());
@ -171,12 +171,14 @@ pub fn mock_tunables() -> TunableValues {
default_room: None, default_room: None,
log_level: Level::INFO, log_level: Level::INFO,
message_shortcode_display: false, message_shortcode_display: false,
normal_after_send: true,
reaction_display: true, reaction_display: true,
reaction_shortcode_display: false, reaction_shortcode_display: false,
read_receipt_send: true, read_receipt_send: true,
read_receipt_display: true, read_receipt_display: true,
request_timeout: 120, request_timeout: 120,
sort: SortOverrides::default().values(), sort: SortOverrides::default().values(),
state_event_display: true,
typing_notice_send: true, typing_notice_send: true,
typing_notice_display: true, typing_notice_display: true,
users: vec![(TEST_USER5.clone(), UserDisplayTunables { users: vec![(TEST_USER5.clone(), UserDisplayTunables {
@ -188,14 +190,17 @@ pub fn mock_tunables() -> TunableValues {
open_command: None, open_command: None,
external_edit_file_suffix: String::from(".md"), external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username, username_display: UserDisplayStyle::Username,
username_display_regex: Some(String::from(".*")),
message_user_color: false, message_user_color: false,
mouse: Default::default(),
notifications: Notifications { notifications: Notifications {
enabled: false, enabled: false,
via: NotifyVia::Desktop, via: NotifyVia::default(),
show_message: true, show_message: true,
}, },
image_preview: None, image_preview: None,
user_gutter_width: 30, 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 //! 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], //! 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::cmp::{Ord, Ordering, PartialOrd};
use std::fmt::{self, Display}; use std::fmt::{self, Display};
use std::ops::Deref; use std::ops::Deref;
@ -23,6 +23,7 @@ use matrix_sdk::{
RoomAliasId, RoomAliasId,
RoomId, RoomId,
}, },
RoomState as MatrixRoomState,
}; };
use ratatui::{ use ratatui::{
@ -75,11 +76,13 @@ use crate::base::{
SortFieldRoom, SortFieldRoom,
SortFieldUser, SortFieldUser,
SortOrder, SortOrder,
SpaceAction,
UnreadInfo, UnreadInfo,
}; };
use self::{room::RoomState, welcome::WelcomeState}; use self::{room::RoomState, welcome::WelcomeState};
use crate::message::MessageTimeStamp; use crate::message::MessageTimeStamp;
use feruca::Collator;
pub mod room; pub mod room;
pub mod welcome; pub mod welcome;
@ -168,7 +171,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 { match field {
SortFieldRoom::Favorite => { SortFieldRoom::Favorite => {
let fava = a.has_tag(TagName::Favorite); let fava = a.has_tag(TagName::Favorite);
@ -184,7 +192,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. // If a has LowPriority and b doesn't, it should sort later in room list.
lowa.cmp(&lowb) 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::Alias => some_cmp(a.alias(), b.alias(), Ord::cmp),
SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()), SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()),
SortFieldRoom::Unread => { SortFieldRoom::Unread => {
@ -195,6 +203,10 @@ fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
// sort larger timestamps towards the top. // sort larger timestamps towards the top.
some_cmp(a.recent_ts(), b.recent_ts(), |a, b| b.cmp(a)) 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 +215,10 @@ fn room_fields_cmp<T: RoomLikeItem>(
a: &T, a: &T,
b: &T, b: &T,
fields: &[SortColumn<SortFieldRoom>], fields: &[SortColumn<SortFieldRoom>],
collator: &mut Collator,
) -> Ordering { ) -> Ordering {
for SortColumn(field, order) in fields { for SortColumn(field, order) in fields {
match (room_cmp(a, b, field), order) { match (room_cmp(a, b, field, collator), order) {
(Ordering::Equal, _) => continue, (Ordering::Equal, _) => continue,
(o, SortOrder::Ascending) => return o, (o, SortOrder::Ascending) => return o,
(o, SortOrder::Descending) => return o.reverse(), (o, SortOrder::Descending) => return o.reverse(),
@ -213,7 +226,7 @@ fn room_fields_cmp<T: RoomLikeItem>(
} }
// Break ties on ascending room id. // Break ties on ascending room id.
room_cmp(a, b, &SortFieldRoom::RoomId) room_cmp(a, b, &SortFieldRoom::RoomId, collator)
} }
fn user_fields_cmp( fn user_fields_cmp(
@ -273,6 +286,7 @@ trait RoomLikeItem {
fn recent_ts(&self) -> Option<&MessageTimeStamp>; fn recent_ts(&self) -> Option<&MessageTimeStamp>;
fn alias(&self) -> Option<&RoomAliasId>; fn alias(&self) -> Option<&RoomAliasId>;
fn name(&self) -> &str; fn name(&self) -> &str;
fn is_invite(&self) -> bool;
} }
#[inline] #[inline]
@ -354,6 +368,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( pub async fn room_command(
&mut self, &mut self,
act: RoomAction, act: RoomAction,
@ -496,7 +523,8 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room_info| DirectItem::new(room_info, store)) .map(|room_info| DirectItem::new(room_info, store))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.dms; 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); state.set(items);
@ -541,7 +569,8 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room_info| RoomItem::new(room_info, store)) .map(|room_info| RoomItem::new(room_info, store))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.rooms; 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); state.set(items);
@ -572,7 +601,8 @@ impl WindowOps<IambInfo> for IambWindow {
items.extend(dms); items.extend(dms);
let fields = &store.application.settings.tunables.sort.chats; 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); state.set(items);
@ -605,7 +635,8 @@ impl WindowOps<IambInfo> for IambWindow {
items.extend(dms); items.extend(dms);
let fields = &store.application.settings.tunables.sort.chats; 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); state.set(items);
@ -625,7 +656,8 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room| SpaceItem::new(room, store)) .map(|room| SpaceItem::new(room, store))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.spaces; 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); state.set(items);
@ -914,6 +946,10 @@ impl RoomLikeItem for GenericChatItem {
fn is_unread(&self) -> bool { fn is_unread(&self) -> bool {
self.unread.is_unread() self.unread.is_unread()
} }
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
} }
impl Display for GenericChatItem { impl Display for GenericChatItem {
@ -1024,11 +1060,15 @@ impl RoomLikeItem for RoomItem {
fn is_unread(&self) -> bool { fn is_unread(&self) -> bool {
self.unread.is_unread() self.unread.is_unread()
} }
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
} }
impl Display for RoomItem { impl Display for RoomItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, ":verify request {}", self.name) write!(f, "{}", self.name)
} }
} }
@ -1124,6 +1164,10 @@ impl RoomLikeItem for DirectItem {
fn is_unread(&self) -> bool { fn is_unread(&self) -> bool {
self.unread.is_unread() self.unread.is_unread()
} }
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
} }
impl Display for DirectItem { impl Display for DirectItem {
@ -1223,11 +1267,15 @@ impl RoomLikeItem for SpaceItem {
// XXX: this needs to check whether the space contains rooms with unread messages // XXX: this needs to check whether the space contains rooms with unread messages
false false
} }
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
} }
impl Display for SpaceItem { impl Display for SpaceItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, ":verify request {}", self.room_id()) write!(f, "{}", self.name)
} }
} }
@ -1556,6 +1604,7 @@ mod tests {
alias: Option<OwnedRoomAliasId>, alias: Option<OwnedRoomAliasId>,
name: &'static str, name: &'static str,
unread: UnreadInfo, unread: UnreadInfo,
invite: bool,
} }
impl RoomLikeItem for &TestRoomItem { impl RoomLikeItem for &TestRoomItem {
@ -1582,10 +1631,16 @@ mod tests {
fn is_unread(&self) -> bool { fn is_unread(&self) -> bool {
self.unread.is_unread() self.unread.is_unread()
} }
fn is_invite(&self) -> bool {
self.invite
}
} }
#[test] #[test]
fn test_sort_rooms() { fn test_sort_rooms() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com"); let server = server_name!("example.com");
let room1 = TestRoomItem { let room1 = TestRoomItem {
@ -1594,6 +1649,7 @@ mod tests {
alias: Some(room_alias_id!("#room1:example.com").to_owned()), alias: Some(room_alias_id!("#room1:example.com").to_owned()),
name: "Z", name: "Z",
unread: UnreadInfo::default(), unread: UnreadInfo::default(),
invite: false,
}; };
let room2 = TestRoomItem { let room2 = TestRoomItem {
@ -1602,6 +1658,7 @@ mod tests {
alias: Some(room_alias_id!("#a:example.com").to_owned()), alias: Some(room_alias_id!("#a:example.com").to_owned()),
name: "Unnamed Room", name: "Unnamed Room",
unread: UnreadInfo::default(), unread: UnreadInfo::default(),
invite: false,
}; };
let room3 = TestRoomItem { let room3 = TestRoomItem {
@ -1610,18 +1667,19 @@ mod tests {
alias: None, alias: None,
name: "Cool Room", name: "Cool Room",
unread: UnreadInfo::default(), unread: UnreadInfo::default(),
invite: false,
}; };
// Sort by Name ascending. // Sort by Name ascending.
let mut rooms = vec![&room1, &room2, &room3]; let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Ascending)]; 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]); assert_eq!(rooms, vec![&room3, &room2, &room1]);
// Sort by Name descending. // Sort by Name descending.
let mut rooms = vec![&room1, &room2, &room3]; let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Descending)]; 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]); assert_eq!(rooms, vec![&room1, &room2, &room3]);
// Sort by Favorite and Alias before Name to show order matters. // Sort by Favorite and Alias before Name to show order matters.
@ -1631,7 +1689,7 @@ mod tests {
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, 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]); assert_eq!(rooms, vec![&room1, &room2, &room3]);
// Now flip order of Favorite with Descending // Now flip order of Favorite with Descending
@ -1641,12 +1699,14 @@ mod tests {
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, 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]); assert_eq!(rooms, vec![&room2, &room3, &room1]);
} }
#[test] #[test]
fn test_sort_room_recents() { fn test_sort_room_recents() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com"); let server = server_name!("example.com");
let room1 = TestRoomItem { let room1 = TestRoomItem {
@ -1655,6 +1715,7 @@ mod tests {
alias: None, alias: None,
name: "Room 1", name: "Room 1",
unread: UnreadInfo { unread: false, latest: None }, unread: UnreadInfo { unread: false, latest: None },
invite: false,
}; };
let room2 = TestRoomItem { let room2 = TestRoomItem {
@ -1666,6 +1727,7 @@ mod tests {
unread: false, unread: false,
latest: Some(MessageTimeStamp::OriginServer(40u32.into())), latest: Some(MessageTimeStamp::OriginServer(40u32.into())),
}, },
invite: false,
}; };
let room3 = TestRoomItem { let room3 = TestRoomItem {
@ -1677,18 +1739,71 @@ mod tests {
unread: false, unread: false,
latest: Some(MessageTimeStamp::OriginServer(20u32.into())), latest: Some(MessageTimeStamp::OriginServer(20u32.into())),
}, },
invite: false,
}; };
// Sort by Recent ascending. // Sort by Recent ascending.
let mut rooms = vec![&room1, &room2, &room3]; let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Ascending)]; 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]); assert_eq!(rooms, vec![&room2, &room3, &room1]);
// Sort by Recent descending. // Sort by Recent descending.
let mut rooms = vec![&room1, &room2, &room3]; let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Descending)]; 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]); 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(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 1",
unread: UnreadInfo::default(),
invite: false,
};
let room2 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 2",
unread: UnreadInfo::default(),
invite: false,
};
let room3 = TestRoomItem {
room_id: RoomId::new(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

@ -14,7 +14,7 @@ use url::Url;
use matrix_sdk::{ use matrix_sdk::{
attachment::AttachmentConfig, attachment::AttachmentConfig,
media::{MediaFormat, MediaRequest}, media::{MediaFormat, MediaRequestParameters},
room::Room as MatrixRoom, room::Room as MatrixRoom,
ruma::{ ruma::{
events::reaction::ReactionEventContent, events::reaction::ReactionEventContent,
@ -86,7 +86,14 @@ use crate::base::{
SendAction, 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 crate::worker::Requester;
use super::scrollback::{Scrollback, ScrollbackState}; use super::scrollback::{Scrollback, ScrollbackState};
@ -226,10 +233,14 @@ impl ChatState {
let links = if let Some(html) = &msg.html { let links = if let Some(html) = &msg.html {
html.get_links() html.get_links()
} else if let Ok(url) = Url::parse(&msg.event.body()) {
vec![('0', url)]
} else { } 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() { if links.is_empty() {
@ -276,7 +287,7 @@ impl ChatState {
} }
if !filename.exists() || flags.contains(DownloadFlags::FORCE) { if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
let req = MediaRequest { source, format: MediaFormat::File }; let req = MediaRequestParameters { source, format: MediaFormat::File };
let bytes = let bytes =
media.get_media_content(&req, true).await.map_err(IambError::from)?; 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::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
let msg = "Cannot react to a redacted message"; let msg = "Cannot react to a redacted message";
let err = UIError::Failure(msg.into()); let err = UIError::Failure(msg.into());
@ -417,6 +429,7 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
let msg = "Cannot redact already redacted message"; let msg = "Cannot redact already redacted message";
let err = UIError::Failure(msg.into()); let err = UIError::Failure(msg.into());
@ -464,6 +477,7 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(), MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => { MessageEvent::Redacted(_) => {
let msg = "Cannot unreact to a redacted message"; let msg = "Cannot unreact to a redacted message";
let err = UIError::Failure(msg.into()); let err = UIError::Failure(msg.into());
@ -596,7 +610,7 @@ impl ChatState {
let dynimage = image::DynamicImage::ImageRgba8(imagebuf); let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
let bytes = Vec::<u8>::new(); let bytes = Vec::<u8>::new();
let mut buff = std::io::Cursor::new(bytes); 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()) Ok(buff.into_inner())
}) })
.map_err(IambError::from)?; .map_err(IambError::from)?;
@ -606,7 +620,7 @@ impl ChatState {
let config = AttachmentConfig::new(); let config = AttachmentConfig::new();
let resp = room let resp = room
.send_attachment(name.as_ref(), &mime, bytes, config) .send_attachment(name, &mime, bytes, config)
.await .await
.map_err(IambError::from)?; .map_err(IambError::from)?;
@ -635,10 +649,7 @@ impl ChatState {
} }
pub fn focus_toggle(&mut self) { pub fn focus_toggle(&mut self) {
self.focus = match self.focus { self.focus.toggle();
RoomFocus::Scrollback => RoomFocus::MessageBar,
RoomFocus::MessageBar => RoomFocus::Scrollback,
};
} }
pub fn room(&self) -> &MatrixRoom { pub fn room(&self) -> &MatrixRoom {
@ -649,6 +660,14 @@ impl ChatState {
&self.room_id &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( pub fn typing_notice(
&self, &self,
act: &EditorAction, act: &EditorAction,
@ -751,8 +770,15 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
ctx: &ProgramContext, ctx: &ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> { ) -> 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); 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)) { match delegate!(self, w => w.editor_command(act, ctx, store)) {
res @ Ok(_) => res, res @ Ok(_) => res,
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus))) Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
@ -849,16 +875,16 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
fn recall( fn recall(
&mut self, &mut self,
filter: &RecallFilter,
dir: &MoveDir1D, dir: &MoveDir1D,
count: &Count, count: &Count,
prefixed: bool,
ctx: &ProgramContext, ctx: &ProgramContext,
_: &mut ProgramStore, _: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> { ) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let count = ctx.resolve(count); let count = ctx.resolve(count);
let rope = self.tbox.get(); 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 { if let Some(text) = text {
self.tbox.set_text(text); self.tbox.set_text(text);
@ -882,9 +908,7 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
match act { match act {
PromptAction::Submit => self.submit(ctx, store), PromptAction::Submit => self.submit(ctx, store),
PromptAction::Abort(empty) => self.abort(*empty, ctx, store), PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
PromptAction::Recall(dir, count, prefixed) => { PromptAction::Recall(filter, dir, count) => self.recall(filter, dir, count, ctx, store),
self.recall(dir, count, *prefixed, ctx, store)
},
} }
} }
} }
@ -906,7 +930,7 @@ impl<'a> Chat<'a> {
} }
} }
impl<'a> StatefulWidget for Chat<'a> { impl StatefulWidget for Chat<'_> {
type State = ChatState; type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
@ -990,3 +1014,158 @@ fn cmd(open_command: &Vec<String>) -> Option<Command> {
} }
None 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, OwnedUserId,
RoomId, RoomId,
}, },
DisplayName, RoomDisplayName,
RoomState as MatrixRoomState, RoomState as MatrixRoomState,
}; };
@ -66,6 +66,7 @@ use crate::base::{
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
SpaceAction,
}; };
use self::chat::ChatState; use self::chat::ChatState;
@ -139,7 +140,7 @@ impl RoomState {
pub fn new( pub fn new(
room: MatrixRoom, room: MatrixRoom,
thread: Option<OwnedEventId>, thread: Option<OwnedEventId>,
name: DisplayName, name: RoomDisplayName,
tags: Option<Tags>, tags: Option<Tags>,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> Self { ) -> 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( pub async fn send_command(
&mut self, &mut self,
act: SendAction, act: SendAction,
@ -406,7 +419,7 @@ impl RoomState {
// Try creating the room alias on the server. // Try creating the room alias on the server.
let alias_create_req = let alias_create_req =
CreateAliasRequest::new(orai.clone(), room.room_id().into()); 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() { if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists. // Ignore when it already exists.
} else { } else {
@ -447,7 +460,7 @@ impl RoomState {
// If the room alias does not exist on the server, create it // If the room alias does not exist on the server, create it
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into()); 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() { if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists. // Ignore when it already exists.
} else { } else {
@ -464,6 +477,9 @@ impl RoomState {
RoomField::Aliases => { RoomField::Aliases => {
// This never happens, aliases is only used for showing // This never happens, aliases is only used for showing
}, },
RoomField::Id => {
// This never happens, id is only used for showing
},
} }
Ok(vec![]) Ok(vec![])
@ -519,7 +535,7 @@ impl RoomState {
.application .application
.worker .worker
.client .client
.send(del_req, None) .send(del_req)
.await .await
.map_err(IambError::from)?; .map_err(IambError::from)?;
}, },
@ -552,13 +568,16 @@ impl RoomState {
.application .application
.worker .worker
.client .client
.send(del_req, None) .send(del_req)
.await .await
.map_err(IambError::from)?; .map_err(IambError::from)?;
}, },
RoomField::Aliases => { RoomField::Aliases => {
// This will not happen, you cannot unset all aliases // This will not happen, you cannot unset all aliases
}, },
RoomField::Id => {
// This never happens, id is only used for showing
},
} }
Ok(vec![]) Ok(vec![])
@ -572,7 +591,12 @@ impl RoomState {
let msg = match field { let msg = match field {
RoomField::History => { RoomField::History => {
let visibility = room.history_visibility(); 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 => { RoomField::Name => {
match room.name() { match room.name() {

View file

@ -79,14 +79,20 @@ fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
} }
fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { 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 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; end = key;
if i >= n { if i >= n {
@ -94,11 +100,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 { 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> { fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> {
@ -150,6 +157,10 @@ impl ScrollbackState {
} }
} }
pub fn is_latest(&self) -> bool {
self.cursor.timestamp.is_none()
}
pub fn goto_latest(&mut self) { pub fn goto_latest(&mut self) {
self.cursor = MessageCursor::latest(); self.cursor = MessageCursor::latest();
} }
@ -829,8 +840,8 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
fn complete( fn complete(
&mut self, &mut self,
_: &CompletionStyle,
_: &CompletionType, _: &CompletionType,
_: &CompletionSelection,
_: &CompletionDisplay, _: &CompletionDisplay,
_: &ProgramContext, _: &ProgramContext,
_: &mut ProgramStore, _: &mut ProgramStore,
@ -1284,7 +1295,7 @@ impl<'a> Scrollback<'a> {
} }
} }
impl<'a> StatefulWidget for Scrollback<'a> { impl StatefulWidget for Scrollback<'_> {
type State = ScrollbackState; type State = ScrollbackState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
@ -1340,7 +1351,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
for (key, item) in thread.range(&corner_key..) { for (key, item) in thread.range(&corner_key..) {
let sel = key == cursor_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); item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings);
let incomplete_ok = !full || !sel; let incomplete_ok = !full || !sel;
@ -1357,11 +1368,17 @@ impl<'a> StatefulWidget for Scrollback<'a> {
continue; 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 { 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(), Some((_, _, y)) if y as usize == row => msg_preview.take(),
_ => None, _ => None,
}; }
.or(match reply_preview {
Some((_, _, y)) if y as usize == row => reply_preview.take(),
_ => None,
});
lines.push((key, row, line, line_preview)); lines.push((key, row, line, line_preview));
sawit |= sel; sawit |= sel;
@ -1396,7 +1413,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
// line. // line.
for (x, y, backend) in image_previews { for (x, y, backend) in image_previews {
let image_widget = Image::new(backend); let image_widget = Image::new(backend);
let mut rect = backend.rect(); let mut rect = backend.area();
rect.x = x; rect.x = x;
rect.y = y; rect.y = y;
// Don't render outside of scrollback area // Don't render outside of scrollback area
@ -1411,7 +1428,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
{ {
// If the cursor is at the last message, then update the read marker. // If the cursor is at the last message, then update the read marker.
if let Some((k, _)) = thread.last_key_value() { 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());
} }
} }
@ -1518,8 +1535,9 @@ mod tests {
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG5_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG5_KEY.clone().into());
// And one more becomes "latest" cursor:
scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); 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] #[tokio::test]
@ -1553,7 +1571,7 @@ mod tests {
// MSG1: | XXXday, Month NN 20XX | // MSG1: | XXXday, Month NN 20XX |
// | @user1:example.com writhe | // | @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); let mut buffer = Buffer::empty(area);
scrollback.draw(area, &mut buffer, true, &mut store); scrollback.draw(area, &mut buffer, true, &mut store);

View file

@ -2,11 +2,14 @@
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use matrix_sdk::ruma::events::space::child::SpaceChildEventContent;
use matrix_sdk::ruma::events::StateEventType;
use matrix_sdk::{ use matrix_sdk::{
room::Room as MatrixRoom, room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId}, ruma::{OwnedRoomId, RoomId},
}; };
use modalkit::prelude::{EditInfo, InfoMessage};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::Rect, layout::Rect,
@ -22,9 +25,18 @@ use modalkit_ratatui::{
WindowOps, 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); const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
@ -68,6 +80,71 @@ impl SpaceState {
last_fetch: self.last_fetch, 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
.can_user_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
.await
.map_err(IambError::from)?
{
return Err(IambError::InsufficientPermission.into());
}
let via = self.room.route().await.map_err(IambError::from)?;
let mut ev = SpaceChildEventContent::new(via);
ev.order = order;
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
.can_user_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
.await
.map_err(IambError::from)?
{
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 { impl TerminalCursor for SpaceState {
@ -107,7 +184,7 @@ impl<'a> Space<'a> {
} }
} }
impl<'a> StatefulWidget for Space<'a> { impl StatefulWidget for Space<'_> {
type State = SpaceState; type State = SpaceState;
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
@ -137,7 +214,8 @@ impl<'a> StatefulWidget for Space<'a> {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let fields = &self.store.application.settings.tunables.sort.rooms; 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.list.set(items);
state.last_fetch = Some(Instant::now()); state.last_fetch = Some(Instant::now());

View file

@ -20,11 +20,12 @@ use tracing::{error, warn};
use url::Url; use url::Url;
use matrix_sdk::{ use matrix_sdk::{
authentication::matrix::MatrixSession,
config::{RequestConfig, SyncSettings}, config::{RequestConfig, SyncSettings},
deserialized_responses::DisplayName,
encryption::verification::{SasVerification, Verification}, encryption::verification::{SasVerification, Verification},
encryption::{BackupDownloadStrategy, EncryptionSettings}, encryption::{BackupDownloadStrategy, EncryptionSettings},
event_handler::Ctx, event_handler::Ctx,
matrix_auth::MatrixSession,
reqwest, reqwest,
room::{Messages, MessagesOptions, Room as MatrixRoom, RoomMember}, room::{Messages, MessagesOptions, Room as MatrixRoom, RoomMember},
ruma::{ ruma::{
@ -58,6 +59,7 @@ use matrix_sdk::{
typing::SyncTypingEvent, typing::SyncTypingEvent,
AnyInitialStateEvent, AnyInitialStateEvent,
AnyMessageLikeEvent, AnyMessageLikeEvent,
AnySyncStateEvent,
AnyTimelineEvent, AnyTimelineEvent,
EmptyStateKey, EmptyStateKey,
InitialStateEvent, InitialStateEvent,
@ -78,8 +80,8 @@ use matrix_sdk::{
}, },
Client, Client,
ClientBuildError, ClientBuildError,
DisplayName,
Error as MatrixError, Error as MatrixError,
RoomDisplayName,
RoomMemberships, RoomMemberships,
}; };
@ -114,8 +116,7 @@ const IAMB_DEVICE_NAME: &str = "iamb";
const IAMB_USER_AGENT: &str = "iamb"; const IAMB_USER_AGENT: &str = "iamb";
const MIN_MSG_LOAD: u32 = 50; const MIN_MSG_LOAD: u32 = 50;
type MessageFetchResult = type MessageFetchResult = IambResult<(Option<String>, Vec<(AnyTimelineEvent, Vec<OwnedUserId>)>)>;
IambResult<(Option<String>, Vec<(AnyMessageLikeEvent, Vec<OwnedUserId>)>)>;
fn initial_devname() -> String { fn initial_devname() -> String {
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy()) format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
@ -209,7 +210,7 @@ async fn update_event_receipts(info: &mut RoomInfo, room: &MatrixRoom, event_id:
}; };
for (user_id, _) in receipts { 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());
} }
} }
@ -293,10 +294,8 @@ async fn load_older_one(
let mut msgs = vec![]; let mut msgs = vec![];
for ev in chunk.into_iter() { for ev in chunk.into_iter() {
let msg = match ev.event.deserialize() { let Ok(msg) = ev.into_raw().deserialize() else {
Ok(AnyTimelineEvent::MessageLike(msg)) => msg, continue;
Ok(AnyTimelineEvent::State(_)) => continue,
Err(_) => continue,
}; };
let event_id = msg.event_id(); let event_id = msg.event_id();
@ -311,6 +310,7 @@ async fn load_older_one(
}, },
}; };
let msg = msg.into_full_event(room_id.to_owned());
msgs.push((msg, receipts)); msgs.push((msg, receipts));
} }
@ -338,27 +338,34 @@ fn load_insert(
let _ = presences.get_or_default(sender); let _ = presences.get_or_default(sender);
for user_id in receipts { 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 { match msg {
AnyMessageLikeEvent::RoomEncrypted(msg) => { AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomEncrypted(msg)) => {
info.insert_encrypted(msg); info.insert_encrypted(msg);
}, },
AnyMessageLikeEvent::RoomMessage(msg) => { AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(msg)) => {
info.insert_with_preview( info.insert_with_preview(
room_id.clone(), room_id.clone(),
store.clone(), store.clone(),
*picker, picker.clone(),
msg, msg,
settings, settings,
client.media(), client.media(),
); );
}, },
AnyMessageLikeEvent::Reaction(ev) => { AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::Reaction(ev)) => {
info.insert_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());
}
},
} }
} }
@ -440,7 +447,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
let mut dms = vec![]; let mut dms = vec![];
for room in client.invited_rooms().into_iter() { 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(); let tags = room.tags().await.unwrap_or_default();
names.push((room.room_id().to_owned(), name)); names.push((room.room_id().to_owned(), name));
@ -455,7 +462,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
} }
for room in client.joined_rooms().into_iter() { 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(); let tags = room.tags().await.unwrap_or_default();
names.push((room.room_id().to_owned(), name)); names.push((room.room_id().to_owned(), name));
@ -490,31 +497,36 @@ async fn refresh_rooms_forever(client: &Client, store: &AsyncProgramStore) {
async fn send_receipts_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 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 { loop {
interval.tick().await; interval.tick().await;
let locked = store.lock().await; let mut locked = store.lock().await;
let user_id = &locked.application.settings.profile.user_id; let ChatStore { settings, open_notifications, rooms, .. } = &mut locked.application;
let updates = client let user_id = &settings.profile.user_id;
.joined_rooms()
.into_iter() let mut updates = Vec::new();
.filter_map(|room| { for room in client.joined_rooms() {
let room_id = room.room_id().to_owned(); let room_id = room.room_id();
let info = locked.application.rooms.get(&room_id)?; let Some(info) = rooms.get(room_id) else {
let new_receipt = info.get_receipt(user_id)?; continue;
let old_receipt = sent.get(&room_id); };
if Some(new_receipt) != old_receipt {
Some((room_id, new_receipt.clone())) let changed = info.receipts(user_id).filter_map(|(thread, new_receipt)| {
} else { let old_receipt = sent.get(room_id).and_then(|ts| ts.get(thread));
None let changed = Some(new_receipt) != old_receipt;
if changed {
open_notifications.remove(room_id);
} }
}) changed.then(|| (room_id.to_owned(), thread.to_owned(), new_receipt.to_owned()))
.collect::<Vec<_>>(); });
updates.extend(changed);
}
drop(locked); 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; use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
let Some(room) = client.get_room(&room_id) else { let Some(room) = client.get_room(&room_id) else {
@ -522,15 +534,11 @@ async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
}; };
match room match room
.send_single_receipt( .send_single_receipt(ReceiptType::Read, thread.to_owned(), new_receipt.clone())
ReceiptType::Read,
ReceiptThread::Unthreaded,
new_receipt.clone(),
)
.await .await
{ {
Ok(()) => { 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}"), Err(e) => tracing::warn!(?room_id, "Failed to set read receipt: {e}"),
} }
@ -603,7 +611,7 @@ fn oneshot<T>() -> (ClientReply<T>, ClientResponse<T>) {
return (reply, response); return (reply, response);
} }
pub type FetchedRoom = (MatrixRoom, DisplayName, Option<Tags>); pub type FetchedRoom = (MatrixRoom, RoomDisplayName, Option<Tags>);
pub enum WorkerTask { pub enum WorkerTask {
Init(AsyncProgramStore, ClientReply<()>), Init(AsyncProgramStore, ClientReply<()>),
@ -1001,7 +1009,7 @@ impl ClientWorker {
info.insert_with_preview( info.insert_with_preview(
room_id.to_owned(), room_id.to_owned(),
store.clone(), store.clone(),
*picker, picker.clone(),
full_ev, full_ev,
settings, settings,
client.media(), client.media(),
@ -1043,14 +1051,32 @@ impl ClientWorker {
let Some(receipts) = receipts.get(&ReceiptType::Read) else { let Some(receipts) = receipts.get(&ReceiptType::Read) else {
continue; continue;
}; };
for user_id in receipts.keys() { for (user_id, rcpt) in receipts.iter() {
info.set_receipt(user_id.to_owned(), event_id.clone()); 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( let _ = self.client.add_event_handler(
|ev: OriginalSyncRoomRedactionEvent, |ev: OriginalSyncRoomRedactionEvent,
room: MatrixRoom, room: MatrixRoom,
@ -1076,11 +1102,12 @@ impl ClientWorker {
let room_id = room.room_id(); let room_id = room.room_id();
let user_id = ev.state_key; let user_id = ev.state_key;
let ambiguous_name = let ambiguous_name = DisplayName::new(
ev.content.displayname.as_deref().unwrap_or_else(|| user_id.localpart()); ev.content.displayname.as_deref().unwrap_or_else(|| user_id.as_str()),
);
let ambiguous = client let ambiguous = client
.store() .store()
.get_users_with_display_name(room_id, ambiguous_name) .get_users_with_display_name(room_id, &ambiguous_name)
.await .await
.map(|users| users.len() > 1) .map(|users| users.len() > 1)
.unwrap_or_default(); .unwrap_or_default();
@ -1309,7 +1336,7 @@ impl ClientWorker {
// Remove the session.json file. // Remove the session.json file.
std::fs::remove_file(&self.settings.session_json)?; 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> { async fn direct_message(&mut self, user: OwnedUserId) -> IambResult<OwnedRoomId> {
@ -1346,7 +1373,7 @@ impl ClientWorker {
async fn get_room(&mut self, room_id: OwnedRoomId) -> IambResult<FetchedRoom> { async fn get_room(&mut self, room_id: OwnedRoomId) -> IambResult<FetchedRoom> {
if let Some(room) = self.client.get_room(&room_id) { 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)?; let tags = room.tags().await.map_err(IambError::from)?;
Ok((room, name, tags)) Ok((room, name, tags))
@ -1389,7 +1416,7 @@ impl ClientWorker {
req.limit = Some(1000u32.into()); req.limit = Some(1000u32.into());
req.max_depth = Some(1u32.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.room_id).collect();