119 Commits

Author SHA1 Message Date
Ulyssa
93fc47d019 Release v0.0.11 2026-01-19 19:22:41 -05:00
vaw
a32149f604 Fix CI on main branch (#545)
Co-authored-by: Benjamin Große <ste3ls@gmail.com>
2025-10-26 07:44:38 -07:00
vaw
3149f79d11 Add :replied to go to the message the selected message replied to (#452) 2025-10-26 14:36:46 +00:00
vaw
7ccb1cbf2c Upgrade Matrix SDK to 0.14 (#521) 2025-10-25 16:23:59 -07:00
Benjamin Grosse
1ec311590d Use cargo crane in Nix flake and set up cachix action (#539) 2025-10-25 22:44:19 +00:00
Thierry Delafontaine
0ddded3b8b Remove deprecated Apple SDK frameworks pattern (#543) 2025-10-25 14:43:25 -07:00
vaw
a8cbc352ff Indicate encryption state of room in messagebar (#522) 2025-10-25 14:41:08 -07:00
vaw
dfa0937077 Remove blocking timeout for first sync on startup (#529) 2025-10-25 13:54:47 -07:00
Sandro Santilli
43485270ee Document how to build from sources (#513) 2025-10-25 20:54:19 +00:00
vaw
28fea03625 Improve error message for UnknownToken on login (#514) 2025-10-25 13:53:47 -07:00
vaw
e021d4a55d Add :forget to forget all left rooms (#507) 2025-10-25 13:41:34 -07:00
vaw
b01dbe5a5d Add more compatibility for unreads (#451) 2025-10-25 20:22:14 +00:00
vaw
4b2382bf93 Fix image preview placeholder rendering (#483) 2025-10-25 13:00:49 -07:00
Electria
0f2442566f Fix incorrect empty unreads window message (#541) 2025-10-25 19:59:06 +00:00
vaw
8c9a2714a1 Fix rustfmt warning (#523) 2025-10-25 12:55:23 -07:00
vaw
d44f861871 Respect user color of replied message with message_user_color (#532) 2025-10-25 12:54:16 -07:00
vaw
14aa97251c Expand ~ and shell variables in dirs config (#538) 2025-10-25 12:52:14 -07:00
vaw
55456dbc1e Treat unknown html tags as plain text (#509) 2025-09-13 13:38:47 -07:00
vaw
d5c330ac72 Fix most clippy warnigs (#501) 2025-09-13 13:32:25 -07:00
weird
7b1dc93f3a Update Nix flake and its lockfile (#500) 2025-09-02 22:10:16 -07:00
vaw
745f547904 Fall back to showing body for unknown message types (#496) 2025-09-02 22:02:21 -07:00
Akseli
6ebb7ac7fd Add config option for playing sound-hints with desktop notifications (#481) 2025-08-22 14:47:33 -07:00
vaw
1bb93c18fb Search :members by display name and user id (#482) 2025-08-22 14:30:57 -07:00
vaw
e3090e537f Handle attachment file names more robustly (#494) 2025-08-22 14:24:35 -07:00
vaw
ad10082c2f Upgrade matrix sdk 0.13 (#485)
Co-authored-by: Ken Rachynski <chief@troublemaker.dev>
2025-08-22 14:16:01 -07:00
Ulyssa
67603d0623 Update to modalkit{,-ratatui}@0.0.24 (#492) 2025-08-16 23:40:59 +00:00
vaw
e9cdb3371a Clear desktop notification when message is read (#427)
Co-authored-by: Ulyssa <git@ulyssa.dev>
2025-07-23 05:19:23 +00:00
vaw
0ff8828a1c Add config option to allow resetting mode after sending a message (#459)
Co-authored-by: Ulyssa <git@ulyssa.dev>
2025-07-23 04:05:40 +00:00
vaw
331a6bca89 Make blockquotes in message visually distict (#466) 2025-07-22 17:26:29 -07:00
Thierry Delafontaine
963ce3c7c2 Support XDG_CONFIG_HOME on macOS for config directory resolution (#478) 2025-07-22 17:19:18 -07:00
vaw
ec88f4441e Recognise URLs in plain text message bodies (#476) 2025-07-22 17:05:23 -07:00
vaw
34d3b844af Highlight border of focused window (#470) 2025-07-05 00:25:38 +00:00
Ulyssa
52010d44d7 Update to modalkit{,-ratatui}@0.0.23 (#473) 2025-07-05 00:12:50 +00:00
vaw
0ef5c39f7f Make merging of configuration options consistent (#471) 2025-06-25 13:14:04 -07:00
VAWVAW
fed19d7a4b Improve image preview placeholder (#453) 2025-06-21 11:25:46 -07:00
VAWVAW
ed9ee26854 Add missing <s> tag in HTML parsing (#465) 2025-06-21 11:22:21 -07:00
VAWVAW
2e6c711644 Make scrollback display stable with typing_notice_display = false (#469) 2025-06-21 10:43:26 -07:00
VAWVAW
d1b03880f3 Remove duplicate documentation from manpage (#454) 2025-06-16 18:35:38 -07:00
Pavlo Rudy
d961fe3f7b Document settings.state_event_display in manual page (#455) 2025-06-16 18:31:01 -07:00
VAWVAW
9e40b49e5e Fix display of tabs in code blocks (#463) 2025-06-16 18:30:07 -07:00
Ulyssa
33d3407694 Apply user highlighting to display name changes (#449) 2025-06-06 02:46:32 +00:00
VAWVAW
f880358a83 Implement receipts per thread (#438) 2025-06-06 01:11:57 +00:00
VAWVAW
f0de97a049 Remove image preview on message redaction (#448) 2025-06-05 10:16:01 -07:00
VAWVAW
a9cb5608f0 Document every client command in the manual page (#441) 2025-06-05 04:57:06 +00:00
Ulyssa
c420c9dd65 Add configuration option for hiding state events (#447) 2025-06-05 02:36:21 +00:00
Ulyssa
ba7d0392d8 Do proper Unicode collation on room names (#440) 2025-05-31 12:52:15 -07:00
Ulyssa
9ed9400b67 Support automatically toggling room focus (#337) 2025-05-31 09:29:49 -07:00
Ulyssa
84eaadc09a Show state events in the timeline (#437) 2025-05-30 23:06:19 -07:00
Ulyssa
998e50f4a5 Update lockfile dependencies (#436) 2025-05-31 03:42:38 +00:00
VAWVAW
f39261ff84 Fix most incorrect unreads on startup (#433) 2025-05-30 08:56:46 -07:00
Ulyssa
98aa2f871d Update to ratatui-image@8.0.1 (#434) 2025-05-30 15:39:13 +00:00
VAWVAW
952374aab0 Show more text in notifications and use "normal" urgency for dbus notifications (#430) 2025-05-29 19:28:08 -07:00
VAWVAW
e99674b245 Query user for profile at startup when none have been specified (#432) 2025-05-29 19:25:07 -07:00
Aleš Katona
82ed796a91 Add support for scrolling w/ mouse when explicitly enabled (#389)
Co-authored-by: Ulyssa <git@ulyssa.dev>
2025-05-29 04:48:10 +00:00
VAWVAW
3296f58859 Omit room name on desktop notifications for DMs (#428) 2025-05-28 20:23:26 -07:00
VAWVAW
26802bab55 Fix Clippy warnings for 1.83 (#429) 2025-05-28 19:59:42 -07:00
VAWVAW
fd3fef5c9e Allow spaces to be searched by name (#404) 2025-05-23 09:26:17 -07:00
Ulyssa
af96bfbb41 Update to latest modalkit, modalkit-ratatui and ratatui-image (#422) 2025-05-16 18:02:43 -07:00
Ulyssa
5f927ce9c3 Binaries worklog should override rust-toolchain.yml (#420) 2025-05-15 21:21:05 -07:00
Jihyeon Kim (김지현)
6e923f3878 Update modalkit and modalkit-ratatui to SHA 45855daeeb (#358) 2025-05-16 03:09:12 +00:00
Ulyssa
ebd89423e9 Bump minimum supported Rust version to 1.83 (#420) 2025-05-16 01:11:34 +00:00
Ulyssa
9fce71f896 Display <unknown> for unknown room history visibility (#397) 2025-05-15 17:56:43 -07:00
Ken Rachynski
93502f9993 Bump matrix-sdk dependency to 0.10.0 (#397) 2025-05-15 17:56:35 -07:00
Ulyssa
6529e61963 Update binaries workflow to mozilla-actions/sccache-action@v0.0.9 (#419) 2025-05-15 09:26:41 -07:00
Andrew Collins
a9c1e69a89 Fix image preview in replies and threads (#366) 2025-05-15 04:23:39 +00:00
VAWVAW
3e45ca3d2c Support adding rooms to spaces (#407) 2025-05-15 03:26:35 +00:00
Felix Van der Jeugt
7dd09e32a8 Support an "invite" field in the room sorting settings (#395)
Co-authored-by: Felix Van der Jeugt <felix.vanderjeugt@posteo.net>
2025-05-14 19:39:22 -07:00
daef
1dcd658928 Support :room topic show (#380) 2025-05-14 19:05:58 -07:00
Repoman
382a72a468 Mention Gentoo's GURU ebuild in the README (#374) 2025-05-15 01:51:19 +00:00
Benjamin Bouvier
591fc0af83 Address some warnings and typos (#408) 2025-05-15 01:46:13 +00:00
Ulyssa
2b6363f529 Update to mozilla-actions/sccache-action@v0.0.9 (#419) 2025-05-15 01:38:22 +00:00
VAWVAW
6470e845e0 Fix warning from cargo doc (#413) 2025-05-14 18:22:27 -07:00
Odd Eivind Ebbesen
b023e38f77 Updated rust version and added sqlite in flake.nix (#396) 2025-02-24 03:16:46 +00:00
Stu Black
e66a8c6716 Bump matrix-sdk dependency to 0.8 (#386) 2025-02-18 03:22:16 +00:00
Nemo157
9a9bdb4862 Support enabling multiple notification sinks (#344) 2024-09-16 22:15:36 -07:00
Nemo157
e40a8a8d2e Fix ratatui-image tmux detection when used with a configured image protocol (#352) 2024-09-16 22:12:16 -07:00
Nemo157
f4492c9f77 Fix Clippy warning for unused format! in 1.81 (#343) 2024-08-30 09:10:15 -07:00
Ulyssa
a32915b7e9 Update Cargo.toml to v0.0.11-alpha.1 (#346) 2024-08-30 16:08:12 +00:00
Ulyssa
3355eb2d26 Do not use icons in MetaInfo (#336) 2024-08-23 18:35:32 +00:00
Ulyssa
7b6c5df268 Update MetaInfo for v0.0.10 release (#335) 2024-08-21 16:10:56 +00:00
Ulyssa
2e6376ff86 Release v0.0.10 (#333) 2024-08-20 22:26:52 -07:00
Ulyssa
480888a1fc Add commands for viewing and clearing unreads (#332) 2024-08-20 19:33:46 -07:00
Ulyssa
4fc05c7b40 Handle message marks on non-64-bit platforms (#329) 2024-08-18 08:31:42 +00:00
Ulyssa
3003f0a528 Add command for setting room history visibility (#328) 2024-08-18 07:33:45 +00:00
Ulyssa
df3896df9c Add ban/unban/kick room commands (#327) 2024-08-18 01:50:48 +00:00
Tony
2a66496913 Add command to set per-room notification levels (#305) 2024-08-17 14:43:19 -07:00
Ulyssa
b4fc574163 Include room name in desktop notifications (#326) 2024-08-17 01:03:13 -07:00
Ulyssa
e63341fe32 Avoid treating simple messages as Markdown (#325) 2024-08-16 23:56:30 -07:00
Ulyssa
657e61fe2e Remove modifyOtherKeys enablement (#324) 2024-08-17 03:06:35 +00:00
Ulyssa
94999dc4c0 Build cross-platform binaries and packages of main (#323) 2024-08-16 10:06:26 -07:00
Ulyssa
54cb7991be Fix underflow panics when using TextPrinter::push_span_nobreak (#322) 2024-08-15 03:48:04 +00:00
Ulyssa
c94d7d0ad7 Add metadata for cargo-deb and cargo-generate-rpm (#321) 2024-08-15 03:37:56 +00:00
Ulyssa
d44961c461 Support reacting literally with non-Emojis (#320) 2024-08-13 06:21:11 +00:00
Ulyssa
6d80b516f8 Update to modalkit{,-ratatui}@0.0.20 (#319) 2024-08-12 04:59:32 +00:00
Ulyssa
04480eda1b Add message slash commands (#317) 2024-08-08 05:49:54 +00:00
Ulyssa
653287478e Add FreeDesktop MetaInfo file (#315) 2024-08-01 07:45:50 +00:00
lymkwi
4571788678 Implement set/unset/show for alternative and canonical aliases (#279) 2024-08-01 06:51:01 +00:00
Andrew Collins
9a1adfb287 Allow notifications on open room if terminal not focused (#281) 2024-08-01 03:37:21 +00:00
Backroom8816
cb4455655f Add Hombrew as install method on MacOS (#303) 2024-08-01 03:10:31 +00:00
Jarkko Sakkinen
4fc71c9291 Fix newer Clippy warnings for 1.80 (#301) 2024-08-01 03:02:42 +00:00
Veronika
d8d8e91295 Remove timeout for desktop notifications (#314) 2024-08-01 02:56:33 +00:00
Aurore Poirier
497be7f099 Display file sizes for attachments (#278) 2024-05-25 16:16:04 -07:00
mordquist
64e4f67e43 Add error for missing username on :logout (#277) 2024-05-25 15:53:52 -07:00
Andrew Collins
a18d0f54eb Trim :editor output and check if it's empty (#275) 2024-05-25 15:52:41 -07:00
Lars E
59e1862e9c Add FreeBSD installation instructions (#280) 2024-05-25 20:42:46 +00:00
Joshua Smith
14415a30fc Fix openSUSE link and installation command in README (#283) 2024-05-25 20:40:25 +00:00
Gabor Pihaj
6c0d126f4b Add missing darwin build dependency (#286) 2024-05-25 20:38:01 +00:00
Ulyssa
c6982c9737 Fix LICENSE file (#274) 2024-04-24 06:59:00 +00:00
Ulyssa
46f6d37f76 Update to modalkit{,-ratatui}@0.0.19 (#273) 2024-04-24 06:30:01 +00:00
Ulyssa
3971801aa3 Allow typing newline with <S-Enter> and enable keyboard enhancement protocol (#272) 2024-04-21 18:19:53 -07:00
Ulyssa
7bc34c8145 Update Cargo.toml to v0.0.10-alpha.1 and update dependencies (#269) 2024-04-17 08:06:08 +00:00
Ethan Reynolds
91ca50aecb Fix image preview placement when messages are preceded by a date in the timeline (#257) 2024-04-13 15:47:08 -07:00
Ulyssa
949100bdc7 Update Welcome window to reference TOML instead of JSON (#254) 2024-04-12 06:20:05 +00:00
Ethan Reynolds
b995906c79 Add external_edit_file_suffix to config (#253) 2024-04-11 20:50:26 -07:00
Ulyssa
e5b284ed19 Use color overrides for users when message_user_color is enabled (#245) 2024-04-02 15:42:27 +00:00
Matthias Ahouansou
0f17bbfa17 Fix reaction count when there are duplicate reaction events from a user (#239) 2024-04-02 15:40:25 +00:00
Matthias Ahouansou
aba72aa64d Prevent sending duplicate reaction events (#240) 2024-04-02 15:21:24 +00:00
Benjamin Grosse
72d35431de Update to ratatui-image@1.0.0 (#241) 2024-04-02 08:01:00 -07:00
Ulyssa
a98bbd97be Support marking a room as a direct message room (#92) 2024-03-31 00:12:57 -07:00
36 changed files with 8378 additions and 2523 deletions

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

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

View File

@@ -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
@@ -45,12 +45,25 @@ jobs:
reporter: 'github-check' reporter: 'github-check'
- name: Run tests - name: Run tests
run: cargo test --locked run: cargo test --locked
- name: Build artifacts
run: cargo build --release --locked nix-flake-test:
- name: Upload artifacts name: Flake checks ❄️
uses: actions/upload-artifact@master strategy:
with: matrix:
name: iamb-${{ matrix.platform }} platform: [ubuntu-latest, macos-latest]
path: | runs-on: ${{ matrix.platform }}
./target/release/iamb steps:
./target/release/iamb.exe - name: Checkout Code
uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- uses: cachix/cachix-action@v15
with:
name: iamb-prs
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Flake check
run: |
nix flake show
nix flake check --print-build-logs

View File

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

4652
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.9" version = "0.0.11"
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,12 +11,13 @@ 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.88"
build = "build.rs" build = "build.rs"
[features] [features]
default = ["bundled"] default = ["bundled", "desktop"]
bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"] bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"]
desktop = ["dep:notify-rust", "modalkit/clipboard"]
native-tls = ["matrix-sdk/native-tls"] native-tls = ["matrix-sdk/native-tls"]
rustls-tls = ["matrix-sdk/rustls-tls"] rustls-tls = ["matrix-sdk/rustls-tls"]
@@ -26,27 +27,27 @@ default-features = false
features = ["build", "git", "gitcl",] features = ["build", "git", "gitcl",]
[dependencies] [dependencies]
arboard = "3.3.0" anyhow = "1.0"
bitflags = "^2.3" bitflags = "^2.3"
chrono = "0.4" chrono = "0.4"
clap = {version = "~4.3", features = ["derive"]} clap = {version = "~4.3", features = ["derive"]}
comrak = {version = "0.18.0", features = ["shortcodes"]}
css-color-parser = "0.1.2" css-color-parser = "0.1.2"
dirs = "4.0.0" dirs = "4.0.0"
emojis = "~0.5.2" 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"
mime_guess = "^2.0.4" mime_guess = "^2.0.4"
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] } nom = "7.0.0"
open = "3.2.0" open = "3.2.0"
rand = "0.8.5" rand = "0.8.5"
ratatui = "0.23" ratatui = "0.29.0"
ratatui-image = { version = "0.8.1", 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"
@@ -62,19 +63,34 @@ unicode-segmentation = "^1.7"
unicode-width = "0.1.10" 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"
linkify = "0.10.0"
shellexpand = "3.1.1"
[dependencies.comrak]
version = "0.22.0"
default-features = false
features = ["shortcodes"]
[dependencies.notify-rust]
version = "~4.10.0"
default-features = false
features = ["zbus", "serde"]
optional = true
[dependencies.modalkit] [dependencies.modalkit]
version = "0.0.18" version = "0.0.24"
default-features = false
#git = "https://github.com/ulyssa/modalkit" #git = "https://github.com/ulyssa/modalkit"
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01" #rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
[dependencies.modalkit-ratatui] [dependencies.modalkit-ratatui]
version = "0.0.18" version = "0.0.24"
#git = "https://github.com/ulyssa/modalkit" #git = "https://github.com/ulyssa/modalkit"
#rev = "cb8c8aeb9a499b9b16615ce144f9014d78036e01" #rev = "e40dbb0bfeabe4cfd08facd2acb446080a330d75"
[dependencies.matrix-sdk] [dependencies.matrix-sdk]
version = "0.7.1" version = "0.14.0"
default-features = false default-features = false
features = ["e2e-encryption", "sqlite", "sso-login"] features = ["e2e-encryption", "sqlite", "sso-login"]
@@ -90,3 +106,33 @@ pretty_assertions = "1.4.0"
inherits = "release" inherits = "release"
incremental = false incremental = false
lto = true lto = true
[package.metadata.deb]
section = "net"
license-file = ["LICENSE", "0"]
assets = [
# Binary:
["target/release/iamb", "usr/bin/iamb", "755"],
# Manual pages:
["docs/iamb.1", "usr/share/man/man1/iamb.1", "644"],
["docs/iamb.5", "usr/share/man/man5/iamb.5", "644"],
# Other assets:
["iamb.desktop", "usr/share/applications/iamb.desktop", "644"],
["config.example.toml", "usr/share/iamb/config.example.toml", "644"],
["docs/iamb.svg", "usr/share/icons/hicolor/scalable/apps/iamb.svg", "644"],
["docs/iamb.metainfo.xml", "usr/share/metainfo/iamb.metainfo.xml", "644"],
]
[package.metadata.generate-rpm]
assets = [
# Binary:
{ source = "target/release/iamb", dest = "/usr/bin/iamb", mode = "755" },
# Manual pages:
{ source = "docs/iamb.1", dest = "/usr/share/man/man1/iamb.1", mode = "644" },
{ source = "docs/iamb.5", dest = "/usr/share/man/man5/iamb.5", mode = "644" },
# Other assets:
{ source = "iamb.desktop", dest = "/usr/share/applications/iamb.desktop", mode = "644" },
{ source = "config.example.toml", dest = "/usr/share/iamb/config.example.toml", mode = "644"},
{ source = "docs/iamb.svg", dest = "/usr/share/icons/hicolor/scalable/apps/iamb.svg", mode = "644"},
{ source = "docs/iamb.metainfo.xml", dest = "/usr/share/metainfo/iamb.metainfo.xml", mode = "644"},
]

View File

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

View File

@@ -28,15 +28,17 @@ and GCC are present.
In addition to the compiled binary, there are other files in the repo that In addition to the compiled binary, there are other files in the repo that
you'll want to install as part of a package: you'll want to install as part of a package:
| Repository Path | Installed Path (may vary per OS) | <!-- Please keep in sync w/ the `deb`/`generate-rpm` sections of `Cargo.toml` -->
| -------------------- | ----------------------------------------------- | | Repository Path | Installed Path (may vary per OS) |
| /iamb.desktop | /usr/share/applications/iamb.desktop | | ----------------------- | ----------------------------------------------- |
| /config.example.toml | /usr/share/iamb/config.example.toml | | /iamb.desktop | /usr/share/applications/iamb.desktop |
| /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png | | /config.example.toml | /usr/share/iamb/config.example.toml |
| /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png | | /docs/iamb-256x256.png | /usr/share/icons/hicolor/256x256/apps/iamb.png |
| /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg | | /docs/iamb-512x512.png | /usr/share/icons/hicolor/512x512/apps/iamb.png |
| /docs/iamb.1 | /usr/share/man/man1/iamb.1 | | /docs/iamb.svg | /usr/share/icons/hicolor/scalable/apps/iamb.svg |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 | | /docs/iamb.1 | /usr/share/man/man1/iamb.1 |
| /docs/iamb.5 | /usr/share/man/man5/iamb.5 |
| /docs/iamb.metainfo.xml | /usr/share/metainfo/iamb.metainfo.xml |
[ring-lto]: https://github.com/briansmith/ring/issues/1444 [ring-lto]: https://github.com/briansmith/ring/issues/1444
[rustls]: https://crates.io/crates/rustls [rustls]: https://crates.io/crates/rustls

149
README.md
View File

@@ -11,7 +11,6 @@
</div> </div>
## About ## About
`iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for: `iamb` is a Matrix client for the terminal that uses Vim keybindings. It includes support for:
@@ -19,6 +18,7 @@
- Threads, spaces, E2EE, and read receipts - Threads, spaces, E2EE, and read receipts
- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't - Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't
- Notifications via terminal bell or desktop environment - Notifications via terminal bell or desktop environment
- Send Markdown, HTML or plaintext messages
- Creating, joining, and leaving rooms - Creating, joining, and leaving rooms
- Sending and accepting room invitations - Sending and accepting room invitations
- Editing, redacting, and reacting to messages - Editing, redacting, and reacting to messages
@@ -32,54 +32,6 @@ _You may want to [see this page as it was when the latest version was published]
You can find documentation for installing, configuring, and using iamb on its You can find documentation for installing, configuring, and using iamb on its
website, [iamb.chat]. website, [iamb.chat].
## Installation
Install Rust (1.70.0 or above) and Cargo, and then run:
```
cargo install --locked iamb
```
See [Configuration](#configuration) for getting a profile set up.
### NetBSD
On NetBSD a package is available from the official repositories. To install it simply run:
```
pkgin install iamb
```
### Arch Linux
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
```
paru iamb-git
```
### openSUSE Tumbleweed
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/home%3Asmolsheep/iamb) is available from openSUSE Build Service (OBS). To install just use OBS Package Installer:
```
opi iamb
```
### Nix / NixOS (flake)
```
nix profile install "github:ulyssa/iamb"
```
### Snap
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
```
snap install iamb
```
## Configuration ## Configuration
You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like: You can create a basic configuration in `$CONFIG_DIR/iamb/config.toml` that looks like:
@@ -99,14 +51,107 @@ url = "https://example.com"
user_id = "@user:example.com" user_id = "@user:example.com"
``` ```
## Installation (from source)
Install Rust and Cargo using [rustup], and then run from the directory
containing the sources (ie: from a git clone):
```
cargo install --locked --path .
```
## Installation (via `crates.io`)
Install Rust (1.83.0 or above) and Cargo, and then run:
```
cargo install --locked iamb
```
See [Configuration](#configuration) for getting a profile set up.
## Installation (via package managers)
### Arch Linux
On Arch Linux a [package](https://aur.archlinux.org/packages/iamb-git) is available in the
Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
```
paru iamb-git
```
### FreeBSD
On FreeBSD a package is available from the official repositories. To install it simply run:
```
pkg install iamb
```
### Gentoo
On Gentoo, an ebuild is available from the community-managed
[GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU).
You can enable the GURU overlay with:
```
eselect repository enable guru
emerge --sync guru
```
And then install `iamb` with:
```
emerge --ask iamb
```
### macOS
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
repository. To install it simply run:
```
brew install iamb
```
### NetBSD
On NetBSD a package is available from the official repositories. To install it simply run:
```
pkgin install iamb
```
### Nix / NixOS (flake)
```
nix profile install "github:ulyssa/iamb"
```
### openSUSE Tumbleweed
On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/iamb) is available from the official repositories. To install it simply run:
```
zypper install iamb
```
### Snap
A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system.
```
snap install iamb
```
## License ## License
iamb is released under the [Apache License, Version 2.0]. iamb is released under the [Apache License, Version 2.0].
[Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE [Apache License, Version 2.0]: https://github.com/ulyssa/iamb/blob/master/LICENSE
[client-comparison-matrix]: https://matrix.org/clients-matrix/
[crates-io-iamb]: https://crates.io/crates/iamb [crates-io-iamb]: https://crates.io/crates/iamb
[iamb.chat]: https://iamb.chat [iamb.chat]: https://iamb.chat
[gomuks]: https://github.com/tulir/gomuks
[weechat-matrix]: https://github.com/poljar/weechat-matrix
[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient [well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient
[rustup]: https://rustup.rs/

View File

@@ -6,6 +6,7 @@ url = "https://matrix.org"
[settings] [settings]
default_room = "#iamb-users:0x.badd.cafe" default_room = "#iamb-users:0x.badd.cafe"
external_edit_file_suffix = ".md"
log_level = "warn" log_level = "warn"
message_shortcode_display = false message_shortcode_display = false
open_command = ["my-open", "--file"] open_command = ["my-open", "--file"]

View File

@@ -54,15 +54,21 @@ 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"
View a list of joined rooms. View a list of joined rooms.
.It Sy ":spaces" .It Sy ":spaces"
View a list of joined spaces. View a list of joined spaces.
.It Sy ":unreads"
View a list of unread rooms.
.It Sy ":unreads clear"
Mark all rooms as read.
.It Sy ":welcome" .It Sy ":welcome"
View the startup Welcome window. View the startup Welcome window.
.It Sy ":forget"
Remove all left rooms from the internal database.
.El .El
.Sh "E2EE COMMANDS" .Sh "E2EE COMMANDS"
@@ -75,37 +81,56 @@ 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 ":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 ":replied"
Go to the message the current message replied to.
.It Sy ":upload [path]"
Upload an attachment and send it to the currently selected room. 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"
@@ -113,7 +138,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"
@@ -122,6 +147,29 @@ 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]"
Set a notification level for the currently focused room.
Valid levels are
.Dq mute ,
.Dq mentions ,
.Dq keywords ,
and
.Dq all .
Note that
.Dq mentions
and
.Dq keywords
are aliases for the same behaviour.
.It Sy ":room notify unset"
Unset any room-level notification configuration.
.It Sy ":room notify show"
Show the current room-level notification configuration.
If the room is using the account-level default, then this will print
.Dq default .
.It Sy ":room tag set [tag]" .It Sy ":room tag set [tag]"
Add a tag to the currently focused room. Add a tag to the currently focused room.
.It Sy ":room tag unset [tag]" .It Sy ":room tag unset [tag]"
@@ -130,6 +178,40 @@ 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]"
Create and point the given alias to the room.
.It Sy ":room alias unset [alias]"
Delete the provided alias from the room's alternative alias list.
.It Sy ":room alias show"
Show alternative aliases to the room, if any are set.
.It Sy ":room id show"
Show the Matrix identifier for the room.
.It Sy ":room canon set [alias]"
Set the room's canonical alias to the one provided, and make the previous one an alternative alias.
.It Sy ":room canon unset [alias]"
Delete the room's canonical alias.
.It Sy ":room canon show"
Show the room's canonical alias, if any is set.
.It Sy ":room ban [user] [reason]"
Ban a user from this room with an optional reason.
.It Sy ":room unban [user] [reason]"
Unban a user from this room with an optional reason.
.It Sy ":room kick [user] [reason]"
Kick a user from this room with an optional reason.
.El
.Sh "SPACE COMMANDS"
.Bl -tag -width Ds
.It Sy ":space child set [room_id] [arguments]"
Add a room to the currently focused space.
.Dq ++suggested
marks the room as a suggested child.
.Dq ++order=[string]
specifies a string by which children are lexicographically ordered.
.It Sy ":space child remove"
Remove the selected room from the currently focused space.
.El .El
.Sh "WINDOW COMMANDS" .Sh "WINDOW COMMANDS"
@@ -176,6 +258,43 @@ Close all but one tab.
Go to the preview tab. Go to the preview tab.
.El .El
.Sh "SLASH COMMANDS"
.Bl -tag -width Ds
.It Sy "/markdown" , Sy "/md"
Interpret the message body as Markdown markup.
This is the default behaviour.
.It Sy "/html" , Sy "/h"
Send the message body as literal HTML.
.It Sy "/plaintext" , Sy "/plain" , Sy "/p"
Do not interpret any markup in the message body and send it as it is.
.It Sy "/me"
Send an emote message.
.It Sy "/confetti"
Produces no effect in
.Nm ,
but will display confetti in Matrix clients that support doing so.
.It Sy "/fireworks"
Produces no effect in
.Nm ,
but will display fireworks in Matrix clients that support doing so.
.It Sy "/hearts"
Produces no effect in
.Nm ,
but will display floating hearts in Matrix clients that support doing so.
.It Sy "/rainfall"
Produces no effect in
.Nm ,
but will display rainfall in Matrix clients that support doing so.
.It Sy "/snowfall"
Produces no effect in
.Nm ,
but will display snowfall in Matrix clients that support doing so.
.It Sy "/spaceinvaders"
Produces no effect in
.Nm ,
but will display aliens from Space Invaders in Matrix clients that support doing so.
.El
.Sh EXAMPLES .Sh EXAMPLES
.Ss Example 1: Starting with a specific profile .Ss Example 1: Starting with a specific profile
To start with a profile named To start with a profile named

View File

@@ -125,6 +125,9 @@ key and can be overridden as described in
.Sx PROFILES . .Sx PROFILES .
.Bl -tag -width Ds .Bl -tag -width Ds
.It Sy external_edit_file_suffix
Suffix to append to temporary file names when using the :editor command. Defaults to .md.
.It Sy default_room .It Sy default_room
The room to show by default instead of the The room to show by default instead of the
.Sy :welcome .Sy :welcome
@@ -170,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
@@ -205,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.
@@ -228,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)
@@ -266,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
@@ -329,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"]

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

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

124
flake.lock generated
View File

@@ -1,33 +1,51 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1759893430,
"narHash": "sha256-yAy4otLYm9iZ+NtQwTMEbqHwswSFUbhn7x826RR6djw=",
"owner": "ipetkov",
"repo": "crane",
"rev": "1979a2524cb8c801520bd94c38bb3d5692419d93",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1760510549,
"narHash": "sha256-NP+kmLMm7zSyv4Fufv+eSJXyqjLMUhUfPT6lXRlg/bU=",
"owner": "nix-community",
"repo": "fenix",
"rev": "ef7178cf086f267113b5c48fdeb6e510729c8214",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"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 +56,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1709703039, "lastModified": 1760284886,
"narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=", "narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d", "rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -52,45 +70,28 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": {
"locked": {
"lastModified": 1706487304,
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"fenix": "fenix",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs"
"rust-overlay": "rust-overlay"
} }
}, },
"rust-overlay": { "rust-analyzer-src": {
"inputs": { "flake": false,
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": { "locked": {
"lastModified": 1709863839, "lastModified": 1760457219,
"narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=", "narHash": "sha256-WJOUGx42hrhmvvYcGkwea+BcJuQJLcns849OnewQqX4=",
"owner": "oxalica", "owner": "rust-lang",
"repo": "rust-overlay", "repo": "rust-analyzer",
"rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a", "rev": "8747cf81540bd1bbbab9ee2702f12c33aa887b46",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "oxalica", "owner": "rust-lang",
"repo": "rust-overlay", "ref": "nightly",
"repo": "rust-analyzer",
"type": "github" "type": "github"
} }
}, },
@@ -108,21 +109,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",

125
flake.nix
View File

@@ -5,40 +5,107 @@
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay"; crane.url = "github:ipetkov/crane";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }: outputs =
flake-utils.lib.eachDefaultSystem (system: {
self,
nixpkgs,
crane,
flake-utils,
fenix,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let let
# We only need the nightly overlay in the devShell because .rs files are formatted with nightly. pkgs = nixpkgs.legacyPackages.${system};
overlays = [ (import rust-overlay) ]; inherit (pkgs) lib;
pkgs = import nixpkgs { inherit system overlays; };
rustNightly = pkgs.rust-bin.nightly."2024-03-08".default; rustToolchain = fenix.packages.${system}.fromToolchainFile {
in file = ./rust-toolchain.toml;
with pkgs; # When the file changes, this hash must be updated.
{ sha256 = "sha256-Qxt8XAuaUR2OMdKbN4u8dBJOhSHxS+uS06Wl9+flVEk=";
packages.default = rustPlatform.buildRustPackage {
pname = "iamb";
version = self.shortRev or self.dirtyShortRev;
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin
(with darwin.apple_sdk.frameworks; [ AppKit Security ]);
}; };
devShell = mkShell { # Nightly toolchain for rustfmt (pinned to current flake lock)
buildInputs = [ # Note that the github CI uses "current nightly" for formatting, it 's not pinned.
(rustNightly.override { rustNightly = fenix.packages.${system}.latest;
extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ];
}) craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
pkg-config craneLibNightly = (crane.mkLib pkgs).overrideToolchain rustNightly.toolchain;
cargo-tarpaulin
cargo-watch src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
(craneLib.fileset.commonCargoSources ./.)
./src/windows/welcome.md
]; ];
}; };
});
commonArgs = {
inherit src;
strictDeps = true;
pname = "iamb";
version = self.shortRev or self.dirtyShortRev;
};
# Build *just* the cargo dependencies, so we can reuse
# all of that work (e.g. via cachix) when running in CI
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Build the actual crate
iamb = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
checks = {
# Build the crate as part of `nix flake check`
inherit iamb;
iamb-clippy = craneLib.cargoClippy (commonArgs // {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
});
iamb-fmt = craneLibNightly.cargoFmt {
inherit src;
};
iamb-nextest = craneLib.cargoNextest (commonArgs // {
inherit cargoArtifacts;
partitions = 1;
partitionType = "count";
});
};
packages.default = iamb;
apps.default = flake-utils.lib.mkApp {
drv = iamb;
};
devShells.default = craneLib.devShell {
# Inherit inputs from checks
checks = self.checks.${system};
packages = with pkgs; [
cargo-tarpaulin
cargo-watch
sqlite
];
shellHook = ''
# Prepend nightly rustfmt to PATH.
export PATH="${rustNightly.rustfmt}/bin:$PATH"
'';
};
}
);
} }

3
rust-toolchain.toml Normal file
View File

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

File diff suppressed because it is too large Load Diff

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},
@@ -20,12 +20,14 @@ use crate::base::{
IambAction, IambAction,
IambId, IambId,
KeysAction, KeysAction,
MemberUpdateAction,
MessageAction, MessageAction,
ProgramCommand, ProgramCommand,
ProgramCommands, ProgramCommands,
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
SpaceAction,
VerifyAction, VerifyAction,
}; };
@@ -34,7 +36,7 @@ type ProgResult = CommandResult<ProgramCommand>;
/// Convert strings the user types into a tag name. /// Convert strings the user types into a tag name.
fn tag_name(name: String) -> Result<TagName, CommandError> { fn tag_name(name: String) -> Result<TagName, CommandError> {
let tag = match name.as_str() { let tag = match name.to_lowercase().as_str() {
"fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite, "fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite,
"low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => { "low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => {
TagName::LowPriority TagName::LowPriority
@@ -198,6 +200,17 @@ fn iamb_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Ok(step); return Ok(step);
} }
fn iamb_forget(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let forget = IambAction::Homeserver(HomeserverAction::Forget);
let step = CommandStep::Continue(forget.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() { if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
@@ -221,24 +234,17 @@ fn iamb_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let args = desc.arg.strings()?; let mut args = desc.arg.strings()?;
if args.len() != 1 { if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let k = args[0].as_str(); let react = args.remove(0);
let mact = IambAction::from(MessageAction::React(react, desc.bang));
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) { return Ok(step);
let mact = IambAction::from(MessageAction::React(emoji.to_string()));
let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step);
} else {
let msg = format!("Invalid Emoji or shortcode: {k}");
return Result::Err(CommandError::Error(msg));
}
} }
fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
@@ -248,20 +254,8 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
let mact = if let Some(k) = args.pop() { let reaction = args.pop();
let k = k.as_str(); let mact = IambAction::from(MessageAction::Unreact(reaction, desc.bang));
if let Some(emoji) = emojis::get(k).or_else(|| emojis::get_by_shortcode(k)) {
IambAction::from(MessageAction::Unreact(Some(emoji.to_string())))
} else {
let msg = format!("Invalid Emoji or shortcode: {k}");
return Result::Err(CommandError::Error(msg));
}
} else {
IambAction::from(MessageAction::Unreact(None))
};
let step = CommandStep::Continue(mact.into(), ctx.context.clone()); let step = CommandStep::Continue(mact.into(), ctx.context.clone());
return Ok(step); return Ok(step);
@@ -292,6 +286,17 @@ fn iamb_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Ok(step); return Ok(step);
} }
fn iamb_replied(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let ract = IambAction::from(MessageAction::Replied);
let step = CommandStep::Continue(ract.into(), ctx.context.clone());
return Ok(step);
}
fn iamb_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() { if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
@@ -325,6 +330,30 @@ fn iamb_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Ok(step); return Ok(step);
} }
fn iamb_unreads(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.strings()?;
if args.len() > 1 {
return Result::Err(CommandError::InvalidArgument);
}
match args.pop().as_deref() {
Some("clear") => {
let clear = IambAction::ClearUnreads;
let step = CommandStep::Continue(clear.into(), ctx.context.clone());
return Ok(step);
},
Some(_) => return Result::Err(CommandError::InvalidArgument),
None => {
let open = ctx.switch(OpenTarget::Application(IambId::UnreadList));
let step = CommandStep::Continue(open, ctx.context.clone());
return Ok(step);
},
}
}
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() { if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
@@ -422,6 +451,37 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
} }
let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) { let act: IambAction = match (field.as_str(), action.as_str(), args.pop()) {
// :room dm set
("dm", "set", None) => RoomAction::SetDirect(true).into(),
("dm", "set", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room dm set
("dm", "unset", None) => RoomAction::SetDirect(false).into(),
("dm", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room [kick|ban|unban] <user>
("kick", u, r) => {
RoomAction::MemberUpdate(MemberUpdateAction::Kick, u.into(), r, desc.bang).into()
},
("ban", u, r) => {
RoomAction::MemberUpdate(MemberUpdateAction::Ban, u.into(), r, desc.bang).into()
},
("unban", u, r) => {
RoomAction::MemberUpdate(MemberUpdateAction::Unban, u.into(), r, desc.bang).into()
},
// :room history set <visibility>
("history", "set", Some(s)) => RoomAction::Set(RoomField::History, s).into(),
("history", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room history unset
("history", "unset", None) => RoomAction::Unset(RoomField::History).into(),
("history", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room history show
("history", "show", None) => RoomAction::Show(RoomField::History).into(),
("history", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room name set <room-name> // :room name set <room-name>
("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(), ("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(),
("name", "set", None) => return Result::Err(CommandError::InvalidArgument), ("name", "set", None) => return Result::Err(CommandError::InvalidArgument),
@@ -438,6 +498,10 @@ 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),
@@ -446,6 +510,139 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(), ("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(),
("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument), ("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument),
// :room notify set <notification-level>
("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(),
("notify", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room notify unset <notification-level>
("notify", "unset", None) => RoomAction::Unset(RoomField::NotificationMode).into(),
("notify", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room notify show
("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(),
("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room aliases show
("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(),
("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument),
// :room aliases unset <alias>
("alias", "unset", Some(s)) => RoomAction::Unset(RoomField::Alias(s)).into(),
("alias", "unset", None) => return Result::Err(CommandError::InvalidArgument),
// :room aliases set <alias>
("alias", "set", Some(s)) => RoomAction::Set(RoomField::Alias(s), "".into()).into(),
("alias", "set", None) => return Result::Err(CommandError::InvalidArgument),
// :room canonicalalias show
("canonicalalias" | "canon", "show", None) => {
RoomAction::Show(RoomField::CanonicalAlias).into()
},
("canonicalalias" | "canon", "show", Some(_)) => {
return Result::Err(CommandError::InvalidArgument)
},
// :room canonicalalias set
("canonicalalias" | "canon", "set", Some(s)) => {
RoomAction::Set(RoomField::CanonicalAlias, s).into()
},
("canonicalalias" | "canon", "set", None) => {
return Result::Err(CommandError::InvalidArgument)
},
// :room canonicalalias unset
("canonicalalias" | "canon", "unset", None) => {
RoomAction::Unset(RoomField::CanonicalAlias).into()
},
("canonicalalias" | "canon", "unset", Some(_)) => {
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),
}; };
@@ -507,6 +704,9 @@ fn iamb_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { fn iamb_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let args = desc.arg.strings()?; let args = desc.arg.strings()?;
if args.is_empty() {
return Result::Err(CommandError::Error("Missing username".to_string()));
}
if args.len() != 1 { if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument); return Result::Err(CommandError::InvalidArgument);
} }
@@ -553,6 +753,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![], aliases: vec![],
f: iamb_leave, f: iamb_leave,
}); });
cmds.add_command(ProgramCommand {
name: "forget".into(),
aliases: vec![],
f: iamb_forget,
});
cmds.add_command(ProgramCommand { cmds.add_command(ProgramCommand {
name: "members".into(), name: "members".into(),
aliases: vec![], aliases: vec![],
@@ -573,17 +778,32 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
aliases: vec![], aliases: vec![],
f: iamb_reply, f: iamb_reply,
}); });
cmds.add_command(ProgramCommand {
name: "replied".into(),
aliases: vec![],
f: iamb_replied,
});
cmds.add_command(ProgramCommand { cmds.add_command(ProgramCommand {
name: "rooms".into(), name: "rooms".into(),
aliases: vec![], aliases: vec![],
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![],
f: iamb_spaces, f: iamb_spaces,
}); });
cmds.add_command(ProgramCommand {
name: "unreads".into(),
aliases: vec![],
f: iamb_unreads,
});
cmds.add_command(ProgramCommand { cmds.add_command(ProgramCommand {
name: "unreact".into(), name: "unreact".into(),
aliases: vec![], aliases: vec![],
@@ -628,7 +848,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;
@@ -789,6 +1009,32 @@ mod tests {
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
} }
#[test]
fn test_cmd_room_dm_set() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room dm set", ctx.clone()).unwrap();
let act = RoomAction::SetDirect(true);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room dm set true", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_room_dm_unset() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room dm unset", ctx.clone()).unwrap();
let act = RoomAction::SetDirect(false);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room dm unset true", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test] #[test]
fn test_cmd_room_tag_set() { fn test_cmd_room_tag_set() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
@@ -923,6 +1169,124 @@ mod tests {
); );
} }
#[test]
fn test_cmd_room_notification_mode_set() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = "room notify set mute";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Set(RoomField::NotificationMode, "mute".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "room notify unset";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Unset(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "room notify show";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = RoomAction::Show(RoomField::NotificationMode);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
}
#[test]
fn test_cmd_room_id_show() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room id show", ctx.clone()).unwrap();
let act = RoomAction::Show(RoomField::Id);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room id show foo", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_space_child() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = "space";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space ++foo bar baz";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child foo";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_space_child_set() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = "space child set !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = SpaceAction::SetChild(room_id!("!roomid:example.org").to_owned(), None, false);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "space child set ++order=abcd ++suggested !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = SpaceAction::SetChild(
room_id!("!roomid:example.org").to_owned(),
Some("abcd".into()),
true,
);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "space child set ++order=abcd ++order=1234 !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(
res,
Err(CommandError::Error("Multiple ++order arguments are not allowed".into()))
);
let cmd = "space child set !roomid:example.org !otherroom:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::Error("Multiple room arguments are not allowed".into())));
let cmd = "space child set ++foo=abcd !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child set ++foo !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child ++order=abcd ++suggested set !roomid:example.org";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let cmd = "space child set foo";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::Error("Invalid room id specified".into())));
let cmd = "space child set";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::Error("Must specify a room to add".into())));
}
#[test]
fn test_cmd_space_child_remove() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let cmd = "space child remove";
let res = cmds.input_cmd(cmd, ctx.clone()).unwrap();
let act = SpaceAction::RemoveChild;
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let cmd = "space child remove foo";
let res = cmds.input_cmd(cmd, ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test] #[test]
fn test_cmd_invite() { fn test_cmd_invite() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();
@@ -960,6 +1324,69 @@ mod tests {
assert_eq!(res, Err(CommandError::InvalidArgument)); assert_eq!(res, Err(CommandError::InvalidArgument));
} }
#[test]
fn test_cmd_room_kick() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds.input_cmd("room kick @user:example.com", ctx.clone()).unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Kick,
"@user:example.com".into(),
None,
false,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("room! kick @user:example.com", ctx.clone()).unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Kick,
"@user:example.com".into(),
None,
true,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd("room! kick @user:example.com \"reason here\"", ctx.clone())
.unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Kick,
"@user:example.com".into(),
Some("reason here".into()),
true,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
}
#[test]
fn test_cmd_room_ban_unban() {
let mut cmds = setup_commands();
let ctx = EditContext::default();
let res = cmds
.input_cmd("room! ban @user:example.com \"spam\"", ctx.clone())
.unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Ban,
"@user:example.com".into(),
Some("spam".into()),
true,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd("room unban @user:example.com \"reconciled\"", ctx.clone())
.unwrap();
let act = IambAction::Room(RoomAction::MemberUpdate(
MemberUpdateAction::Unban,
"@user:example.com".into(),
Some("reconciled".into()),
false,
));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
}
#[test] #[test]
fn test_cmd_redact() { fn test_cmd_redact() {
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::SessionTokens {
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,
} }
@@ -398,14 +402,77 @@ pub enum UserDisplayStyle {
DisplayName, DisplayName,
} }
#[derive(Clone, Copy, Debug, Default, 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.
#[default] #[cfg(feature = "desktop")]
Desktop, pub desktop: bool,
}
pub struct NotifyViaVisitor;
impl Default for NotifyVia {
fn default() -> Self {
Self {
bell: cfg!(not(feature = "desktop")),
#[cfg(feature = "desktop")]
desktop: true,
}
}
}
impl Visitor<'_> for NotifyViaVisitor {
type Value = NotifyVia;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid notify destination (e.g. \"bell\" or \"desktop\")")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: SerdeError,
{
let mut via = NotifyVia {
bell: false,
#[cfg(feature = "desktop")]
desktop: false,
};
for value in value.split('|') {
match value.to_ascii_lowercase().as_str() {
"bell" => {
via.bell = true;
},
#[cfg(feature = "desktop")]
"desktop" => {
via.desktop = true;
},
#[cfg(not(feature = "desktop"))]
"desktop" => {
return Err(E::custom("desktop notification support was compiled out"))
},
_ => return Err(E::custom("could not parse into a notify destination")),
};
}
Ok(via)
}
}
impl<'de> Deserialize<'de> for NotifyVia {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(NotifyViaVisitor)
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct Mouse {
#[serde(default)]
pub enabled: bool,
} }
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
@@ -416,6 +483,8 @@ pub struct Notifications {
pub via: NotifyVia, pub via: NotifyVia,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub show_message: bool, pub show_message: bool,
#[serde(default)]
pub sound_hint: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -491,12 +560,14 @@ 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,
@@ -504,15 +575,19 @@ pub struct TunableValues {
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 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>,
@@ -520,6 +595,7 @@ 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>,
@@ -527,9 +603,12 @@ pub struct Tunables {
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 tabstop: Option<usize>,
} }
impl Tunables { impl Tunables {
@@ -539,6 +618,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
@@ -547,6 +627,7 @@ 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),
@@ -554,9 +635,14 @@ impl Tunables {
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
.or(other.external_edit_file_suffix),
tabstop: self.tabstop.or(other.tabstop),
} }
} }
@@ -564,12 +650,14 @@ 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(),
@@ -577,9 +665,14 @@ impl Tunables {
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
.unwrap_or_else(|| ".md".to_string()),
tabstop: self.tabstop.unwrap_or(4),
} }
} }
} }
@@ -614,11 +707,11 @@ impl DirectoryValues {
#[derive(Clone, Default, Deserialize)] #[derive(Clone, Default, Deserialize)]
pub struct Directories { pub struct Directories {
pub cache: Option<PathBuf>, pub cache: Option<String>,
pub data: Option<PathBuf>, pub data: Option<String>,
pub logs: Option<PathBuf>, pub logs: Option<String>,
pub downloads: Option<PathBuf>, pub downloads: Option<String>,
pub image_previews: Option<PathBuf>, pub image_previews: Option<String>,
} }
impl Directories { impl Directories {
@@ -635,6 +728,11 @@ impl Directories {
fn values(self) -> DirectoryValues { fn values(self) -> DirectoryValues {
let cache = self let cache = self
.cache .cache
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.or_else(|| { .or_else(|| {
let mut dir = dirs::cache_dir()?; let mut dir = dirs::cache_dir()?;
dir.push("iamb"); dir.push("iamb");
@@ -644,6 +742,11 @@ impl Directories {
let data = self let data = self
.data .data
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.or_else(|| { .or_else(|| {
let mut dir = dirs::data_dir()?; let mut dir = dirs::data_dir()?;
dir.push("iamb"); dir.push("iamb");
@@ -651,19 +754,40 @@ impl Directories {
}) })
.expect("no dirs.data value configured!"); .expect("no dirs.data value configured!");
let logs = self.logs.unwrap_or_else(|| { let logs = self
let mut dir = cache.clone(); .logs
dir.push("logs"); .map(|dir| {
dir let dir = shellexpand::full(&dir)
}); .expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.unwrap_or_else(|| {
let mut dir = cache.clone();
dir.push("logs");
dir
});
let downloads = self.downloads.or_else(dirs::download_dir); let downloads = self
.downloads
.map(|dir| {
let dir = shellexpand::full(&dir)
.expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.or_else(dirs::download_dir);
let image_previews = self.image_previews.unwrap_or_else(|| { let image_previews = self
let mut dir = cache.clone(); .image_previews
dir.push("image_preview_downloads"); .map(|dir| {
dir let dir = shellexpand::full(&dir)
}); .expect("unable to expand shell variables in dirs.cache");
Path::new(dir.as_ref()).to_owned()
})
.unwrap_or_else(|| {
let mut dir = cache.clone();
dir.push("image_preview_downloads");
dir
});
DirectoryValues { cache, data, logs, downloads, image_previews } DirectoryValues { cache, data, logs, downloads, image_previews }
} }
@@ -711,7 +835,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>,
@@ -751,14 +875,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");
@@ -798,14 +930,33 @@ 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.keys().enumerate().for_each(|(i, name)| println!("{i}: {name}"));
For more information try '--help'",
); 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();
@@ -880,7 +1031,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
@@ -912,15 +1063,16 @@ impl ApplicationSettings {
.unwrap_or_default() .unwrap_or_default()
} }
pub fn get_user_style(&self, user_id: &UserId) -> Style { pub fn get_user_color(&self, user_id: &UserId) -> Color {
let color = self self.tunables
.tunables
.users .users
.get(user_id) .get(user_id)
.and_then(|user| user.color.as_ref().map(|c| c.0)) .and_then(|user| user.color.as_ref().map(|c| c.0))
.unwrap_or_else(|| user_color(user_id.as_str())); .unwrap_or_else(|| user_color(user_id.as_str()))
}
user_style_from_color(color) pub fn get_user_style(&self, user_id: &UserId) -> Style {
user_style_from_color(self.get_user_color(user_id))
} }
pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> { pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> {
@@ -1003,10 +1155,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]
@@ -1164,12 +1316,35 @@ mod tests {
let j = "j".parse::<TerminalKey>().unwrap(); let j = "j".parse::<TerminalKey>().unwrap();
let esc = "<Esc>".parse::<TerminalKey>().unwrap(); let esc = "<Esc>".parse::<TerminalKey>().unwrap();
let jj = Keys(vec![j.clone(), j], "jj".into()); let jj = Keys(vec![j, j], "jj".into());
let run = mapped.get(&jj).unwrap(); let run = mapped.get(&jj).unwrap();
let exp = Keys(vec![esc], "<Esc>".into()); let exp = Keys(vec![esc], "<Esc>".into());
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

@@ -3,13 +3,13 @@
//! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim //! The keybindings are set up here. We define some iamb-specific keybindings, but the default Vim
//! keys come from [modalkit::env::vim::keybindings]. //! keys come from [modalkit::env::vim::keybindings].
use modalkit::{ use modalkit::{
actions::{MacroAction, WindowAction}, actions::{InsertTextAction, MacroAction, WindowAction},
env::vim::keybindings::{InputStep, VimBindings}, env::vim::keybindings::{InputStep, VimBindings},
env::vim::VimMode, env::vim::VimMode,
env::CommonKeyClass, env::CommonKeyClass,
key::TerminalKey, key::TerminalKey,
keybindings::{EdgeEvent, EdgeRepeat, InputBindings}, keybindings::{EdgeEvent, EdgeRepeat, InputBindings},
prelude::Count, prelude::*,
}; };
use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD}; use crate::base::{IambAction, IambInfo, Keybindings, MATRIX_ID_WORD};
@@ -36,6 +36,7 @@ pub fn setup_keybindings() -> Keybindings {
let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap(); let ctrl_z = "<C-Z>".parse::<TerminalKey>().unwrap();
let key_m_lc = "m".parse::<TerminalKey>().unwrap(); let key_m_lc = "m".parse::<TerminalKey>().unwrap();
let key_z_lc = "z".parse::<TerminalKey>().unwrap(); let key_z_lc = "z".parse::<TerminalKey>().unwrap();
let shift_enter = "<S-Enter>".parse::<TerminalKey>().unwrap();
let cwz = vec![once(&ctrl_w), once(&key_z_lc)]; let cwz = vec![once(&ctrl_w), once(&key_z_lc)];
let cwcz = vec![once(&ctrl_w), once(&ctrl_z)]; let cwcz = vec![once(&ctrl_w), once(&ctrl_z)];
@@ -57,6 +58,17 @@ pub fn setup_keybindings() -> Keybindings {
ism.add_mapping(VimMode::Visual, &cwm, &stoggle); ism.add_mapping(VimMode::Visual, &cwm, &stoggle);
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle); ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
ism.add_mapping(VimMode::Visual, &cwcm, &stoggle); ism.add_mapping(VimMode::Visual, &cwcm, &stoggle);
let shift_enter = vec![once(&shift_enter)];
let newline = IambStep::new().actions(vec![InsertTextAction::Type(
Char::Single('\n').into(),
MoveDir1D::Previous,
1.into(),
)
.into()]);
ism.add_mapping(VimMode::Insert, &cwm, &newline);
ism.add_mapping(VimMode::Insert, &shift_enter, &newline);
ism ism
} }

View File

@@ -44,10 +44,16 @@ use modalkit::crossterm::{
read, read,
DisableBracketedPaste, DisableBracketedPaste,
DisableFocusChange, DisableFocusChange,
DisableMouseCapture,
EnableBracketedPaste, EnableBracketedPaste,
EnableFocusChange, EnableFocusChange,
EnableMouseCapture,
Event, Event,
KeyEventKind, KeyEventKind,
KeyboardEnhancementFlags,
MouseEventKind,
PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
}, },
execute, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
@@ -56,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,
@@ -83,6 +89,7 @@ use crate::{
ChatStore, ChatStore,
HomeserverAction, HomeserverAction,
IambAction, IambAction,
IambCompleter,
IambError, IambError,
IambId, IambId,
IambInfo, IambInfo,
@@ -126,8 +133,8 @@ use modalkit::{
use modalkit_ratatui::{ use modalkit_ratatui::{
cmdbar::CommandBarState, cmdbar::CommandBarState,
screen::{Screen, ScreenState, TabLayoutDescription}, screen::{Screen, ScreenState, TabbedLayoutDescription},
windows::WindowLayoutDescription, windows::{WindowLayoutDescription, WindowLayoutState},
TerminalCursor, TerminalCursor,
TerminalExtOps, TerminalExtOps,
Window, Window,
@@ -173,6 +180,17 @@ fn config_tab_to_desc(
Ok(desc) Ok(desc)
} }
fn restore_layout(
area: Rect,
settings: &ApplicationSettings,
store: &mut ProgramStore,
) -> IambResult<FocusList<WindowLayoutState<IambWindow, IambInfo>>> {
let layout = std::fs::read(&settings.layout_json)?;
let tabs: TabbedLayoutDescription<IambInfo> =
serde_json::from_slice(&layout).map_err(IambError::from)?;
tabs.to_layout(area.into(), store)
}
fn setup_screen( fn setup_screen(
settings: ApplicationSettings, settings: ApplicationSettings,
store: &mut ProgramStore, store: &mut ProgramStore,
@@ -183,12 +201,14 @@ fn setup_screen(
match settings.layout { match settings.layout {
config::Layout::Restore => { config::Layout::Restore => {
if let Ok(layout) = std::fs::read(&settings.layout_json) { match restore_layout(area, &settings, store) {
let tabs: TabLayoutDescription<IambInfo> = Ok(tabs) => {
serde_json::from_slice(&layout).map_err(IambError::from)?; return Ok(ScreenState::from_list(tabs, cmd));
let tabs = tabs.to_layout(area.into(), store)?; },
Err(e) => {
return Ok(ScreenState::from_list(tabs, cmd)); // Log the issue with restoring and then continue.
tracing::warn!(err = %e, "Failed to restore layout from disk");
},
} }
}, },
config::Layout::New => {}, config::Layout::New => {},
@@ -239,7 +259,7 @@ struct Application {
focused: bool, focused: bool,
/// The tab layout before the last executed [TabAction]. /// The tab layout before the last executed [TabAction].
last_layout: Option<TabLayoutDescription<IambInfo>>, last_layout: Option<TabbedLayoutDescription<IambInfo>>,
/// Whether we need to do a full redraw (e.g., after running a subprocess). /// Whether we need to do a full redraw (e.g., after running a subprocess).
dirty: bool, dirty: bool,
@@ -250,16 +270,7 @@ impl Application {
settings: ApplicationSettings, settings: ApplicationSettings,
store: AsyncProgramStore, store: AsyncProgramStore,
) -> IambResult<Application> { ) -> IambResult<Application> {
let mut stdout = stdout(); let backend = CrosstermBackend::new(stdout());
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen)?;
crossterm::execute!(stdout, EnableBracketedPaste)?;
crossterm::execute!(stdout, EnableFocusChange)?;
let title = format!("iamb ({})", settings.profile.user_id);
crossterm::execute!(stdout, SetTitle(title))?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?; let terminal = Terminal::new(backend)?;
let mut bindings = crate::keybindings::setup_keybindings(); let mut bindings = crate::keybindings::setup_keybindings();
@@ -303,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();
@@ -317,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);
@@ -332,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));
} }
})?; })?;
@@ -357,13 +371,39 @@ 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;
store.application.focused = true;
self.focused = true; self.focused = true;
}, },
Event::FocusLost => { Event::FocusLost => {
let mut store = self.store.lock().await;
store.application.focused = false;
self.focused = false; self.focused = false;
}, },
Event::Resize(_, _) => { Event::Resize(_, _) => {
@@ -481,7 +521,7 @@ impl Application {
None None
}, },
Action::Command(act) => { Action::Command(act) => {
let acts = store.application.cmds.command(&act, &ctx)?; let acts = store.application.cmds.command(&act, &ctx, &mut store.registers)?;
self.action_prepend(acts); self.action_prepend(acts);
None None
@@ -493,7 +533,7 @@ impl Application {
}, },
// Unimplemented. // Unimplemented.
Action::KeywordLookup => { Action::KeywordLookup(_) => {
// XXX: implement // XXX: implement
None None
}, },
@@ -518,6 +558,21 @@ impl Application {
} }
let info = match action { let info = match action {
IambAction::ClearUnreads => {
let user_id = &store.application.settings.profile.user_id;
// Clear any notifications we displayed:
store.application.open_notifications.clear();
for room_id in store.application.sync_info.chats() {
if let Some(room) = store.application.rooms.get_mut(room_id) {
room.fully_read(user_id);
}
}
None
},
IambAction::ToggleScrollbackFocus => { IambAction::ToggleScrollbackFocus => {
self.screen.current_window_mut()?.focus_toggle(); self.screen.current_window_mut()?.focus_toggle();
@@ -534,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);
@@ -541,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?
}, },
@@ -602,6 +663,13 @@ impl Application {
Err(UIError::NeedConfirm(prompt)) Err(UIError::NeedConfirm(prompt))
}, },
HomeserverAction::Forget => {
let client = &store.application.worker.client;
for room in client.left_rooms() {
room.forget().await.map_err(IambError::from)?;
}
Ok(vec![])
},
} }
} }
@@ -824,7 +892,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);
}, },
}; };
@@ -905,6 +973,50 @@ async fn login_normal(
Ok(()) Ok(())
} }
/// Set up the terminal for drawing the TUI, and getting additional info.
fn setup_tty(settings: &ApplicationSettings, enable_enhanced_keys: bool) -> std::io::Result<()> {
let title = format!("iamb ({})", settings.profile.user_id.as_str());
// Enable raw mode and enter the alternate screen.
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout(), EnterAlternateScreen)?;
if enable_enhanced_keys {
// Enable the Kitty keyboard enhancement protocol for improved keypresses.
crossterm::queue!(
stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
}
if settings.tunables.mouse.enabled {
crossterm::execute!(stdout(), EnableMouseCapture)?;
}
crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title))
}
// Do our best to reverse what we did in setup_tty() when we exit or crash.
fn restore_tty(enable_enhanced_keys: bool, enable_mouse: bool) {
if enable_enhanced_keys {
let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags);
}
if enable_mouse {
let _ = crossterm::queue!(stdout(), DisableMouseCapture);
}
let _ = crossterm::execute!(
stdout(),
DisableBracketedPaste,
DisableFocusChange,
LeaveAlternateScreen,
CursorShow,
);
let _ = crossterm::terminal::disable_raw_mode();
}
async fn run(settings: ApplicationSettings) -> IambResult<()> { async fn run(settings: ApplicationSettings) -> IambResult<()> {
// Get old keys the first time we run w/ the upgraded SDK. // Get old keys the first time we run w/ the upgraded SDK.
let import_keys = check_import_keys(&settings).await?; let import_keys = check_import_keys(&settings).await?;
@@ -916,7 +1028,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());
@@ -929,7 +1043,7 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
match res { match res {
Err(UIError::Application(IambError::Matrix(e))) => { Err(UIError::Application(IambError::Matrix(e))) => {
if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() { if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() {
print_exit("Server did not recognize our API token; did you log out from this session elsewhere?") print_exit(format!("Server did not recognize our API token; did you log out from this session elsewhere?\nTry deleting `{}` to force a clean login.", settings.session_json.display()))
} else { } else {
print_exit(e) print_exit(e)
} }
@@ -938,27 +1052,31 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> {
Ok(()) => (), Ok(()) => (),
} }
fn restore_tty() { // Set up the terminal for drawing, and cleanup properly on panics.
let _ = crossterm::terminal::disable_raw_mode(); let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() {
let _ = crossterm::execute!(stdout(), DisableBracketedPaste); Ok(supported) => supported,
let _ = crossterm::execute!(stdout(), DisableFocusChange); Err(e) => {
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen); tracing::warn!(err = %e,
let _ = crossterm::execute!(stdout(), CursorShow); "Failed to determine whether the terminal supports keyboard enhancements");
} false
},
};
setup_tty(&settings, enable_enhanced_keys)?;
// Make sure panics clean up the terminal properly.
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(); restore_tty(enable_enhanced_keys, enable_mouse);
orig_hook(panic_info); orig_hook(panic_info);
process::exit(1); process::exit(1);
})); }));
// And finally, start running the terminal UI.
let mut application = Application::new(settings, store).await?; let mut application = Application::new(settings, store).await?;
// We can now run the application.
application.run().await?; application.run().await?;
restore_tty();
// Clean up the terminal on exit.
restore_tty(enable_enhanced_keys, enable_mouse);
Ok(()) Ok(())
} }

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

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

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)
}; };
@@ -260,6 +270,7 @@ pub enum StyleTreeNode {
Anchor(Box<StyleTreeNode>, char, Url), Anchor(Box<StyleTreeNode>, char, Url),
Blockquote(Box<StyleTreeNode>), Blockquote(Box<StyleTreeNode>),
Break, Break,
#[allow(dead_code)]
Code(Box<StyleTreeNode>, Option<String>), Code(Box<StyleTreeNode>, Option<String>),
Header(Box<StyleTreeNode>, usize), Header(Box<StyleTreeNode>, usize),
Image(Option<String>), Image(Option<String>),
@@ -270,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()
} }
@@ -311,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(_, _) => {},
} }
} }
@@ -327,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);
} }
}, },
@@ -429,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)),
@@ -445,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 {
@@ -465,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);
@@ -483,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 {
@@ -660,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.
@@ -707,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);
@@ -774,7 +820,8 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren {
*c2t(&node.children.borrow(), state) *c2t(&node.children.borrow(), state)
}, },
_ => return vec![], // Treat unknown tags as plain text.
_ => *c2t(&node.children.borrow(), state),
} }
}, },
@@ -810,17 +857,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),
@@ -832,7 +881,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),
@@ -845,7 +894,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),
@@ -859,7 +908,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),
@@ -874,7 +923,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),
@@ -890,7 +939,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),
@@ -908,6 +957,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);
@@ -917,7 +967,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),
@@ -926,7 +976,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),
@@ -935,7 +985,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),
@@ -944,7 +994,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),
@@ -953,7 +1003,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),
@@ -962,7 +1012,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),
@@ -971,7 +1021,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),
@@ -980,7 +1030,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),
@@ -989,7 +1039,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),
@@ -999,9 +1049,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],
@@ -1026,25 +1077,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],
@@ -1104,9 +1172,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],
@@ -1166,6 +1235,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>
@@ -1176,7 +1246,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);
@@ -1266,10 +1336,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],
@@ -1306,7 +1377,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],
@@ -1331,9 +1402,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(" "),]));
@@ -1342,9 +1414,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],
@@ -1359,16 +1432,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![
@@ -1399,6 +1474,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(" "),
@@ -1411,7 +1500,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("}"),
@@ -1420,7 +1509,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)),
@@ -1431,6 +1520,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);
@@ -1439,13 +1533,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,14 +2,16 @@
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; use std::convert::{TryFrom, TryInto};
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 comrak::{markdown_to_html, ComrakOptions}; use humansize::{format_size, DECIMAL};
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use matrix_sdk::ruma::room_version_rules::RedactionRules;
use serde_json::json; use serde_json::json;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@@ -31,10 +33,10 @@ use matrix_sdk::ruma::{
Relation, Relation,
RoomMessageEvent, RoomMessageEvent,
RoomMessageEventContent, RoomMessageEventContent,
TextMessageEventContent,
}, },
redaction::SyncRoomRedactionEvent, redaction::SyncRoomRedactionEvent,
}, },
AnySyncStateEvent,
RedactContent, RedactContent,
RedactedUnsigned, RedactedUnsigned,
}, },
@@ -42,7 +44,6 @@ use matrix_sdk::ruma::{
MilliSecondsSinceUnixEpoch, MilliSecondsSinceUnixEpoch,
OwnedEventId, OwnedEventId,
OwnedUserId, OwnedUserId,
RoomVersionId,
UInt, UInt,
}; };
@@ -64,13 +65,20 @@ use crate::{
util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text}, util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text},
}; };
mod compose;
mod html; mod html;
mod printer; mod printer;
mod state;
pub use self::compose::text_to_message;
use self::state::{body_cow_state, html_state};
pub use html::TreeGenState;
type ProtocolPreview<'a> = (&'a Protocol, u16, u16);
pub type MessageKey = (MessageTimeStamp, OwnedEventId); 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>;
@@ -87,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();
@@ -127,20 +147,22 @@ const MIN_MSG_LEN: usize = 30;
const TIME_GUTTER_EMPTY: &str = " "; const TIME_GUTTER_EMPTY: &str = " ";
const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY); const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY);
fn text_to_message_content(input: String) -> TextMessageEventContent { const USIZE_TOO_SMALL: bool = usize::BITS < u64::BITS;
let mut options = ComrakOptions::default();
options.extension.autolink = true;
options.extension.shortcodes = true;
options.extension.strikethrough = true;
options.render.hardbreaks = true;
let html = markdown_to_html(input.as_str(), &options);
TextMessageEventContent::html(input, html) /// Convert the [u64] hash to [usize] as needed.
fn hash_finish_usize(hasher: DefaultHasher) -> Option<usize> {
if USIZE_TOO_SMALL {
(hasher.finish() % usize::MAX as u64).try_into().ok()
} else {
hasher.finish().try_into().ok()
}
} }
pub fn text_to_message(input: String) -> RoomMessageEventContent { /// Hash an [EventId] into a [usize].
let msg = MessageType::Text(text_to_message_content(input)); fn hash_event_id(event_id: &EventId) -> Option<usize> {
RoomMessageEventContent::new(msg) let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
hash_finish_usize(hasher)
} }
/// Before the image is loaded, already display a placeholder frame of the image size. /// Before the image is loaded, already display a placeholder frame of the image size.
@@ -150,12 +172,15 @@ fn placeholder_frame(
image_preview_size: &ImagePreviewSize, image_preview_size: &ImagePreviewSize,
) -> Option<String> { ) -> Option<String> {
let ImagePreviewSize { width, height } = image_preview_size; let ImagePreviewSize { width, height } = image_preview_size;
if outer_width < *width || (*width < 2 || *height < 2) { let width = usize::min(*width, outer_width);
if width < 2 || *height < 2 {
return None; return None;
} }
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 {
@@ -165,7 +190,7 @@ fn placeholder_frame(
} }
} }
placeholder.push_str(&"\n".repeat(height - 2)); placeholder.push_str(&"\n".repeat(height / 2));
placeholder.push('\u{230e}'); placeholder.push('\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");
@@ -175,9 +200,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)]
@@ -210,13 +234,13 @@ impl MessageTimeStamp {
dt1.date_naive() == dt2.date_naive() dt1.date_naive() == dt2.date_naive()
} }
fn show_date(&self) -> Option<Span> { fn show_date(&self) -> Option<Span<'_>> {
let time = self.as_datetime().format("%A, %B %d %Y").to_string(); let time = self.as_datetime().format("%A, %B %d %Y").to_string();
Span::styled(time, BOLD_STYLE).into() Span::styled(time, BOLD_STYLE).into()
} }
fn show_time(&self) -> Option<Span> { fn show_time(&self) -> Option<Span<'_>> {
match self { match self {
MessageTimeStamp::OriginServer(ms) => { MessageTimeStamp::OriginServer(ms) => {
let time = millis_to_datetime(*ms).format("%T"); let time = millis_to_datetime(*ms).format("%T");
@@ -326,17 +350,14 @@ impl MessageCursor {
} }
pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> { pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option<Self> {
let ev_hash = u64::try_from(cursor.get_x()).ok()?; let ev_hash = cursor.get_x();
let ev_term = OwnedEventId::try_from("$").ok()?; let ev_term = OwnedEventId::try_from("$").ok()?;
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?; let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
let start = (ts_start, ev_term); let start = (ts_start, ev_term);
for ((ts, event_id), _) in thread.range(&start..) { for ((ts, event_id), _) in thread.range(&start..) {
let mut hasher = DefaultHasher::new(); if hash_event_id(event_id)? == ev_hash {
event_id.hash(&mut hasher);
if hasher.finish() == ev_hash {
return Self::from((*ts, event_id.clone())).into(); return Self::from((*ts, event_id.clone())).into();
} }
@@ -355,11 +376,8 @@ impl MessageCursor {
pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> { pub fn to_cursor(&self, thread: &Messages) -> Option<Cursor> {
let (ts, event_id) = self.to_key(thread)?; let (ts, event_id) = self.to_key(thread)?;
let y: usize = usize::try_from(ts).ok()?; let y = usize::try_from(ts).ok()?;
let x = hash_event_id(event_id)?;
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
let x = usize::try_from(hasher.finish()).ok()?;
Cursor::new(y, x).into() Cursor::new(y, x).into()
} }
@@ -427,6 +445,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>),
} }
@@ -437,6 +456,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(),
} }
} }
@@ -447,6 +467,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),
} }
} }
@@ -464,6 +485,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),
} }
} }
@@ -474,6 +496,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,
}; };
@@ -488,15 +511,16 @@ impl MessageEvent {
} }
} }
fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) { fn redact(&mut self, redaction: SyncRoomRedactionEvent, rules: &RedactionRules) {
match self { match self {
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 {
content: ev.content.clone().redact(version), content: ev.content.clone().redact(rules),
event_id: ev.event_id.clone(), event_id: ev.event_id.clone(),
sender: ev.sender.clone(), sender: ev.sender.clone(),
origin_server_ts: ev.origin_server_ts, origin_server_ts: ev.origin_server_ts,
@@ -509,6 +533,27 @@ impl MessageEvent {
} }
} }
/// Macro rule converting a File / Image / Audio / Video to its text content with the shape:
/// `[Attached <type>: <content>[ (<human readable file size>)]]`
macro_rules! display_file_to_text {
( $msgtype:ident, $content:expr ) => {
return Cow::Owned(format!(
"[Attached {}: {}{}]",
stringify!($msgtype),
$content.body,
$content
.info
.as_ref()
.map(|info| {
info.size
.map(|s| format!(" ({})", format_size(u64::from(s), DECIMAL)))
.unwrap_or_else(String::new)
})
.unwrap_or_else(String::new)
))
};
}
fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
let s = match &content.msgtype { let s = match &content.msgtype {
MessageType::Text(content) => content.body.as_str(), MessageType::Text(content) => content.body.as_str(),
@@ -518,27 +563,29 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
MessageType::ServerNotice(content) => content.body.as_str(), MessageType::ServerNotice(content) => content.body.as_str(),
MessageType::Audio(content) => { MessageType::Audio(content) => {
return Cow::Owned(format!("[Attached Audio: {}]", content.body)); display_file_to_text!(Audio, content);
}, },
MessageType::File(content) => { MessageType::File(content) => {
return Cow::Owned(format!("[Attached File: {}]", content.body)); display_file_to_text!(File, content);
}, },
MessageType::Image(content) => { MessageType::Image(content) => {
return Cow::Owned(format!("[Attached Image: {}]", content.body)); display_file_to_text!(Image, content);
}, },
MessageType::Video(content) => { MessageType::Video(content) => {
return Cow::Owned(format!("[Attached Video: {}]", content.body)); display_file_to_text!(Video, content);
},
_ => {
return Cow::Owned(format!("[Unknown message type: {:?}]", content.msgtype()));
}, },
_ => content.body(),
}; };
Cow::Borrowed(s) Cow::Borrowed(s)
} }
fn body_cow_reason(unsigned: &RedactedUnsigned) -> Cow<'_, str> { fn body_cow_reason(unsigned: &RedactedUnsigned) -> Cow<'_, str> {
let reason = unsigned.redacted_because.content.reason.as_ref(); let reason = unsigned
.redacted_because
.deserialize()
.ok()
.and_then(|ev| ev.content.reason);
if let Some(r) = reason { if let Some(r) = reason {
Cow::Owned(format!("[Redacted: {r:?}]")) Cow::Owned(format!("[Redacted: {r:?}]"))
@@ -592,8 +639,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> {
@@ -626,13 +673,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);
@@ -685,39 +730,55 @@ 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 reply_style = if settings.tunables.message_user_color {
style.patch(settings.get_user_color(&msg.sender))
} else {
style
};
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, reply_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);
sender.style = sender.style.patch(style); sender.style = sender.style.patch(reply_style);
self.push_spans( self.push_spans(
Line::from(vec![ Line::from(vec![
Span::styled(" ", style), Span::styled(" ", style),
Span::styled(THICK_VERTICAL, style), Span::styled(THICK_VERTICAL, style),
sender, sender,
Span::styled(":", style), Span::styled(":", reply_style),
space_span(trailing, style), space_span(trailing, reply_style),
]), ]),
style, style,
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, reply_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 {
@@ -766,7 +827,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);
@@ -783,7 +844,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),
} }
@@ -818,6 +879,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 {
@@ -838,6 +900,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 {
@@ -863,7 +926,7 @@ impl Message {
} }
if settings.tunables.message_user_color { if settings.tunables.message_user_color {
let color = crate::config::user_color(self.sender.as_str()); let color = settings.get_user_color(&self.sender);
style = style.fg(color); style = style.fg(color);
} }
@@ -891,7 +954,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 {
@@ -899,7 +968,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 {
@@ -907,7 +976,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 {
@@ -915,7 +984,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 }
} }
@@ -931,12 +1000,12 @@ 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);
let mut fmt = self.get_render_format(prev, width, info, settings); let mut fmt = self.get_render_format(prev, width, info, settings);
let mut text = Text { lines: vec![] }; let mut text = Text::default();
let width = fmt.width(); let width = fmt.width();
// Show the message that this one replied to, if any. // Show the message that this one replied to, if any.
@@ -944,23 +1013,21 @@ 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 we're going to print.
let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off };
(p, x_off, y_off) (p, x_off, y_off)
}); });
@@ -980,7 +1047,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>(
@@ -994,18 +1061,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()));
} }
@@ -1020,8 +1087,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("No Space..."), width, &backend.area().into())
}, },
ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")), ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")),
}; };
@@ -1064,17 +1131,19 @@ 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()
} }
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) { pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, rules: &RedactionRules) {
self.event.redact(redaction, version); self.event.redact(redaction, rules);
self.html = None; self.html = None;
self.downloaded = false;
self.image_preview = ImageStatus::None;
} }
} }
@@ -1120,14 +1189,37 @@ impl From<RoomMessageEvent> for Message {
} }
} }
impl ToString for Message { impl From<AnySyncStateEvent> for Message {
fn to_string(&self) -> String { fn from(event: AnySyncStateEvent) -> Self {
self.event.body().into_owned() let timestamp = event.origin_server_ts().into();
let user_id = event.sender().to_owned();
let event = MessageEvent::State(event.into());
Message::new(event, user_id, timestamp)
}
}
impl Display for Message {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.event.body())
} }
} }
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use matrix_sdk::ruma::events::room::{
message::{
AudioInfo,
AudioMessageEventContent,
FileInfo,
FileMessageEventContent,
ImageMessageEventContent,
VideoInfo,
VideoMessageEventContent,
},
ImageInfo,
};
use super::*; use super::*;
use crate::tests::*; use crate::tests::*;
@@ -1205,7 +1297,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);
} }
@@ -1236,82 +1328,6 @@ pub mod tests {
assert_eq!(identity(&mc6), mc1); assert_eq!(identity(&mc6), mc1);
} }
#[test]
fn test_markdown_autolink() {
let input = "http://example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
);
let input = "www.example.com\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
);
let input = "See docs (they're at https://iamb.chat)\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
);
}
#[test]
fn test_markdown_message() {
let input = "**bold**\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
let input = "*emphasis*\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
let input = "`code`\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
let input = "```rust\nconst A: usize = 1;\n```\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
);
let input = ":heart:\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
let input = "para 1\n\npara 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>para 1</p>\n<p>para 2</p>\n");
let input = "line 1\nline 2\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline 2</p>\n");
let input = "# Heading\n## Subheading\n\ntext\n";
let content = text_to_message_content(input.into());
assert_eq!(content.body, input);
assert_eq!(
content.formatted.unwrap().body,
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
);
}
#[test] #[test]
fn test_placeholder_frame() { fn test_placeholder_frame() {
fn pretty_frame_test(str: &str) -> Option<String> { fn pretty_frame_test(str: &str) -> Option<String> {
@@ -1330,7 +1346,17 @@ pub mod tests {
) )
); );
assert_eq!(placeholder_frame(None, 2, &ImagePreviewSize { width: 4, height: 4 }), None); assert_eq!(
placeholder_frame(None, 2, &ImagePreviewSize { width: 4, height: 4 }),
pretty_frame_test(
r#"
⌌⌍
⌎⌏
"#
)
);
assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 1, height: 4 }), None); assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 1, height: 4 }), None);
assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 4, height: 1 }), None); assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 4, height: 1 }), None);
@@ -1343,6 +1369,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
⌎ ⌏
"# "#
) )
); );
@@ -1377,4 +1430,83 @@ pub mod tests {
) )
); );
} }
#[test]
fn test_display_attachment_size() {
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
ImageMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::default()))
))),
"[Attached Image: Alt text]".to_string()
);
let mut info = ImageInfo::default();
info.size = Some(442630_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
ImageMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Image: Alt text (442.63 kB)]".to_string()
);
let mut info = ImageInfo::default();
info.size = Some(12_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Image(
ImageMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Image: Alt text (12 B)]".to_string()
);
let mut info = AudioInfo::default();
info.size = Some(4294967295_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Audio(
AudioMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Audio: Alt text (4.29 GB)]".to_string()
);
let mut info = FileInfo::default();
info.size = Some(4426300_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::File(
FileMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached File: Alt text (4.43 MB)]".to_string()
);
let mut info = VideoInfo::default();
info.size = Some(44000_u32.into());
assert_eq!(
body_cow_content(&RoomMessageEventContent::new(MessageType::Video(
VideoMessageEventContent::plain(
"Alt text".to_string(),
"mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into()
)
.info(Some(Box::new(info)))
))),
"[Attached Video: Alt text (44 kB)]".to_string()
);
}
} }

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,17 +99,17 @@ 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,
} }
} }
fn remaining(&self) -> usize { fn remaining(&self) -> usize {
self.width - self.curr_width self.width.saturating_sub(self.curr_width)
} }
/// If there is any text on the current line, start a new one. /// If there is any text on the current line, start a new one.
@@ -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);
} }
@@ -276,3 +299,20 @@ impl<'a> TextPrinter<'a> {
self.text self.text
} }
} }
#[cfg(test)]
pub mod tests {
use super::*;
use crate::tests::mock_settings;
#[test]
fn test_push_nobreak() {
let settings = mock_settings();
let mut printer = TextPrinter::new(5, Style::default(), false, &settings);
printer.push_span_nobreak("hello world".into());
let text = printer.finish();
assert_eq!(text.lines.len(), 1);
assert_eq!(text.lines[0].spans.len(), 1);
assert_eq!(text.lines[0].spans[0].content, "hello world");
}
}

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

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

View File

@@ -1,23 +1,46 @@
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,
EncryptionState,
}; };
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use crate::{ use crate::{
base::{AsyncProgramStore, IambError, IambResult}, base::{AsyncProgramStore, IambError, IambResult, ProgramStore},
config::{ApplicationSettings, NotifyVia}, config::{ApplicationSettings, NotifyVia},
}; };
const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
None => "iamb",
Some(iamb) => iamb,
};
/// Handle for an open notification that should be closed when the user views it.
pub struct NotificationHandle(
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
Option<notify_rust::NotificationHandle>,
);
impl Drop for NotificationHandle {
fn drop(&mut self) {
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
if let Some(handle) = self.0.take() {
handle.close();
}
}
}
pub async fn register_notifications( pub async fn register_notifications(
client: &Client, client: &Client,
settings: &ApplicationSettings, settings: &ApplicationSettings,
@@ -28,6 +51,7 @@ pub async fn register_notifications(
} }
let notify_via = settings.tunables.notifications.via; let notify_via = settings.tunables.notifications.via;
let show_message = settings.tunables.notifications.show_message; let show_message = settings.tunables.notifications.show_message;
let sound_hint = settings.tunables.notifications.sound_hint.clone();
let server_settings = client.notification_settings().await; let server_settings = client.notification_settings().await;
let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else { let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else {
return; return;
@@ -38,59 +62,122 @@ pub async fn register_notifications(
.register_notification_handler(move |notification, room: MatrixRoom, client: Client| { .register_notification_handler(move |notification, room: MatrixRoom, client: Client| {
let store = store.clone(); let store = store.clone();
let server_settings = server_settings.clone(); let server_settings = server_settings.clone();
let sound_hint = sound_hint.clone();
async move { async move {
let mode = global_or_room_mode(&server_settings, &room).await; let mode = global_or_room_mode(&server_settings, &room).await;
if mode == RoomNotificationMode::Mute { if mode == RoomNotificationMode::Mute {
return; return;
} }
if is_open(&store, room.room_id()).await { if is_visible_room(&store, room.room_id()).await {
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(
NotifyVia::Desktop => send_notification_desktop(summary, body), &notify_via,
NotifyVia::Bell => send_notification_bell(&store).await, &summary,
body.as_deref(),
room_id,
&store,
sound_hint.as_deref(),
)
.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,
sound_hint: Option<&str>,
) {
#[cfg(feature = "desktop")]
if via.desktop {
send_notification_desktop(summary, body, room_id, store, sound_hint).await;
}
#[cfg(not(feature = "desktop"))]
{
let _ = (summary, body, IAMB_XDG_NAME);
}
if via.bell {
send_notification_bell(store).await;
}
}
async fn send_notification_bell(store: &AsyncProgramStore) { 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;
} }
fn send_notification_desktop(summary: String, body: Option<String>) { #[cfg(feature = "desktop")]
#[cfg_attr(target_os = "macos", allow(unused_variables))]
async fn send_notification_desktop(
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
_store: &AsyncProgramStore,
sound_hint: Option<&str>,
) {
let mut desktop_notification = notify_rust::Notification::new(); let mut desktop_notification = notify_rust::Notification::new();
desktop_notification desktop_notification
.summary(&summary) .summary(summary)
.appname("iamb") .appname(IAMB_XDG_NAME)
.timeout(notify_rust::Timeout::Milliseconds(3000)) .icon(IAMB_XDG_NAME)
.action("default", "default"); .action("default", "default");
if let Some(body) = body { if let Some(sound_hint) = sound_hint {
desktop_notification.body(&body); desktop_notification.sound_name(sound_hint);
} }
if let Err(err) = desktop_notification.show() { #[cfg(all(unix, not(target_os = "macos")))]
tracing::error!("Failed to send notification: {err}") desktop_notification.urgency(notify_rust::Urgency::Normal);
if let Some(body) = body {
desktop_notification.body(body);
}
match desktop_notification.show() {
Err(err) => tracing::error!("Failed to send notification: {err}"),
Ok(handle) => {
#[cfg(all(unix, not(target_os = "macos")))]
_store
.lock()
.await
.application
.open_notifications
.entry(room_id)
.or_default()
.push(NotificationHandle(Some(handle)));
},
} }
} }
@@ -106,8 +193,8 @@ async fn global_or_room_mode(
Ok(true) => IsOneToOne::Yes, Ok(true) => IsOneToOne::Yes,
_ => IsOneToOne::No, _ => IsOneToOne::No,
}; };
let is_encrypted = match room.is_encrypted().await { let is_encrypted = match room.latest_encryption_state().await {
Ok(true) => IsEncrypted::Yes, Ok(EncryptionState::Encrypted) => IsEncrypted::Yes,
_ => IsEncrypted::No, _ => IsEncrypted::No,
}; };
settings settings
@@ -128,8 +215,7 @@ fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client:
false false
} }
async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool { fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool {
let mut locked = store.lock().await;
if let Some(draw_curr) = locked.application.draw_curr { if let Some(draw_curr) = locked.application.draw_curr {
let info = locked.application.get_room_info(room_id.to_owned()); let info = locked.application.get_room_info(room_id.to_owned());
if let Some(draw_last) = info.draw_last { if let Some(draw_last) = info.draw_last {
@@ -139,12 +225,22 @@ async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
false false
} }
pub async fn parse_notification( fn is_focused(locked: &ProgramStore) -> bool {
notification: Notification, locked.application.focused
}
async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
let mut locked = store.lock().await;
is_focused(&locked) && is_open(&mut locked, room_id)
}
pub async fn parse_full_notification(
event: Raw<AnySyncTimelineEvent>,
room: MatrixRoom, 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();
@@ -156,25 +252,27 @@ 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 Some(room_name) = room.cached_display_name() {
if room.is_direct().await.map_err(IambError::from)? && sender_name == room_name.to_string()
{
sender_name.to_string()
} else {
format!("{sender_name} in {room_name}")
}
} else {
sender_name.to_string()
};
let body = if show_body { 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
}; };
return Ok((sender_name.to_string(), 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;
}; };
@@ -185,10 +283,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.")
}, },
@@ -198,29 +293,18 @@ 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.")
}, },
MessageType::VerificationRequest(_) => { MessageType::VerificationRequest(_) => {
format!("{sender_name} sent a verification request.") format!("{sender_name} sent a verification request.")
}, },
_ => unimplemented!(), _ => {
format!("[Unknown message type: {:?}]", &message.msgtype)
},
}; };
Some(body) Some(body)
}, },
@@ -230,7 +314,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));
@@ -100,7 +100,7 @@ pub fn spawn_insert_preview(
}) })
.and_then(|(picker, msg, image_preview)| { .and_then(|(picker, msg, image_preview)| {
picker picker
.new_protocol(img, image_preview.size.into(), Resize::Fit) .new_protocol(img, image_preview.size.into(), Resize::Fit(None))
.map_err(|err| IambError::Preview(format!("{err:?}"))) .map_err(|err| IambError::Preview(format!("{err:?}")))
.map(|backend| (backend, msg)) .map(|backend| (backend, msg))
}) { }) {
@@ -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

@@ -49,7 +49,8 @@ use crate::{
const TEST_ROOM1_ALIAS: &str = "#room1:example.com"; const TEST_ROOM1_ALIAS: &str = "#room1:example.com";
lazy_static! { lazy_static! {
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned(); pub static ref TEST_ROOM1_ID: OwnedRoomId =
RoomId::new_v1(server_name!("example.com")).to_owned();
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned(); pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned(); pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned(); pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned();
@@ -137,7 +138,7 @@ pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
} }
pub fn mock_messages() -> Messages { 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 +172,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 {
@@ -186,15 +189,19 @@ pub fn mock_tunables() -> TunableValues {
.into_iter() .into_iter()
.collect::<HashMap<_, _>>(), .collect::<HashMap<_, _>>(),
open_command: None, open_command: None,
external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username, username_display: UserDisplayStyle::Username,
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,
sound_hint: None,
}, },
image_preview: None, image_preview: None,
user_gutter_width: 30, user_gutter_width: 30,
tabstop: 4,
} }
} }

View File

@@ -128,9 +128,7 @@ pub fn space_text(width: usize, style: Style) -> Text<'static> {
pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> { pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> {
let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0); let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0);
let mut text = Text { let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]);
lines: vec![Line::from(vec![join.clone()]); height],
};
for (mut t, w) in texts.into_iter() { for (mut t, w) in texts.into_iter() {
for i in 0..height { for i in 0..height {

View File

@@ -5,8 +5,9 @@
//! //!
//! 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::ops::Deref; use std::ops::Deref;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -22,6 +23,7 @@ use matrix_sdk::{
RoomAliasId, RoomAliasId,
RoomId, RoomId,
}, },
RoomState as MatrixRoomState,
}; };
use ratatui::{ use ratatui::{
@@ -64,7 +66,6 @@ use crate::base::{
IambInfo, IambInfo,
IambResult, IambResult,
MessageAction, MessageAction,
Need,
ProgramAction, ProgramAction,
ProgramContext, ProgramContext,
ProgramStore, ProgramStore,
@@ -74,11 +75,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;
@@ -93,12 +96,12 @@ fn bold_style() -> Style {
} }
#[inline] #[inline]
fn bold_span(s: &str) -> Span { fn bold_span(s: &str) -> Span<'_> {
Span::styled(s, bold_style()) Span::styled(s, bold_style())
} }
#[inline] #[inline]
fn bold_spans(s: &str) -> Line { fn bold_spans(s: &str) -> Line<'_> {
bold_span(s).into() bold_span(s).into()
} }
@@ -112,12 +115,12 @@ fn selected_style(selected: bool) -> Style {
} }
#[inline] #[inline]
fn selected_span(s: &str, selected: bool) -> Span { fn selected_span(s: &str, selected: bool) -> Span<'_> {
Span::styled(s, selected_style(selected)) Span::styled(s, selected_style(selected))
} }
#[inline] #[inline]
fn selected_text(s: &str, selected: bool) -> Text { fn selected_text(s: &str, selected: bool) -> Text<'_> {
Text::from(selected_span(s, selected)) Text::from(selected_span(s, selected))
} }
@@ -167,7 +170,12 @@ fn user_cmp(a: &MemberItem, b: &MemberItem, field: &SortFieldUser) -> Ordering {
} }
} }
fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering { fn room_cmp<T: RoomLikeItem>(
a: &T,
b: &T,
field: &SortFieldRoom,
collator: &mut Collator,
) -> Ordering {
match field { match field {
SortFieldRoom::Favorite => { SortFieldRoom::Favorite => {
let fava = a.has_tag(TagName::Favorite); let fava = a.has_tag(TagName::Favorite);
@@ -183,7 +191,7 @@ fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
// If a has LowPriority and b doesn't, it should sort later in room list. // 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 => {
@@ -194,6 +202,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())
},
} }
} }
@@ -202,9 +214,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(),
@@ -212,7 +225,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(
@@ -272,6 +285,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]
@@ -314,6 +328,7 @@ macro_rules! delegate {
IambWindow::VerifyList($id) => $e, IambWindow::VerifyList($id) => $e,
IambWindow::Welcome($id) => $e, IambWindow::Welcome($id) => $e,
IambWindow::ChatList($id) => $e, IambWindow::ChatList($id) => $e,
IambWindow::UnreadList($id) => $e,
} }
}; };
} }
@@ -327,6 +342,7 @@ pub enum IambWindow {
SpaceList(SpaceListState), SpaceList(SpaceListState),
Welcome(WelcomeState), Welcome(WelcomeState),
ChatList(ChatListState), ChatList(ChatListState),
UnreadList(UnreadListState),
} }
impl IambWindow { impl IambWindow {
@@ -351,6 +367,19 @@ impl IambWindow {
} }
} }
pub async fn space_command(
&mut self,
act: SpaceAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
if let IambWindow::Room(w) = self {
w.space_command(act, ctx, store).await
} else {
return Err(IambError::NoSelectedRoom.into());
}
}
pub async fn room_command( pub async fn room_command(
&mut self, &mut self,
act: RoomAction, act: RoomAction,
@@ -382,6 +411,7 @@ pub type DirectListState = ListState<DirectItem, IambInfo>;
pub type MemberListState = ListState<MemberItem, IambInfo>; pub type MemberListState = ListState<MemberItem, IambInfo>;
pub type RoomListState = ListState<RoomItem, IambInfo>; pub type RoomListState = ListState<RoomItem, IambInfo>;
pub type ChatListState = ListState<GenericChatItem, IambInfo>; pub type ChatListState = ListState<GenericChatItem, IambInfo>;
pub type UnreadListState = ListState<GenericChatItem, IambInfo>;
pub type SpaceListState = ListState<SpaceItem, IambInfo>; pub type SpaceListState = ListState<SpaceItem, IambInfo>;
pub type VerifyListState = ListState<VerifyItem, IambInfo>; pub type VerifyListState = ListState<VerifyItem, IambInfo>;
@@ -492,7 +522,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);
@@ -537,7 +568,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);
@@ -568,7 +600,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);
@@ -578,6 +611,40 @@ impl WindowOps<IambInfo> for IambWindow {
.focus(focused) .focus(focused)
.render(area, buf, state); .render(area, buf, state);
}, },
IambWindow::UnreadList(state) => {
let mut items = store
.application
.sync_info
.rooms
.clone()
.into_iter()
.map(|room_info| GenericChatItem::new(room_info, store, false))
.filter(RoomLikeItem::is_unread)
.collect::<Vec<_>>();
let dms = store
.application
.sync_info
.dms
.clone()
.into_iter()
.map(|room_info| GenericChatItem::new(room_info, store, true))
.filter(RoomLikeItem::is_unread);
items.extend(dms);
let fields = &store.application.settings.tunables.sort.chats;
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
state.set(items);
List::new(store)
.empty_message("You do not have any unreads yet")
.empty_alignment(Alignment::Center)
.focus(focused)
.render(area, buf, state);
},
IambWindow::SpaceList(state) => { IambWindow::SpaceList(state) => {
let mut items = store let mut items = store
.application .application
@@ -588,7 +655,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);
@@ -629,6 +697,7 @@ impl WindowOps<IambInfo> for IambWindow {
IambWindow::VerifyList(w) => w.dup(store).into(), IambWindow::VerifyList(w) => w.dup(store).into(),
IambWindow::Welcome(w) => w.dup(store).into(), IambWindow::Welcome(w) => w.dup(store).into(),
IambWindow::ChatList(w) => w.dup(store).into(), IambWindow::ChatList(w) => w.dup(store).into(),
IambWindow::UnreadList(w) => w.dup(store).into(),
} }
} }
@@ -669,10 +738,11 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => IambId::VerifyList, IambWindow::VerifyList(_) => IambId::VerifyList,
IambWindow::Welcome(_) => IambId::Welcome, IambWindow::Welcome(_) => IambId::Welcome,
IambWindow::ChatList(_) => IambId::ChatList, IambWindow::ChatList(_) => IambId::ChatList,
IambWindow::UnreadList(_) => IambId::UnreadList,
} }
} }
fn get_tab_title(&self, store: &mut ProgramStore) -> Line { fn get_tab_title(&self, store: &mut ProgramStore) -> Line<'_> {
match self { match self {
IambWindow::DirectList(_) => bold_spans("Direct Messages"), IambWindow::DirectList(_) => bold_spans("Direct Messages"),
IambWindow::RoomList(_) => bold_spans("Rooms"), IambWindow::RoomList(_) => bold_spans("Rooms"),
@@ -680,6 +750,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => bold_spans("Verifications"), IambWindow::VerifyList(_) => bold_spans("Verifications"),
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"), IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"), IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
IambWindow::Room(w) => { IambWindow::Room(w) => {
let title = store.application.get_room_title(w.id()); let title = store.application.get_room_title(w.id());
@@ -699,7 +770,7 @@ impl Window<IambInfo> for IambWindow {
} }
} }
fn get_win_title(&self, store: &mut ProgramStore) -> Line { fn get_win_title(&self, store: &mut ProgramStore) -> Line<'_> {
match self { match self {
IambWindow::DirectList(_) => bold_spans("Direct Messages"), IambWindow::DirectList(_) => bold_spans("Direct Messages"),
IambWindow::RoomList(_) => bold_spans("Rooms"), IambWindow::RoomList(_) => bold_spans("Rooms"),
@@ -707,6 +778,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::VerifyList(_) => bold_spans("Verifications"), IambWindow::VerifyList(_) => bold_spans("Verifications"),
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"), IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
IambWindow::ChatList(_) => bold_spans("DMs & Rooms"), IambWindow::ChatList(_) => bold_spans("DMs & Rooms"),
IambWindow::UnreadList(_) => bold_spans("Unread Messages"),
IambWindow::Room(w) => w.get_title(store), IambWindow::Room(w) => w.get_title(store),
IambWindow::MemberList(state, room_id, _) => { IambWindow::MemberList(state, room_id, _) => {
@@ -728,7 +800,7 @@ impl Window<IambInfo> for IambWindow {
let (room, name, tags) = store.application.worker.get_room(room_id)?; let (room, name, tags) = store.application.worker.get_room(room_id)?;
let room = RoomState::new(room, thread, name, tags, store); let room = RoomState::new(room, thread, name, tags, store);
store.application.need_load.insert(room.id().to_owned(), Need::MEMBERS); store.application.need_load.need_members(room.id().to_owned());
return Ok(room.into()); return Ok(room.into());
}, },
IambId::DirectList => { IambId::DirectList => {
@@ -768,6 +840,11 @@ impl Window<IambInfo> for IambWindow {
Ok(list.into()) Ok(list.into())
}, },
IambId::UnreadList => {
let list = UnreadListState::new(IambBufferId::UnreadList, vec![]);
Ok(IambWindow::UnreadList(list))
},
} }
} }
@@ -785,7 +862,7 @@ impl Window<IambInfo> for IambWindow {
let (room, name, tags) = store.application.worker.get_room(room_id)?; let (room, name, tags) = store.application.worker.get_room(room_id)?;
let room = RoomState::new(room, None, name, tags, store); let room = RoomState::new(room, None, name, tags, store);
store.application.need_load.insert(room.id().to_owned(), Need::MEMBERS); store.application.need_load.need_members(room.id().to_owned());
Ok(room.into()) Ok(room.into())
} }
} }
@@ -820,7 +897,7 @@ impl GenericChatItem {
let name = info.name.clone().unwrap_or_default(); let name = info.name.clone().unwrap_or_default();
let alias = room.canonical_alias(); let alias = room.canonical_alias();
let unread = info.unreads(&store.application.settings); let unread = info.unreads(&store.application.settings);
info.tags = room_info.deref().1.clone(); info.tags.clone_from(&room_info.deref().1);
if let Some(alias) = &alias { if let Some(alias) = &alias {
store.application.names.insert(alias.to_string(), room_id.to_owned()); store.application.names.insert(alias.to_string(), room_id.to_owned());
@@ -868,16 +945,25 @@ 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 ToString for GenericChatItem { impl Display for GenericChatItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.name.clone(); write!(f, "{}", self.name)
} }
} }
impl ListItem<IambInfo> for GenericChatItem { impl ListItem<IambInfo> for GenericChatItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text { fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let unread = self.unread.is_unread(); let unread = self.unread.is_unread();
let style = selected_style(selected); let style = selected_style(selected);
let (name, mut labels) = name_and_labels(&self.name, unread, style); let (name, mut labels) = name_and_labels(&self.name, unread, style);
@@ -930,7 +1016,7 @@ impl RoomItem {
let name = info.name.clone().unwrap_or_default(); let name = info.name.clone().unwrap_or_default();
let alias = room.canonical_alias(); let alias = room.canonical_alias();
let unread = info.unreads(&store.application.settings); let unread = info.unreads(&store.application.settings);
info.tags = room_info.deref().1.clone(); info.tags.clone_from(&room_info.deref().1);
if let Some(alias) = &alias { if let Some(alias) = &alias {
store.application.names.insert(alias.to_string(), room_id.to_owned()); store.application.names.insert(alias.to_string(), room_id.to_owned());
@@ -978,16 +1064,25 @@ 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 ToString for RoomItem { impl Display for RoomItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.name.clone(); write!(f, "{}", self.name)
} }
} }
impl ListItem<IambInfo> for RoomItem { impl ListItem<IambInfo> for RoomItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text { fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let unread = self.unread.is_unread(); let unread = self.unread.is_unread();
let style = selected_style(selected); let style = selected_style(selected);
let (name, mut labels) = name_and_labels(&self.name, unread, style); let (name, mut labels) = name_and_labels(&self.name, unread, style);
@@ -1034,7 +1129,7 @@ impl DirectItem {
let info = store.application.rooms.get_or_default(room_id); let info = store.application.rooms.get_or_default(room_id);
let name = info.name.clone().unwrap_or_default(); let name = info.name.clone().unwrap_or_default();
let unread = info.unreads(&store.application.settings); let unread = info.unreads(&store.application.settings);
info.tags = room_info.deref().1.clone(); info.tags.clone_from(&room_info.deref().1);
DirectItem { room_info, name, alias, unread } DirectItem { room_info, name, alias, unread }
} }
@@ -1078,16 +1173,25 @@ 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 ToString for DirectItem { impl Display for DirectItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.name.clone(); write!(f, ":verify request {}", self.name)
} }
} }
impl ListItem<IambInfo> for DirectItem { impl ListItem<IambInfo> for DirectItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text { fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let unread = self.unread.is_unread(); let unread = self.unread.is_unread();
let style = selected_style(selected); let style = selected_style(selected);
let (name, mut labels) = name_and_labels(&self.name, unread, style); let (name, mut labels) = name_and_labels(&self.name, unread, style);
@@ -1177,16 +1281,25 @@ 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 ToString for SpaceItem { impl Display for SpaceItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
return self.room_id().to_string(); write!(f, "{}", self.name)
} }
} }
impl ListItem<IambInfo> for SpaceItem { impl ListItem<IambInfo> for SpaceItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text { fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
selected_text(self.name.as_str(), selected) selected_text(self.name.as_str(), selected)
} }
@@ -1300,22 +1413,29 @@ impl From<(&String, &SasVerification)> for VerifyItem {
} }
} }
impl ToString for VerifyItem { impl Display for VerifyItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.sasv1.is_done() { if self.sasv1.is_done() {
String::new() return Ok(());
} else if self.sasv1.is_cancelled() { }
format!(":verify request {}", self.sasv1.other_user_id())
if self.sasv1.is_cancelled() {
write!(f, ":verify request {}", self.sasv1.other_user_id())
} else if self.sasv1.emoji().is_some() { } else if self.sasv1.emoji().is_some() {
format!(":verify confirm {}", self.user_dev) write!(f, ":verify confirm {}", self.user_dev)
} else { } else {
format!(":verify accept {}", self.user_dev) write!(f, ":verify accept {}", self.user_dev)
} }
} }
} }
impl ListItem<IambInfo> for VerifyItem { impl ListItem<IambInfo> for VerifyItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text { fn show(
&self,
selected: bool,
_: &ViewportContext<ListCursor>,
_: &mut ProgramStore,
) -> Text<'_> {
let mut lines = vec![]; let mut lines = vec![];
let bold = Style::default().add_modifier(StyleModifier::BOLD); let bold = Style::default().add_modifier(StyleModifier::BOLD);
@@ -1368,7 +1488,7 @@ impl ListItem<IambInfo> for VerifyItem {
])); ]));
} }
Text { lines } Text::from(lines)
} }
fn get_word(&self) -> Option<String> { fn get_word(&self) -> Option<String> {
@@ -1413,9 +1533,9 @@ impl MemberItem {
} }
} }
impl ToString for MemberItem { impl Display for MemberItem {
fn to_string(&self) -> String { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.member.user_id().to_string() write!(f, "{}", self.member.user_id())
} }
} }
@@ -1425,7 +1545,7 @@ impl ListItem<IambInfo> for MemberItem {
selected: bool, selected: bool,
_: &ViewportContext<ListCursor>, _: &ViewportContext<ListCursor>,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> Text { ) -> Text<'_> {
let info = store.application.rooms.get_or_default(self.room_id.clone()); let info = store.application.rooms.get_or_default(self.room_id.clone());
let user_id = self.member.user_id(); let user_id = self.member.user_id();
@@ -1469,6 +1589,10 @@ impl ListItem<IambInfo> for MemberItem {
fn get_word(&self) -> Option<String> { fn get_word(&self) -> Option<String> {
self.member.user_id().to_string().into() self.member.user_id().to_string().into()
} }
fn matches(&self, needle: &regex::Regex) -> bool {
needle.is_match(self.member.name()) || needle.is_match(self.member.user_id().as_str())
}
} }
impl Promptable<ProgramContext, ProgramStore, IambInfo> for MemberItem { impl Promptable<ProgramContext, ProgramStore, IambInfo> for MemberItem {
@@ -1508,6 +1632,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 {
@@ -1534,46 +1659,55 @@ 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 {
room_id: RoomId::new(server).to_owned(), room_id: RoomId::new_v1(server).to_owned(),
tags: vec![TagName::Favorite], tags: vec![TagName::Favorite],
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 {
room_id: RoomId::new(server).to_owned(), room_id: RoomId::new_v1(server).to_owned(),
tags: vec![], tags: vec![],
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 {
room_id: RoomId::new(server).to_owned(), room_id: RoomId::new_v1(server).to_owned(),
tags: vec![], tags: vec![],
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.
@@ -1583,7 +1717,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
@@ -1593,24 +1727,27 @@ 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 {
room_id: RoomId::new(server).to_owned(), room_id: RoomId::new_v1(server).to_owned(),
tags: vec![], tags: vec![],
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 {
room_id: RoomId::new(server).to_owned(), room_id: RoomId::new_v1(server).to_owned(),
tags: vec![], tags: vec![],
alias: None, alias: None,
name: "Room 2", name: "Room 2",
@@ -1618,10 +1755,11 @@ 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 {
room_id: RoomId::new(server).to_owned(), room_id: RoomId::new_v1(server).to_owned(),
tags: vec![], tags: vec![],
alias: None, alias: None,
name: "Room 3", name: "Room 3",
@@ -1629,18 +1767,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_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 1",
unread: UnreadInfo::default(),
invite: false,
};
let room2 = TestRoomItem {
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 2",
unread: UnreadInfo::default(),
invite: false,
};
let room3 = TestRoomItem {
room_id: RoomId::new_v1(server).to_owned(),
tags: vec![],
alias: None,
name: "New Fancy Room",
unread: UnreadInfo::default(),
invite: true,
};
// Sort invites first
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[
SortColumn(SortFieldRoom::Invite, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room3, &room1, &room2]);
// Sort invites after
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[
SortColumn(SortFieldRoom::Invite, SortOrder::Descending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room1, &room2, &room3]);
}
} }

View File

@@ -5,15 +5,18 @@ use std::fs;
use std::ops::Deref; use std::ops::Deref;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use edit::edit as external_edit; use edit::edit_with_builder as external_edit;
use edit::Builder;
use matrix_sdk::EncryptionState;
use modalkit::editing::store::RegisterError; use modalkit::editing::store::RegisterError;
use ratatui::style::{Color, Style};
use std::process::Command; use std::process::Command;
use tokio; use tokio;
use url::Url; 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,
@@ -85,7 +88,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};
@@ -212,12 +222,10 @@ impl ChatState {
}; };
let (source, msg_filename) = match &ev.content.msgtype { let (source, msg_filename) = match &ev.content.msgtype {
MessageType::Audio(c) => (c.source.clone(), c.body.as_str()), MessageType::Audio(c) => (c.source.clone(), c.filename()),
MessageType::File(c) => { MessageType::File(c) => (c.source.clone(), c.filename()),
(c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str())) MessageType::Image(c) => (c.source.clone(), c.filename()),
}, MessageType::Video(c) => (c.source.clone(), c.filename()),
MessageType::Image(c) => (c.source.clone(), c.body.as_str()),
MessageType::Video(c) => (c.source.clone(), c.body.as_str()),
_ => { _ => {
if !flags.contains(DownloadFlags::OPEN) { if !flags.contains(DownloadFlags::OPEN) {
return Err(IambError::NoAttachment.into()); return Err(IambError::NoAttachment.into());
@@ -225,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() {
@@ -251,7 +263,7 @@ impl ChatState {
}; };
if filename.is_dir() { if filename.is_dir() {
filename.push(msg_filename); filename.push(msg_filename.replace(std::path::MAIN_SEPARATOR_STR, "_"));
} }
if filename.exists() && !flags.contains(DownloadFlags::FORCE) { if filename.exists() && !flags.contains(DownloadFlags::FORCE) {
@@ -261,9 +273,9 @@ impl ChatState {
let mut filename_incr = filename.clone(); let mut filename_incr = filename.clone();
for n in 1..=1000 { for n in 1..=1000 {
if let Some(ext) = ext.and_then(OsStr::to_str) { if let Some(ext) = ext.and_then(OsStr::to_str) {
filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext)); filename_incr.set_file_name(format!("{stem}-{n}.{ext}"));
} else { } else {
filename_incr.set_file_name(format!("{}-{}", stem, n)); filename_incr.set_file_name(format!("{stem}-{n}"));
} }
if !filename_incr.exists() { if !filename_incr.exists() {
@@ -275,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)?;
@@ -357,13 +369,29 @@ impl ChatState {
Ok(None) Ok(None)
}, },
MessageAction::React(emoji) => { MessageAction::React(reaction, literal) => {
let emoji = if literal {
reaction
} else if let Some(emoji) =
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
{
emoji.to_string()
} else {
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?");
let act = IambAction::Message(MessageAction::React(reaction, true));
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
};
let room = self.get_joined(&store.application.worker)?; let room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event { let event_id = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
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());
@@ -372,6 +400,13 @@ impl ChatState {
}, },
}; };
if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) {
let msg = format!("Youve already reacted to this message with {emoji}");
let err = UIError::Failure(msg);
return Err(err);
}
let reaction = Annotation::new(event_id, emoji); let reaction = Annotation::new(event_id, emoji);
let msg = ReactionEventContent::new(reaction); let msg = ReactionEventContent::new(reaction);
let _ = room.send(msg).await.map_err(IambError::from)?; let _ = room.send(msg).await.map_err(IambError::from)?;
@@ -394,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());
@@ -414,13 +450,49 @@ impl ChatState {
Ok(None) Ok(None)
}, },
MessageAction::Unreact(emoji) => { MessageAction::Replied => {
let Some(reply) = msg.reply_to() else {
let msg = "Selected message is not a reply";
return Err(UIError::Failure(msg.into()));
};
let Some(key) = info.get_message_key(&reply) else {
store.application.need_load.need_message(self.room_id.clone(), reply);
let msg = "Replied to message will be loaded in the background";
return Err(UIError::Failure(msg.into()));
};
self.scrollback.goto_message(key.clone());
Ok(None)
},
MessageAction::Unreact(reaction, literal) => {
let emoji = match reaction {
reaction if literal => reaction,
Some(reaction) => {
if let Some(emoji) =
emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction))
{
Some(emoji.to_string())
} else {
let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?");
let act =
IambAction::Message(MessageAction::Unreact(Some(reaction), true));
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
}
},
None => None,
};
let room = self.get_joined(&store.application.worker)?; let room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event { let event_id = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
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());
@@ -474,7 +546,16 @@ impl ChatState {
let msg = self.tbox.get(); let msg = self.tbox.get();
let msg = if let SendAction::SubmitFromEditor = act { let msg = if let SendAction::SubmitFromEditor = act {
external_edit(msg.trim_end().to_string())? let suffix =
store.application.settings.tunables.external_edit_file_suffix.as_str();
let edited_msg =
external_edit(msg.trim_end().to_string(), Builder::new().suffix(suffix))?
.trim_end()
.to_string();
if edited_msg.is_empty() {
return Ok(None);
}
edited_msg
} else if msg.is_blank() { } else if msg.is_blank() {
return Ok(None); return Ok(None);
} else { } else {
@@ -544,17 +625,16 @@ 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)?;
let mime = mime::IMAGE_PNG; let mime = mime::IMAGE_PNG;
let name = "Clipboard.png"; let name = "Clipboard.png";
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)?;
@@ -583,10 +663,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 {
@@ -597,6 +674,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,
@@ -699,8 +784,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)))
@@ -797,16 +889,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);
@@ -830,9 +922,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)
},
} }
} }
} }
@@ -854,7 +944,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) {
@@ -902,7 +992,16 @@ impl<'a> StatefulWidget for Chat<'a> {
Paragraph::new(desc_spans).render(descarea, buf); Paragraph::new(desc_spans).render(descarea, buf);
} }
let prompt = if self.focused { "> " } else { " " }; let prompt = match (self.focused, state.room().encryption_state()) {
(false, _) => Span::raw(" "),
(_, EncryptionState::Encrypted) => {
Span::styled("\u{1F512}\u{FE0E} ", Style::new().fg(Color::LightGreen))
},
(_, EncryptionState::NotEncrypted) => {
Span::styled("\u{1F513}\u{FE0E} ", Style::new().fg(Color::Red))
},
(_, EncryptionState::Unknown) => Span::styled("> ", Style::new().fg(Color::Red)),
};
let tbox = TextBox::new().prompt(prompt); let tbox = TextBox::new().prompt(prompt);
tbox.render(textarea, buf, &mut state.tbox); tbox.render(textarea, buf, &mut state.tbox);
@@ -938,3 +1037,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

@@ -1,15 +1,32 @@
//! # Windows for Matrix rooms and spaces //! # Windows for Matrix rooms and spaces
use std::collections::HashSet;
use matrix_sdk::{ use matrix_sdk::{
notification_settings::RoomNotificationMode,
room::Room as MatrixRoom, room::Room as MatrixRoom,
ruma::{ ruma::{
api::client::{
alias::{
create_alias::v3::Request as CreateAliasRequest,
delete_alias::v3::Request as DeleteAliasRequest,
},
error::ErrorKind as ClientApiErrorKind,
},
events::{ events::{
room::{name::RoomNameEventContent, topic::RoomTopicEventContent}, room::{
canonical_alias::RoomCanonicalAliasEventContent,
history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent},
name::RoomNameEventContent,
topic::RoomTopicEventContent,
},
tag::{TagInfo, Tags}, tag::{TagInfo, Tags},
}, },
OwnedEventId, OwnedEventId,
OwnedRoomAliasId,
OwnedUserId,
RoomId, RoomId,
}, },
DisplayName, RoomDisplayName,
RoomState as MatrixRoomState, RoomState as MatrixRoomState,
}; };
@@ -41,6 +58,7 @@ use crate::base::{
IambId, IambId,
IambInfo, IambInfo,
IambResult, IambResult,
MemberUpdateAction,
MessageAction, MessageAction,
ProgramAction, ProgramAction,
ProgramContext, ProgramContext,
@@ -48,11 +66,14 @@ use crate::base::{
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
SpaceAction,
}; };
use self::chat::ChatState; use self::chat::ChatState;
use self::space::{Space, SpaceState}; use self::space::{Space, SpaceState};
use std::convert::TryFrom;
mod chat; mod chat;
mod scrollback; mod scrollback;
mod space; mod space;
@@ -66,25 +87,52 @@ macro_rules! delegate {
}; };
} }
fn notification_mode(name: impl Into<String>) -> IambResult<RoomNotificationMode> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"mute" => RoomNotificationMode::Mute,
"mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly,
"all" => RoomNotificationMode::AllMessages,
_ => return Err(IambError::InvalidNotificationLevel(name).into()),
};
Ok(mode)
}
fn hist_visibility_mode(name: impl Into<String>) -> IambResult<HistoryVisibility> {
let name = name.into();
let mode = match name.to_lowercase().as_str() {
"invited" => HistoryVisibility::Invited,
"joined" => HistoryVisibility::Joined,
"shared" => HistoryVisibility::Shared,
"world" | "world_readable" => HistoryVisibility::WorldReadable,
_ => return Err(IambError::InvalidHistoryVisibility(name).into()),
};
Ok(mode)
}
/// State for a Matrix room or space. /// State for a Matrix room or space.
/// ///
/// Since spaces function as special rooms within Matrix, we wrap their window state together, so /// Since spaces function as special rooms within Matrix, we wrap their window state together, so
/// that operations like sending and accepting invites, opening the members window, etc., all work /// that operations like sending and accepting invites, opening the members window, etc., all work
/// similarly. /// similarly.
pub enum RoomState { pub enum RoomState {
Chat(ChatState), Chat(Box<ChatState>),
Space(SpaceState), Space(Box<SpaceState>),
} }
impl From<ChatState> for RoomState { impl From<ChatState> for RoomState {
fn from(chat: ChatState) -> Self { fn from(chat: ChatState) -> Self {
RoomState::Chat(chat) RoomState::Chat(Box::new(chat))
} }
} }
impl From<SpaceState> for RoomState { impl From<SpaceState> for RoomState {
fn from(space: SpaceState) -> Self { fn from(space: SpaceState) -> Self {
RoomState::Space(space) RoomState::Space(Box::new(space))
} }
} }
@@ -92,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 {
@@ -148,7 +196,7 @@ impl RoomState {
let l2 = Line::from( let l2 = Line::from(
"You can run `:invite accept` or `:invite reject` to accept or reject this invitation.", "You can run `:invite accept` or `:invite reject` to accept or reject this invitation.",
); );
let text = Text { lines: vec![l1, l2] }; let text = Text::from(vec![l1, l2]);
Paragraph::new(text).alignment(Alignment::Center).render(area, buf); Paragraph::new(text).alignment(Alignment::Center).render(area, buf);
@@ -167,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,
@@ -182,7 +242,7 @@ impl RoomState {
pub async fn room_command( pub async fn room_command(
&mut self, &mut self,
act: RoomAction, act: RoomAction,
_: ProgramContext, ctx: ProgramContext,
store: &mut ProgramStore, store: &mut ProgramStore,
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> { ) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
match act { match act {
@@ -239,6 +299,47 @@ impl RoomState {
Err(IambError::NotJoined.into()) Err(IambError::NotJoined.into())
} }
}, },
RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => {
let Some(room) = store.application.worker.client.get_room(self.id()) else {
return Err(IambError::NotJoined.into());
};
let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else {
let err = IambError::InvalidUserId(user);
return Err(err.into());
};
if !skip_confirm {
let msg = format!("Do you really want to {mua} {user} from this room?");
let act = RoomAction::MemberUpdate(mua, user, reason, true);
let act = IambAction::from(act);
let prompt = PromptYesNo::new(msg, vec![Action::from(act)]);
let prompt = Box::new(prompt);
return Err(UIError::NeedConfirm(prompt));
}
match mua {
MemberUpdateAction::Ban => {
room.ban_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
MemberUpdateAction::Unban => {
room.unban_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
MemberUpdateAction::Kick => {
room.kick_user(&user_id, reason.as_deref())
.await
.map_err(IambError::from)?;
},
}
Ok(vec![])
},
RoomAction::Members(mut cmd) => { RoomAction::Members(mut cmd) => {
let width = Count::Exact(30); let width = Count::Exact(30);
let act = let act =
@@ -249,6 +350,16 @@ impl RoomState {
Ok(vec![(act, cmd.context.clone())]) Ok(vec![(act, cmd.context.clone())])
}, },
RoomAction::SetDirect(is_direct) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
room.set_is_direct(is_direct).await.map_err(IambError::from)?;
Ok(vec![])
},
RoomAction::Set(field, value) => { RoomAction::Set(field, value) => {
let room = store let room = store
.application .application
@@ -256,6 +367,11 @@ impl RoomState {
.ok_or(UIError::Application(IambError::NotJoined))?; .ok_or(UIError::Application(IambError::NotJoined))?;
match field { match field {
RoomField::History => {
let visibility = hist_visibility_mode(value)?;
let ev = RoomHistoryVisibilityEventContent::new(visibility);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Name => { RoomField::Name => {
let ev = RoomNameEventContent::new(value); let ev = RoomNameEventContent::new(value);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
@@ -270,6 +386,100 @@ impl RoomState {
let ev = RoomTopicEventContent::new(value); let ev = RoomTopicEventContent::new(value);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
}, },
RoomField::NotificationMode => {
let mode = notification_mode(value)?;
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
notifications
.set_room_notification_mode(self.id(), mode)
.await
.map_err(IambError::from)?;
},
RoomField::CanonicalAlias => {
let client = &mut store.application.worker.client;
let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else {
let err = IambError::InvalidRoomAlias(value);
return Err(err.into());
};
let mut alt_aliases =
room.alt_aliases().into_iter().collect::<HashSet<_>>();
let canonical_old = room.canonical_alias();
// If the room's alias is already that, ignore it
if canonical_old.as_ref() == Some(&orai) {
let msg = format!("The canonical room alias is already {orai}");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
}
// Try creating the room alias on the server.
let alias_create_req =
CreateAliasRequest::new(orai.clone(), room.room_id().into());
if let Err(e) = client.send(alias_create_req).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
return Err(IambError::from(e).into());
}
}
// Demote the previous one to an alt alias.
alt_aliases.extend(canonical_old);
// At this point the room alias definitely exists, and we can update the
// state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = Some(orai);
ev.alt_aliases = alt_aliases.into_iter().collect();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Alias(alias) => {
let client = &mut store.application.worker.client;
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
let err = IambError::InvalidRoomAlias(alias);
return Err(err.into());
};
let mut alt_aliases =
room.alt_aliases().into_iter().collect::<HashSet<_>>();
let canonical = room.canonical_alias();
if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) {
let msg = format!("The alias {orai} already maps to this room");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
} else {
alt_aliases.insert(orai.clone());
}
// If the room alias does not exist on the server, create it
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
if let Err(e) = client.send(alias_create_req).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
return Err(IambError::from(e).into());
}
}
// And add it to the aliases in the state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = canonical;
ev.alt_aliases = alt_aliases.into_iter().collect();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Aliases => {
// This never happens, aliases is only used for showing
},
RoomField::Id => {
// This never happens, id is only used for showing
},
} }
Ok(vec![]) Ok(vec![])
@@ -281,6 +491,11 @@ impl RoomState {
.ok_or(UIError::Application(IambError::NotJoined))?; .ok_or(UIError::Application(IambError::NotJoined))?;
match field { match field {
RoomField::History => {
let visibility = HistoryVisibility::Joined;
let ev = RoomHistoryVisibilityEventContent::new(visibility);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
},
RoomField::Name => { RoomField::Name => {
let ev = RoomNameEventContent::new("".into()); let ev = RoomNameEventContent::new("".into());
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
@@ -292,14 +507,158 @@ impl RoomState {
let ev = RoomTopicEventContent::new("".into()); let ev = RoomTopicEventContent::new("".into());
let _ = room.send_state_event(ev).await.map_err(IambError::from)?; let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
}, },
RoomField::NotificationMode => {
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
notifications
.delete_user_defined_room_rules(self.id())
.await
.map_err(IambError::from)?;
},
RoomField::CanonicalAlias => {
let Some(alias_to_destroy) = room.canonical_alias() else {
let msg = "This room has no canonical alias to unset";
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
};
// Remove the canonical alias from the state event.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = None;
ev.alt_aliases = room.alt_aliases();
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
// And then unmap it on the server.
let del_req = DeleteAliasRequest::new(alias_to_destroy);
let _ = store
.application
.worker
.client
.send(del_req)
.await
.map_err(IambError::from)?;
},
RoomField::Alias(alias) => {
let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else {
let err = IambError::InvalidRoomAlias(alias);
return Err(err.into());
};
let alt_aliases = room.alt_aliases();
let canonical = room.canonical_alias();
if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) {
let msg = format!("The alias {orai:?} isn't mapped to this room");
return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]);
}
// Remove the alias from the state event if it's in it.
let mut ev = RoomCanonicalAliasEventContent::new();
ev.alias = canonical.filter(|canon| canon != &orai);
ev.alt_aliases = alt_aliases;
ev.alt_aliases.retain(|in_orai| in_orai != &orai);
let _ = room.send_state_event(ev).await.map_err(IambError::from)?;
// And then unmap it on the server.
let del_req = DeleteAliasRequest::new(orai);
let _ = store
.application
.worker
.client
.send(del_req)
.await
.map_err(IambError::from)?;
},
RoomField::Aliases => {
// This will not happen, you cannot unset all aliases
},
RoomField::Id => {
// This never happens, id is only used for showing
},
} }
Ok(vec![]) Ok(vec![])
}, },
RoomAction::Show(field) => {
let room = store
.application
.get_joined_room(self.id())
.ok_or(UIError::Application(IambError::NotJoined))?;
let msg = match field {
RoomField::History => {
let visibility = room.history_visibility();
let visibility = visibility.as_ref().map(|v| v.as_str());
format!("Room history visibility: {}", visibility.unwrap_or("<unknown>"))
},
RoomField::Id => {
let id = room.room_id();
format!("Room identifier: {id}")
},
RoomField::Name => {
match room.name() {
None => "Room has no name".into(),
Some(name) => format!("Room name: {name:?}"),
}
},
RoomField::Topic => {
match room.topic() {
None => "Room has no topic".into(),
Some(topic) => format!("Room topic: {topic:?}"),
}
},
RoomField::NotificationMode => {
let client = &store.application.worker.client;
let notifications = client.notification_settings().await;
let mode =
notifications.get_user_defined_room_notification_mode(self.id()).await;
let level = match mode {
Some(RoomNotificationMode::Mute) => "mute",
Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords",
Some(RoomNotificationMode::AllMessages) => "all",
None => "default",
};
format!("Room notification level: {level:?}")
},
RoomField::Aliases => {
let aliases = room
.alt_aliases()
.iter()
.map(OwnedRoomAliasId::to_string)
.collect::<Vec<String>>();
if aliases.is_empty() {
"No alternative aliases in room".into()
} else {
format!("Alternative aliases: {}.", aliases.join(", "))
}
},
RoomField::CanonicalAlias => {
match room.canonical_alias() {
None => "No canonical alias for room".into(),
Some(can) => format!("Canonical alias: {can}"),
}
},
RoomField::Tag(_) => "Cannot currently show value for a tag".into(),
RoomField::Alias(_) => {
"Cannot show a single alias; use `:room aliases show` instead.".into()
},
};
let msg = InfoMessage::Pager(msg);
let act = Action::ShowInfoMessage(msg);
Ok(vec![(act, ctx)])
},
} }
} }
pub fn get_title(&self, store: &mut ProgramStore) -> Line { pub fn get_title(&self, store: &mut ProgramStore) -> Line<'_> {
let title = store.application.get_room_title(self.id()); let title = store.application.get_room_title(self.id());
let style = Style::default().add_modifier(StyleModifier::BOLD); let style = Style::default().add_modifier(StyleModifier::BOLD);
let mut spans = vec![]; let mut spans = vec![];
@@ -417,8 +776,8 @@ impl WindowOps<IambInfo> for RoomState {
fn dup(&self, store: &mut ProgramStore) -> Self { fn dup(&self, store: &mut ProgramStore) -> Self {
match self { match self {
RoomState::Chat(chat) => RoomState::Chat(chat.dup(store)), RoomState::Chat(chat) => RoomState::Chat(Box::new(chat.dup(store))),
RoomState::Space(space) => RoomState::Space(space.dup(store)), RoomState::Space(space) => RoomState::Space(Box::new(space.dup(store))),
} }
} }
@@ -462,3 +821,27 @@ impl WindowOps<IambInfo> for RoomState {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_room_notification_level() {
let tests = vec![
("mute", RoomNotificationMode::Mute),
("mentions", RoomNotificationMode::MentionsAndKeywordsOnly),
("keywords", RoomNotificationMode::MentionsAndKeywordsOnly),
("all", RoomNotificationMode::AllMessages),
];
for (input, expect) in tests {
let res = notification_mode(input).unwrap();
assert_eq!(expect, res);
}
assert!(notification_mode("invalid").is_err());
assert!(notification_mode("not a level").is_err());
assert!(notification_mode("@user:example.com").is_err());
}
}

View File

@@ -47,7 +47,6 @@ use crate::{
IambId, IambId,
IambInfo, IambInfo,
IambResult, IambResult,
Need,
ProgramContext, ProgramContext,
ProgramStore, ProgramStore,
RoomFetchStatus, RoomFetchStatus,
@@ -79,14 +78,20 @@ fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
} }
fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { 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 +99,12 @@ fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey {
} }
} }
end.clone() // Avoid returning the key if it's at the end.
iter.next().map(|_| end.clone())
} }
fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { 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,10 +156,20 @@ 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();
} }
pub fn goto_message(&mut self, target: MessageKey) {
let mut cursor = MessageCursor::new(target, 0);
std::mem::swap(&mut cursor, &mut self.cursor);
self.jumped.push(cursor);
}
/// Set the dimensions and placement within the terminal window for this list. /// Set the dimensions and placement within the terminal window for this list.
pub fn set_term_info(&mut self, area: Rect) { pub fn set_term_info(&mut self, area: Rect) {
self.viewctx.dimensions = (area.width as usize, area.height as usize); self.viewctx.dimensions = (area.width as usize, area.height as usize);
@@ -673,16 +689,12 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let dir = ctx.get_search_regex_dir(); let dir = ctx.get_search_regex_dir();
let dir = flip.resolve(&dir); let dir = flip.resolve(&dir);
let lsearch = store.registers.get(&Register::LastSearch)?; let lsearch = store.registers.get_last_search().to_string();
let lsearch = lsearch.value.to_string();
let needle = Regex::new(lsearch.as_ref())?; let needle = Regex::new(lsearch.as_ref())?;
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info); let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
if needs_load { if needs_load {
store store.application.need_load.need_messages(self.room_id.clone());
.application
.need_load
.insert(self.room_id.clone(), Need::MESSAGES);
} }
mc mc
}, },
@@ -753,16 +765,12 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let dir = ctx.get_search_regex_dir(); let dir = ctx.get_search_regex_dir();
let dir = flip.resolve(&dir); let dir = flip.resolve(&dir);
let lsearch = store.registers.get(&Register::LastSearch)?; let lsearch = store.registers.get_last_search().to_string();
let lsearch = lsearch.value.to_string();
let needle = Regex::new(lsearch.as_ref())?; let needle = Regex::new(lsearch.as_ref())?;
let (mc, needs_load) = self.find_message(key, dir, &needle, count, info); let (mc, needs_load) = self.find_message(key, dir, &needle, count, info);
if needs_load { if needs_load {
store store.application.need_load.need_messages(self.room_id.to_owned());
.application
.need_load
.insert(self.room_id.to_owned(), Need::MESSAGES);
} }
mc.map(|c| self._range_to(c)) mc.map(|c| self._range_to(c))
@@ -831,8 +839,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,
@@ -1286,7 +1294,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) {
@@ -1319,10 +1327,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
k k
} else { } else {
if state.need_more_messages(info) { if state.need_more_messages(info) {
self.store self.store.application.need_load.need_messages(state.room_id.to_owned());
.application
.need_load
.insert(state.room_id.to_owned(), Need::MESSAGES);
} }
return; return;
}; };
@@ -1342,7 +1347,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;
@@ -1359,11 +1364,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;
@@ -1398,7 +1409,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
@@ -1413,17 +1424,14 @@ 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());
} }
} }
// Check whether we should load older messages for this room. // Check whether we should load older messages for this room.
if state.need_more_messages(info) { if state.need_more_messages(info) {
// If the top of the screen is the older message, load more. // If the top of the screen is the older message, load more.
self.store self.store.application.need_load.need_messages(state.room_id.to_owned());
.application
.need_load
.insert(state.room_id.to_owned(), Need::MESSAGES);
} }
info.draw_last = self.store.application.draw_curr; info.draw_last = self.store.application.draw_curr;
@@ -1433,7 +1441,7 @@ impl<'a> StatefulWidget for Scrollback<'a> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::tests::*; use crate::{base::Need, tests::*};
#[tokio::test] #[tokio::test]
async fn test_search_messages() { async fn test_search_messages() {
@@ -1452,16 +1460,16 @@ mod tests {
// MSG4: "help" // MSG4: "help"
// MSG5: "character" // MSG5: "character"
// MSG1: "writhe" // MSG1: "writhe"
store.set_last_search("he"); store.registers.set_last_search("he");
assert_eq!(scrollback.cursor, MessageCursor::latest()); assert_eq!(scrollback.cursor, MessageCursor::latest());
// Search backwards to MSG4. // Search backwards to MSG4.
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap(); scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG4_KEY.clone().into());
// Search backwards to MSG2. // Search backwards to MSG2.
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap(); scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
assert_eq!( assert_eq!(
std::mem::take(&mut store.application.need_load) std::mem::take(&mut store.application.need_load)
@@ -1472,21 +1480,21 @@ mod tests {
); );
// Can't go any further; need_load now contains the room ID. // Can't go any further; need_load now contains the room ID.
scrollback.search(prev.clone(), 1.into(), &ctx, &mut store).unwrap(); scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG2_KEY.clone().into());
assert_eq!( assert_eq!(
std::mem::take(&mut store.application.need_load) std::mem::take(&mut store.application.need_load)
.into_iter() .into_iter()
.collect::<Vec<(OwnedRoomId, Need)>>(), .collect::<Vec<(OwnedRoomId, Need)>>(),
vec![(room_id.clone(), Need::MESSAGES)] vec![(room_id.clone(), Need { messages: Some(Vec::new()), members: false })]
); );
// Search forward twice to MSG1. // Search forward twice to MSG1.
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap(); scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
// Can't go any further. // Can't go any further.
scrollback.search(next.clone(), 2.into(), &ctx, &mut store).unwrap(); scrollback.search(next, 2.into(), &ctx, &mut store).unwrap();
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
} }
@@ -1520,8 +1528,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]
@@ -1555,7 +1564,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

@@ -1,12 +1,17 @@
//! Window for Matrix spaces //! Window for Matrix spaces
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::str::FromStr;
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::ruma::OwnedSpaceChildOrder;
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 +27,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 +82,79 @@ 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
.power_levels()
.await
.map_err(matrix_sdk::Error::from)
.map_err(IambError::from)?
.user_can_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
{
return Err(IambError::InsufficientPermission.into());
}
let via = self.room.route().await.map_err(IambError::from)?;
let mut ev = SpaceChildEventContent::new(via);
ev.order = order
.as_deref()
.map(OwnedSpaceChildOrder::from_str)
.transpose()
.map_err(IambError::InvalidSpaceChildOrder)?;
ev.suggested = suggested;
let _ = self
.room
.send_state_event_for_key(&child_id, ev)
.await
.map_err(IambError::from)?;
Ok(InfoMessage::from("Space updated").into())
},
SpaceAction::RemoveChild => {
let space = self.list.get().ok_or(IambError::NoSelectedRoomOrSpaceItem)?;
if !self
.room
.power_levels()
.await
.map_err(matrix_sdk::Error::from)
.map_err(IambError::from)?
.user_can_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
{
return Err(IambError::InsufficientPermission.into());
}
let ev = SpaceChildEventContent::new(vec![]);
let event_id = self
.room
.send_state_event_for_key(&space.room_id().to_owned(), ev)
.await
.map_err(IambError::from)?;
// Fix for element (see https://github.com/element-hq/element-web/issues/29606)
let _ = self
.room
.redact(&event_id.event_id, Some("workaround for element bug"), None)
.await
.map_err(IambError::from)?;
Ok(InfoMessage::from("Room removed").into())
},
}
}
} }
impl TerminalCursor for SpaceState { impl TerminalCursor for SpaceState {
@@ -107,7 +194,7 @@ impl<'a> Space<'a> {
} }
} }
impl<'a> StatefulWidget for Space<'a> { impl StatefulWidget for Space<'_> {
type State = SpaceState; 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 +224,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());
@@ -148,7 +236,7 @@ impl<'a> StatefulWidget for Space<'a> {
Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(), Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(),
]; ];
empty_message = Text { lines }.into(); empty_message = Text::from(lines).into();
}, },
} }
} }

View File

@@ -37,10 +37,10 @@ The different subcommands are:
## Additional Configuration ## Additional Configuration
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where You can customize iamb in your `$CONFIG_DIR/iamb/config.toml` file, where
`$CONFIG_DIR` is your system's per-user configuration directory. `$CONFIG_DIR` is your system's per-user configuration directory. For example,
this is typically `~/.config/iamb/config.toml` on systems that use the XDG
Base Directory Specification.
You can edit the following values in the file: See the manual pages or <https://iamb.chat> for more details on how to
further configure or use iamb.
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
- `"cache"`, a directory for cached iamb

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,15 +80,15 @@ use matrix_sdk::{
}, },
Client, Client,
ClientBuildError, ClientBuildError,
DisplayName,
Error as MatrixError, Error as MatrixError,
RoomDisplayName,
RoomMemberships, RoomMemberships,
}; };
use modalkit::errors::UIError; use modalkit::errors::UIError;
use modalkit::prelude::{EditInfo, InfoMessage}; use modalkit::prelude::{EditInfo, InfoMessage};
use crate::base::Need; use crate::base::MessageNeed;
use crate::notifications::register_notifications; use crate::notifications::register_notifications;
use crate::{ use crate::{
base::{ base::{
@@ -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,13 +210,13 @@ 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());
} }
} }
#[derive(Debug)] #[derive(Debug)]
enum Plan { enum Plan {
Messages(OwnedRoomId, Option<String>), Messages(OwnedRoomId, Option<String>, Vec<MessageNeed>),
Members(OwnedRoomId), Members(OwnedRoomId),
} }
@@ -224,8 +225,8 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec<Plan> {
let ChatStore { need_load, rooms, .. } = &mut locked.application; let ChatStore { need_load, rooms, .. } = &mut locked.application;
let mut plan = Vec::with_capacity(need_load.rooms() * 2); let mut plan = Vec::with_capacity(need_load.rooms() * 2);
for (room_id, mut need) in std::mem::take(need_load).into_iter() { for (room_id, need) in std::mem::take(need_load).into_iter() {
if need.contains(Need::MESSAGES) { if let Some(message_need) = need.messages {
let info = rooms.get_or_default(room_id.clone()); let info = rooms.get_or_default(room_id.clone());
if !info.recently_fetched() && !info.fetching { if !info.recently_fetched() && !info.fetching {
@@ -238,16 +239,11 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec<Plan> {
RoomFetchStatus::NotStarted => None, RoomFetchStatus::NotStarted => None,
}; };
plan.push(Plan::Messages(room_id.to_owned(), fetch_id)); plan.push(Plan::Messages(room_id.to_owned(), fetch_id, message_need));
need.remove(Need::MESSAGES);
} }
} }
if need.contains(Need::MEMBERS) { if need.members {
plan.push(Plan::Members(room_id.to_owned())); plan.push(Plan::Members(room_id.to_owned()));
need.remove(Need::MEMBERS);
}
if !need.is_empty() {
need_load.insert(room_id, need);
} }
} }
@@ -257,14 +253,14 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec<Plan> {
async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permits: &Semaphore) { async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permits: &Semaphore) {
let permit = permits.acquire().await; let permit = permits.acquire().await;
match plan { match plan {
Plan::Messages(room_id, fetch_id) => { Plan::Messages(room_id, fetch_id, message_need) => {
let limit = MIN_MSG_LOAD; let limit = MIN_MSG_LOAD;
let client = client.clone(); let client = client.clone();
let store_clone = store.clone(); let store_clone = store.clone();
let res = load_older_one(&client, &room_id, fetch_id, limit).await; let res = load_older_one(&client, &room_id, fetch_id, limit).await;
let mut locked = store.lock().await; let mut locked = store.lock().await;
load_insert(room_id, res, locked.deref_mut(), store_clone); load_insert(room_id, res, locked.deref_mut(), store_clone, message_need);
}, },
Plan::Members(room_id) => { Plan::Members(room_id) => {
let res = members_load(client, &room_id).await; let res = members_load(client, &room_id).await;
@@ -282,6 +278,9 @@ async fn load_older_one(
limit: u32, limit: u32,
) -> MessageFetchResult { ) -> MessageFetchResult {
if let Some(room) = client.get_room(room_id) { if let Some(room) = client.get_room(room_id) {
// Update cached encryption state. This is a noop if the state is already cached.
let _ = room.request_encryption_state().await;
let mut opts = match &fetch_id { let mut opts = match &fetch_id {
Some(id) => MessagesOptions::backward().from(id.as_str()), Some(id) => MessagesOptions::backward().from(id.as_str()),
None => MessagesOptions::backward(), None => MessagesOptions::backward(),
@@ -293,10 +292,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 +308,7 @@ async fn load_older_one(
}, },
}; };
let msg = msg.into_full_event(room_id.to_owned());
msgs.push((msg, receipts)); msgs.push((msg, receipts));
} }
@@ -325,6 +323,7 @@ fn load_insert(
res: MessageFetchResult, res: MessageFetchResult,
locked: &mut ProgramStore, locked: &mut ProgramStore,
store: AsyncProgramStore, store: AsyncProgramStore,
message_needs: Vec<MessageNeed>,
) { ) {
let ChatStore { presences, rooms, worker, picker, settings, .. } = &mut locked.application; let ChatStore { presences, rooms, worker, picker, settings, .. } = &mut locked.application;
let info = rooms.get_or_default(room_id.clone()); let info = rooms.get_or_default(room_id.clone());
@@ -338,37 +337,57 @@ 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());
}
},
} }
} }
info.fetch_id = fetch_id.map_or(RoomFetchStatus::Done, RoomFetchStatus::HaveMore); info.fetch_id = fetch_id.map_or(RoomFetchStatus::Done, RoomFetchStatus::HaveMore);
// check if more are needed
let needs: Vec<_> = message_needs
.into_iter()
.filter(|need| !info.keys.contains_key(&need.event_id) && need.ttl > 0)
.map(|mut need| {
need.ttl -= 1;
need
})
.collect();
if !needs.is_empty() {
locked.application.need_load.need_messages_all(room_id, needs);
}
}, },
Err(e) => { Err(e) => {
warn!(room_id = room_id.as_str(), err = e.to_string(), "Failed to load older messages"); warn!(room_id = room_id.as_str(), err = e.to_string(), "Failed to load older messages");
// Wait and try again. // Wait and try again.
locked.application.need_load.insert(room_id, Need::MESSAGES); locked.application.need_load.need_messages_all(room_id, message_needs);
}, },
} }
} }
@@ -440,7 +459,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) {
let mut dms = vec![]; 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 +474,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 +509,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 +546,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}"),
} }
@@ -549,7 +569,7 @@ pub async fn do_first_sync(client: &Client, store: &AsyncProgramStore) -> Result
let mut filter = FilterDefinition::default(); let mut filter = FilterDefinition::default();
filter.room = room_ev; filter.room = room_ev;
let settings = SyncSettings::new().filter(filter.into()); let settings = SyncSettings::new().filter(filter.into()).timeout(Duration::from_secs(0));
client.sync_once(settings).await?; client.sync_once(settings).await?;
@@ -562,12 +582,12 @@ pub async fn do_first_sync(client: &Client, store: &AsyncProgramStore) -> Result
for room in sync_info.rooms.iter() { for room in sync_info.rooms.iter() {
let room_id = room.as_ref().0.room_id().to_owned(); let room_id = room.as_ref().0.room_id().to_owned();
need_load.insert(room_id, Need::MESSAGES); need_load.need_messages(room_id);
} }
for room in sync_info.dms.iter() { for room in sync_info.dms.iter() {
let room_id = room.as_ref().0.room_id().to_owned(); let room_id = room.as_ref().0.room_id().to_owned();
need_load.insert(room_id, Need::MESSAGES); need_load.need_messages(room_id);
} }
Ok(()) Ok(())
@@ -603,7 +623,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<()>),
@@ -700,7 +720,7 @@ async fn create_client_inner(
.build() .build()
.unwrap(); .unwrap();
let req_config = RequestConfig::new().timeout(req_timeout).retry_timeout(req_timeout); let req_config = RequestConfig::new().timeout(req_timeout).max_retry_time(req_timeout);
// Set up the Matrix client for the selected profile. // Set up the Matrix client for the selected profile.
let builder = Client::builder() let builder = Client::builder()
@@ -1001,7 +1021,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 +1063,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,
@@ -1058,11 +1096,15 @@ impl ClientWorker {
async move { async move {
let room_id = room.room_id(); let room_id = room.room_id();
let room_info = room.clone_info(); let room_info = room.clone_info();
let room_version = room_info.room_version().unwrap_or(&RoomVersionId::V1); let rules = &room_info
.room_version()
.and_then(RoomVersionId::rules)
.unwrap_or(RoomVersionId::V1.rules().unwrap())
.redaction;
let mut locked = store.lock().await; let mut locked = store.lock().await;
let info = locked.application.get_room_info(room_id.to_owned()); let info = locked.application.get_room_info(room_id.to_owned());
info.redact(ev, room_version); info.redact(ev, rules);
} }
}, },
); );
@@ -1076,11 +1118,12 @@ impl ClientWorker {
let room_id = room.room_id(); let 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() .state_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();
@@ -1217,7 +1260,7 @@ impl ClientWorker {
let settings = self.settings.clone(); let settings = self.settings.clone();
async move { async move {
while !client.logged_in() { while !client.is_active() {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
} }
@@ -1309,7 +1352,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 +1389,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,9 +1432,9 @@ 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.summary.room_id).collect();
Ok(rooms) Ok(rooms)
} }