Compare commits

..

No commits in common. "main" and "latest" have entirely different histories.
main ... latest

28 changed files with 1754 additions and 5081 deletions

View file

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

View file

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

3532
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -53,7 +53,7 @@ user_id = "@user:example.com"
## Installation (via `crates.io`)
Install Rust (1.83.0 or above) and Cargo, and then run:
Install Rust (1.70.0 or above) and Cargo, and then run:
```
cargo install --locked iamb
@ -80,27 +80,9 @@ On FreeBSD a package is available from the official repositories. To install it
pkg install iamb
```
### Gentoo
On Gentoo, an ebuild is available from the community-managed
[GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU).
You can enable the GURU overlay with:
```
eselect repository enable guru
emerge --sync guru
```
And then install `iamb` with:
```
emerge --ask iamb
```
### macOS
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is available in Homebrew's
On macOS a [package](https://formulae.brew.sh/formula/iamb#default) is availabe in Homebrew's
repository. To install it simply run:
```

View file

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

View file

@ -173,9 +173,6 @@ respective shortcodes.
.It Sy message_user_color
Defines whether or not the message body is colored like the username.
.It Sy normal_after_send
Defines whether to reset input to Normal mode after sending a message.
.It Sy notifications
When this subsection is present, you can enable and configure push notifications.
See
@ -211,9 +208,6 @@ See
.Sx "SORTING LISTS"
for more details.
.It Sy state_event_display
Defines whether the state events like joined or left are shown.
.It Sy typing_notice_send
Defines whether or not the typing state is sent.
@ -237,10 +231,6 @@ Possible values are
Specify the width of the column where usernames are displayed in a room.
Usernames that are too long are truncated.
Defaults to 30.
.It Sy tabstop
Number of spaces that a <Tab> counts for.
Defaults to 4.
.El
.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support)
@ -279,8 +269,6 @@ to use the desktop mechanism (default).
Setting this field to
.Dq Sy bell
will use the terminal bell instead.
Both can be used via
.Dq Sy desktop|bell .
.It Sy show_message
controls whether to show the message in the desktop notification, and defaults to
@ -344,29 +332,9 @@ window.
Defaults to
.Sy ["power",\ "id"] .
.El
The available values are:
.Bl -tag -width Ds
.It Sy favorite
Put favorite rooms before other rooms.
.It Sy lowpriority
Put lowpriority rooms after other rooms.
.It Sy name
Sort rooms by alphabetically ascending room name.
.It Sy alias
Sort rooms by alphabetically ascending canonical room alias.
.It Sy id
Sort rooms by alphabetically ascending Matrix room identifier.
.It Sy unread
Put unread rooms before other rooms.
.It Sy recent
Sort rooms by most recent message timestamp.
.It Sy invite
Put invites before other rooms.
.El
.El
.Ss Example 1: Group room members by their server first
.Ss Example 1: Group room members by ther server first
.Bd -literal -offset indent
[settings.sort]
members = ["server", "localpart"]

View file

@ -1,13 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>chat.iamb.iamb</id>
<id>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.10" date="2024-08-20"/>
<release version="0.0.9" date="2024-03-28"/>
</releases>
@ -15,7 +14,6 @@
<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>
@ -25,8 +23,8 @@
<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>
<image>https://iamb.chat/static/images/iamb-demo.gif</image>
<caption>Example conversation within iamb</caption>
</screenshot>
</screenshots>
@ -39,6 +37,7 @@
</p>
</description>
<icon type="remote">https://iamb.chat/images/iamb.svg</icon>
<launchable type="desktop-id">iamb.desktop</launchable>
<categories>

58
flake.lock generated
View file

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

View file

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

View file

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

View file

@ -12,7 +12,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use emojis::Emoji;
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
@ -48,7 +47,6 @@ use matrix_sdk::{
},
room::redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent},
tag::{TagName, Tags},
AnySyncStateEvent,
MessageLikeEvent,
},
presence::PresenceState,
@ -74,7 +72,7 @@ use modalkit::{
ApplicationStore,
ApplicationWindowId,
},
completion::{complete_path, Completer, CompletionMap},
completion::{complete_path, CompletionMap},
context::EditContext,
cursor::Cursor,
rope::EditRope,
@ -92,7 +90,6 @@ use modalkit::{
use crate::config::ImagePreviewProtocolValues;
use crate::message::ImageStatus;
use crate::notifications::NotificationHandle;
use crate::preview::{source_from_event, spawn_insert_preview};
use crate::{
message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages},
@ -180,19 +177,6 @@ pub enum MessageAction {
Unreact(Option<String>, bool),
}
/// An action taken in the currently selected space.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SpaceAction {
/// Add a room or update metadata.
///
/// The [`Option<String>`] argument is the order parameter.
/// The [`bool`] argument indicates whether the room is suggested.
SetChild(OwnedRoomId, Option<String>, bool),
/// Remove the selected room.
RemoveChild,
}
/// The type of room being created.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CreateRoomType {
@ -259,9 +243,6 @@ pub enum SortFieldRoom {
/// Sort rooms by the timestamps of their most recent messages.
Recent,
/// Sort rooms by whether they are invites.
Invite,
}
/// Fields that users can be sorted by.
@ -296,7 +277,7 @@ impl<'de> Deserialize<'de> for SortColumn<SortFieldRoom> {
/// [serde] visitor for deserializing [SortColumn] for rooms and spaces.
struct SortRoomVisitor;
impl Visitor<'_> for SortRoomVisitor {
impl<'de> Visitor<'de> for SortRoomVisitor {
type Value = SortColumn<SortFieldRoom>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -326,7 +307,6 @@ impl Visitor<'_> for SortRoomVisitor {
"name" => SortFieldRoom::Name,
"alias" => SortFieldRoom::Alias,
"id" => SortFieldRoom::RoomId,
"invite" => SortFieldRoom::Invite,
_ => {
let msg = format!("Unknown sort field: {value:?}");
return Err(E::custom(msg));
@ -349,7 +329,7 @@ impl<'de> Deserialize<'de> for SortColumn<SortFieldUser> {
/// [serde] visitor for deserializing [SortColumn] for users.
struct SortUserVisitor;
impl Visitor<'_> for SortUserVisitor {
impl<'de> Visitor<'de> for SortUserVisitor {
type Value = SortColumn<SortFieldUser>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -395,9 +375,6 @@ pub enum RoomField {
/// The room name.
Name,
/// The room id.
Id,
/// A room tag.
Tag(TagName),
@ -516,9 +493,6 @@ pub enum IambAction {
/// Perform an action on the currently selected message.
Message(MessageAction),
/// Perform an action on the current space.
Space(SpaceAction),
/// Open a URL.
OpenLink(String),
@ -560,12 +534,6 @@ impl From<MessageAction> for IambAction {
}
}
impl From<SpaceAction> for IambAction {
fn from(act: SpaceAction) -> Self {
IambAction::Space(act)
}
}
impl From<RoomAction> for IambAction {
fn from(act: RoomAction) -> Self {
IambAction::Room(act)
@ -585,7 +553,6 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Break,
IambAction::Keys(..) => SequenceStatus::Break,
IambAction::Message(..) => SequenceStatus::Break,
IambAction::Space(..) => SequenceStatus::Break,
IambAction::Room(..) => SequenceStatus::Break,
IambAction::OpenLink(..) => SequenceStatus::Break,
IambAction::Send(..) => SequenceStatus::Break,
@ -601,7 +568,6 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Atom,
IambAction::Keys(..) => SequenceStatus::Atom,
IambAction::Message(..) => SequenceStatus::Atom,
IambAction::Space(..) => SequenceStatus::Atom,
IambAction::OpenLink(..) => SequenceStatus::Atom,
IambAction::Room(..) => SequenceStatus::Atom,
IambAction::Send(..) => SequenceStatus::Atom,
@ -617,7 +583,6 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Ignore,
IambAction::Keys(..) => SequenceStatus::Ignore,
IambAction::Message(..) => SequenceStatus::Ignore,
IambAction::Space(..) => SequenceStatus::Ignore,
IambAction::Room(..) => SequenceStatus::Ignore,
IambAction::OpenLink(..) => SequenceStatus::Ignore,
IambAction::Send(..) => SequenceStatus::Ignore,
@ -632,7 +597,6 @@ impl ApplicationAction for IambAction {
IambAction::ClearUnreads => false,
IambAction::Homeserver(..) => false,
IambAction::Message(..) => false,
IambAction::Space(..) => false,
IambAction::Room(..) => false,
IambAction::Keys(..) => false,
IambAction::Send(..) => false,
@ -650,12 +614,6 @@ impl From<RoomAction> for ProgramAction {
}
}
impl From<SpaceAction> for ProgramAction {
fn from(act: SpaceAction) -> Self {
IambAction::from(act).into()
}
}
impl From<IambAction> for ProgramAction {
fn from(act: IambAction) -> Self {
Action::Application(act)
@ -751,22 +709,10 @@ pub enum IambError {
#[error("Current window is not a room or space")]
NoSelectedRoomOrSpace,
/// A failure due to not having a room or space item selected in a list.
#[error("No room or space currently selected in list")]
NoSelectedRoomOrSpaceItem,
/// A failure due to not having a room selected.
#[error("Current window is not a room")]
NoSelectedRoom,
/// A failure due to not having a space selected.
#[error("Current window is not a space")]
NoSelectedSpace,
/// A failure due to not having sufficient permission to perform an action in a room.
#[error("You do not have the permission to do that")]
InsufficientPermission,
/// A failure due to not having an outstanding room invitation.
#[error("You do not have a current invitation to this room")]
NotInvited,
@ -839,9 +785,6 @@ pub enum EventLocation {
/// The [EventId] belongs to a reaction to the given event.
Reaction(OwnedEventId),
/// The [EventId] belongs to a state event in the main timeline of the room.
State(MessageKey),
}
impl EventLocation {
@ -871,6 +814,7 @@ impl UnreadInfo {
}
/// Information about room's the user's joined.
#[derive(Default)]
pub struct RoomInfo {
/// The display name for this room.
pub name: Option<String>,
@ -885,13 +829,15 @@ pub struct RoomInfo {
messages: Messages,
/// A map of read markers to display on different events.
pub event_receipts: HashMap<ReceiptThread, HashMap<OwnedEventId, HashSet<OwnedUserId>>>,
pub event_receipts: HashMap<OwnedEventId, HashSet<OwnedUserId>>,
/// A map of the most recent read marker for each user.
///
/// Every receipt in this map should also have an entry in [`event_receipts`](`Self::event_receipts`),
/// Every receipt in this map should also have an entry in [`event_receipts`],
/// however not every user has an entry. If a user's most recent receipt is
/// older than the oldest loaded event, that user will not be included.
pub user_receipts: HashMap<ReceiptThread, HashMap<OwnedUserId, OwnedEventId>>,
pub user_receipts: HashMap<OwnedUserId, OwnedEventId>,
/// A map of message identifiers to a map of reaction events.
pub reactions: HashMap<OwnedEventId, MessageReactions>,
@ -917,28 +863,6 @@ pub struct RoomInfo {
pub draw_last: Option<Instant>,
}
impl Default for RoomInfo {
fn default() -> Self {
Self {
messages: Messages::new(ReceiptThread::Main),
name: Default::default(),
tags: Default::default(),
keys: Default::default(),
event_receipts: Default::default(),
user_receipts: Default::default(),
reactions: Default::default(),
threads: Default::default(),
fetching: Default::default(),
fetch_id: Default::default(),
fetch_last: Default::default(),
users_typing: Default::default(),
display_names: Default::default(),
draw_last: Default::default(),
}
}
}
impl RoomInfo {
pub fn get_thread(&self, root: Option<&EventId>) -> Option<&Messages> {
if let Some(thread_root) = root {
@ -950,9 +874,7 @@ impl RoomInfo {
pub fn get_thread_mut(&mut self, root: Option<OwnedEventId>) -> &mut Messages {
if let Some(thread_root) = root {
self.threads
.entry(thread_root.clone())
.or_insert_with(|| Messages::thread(thread_root))
self.threads.entry(thread_root).or_default()
} else {
&mut self.messages
}
@ -1030,12 +952,6 @@ impl RoomInfo {
match self.keys.get(redacts) {
None => return,
Some(EventLocation::State(key)) => {
if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, room_version);
}
},
Some(EventLocation::Message(None, key)) => {
if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
@ -1092,9 +1008,7 @@ impl RoomInfo {
};
let source = if let Some(thread) = thread {
self.threads
.entry(thread.clone())
.or_insert_with(|| Messages::thread(thread.clone()))
self.threads.entry(thread.clone()).or_default()
} else {
&mut self.messages
};
@ -1111,7 +1025,6 @@ impl RoomInfo {
content.apply_replacement(new_msgtype);
},
MessageEvent::Redacted(_) |
MessageEvent::State(_) |
MessageEvent::EncryptedOriginal(_) |
MessageEvent::EncryptedRedacted(_) => {
return;
@ -1121,32 +1034,16 @@ impl RoomInfo {
msg.html = msg.event.html();
}
pub fn insert_any_state(&mut self, msg: AnySyncStateEvent) {
let event_id = msg.event_id().to_owned();
let key = (msg.origin_server_ts().into(), event_id.clone());
let loc = EventLocation::State(key.clone());
self.keys.insert(event_id, loc);
self.messages.insert_message(key, msg);
}
/// Indicates whether this room has unread messages.
pub fn unreads(&self, settings: &ApplicationSettings) -> UnreadInfo {
let last_message = self.messages.last_key_value();
let last_receipt = self
.user_receipts
.get(&ReceiptThread::Main)
.and_then(|receipts| receipts.get(&settings.profile.user_id));
let last_receipt = self.get_receipt(&settings.profile.user_id);
match (last_message, last_receipt) {
(Some(((ts, recent), _)), Some(last_read)) => {
UnreadInfo { unread: last_read != recent, latest: Some(*ts) }
},
(Some(((ts, _), _)), None) => {
// If we've never loaded/generated a room's receipt (example,
// a newly joined but never viewed room), show it as unread.
UnreadInfo { unread: true, latest: Some(*ts) }
},
(Some(((ts, _), _)), None) => UnreadInfo { unread: false, latest: Some(*ts) },
(None, _) => UnreadInfo::default(),
}
}
@ -1174,10 +1071,7 @@ impl RoomInfo {
let event_id = msg.event_id().to_owned();
let key = (msg.origin_server_ts().into(), event_id.clone());
let replies = self
.threads
.entry(thread_root.clone())
.or_insert_with(|| Messages::thread(thread_root.clone()));
let replies = self.threads.entry(thread_root.clone()).or_default();
let loc = EventLocation::Message(Some(thread_root), key.clone());
self.keys.insert(event_id, loc);
replies.insert_message(key, msg);
@ -1237,73 +1131,40 @@ impl RoomInfo {
/// Indicates whether we've recently fetched scrollback for this room.
pub fn recently_fetched(&self) -> bool {
self.fetch_last.is_some_and(|i| i.elapsed() < ROOM_FETCH_DEBOUNCE)
self.fetch_last.map_or(false, |i| i.elapsed() < ROOM_FETCH_DEBOUNCE)
}
fn clear_receipt(&mut self, thread: &ReceiptThread, user_id: &OwnedUserId) -> Option<()> {
let old_event_id =
self.user_receipts.get(thread).and_then(|receipts| receipts.get(user_id))?;
let old_thread = self.event_receipts.get_mut(thread)?;
let old_receipts = old_thread.get_mut(old_event_id)?;
fn clear_receipt(&mut self, user_id: &OwnedUserId) -> Option<()> {
let old_event_id = self.user_receipts.get(user_id)?;
let old_receipts = self.event_receipts.get_mut(old_event_id)?;
old_receipts.remove(user_id);
if old_receipts.is_empty() {
old_thread.remove(old_event_id);
}
if old_thread.is_empty() {
self.event_receipts.remove(thread);
self.event_receipts.remove(old_event_id);
}
None
}
pub fn set_receipt(
&mut self,
thread: ReceiptThread,
user_id: OwnedUserId,
event_id: OwnedEventId,
) {
self.clear_receipt(&thread, &user_id);
pub fn set_receipt(&mut self, user_id: OwnedUserId, event_id: OwnedEventId) {
self.clear_receipt(&user_id);
self.event_receipts
.entry(thread.clone())
.or_default()
.entry(event_id.clone())
.or_default()
.insert(user_id.clone());
self.user_receipts.entry(thread).or_default().insert(user_id, event_id);
self.user_receipts.insert(user_id, event_id);
}
pub fn fully_read(&mut self, user_id: &UserId) {
pub fn fully_read(&mut self, user_id: OwnedUserId) {
let Some(((_, event_id), _)) = self.messages.last_key_value() else {
return;
};
self.set_receipt(ReceiptThread::Main, user_id.to_owned(), event_id.clone());
let newest = self
.threads
.iter()
.filter_map(|(thread_id, messages)| {
let thread = ReceiptThread::Thread(thread_id.to_owned());
messages
.last_key_value()
.map(|((_, event_id), _)| (thread, event_id.to_owned()))
})
.collect::<Vec<_>>();
for (thread, event_id) in newest.into_iter() {
self.set_receipt(thread, user_id.to_owned(), event_id.clone());
}
self.set_receipt(user_id, event_id.clone());
}
pub fn receipts<'a>(
&'a self,
user_id: &'a UserId,
) -> impl Iterator<Item = (&'a ReceiptThread, &'a OwnedEventId)> + 'a {
self.user_receipts
.iter()
.filter_map(move |(t, rs)| rs.get(user_id).map(|r| (t, r)))
pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> {
self.user_receipts.get(user_id)
}
fn get_typers(&self) -> &[OwnedUserId] {
@ -1362,9 +1223,7 @@ impl RoomInfo {
}
if !settings.tunables.typing_notice_display {
// still keep one line blank, so `render_jump_to_recent` doesn't immediately hide the
// last line in scrollback
return Rect::new(area.x, area.y, area.width, area.height - 1);
return area;
}
let top = Rect::new(area.x, area.y, area.width, area.height - 1);
@ -1409,7 +1268,7 @@ fn emoji_map() -> CompletionMap<String, &'static Emoji> {
#[cfg(unix)]
fn picker_from_termios(protocol_type: Option<ProtocolType>) -> Option<Picker> {
let mut picker = match Picker::from_query_stdio() {
let mut picker = match Picker::from_termios() {
Ok(picker) => picker,
Err(e) => {
tracing::error!("Failed to setup image previews: {e}");
@ -1418,7 +1277,9 @@ fn picker_from_termios(protocol_type: Option<ProtocolType>) -> Option<Picker> {
};
if let Some(protocol_type) = protocol_type {
picker.set_protocol_type(protocol_type);
picker.protocol_type = protocol_type;
} else {
picker.guess_protocol();
}
Some(picker)
@ -1441,8 +1302,8 @@ fn picker_from_settings(settings: &ApplicationSettings) -> Option<Picker> {
}) = image_preview_protocol
{
// User forced type and font_size: use that.
let mut picker = Picker::from_fontsize(font_size);
picker.set_protocol_type(protocol_type);
let mut picker = Picker::new(font_size);
picker.protocol_type = protocol_type;
Some(picker)
} else {
// Guess, but use type if forced.
@ -1556,12 +1417,6 @@ pub struct ChatStore {
/// Whether the application is currently focused
pub focused: bool,
/// Collator for locale-aware text sorting.
pub collator: feruca::Collator,
/// Notifications that should be dismissed when the user opens the room.
pub open_notifications: HashMap<OwnedRoomId, Vec<NotificationHandle>>,
}
impl ChatStore {
@ -1576,7 +1431,6 @@ impl ChatStore {
cmds: crate::commands::setup_commands(),
emojis: emoji_map(),
collator: Default::default(),
names: Default::default(),
rooms: Default::default(),
presences: Default::default(),
@ -1586,7 +1440,6 @@ impl ChatStore {
draw_curr: None,
ring_bell: false,
focused: true,
open_notifications: Default::default(),
}
}
@ -1707,7 +1560,7 @@ impl<'de> Deserialize<'de> for IambId {
/// [serde] visitor for deserializing [IambId].
struct IambIdVisitor;
impl Visitor<'_> for IambIdVisitor {
impl<'de> Visitor<'de> for IambIdVisitor {
type Value = IambId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
@ -1844,13 +1697,6 @@ impl RoomFocus {
pub fn is_msgbar(&self) -> bool {
matches!(self, RoomFocus::MessageBar)
}
pub fn toggle(&mut self) {
*self = match self {
RoomFocus::MessageBar => RoomFocus::Scrollback,
RoomFocus::Scrollback => RoomFocus::MessageBar,
};
}
}
/// Identifiers used to track where a mark was placed.
@ -1919,20 +1765,11 @@ impl ApplicationInfo for IambInfo {
type WindowId = IambId;
type ContentId = IambBufferId;
fn content_of_command(ct: CommandType) -> IambBufferId {
IambBufferId::Command(ct)
}
}
pub struct IambCompleter;
impl Completer<IambInfo> for IambCompleter {
fn complete(
&mut self,
text: &EditRope,
cursor: &mut Cursor,
content: &IambBufferId,
store: &mut ChatStore,
store: &mut ProgramStore,
) -> Vec<String> {
match content {
IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store),
@ -1950,16 +1787,21 @@ impl Completer<IambInfo> for IambCompleter {
IambBufferId::UnreadList => vec![],
}
}
fn content_of_command(ct: CommandType) -> IambBufferId {
IambBufferId::Command(ct)
}
}
/// Tab completion for user IDs.
fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);
store
.application
.presences
.complete(id.as_ref())
.into_iter()
@ -1968,7 +1810,7 @@ fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Ve
}
/// Tab completion within the message bar.
fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
@ -1977,12 +1819,13 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> V
match id.chars().next() {
// Complete room aliases.
Some('#') => {
return store.names.complete(id.as_ref());
return store.application.names.complete(id.as_ref());
},
// Complete room identifiers.
Some('!') => {
return store
.application
.rooms
.complete(id.as_ref())
.into_iter()
@ -1992,7 +1835,7 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> V
// Complete Emoji shortcodes.
Some(':') => {
let list = store.emojis.complete(&id[1..]);
let list = store.application.emojis.complete(&id[1..]);
let iter = list.into_iter().take(200).map(|s| format!(":{}:", s));
return iter.collect();
@ -2001,6 +1844,7 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> V
// Complete usernames for @ and empty strings.
Some('@') | None => {
return store
.application
.presences
.complete(id.as_ref())
.into_iter()
@ -2014,23 +1858,28 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> V
}
/// Tab completion for Matrix identifiers (usernames, room aliases, etc.)
fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
fn complete_matrix_names(
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);
let list = store.names.complete(id.as_ref());
let list = store.application.names.complete(id.as_ref());
if !list.is_empty() {
return list;
}
let list = store.presences.complete(id.as_ref());
let list = store.application.presences.complete(id.as_ref());
if !list.is_empty() {
return list.into_iter().map(|i| i.to_string()).collect();
}
store
.application
.rooms
.complete(id.as_ref())
.into_iter()
@ -2039,12 +1888,12 @@ fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore
}
/// Tab completion for Emoji shortcode names.
fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
let sc = text.get_prefix_word_mut(cursor, &WordStyle::Little);
let sc = sc.unwrap_or_else(EditRope::empty);
let sc = Cow::from(&sc);
store.emojis.complete(sc.as_ref())
store.application.emojis.complete(sc.as_ref())
}
/// Tab completion for command names.
@ -2052,11 +1901,11 @@ fn complete_cmdname(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ChatStore,
store: &ProgramStore,
) -> Vec<String> {
// Complete command name and set cursor position.
let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little);
store.cmds.complete_name(desc.command.as_str())
store.application.cmds.complete_name(desc.command.as_str())
}
/// Tab completion for command arguments.
@ -2064,9 +1913,9 @@ fn complete_cmdarg(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ChatStore,
store: &ProgramStore,
) -> Vec<String> {
let cmd = match store.cmds.get(desc.command.as_str()) {
let cmd = match store.application.cmds.get(desc.command.as_str()) {
Ok(cmd) => cmd,
Err(_) => return vec![],
};
@ -2089,7 +1938,12 @@ fn complete_cmdarg(
}
/// Tab completion for commands.
fn complete_cmd(cmd: &str, text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
fn complete_cmd(
cmd: &str,
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
match CommandDescription::from_str(cmd) {
Ok(desc) => {
if desc.arg.untrimmed.is_empty() {
@ -2106,7 +1960,7 @@ fn complete_cmd(cmd: &str, text: &EditRope, cursor: &mut Cursor, store: &ChatSto
}
/// Tab completion for the command bar.
fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
let eo = text.cursor_to_offset(cursor);
let slice = text.slice(..eo);
let cow = Cow::from(&slice);
@ -2286,7 +2140,6 @@ pub mod tests {
#[tokio::test]
async fn test_complete_msgbar() {
let store = mock_store().await;
let store = store.application;
let text = EditRope::from("going for a walk :walk ");
let mut cursor = Cursor::new(0, 22);
@ -2310,7 +2163,6 @@ pub mod tests {
#[tokio::test]
async fn test_complete_cmdbar() {
let store = mock_store().await;
let store = store.application;
let users = vec![
"@user1:example.com",
"@user2:example.com",

View file

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

View file

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

View file

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

View file

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

View file

@ -2,15 +2,15 @@
use std::borrow::Cow;
use std::cmp::{Ord, Ordering, PartialOrd};
use std::collections::hash_map::DefaultHasher;
use std::collections::hash_set;
use std::collections::BTreeMap;
use std::convert::{TryFrom, TryInto};
use std::fmt::{self, Display};
use std::hash::{Hash, Hasher};
use std::ops::{Deref, DerefMut};
use chrono::{DateTime, Local as LocalTz};
use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone};
use humansize::{format_size, DECIMAL};
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use serde_json::json;
use unicode_width::UnicodeWidthStr;
@ -35,7 +35,6 @@ use matrix_sdk::ruma::{
},
redaction::SyncRoomRedactionEvent,
},
AnySyncStateEvent,
RedactContent,
RedactedUnsigned,
},
@ -68,17 +67,13 @@ use crate::{
mod compose;
mod html;
mod printer;
mod state;
pub use self::compose::text_to_message;
use self::state::{body_cow_state, html_state};
pub use html::TreeGenState;
type ProtocolPreview<'a> = (&'a Protocol, u16, u16);
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
pub struct Messages(BTreeMap<MessageKey, Message>, pub ReceiptThread);
#[derive(Default)]
pub struct Messages(BTreeMap<MessageKey, Message>);
impl Deref for Messages {
type Target = BTreeMap<MessageKey, Message>;
@ -95,18 +90,6 @@ impl DerefMut for Messages {
}
impl Messages {
pub fn new(thread: ReceiptThread) -> Self {
Self(Default::default(), thread)
}
pub fn main() -> Self {
Self::new(ReceiptThread::Main)
}
pub fn thread(root: OwnedEventId) -> Self {
Self::new(ReceiptThread::Thread(root))
}
pub fn insert_message(&mut self, key: MessageKey, msg: impl Into<Message>) {
let event_id = key.1.clone();
let msg = msg.into();
@ -177,9 +160,7 @@ fn placeholder_frame(
}
let mut placeholder = "\u{230c}".to_string();
placeholder.push_str(&" ".repeat(width - 2));
placeholder.push('\u{230d}');
placeholder.push_str(&"\n".repeat((height - 1) / 2));
placeholder.push_str("\u{230d}\n");
if *height > 2 {
if let Some(text) = text {
if text.width() <= width - 2 {
@ -189,7 +170,7 @@ fn placeholder_frame(
}
}
placeholder.push_str(&"\n".repeat(height / 2));
placeholder.push_str(&"\n".repeat(height - 2));
placeholder.push('\u{230e}');
placeholder.push_str(&" ".repeat(width - 2));
placeholder.push_str("\u{230f}\n");
@ -199,8 +180,9 @@ fn placeholder_frame(
#[inline]
fn millis_to_datetime(ms: UInt) -> DateTime<LocalTz> {
let time = i64::from(ms) / 1000;
let time = DateTime::from_timestamp(time, 0).unwrap_or_default();
time.into()
let time = NaiveDateTime::from_timestamp_opt(time, 0).unwrap_or_default();
LocalTz.from_utc_datetime(&time)
}
#[derive(thiserror::Error, Debug)]
@ -444,7 +426,6 @@ pub enum MessageEvent {
EncryptedRedacted(Box<RedactedRoomEncryptedEvent>),
Original(Box<OriginalRoomMessageEvent>),
Redacted(Box<RedactedRoomMessageEvent>),
State(Box<AnySyncStateEvent>),
Local(OwnedEventId, Box<RoomMessageEventContent>),
}
@ -455,7 +436,6 @@ impl MessageEvent {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
MessageEvent::Original(ev) => ev.event_id.as_ref(),
MessageEvent::Redacted(ev) => ev.event_id.as_ref(),
MessageEvent::State(ev) => ev.event_id(),
MessageEvent::Local(event_id, _) => event_id.as_ref(),
}
}
@ -466,7 +446,6 @@ impl MessageEvent {
MessageEvent::Original(ev) => Some(&ev.content),
MessageEvent::EncryptedRedacted(_) => None,
MessageEvent::Redacted(_) => None,
MessageEvent::State(_) => None,
MessageEvent::Local(_, content) => Some(content),
}
}
@ -484,7 +463,6 @@ impl MessageEvent {
MessageEvent::Original(ev) => body_cow_content(&ev.content),
MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned),
MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned),
MessageEvent::State(ev) => body_cow_state(ev),
MessageEvent::Local(_, content) => body_cow_content(content),
}
}
@ -495,7 +473,6 @@ impl MessageEvent {
MessageEvent::EncryptedRedacted(_) => return None,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
MessageEvent::State(ev) => return Some(html_state(ev)),
MessageEvent::Local(_, content) => content,
};
@ -515,7 +492,6 @@ impl MessageEvent {
MessageEvent::EncryptedOriginal(_) => return,
MessageEvent::EncryptedRedacted(_) => return,
MessageEvent::Redacted(_) => return,
MessageEvent::State(_) => return,
MessageEvent::Local(_, _) => return,
MessageEvent::Original(ev) => {
let redacted = RedactedRoomMessageEvent {
@ -647,8 +623,8 @@ struct MessageFormatter<'a> {
/// The date the message was sent.
date: Option<Span<'a>>,
/// The users who have read up to this message.
read: Vec<OwnedUserId>,
/// Iterator over the users who have read up to this message.
read: Option<hash_set::Iter<'a, OwnedUserId>>,
}
impl<'a> MessageFormatter<'a> {
@ -681,11 +657,13 @@ impl<'a> MessageFormatter<'a> {
line.push(time);
// Show read receipts.
let user_char = |user: OwnedUserId| -> Span { settings.get_user_char_span(&user) };
let user_char =
|user: &'a OwnedUserId| -> Span<'a> { settings.get_user_char_span(user) };
let mut read = self.read.iter_mut().flatten();
let a = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
let b = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
let c = self.read.pop().map(user_char).unwrap_or_else(|| Span::raw(" "));
let a = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
let b = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
let c = read.next().map(user_char).unwrap_or_else(|| Span::raw(" "));
line.push(Span::raw(" "));
line.push(c);
@ -738,11 +716,11 @@ impl<'a> MessageFormatter<'a> {
style: Style,
text: &mut Text<'a>,
info: &'a RoomInfo,
settings: &'a ApplicationSettings,
) -> Option<ProtocolPreview<'a>> {
) {
let width = self.width();
let w = width.saturating_sub(2);
let (mut replied, proto) = msg.show_msg(w, style, true, settings);
let shortcodes = self.settings.tunables.message_shortcode_display;
let (mut replied, _) = msg.show_msg(w, style, true, shortcodes);
let mut sender = msg.sender_span(info, self.settings);
let sender_width = UnicodeWidthStr::width(sender.content.as_ref());
let trailing = w.saturating_sub(sender_width + 1);
@ -761,26 +739,16 @@ impl<'a> MessageFormatter<'a> {
text,
);
// Determine the image offset of the reply header, taking into account the formatting
let proto = proto.map(|p| {
let y_off = text.lines.len() as u16;
// Adjust x_off by 2 to account for the vertical line and indent
let x_off = self.cols.user_gutter_width(settings) + 2;
(p, x_off, y_off)
});
for line in replied.lines.iter_mut() {
line.spans.insert(0, Span::styled(THICK_VERTICAL, style));
line.spans.insert(0, Span::styled(" ", style));
}
self.push_text(replied, style, text);
proto
}
fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) {
let mut emojis = printer::TextPrinter::new(self.width(), style, false, self.settings);
let mut emojis = printer::TextPrinter::new(self.width(), style, false, false);
let mut reactions = 0;
for (key, count) in counts {
@ -829,7 +797,7 @@ impl<'a> MessageFormatter<'a> {
let plural = len != 1;
let style = Style::default();
let mut threaded =
printer::TextPrinter::new(self.width(), style, false, self.settings).literal(true);
printer::TextPrinter::new(self.width(), style, false, false).literal(true);
let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD));
threaded.push_str(" \u{2937} ", style);
threaded.push_span_nobreak(len);
@ -846,7 +814,7 @@ impl<'a> MessageFormatter<'a> {
pub enum ImageStatus {
None,
Downloading(ImagePreviewSize),
Loaded(Protocol),
Loaded(Box<dyn Protocol>),
Error(String),
}
@ -881,7 +849,6 @@ impl Message {
MessageEvent::Local(_, content) => content,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
MessageEvent::State(_) => return None,
};
match &content.relates_to {
@ -902,7 +869,6 @@ impl Message {
MessageEvent::Local(_, content) => content,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
MessageEvent::State(_) => return None,
};
match &content.relates_to {
@ -956,13 +922,7 @@ impl Message {
let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER;
let user = self.show_sender(prev, true, info, settings);
let time = self.timestamp.show_time();
let read = info
.event_receipts
.values()
.filter_map(|receipts| receipts.get(self.event.event_id()))
.flat_map(|read| read.iter())
.map(|user_id| user_id.to_owned())
.collect();
let read = info.event_receipts.get(self.event.event_id()).map(|read| read.iter());
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width {
@ -970,7 +930,7 @@ impl Message {
let fill = width - user_gutter - TIME_GUTTER;
let user = self.show_sender(prev, true, info, settings);
let time = self.timestamp.show_time();
let read = Vec::new();
let read = None;
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else if user_gutter + MIN_MSG_LEN <= width {
@ -978,7 +938,7 @@ impl Message {
let fill = width - user_gutter;
let user = self.show_sender(prev, true, info, settings);
let time = None;
let read = Vec::new();
let read = None;
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
} else {
@ -986,7 +946,7 @@ impl Message {
let fill = width.saturating_sub(2);
let user = self.show_sender(prev, false, info, settings);
let time = None;
let read = Vec::new();
let read = None;
MessageFormatter { settings, cols, orig, fill, user, date, time, read }
}
@ -1002,7 +962,7 @@ impl Message {
vwctx: &ViewportContext<MessageCursor>,
info: &'a RoomInfo,
settings: &'a ApplicationSettings,
) -> (Text<'a>, [Option<ProtocolPreview<'a>>; 2]) {
) -> (Text<'a>, Option<(&dyn Protocol, u16, u16)>) {
let width = vwctx.get_width();
let style = self.get_render_style(selected, settings);
@ -1015,20 +975,24 @@ impl Message {
.reply_to()
.or_else(|| self.thread_root())
.and_then(|e| info.get_event(&e));
let proto_reply = reply.as_ref().and_then(|r| {
// Format the reply header, push it into the `Text` buffer, and get any image.
fmt.push_in_reply(r, style, &mut text, info, settings)
});
if let Some(r) = &reply {
fmt.push_in_reply(r, style, &mut text, info);
}
// Now show the message contents, and the inlined reply if we couldn't find it above.
let (msg, proto) = self.show_msg(width, style, reply.is_some(), settings);
let (msg, proto) = self.show_msg(
width,
style,
reply.is_some(),
settings.tunables.message_shortcode_display,
);
// Given our text so far, determine the image offset.
let proto_main = proto.map(|p| {
let proto = proto.map(|p| {
let y_off = text.lines.len() as u16;
let x_off = fmt.cols.user_gutter_width(settings);
// Adjust y_off by 1 if a date was printed before the message to account for
// the extra line we're going to print.
// Adjust y_off by 1 if a date was printed before the message to account for the extra line.
let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off };
(p, x_off, y_off)
});
@ -1049,7 +1013,7 @@ impl Message {
fmt.push_thread_reply_count(thread.len(), &mut text);
}
(text, [proto_main, proto_reply])
(text, proto)
}
pub fn show<'a>(
@ -1063,18 +1027,18 @@ impl Message {
self.show_with_preview(prev, selected, vwctx, info, settings).0
}
fn show_msg<'a>(
&'a self,
fn show_msg(
&self,
width: usize,
style: Style,
hide_reply: bool,
settings: &'a ApplicationSettings,
) -> (Text<'a>, Option<&'a Protocol>) {
emoji_shortcodes: bool,
) -> (Text, Option<&dyn Protocol>) {
if let Some(html) = &self.html {
(html.to_text(width, style, hide_reply, settings), None)
(html.to_text(width, style, hide_reply, emoji_shortcodes), None)
} else {
let mut msg = self.event.body();
if settings.tunables.message_shortcode_display {
if emoji_shortcodes {
msg = Cow::Owned(replace_emojis_in_str(msg.as_ref()));
}
@ -1089,8 +1053,8 @@ impl Message {
placeholder_frame(Some("Downloading..."), width, image_preview_size)
},
ImageStatus::Loaded(backend) => {
proto = Some(backend);
placeholder_frame(Some("Cut off..."), width, &backend.area().into())
proto = Some(backend.as_ref());
placeholder_frame(None, width, &backend.rect().into())
},
ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")),
};
@ -1133,9 +1097,9 @@ impl Message {
let padding = user_gutter - 2 - width;
let sender = if align_right {
format!("{}{} ", space(padding), truncated)
space(padding) + &truncated + " "
} else {
format!("{}{} ", truncated, space(padding))
truncated.into_owned() + &space(padding) + " "
};
Span::styled(sender, style).into()
@ -1144,8 +1108,6 @@ impl Message {
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
self.event.redact(redaction, version);
self.html = None;
self.downloaded = false;
self.image_preview = ImageStatus::None;
}
}
@ -1191,16 +1153,6 @@ impl From<RoomMessageEvent> for Message {
}
}
impl From<AnySyncStateEvent> for Message {
fn from(event: AnySyncStateEvent) -> Self {
let timestamp = event.origin_server_ts().into();
let user_id = event.sender().to_owned();
let event = MessageEvent::State(event.into());
Message::new(event, user_id, timestamp)
}
}
impl Display for Message {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.event.body())
@ -1299,7 +1251,7 @@ pub mod tests {
assert_eq!(k6, &MSG1_KEY.clone());
// MessageCursor::latest() fails to convert for a room w/o messages.
let messages_empty = Messages::new(ReceiptThread::Main);
let messages_empty = Messages::default();
assert_eq!(mc6.to_key(&messages_empty), None);
}
@ -1360,33 +1312,6 @@ pub mod tests {
OK
"#
)
);
assert_eq!(
placeholder_frame(Some("OK"), 6, &ImagePreviewSize { width: 6, height: 6 }),
pretty_frame_test(
r#"
OK
"#
)
);
assert_eq!(
placeholder_frame(Some("OK"), 6, &ImagePreviewSize { width: 6, height: 7 }),
pretty_frame_test(
r#"
OK
"#
)

View file

@ -11,7 +11,6 @@ use ratatui::text::{Line, Span, Text};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::config::{ApplicationSettings, TunableValues};
use crate::util::{
replace_emojis_in_line,
replace_emojis_in_span,
@ -26,34 +25,28 @@ pub struct TextPrinter<'a> {
width: usize,
base_style: Style,
hide_reply: bool,
emoji_shortcodes: bool,
alignment: Alignment,
curr_spans: Vec<Span<'a>>,
curr_width: usize,
literal: bool,
pub(super) settings: &'a ApplicationSettings,
}
impl<'a> TextPrinter<'a> {
/// Create a new printer.
pub fn new(
width: usize,
base_style: Style,
hide_reply: bool,
settings: &'a ApplicationSettings,
) -> Self {
pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self {
TextPrinter {
text: Text::default(),
width,
base_style,
hide_reply,
emoji_shortcodes,
alignment: Alignment::Left,
curr_spans: vec![],
curr_width: 0,
literal: false,
settings,
}
}
@ -76,15 +69,7 @@ impl<'a> TextPrinter<'a> {
/// Indicates whether emojis should be replaced by shortcodes
pub fn emoji_shortcodes(&self) -> bool {
self.tunables().message_shortcode_display
}
pub fn settings(&self) -> &ApplicationSettings {
self.settings
}
pub fn tunables(&self) -> &TunableValues {
&self.settings.tunables
self.emoji_shortcodes
}
/// Indicates the current printer's width.
@ -99,12 +84,12 @@ impl<'a> TextPrinter<'a> {
width: self.width.saturating_sub(indent),
base_style: self.base_style,
hide_reply: self.hide_reply,
emoji_shortcodes: self.emoji_shortcodes,
alignment: self.alignment,
curr_spans: vec![],
curr_width: 0,
literal: self.literal,
settings: self.settings,
}
}
@ -194,7 +179,7 @@ impl<'a> TextPrinter<'a> {
/// Push a [Span] that isn't allowed to break across lines.
pub fn push_span_nobreak(&mut self, mut span: Span<'a>) {
if self.emoji_shortcodes() {
if self.emoji_shortcodes {
replace_emojis_in_span(&mut span);
}
let sw = UnicodeWidthStr::width(span.content.as_ref());
@ -216,8 +201,6 @@ impl<'a> TextPrinter<'a> {
return;
}
let tabstop = self.settings().tunables.tabstop;
for mut word in UnicodeSegmentation::split_word_bounds(s) {
if let "\n" | "\r\n" = word {
if self.literal {
@ -234,17 +217,11 @@ impl<'a> TextPrinter<'a> {
continue;
}
let mut cow = if self.emoji_shortcodes() {
let cow = if self.emoji_shortcodes {
Cow::Owned(replace_emojis_in_str(word))
} else {
Cow::Borrowed(word)
};
if cow == "\t" {
let tablen = tabstop - (self.curr_width % tabstop);
cow = Cow::Owned(" ".repeat(tablen));
}
let sw = UnicodeWidthStr::width(cow.as_ref());
if sw > self.width {
@ -276,7 +253,7 @@ impl<'a> TextPrinter<'a> {
/// Push a [Line] into the printer.
pub fn push_line(&mut self, mut line: Line<'a>) {
self.commit();
if self.emoji_shortcodes() {
if self.emoji_shortcodes {
replace_emojis_in_line(&mut line);
}
self.text.lines.push(line);
@ -285,7 +262,7 @@ impl<'a> TextPrinter<'a> {
/// Push multiline [Text] into the printer.
pub fn push_text(&mut self, mut text: Text<'a>) {
self.commit();
if self.emoji_shortcodes() {
if self.emoji_shortcodes {
for line in &mut text.lines {
replace_emojis_in_line(line);
}
@ -303,12 +280,10 @@ impl<'a> TextPrinter<'a> {
#[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);
let mut printer = TextPrinter::new(5, Style::default(), false, false);
printer.push_span_nobreak("hello world".into());
let text = printer.finish();
assert_eq!(text.lines.len(), 1);

View file

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

View file

@ -1,14 +1,12 @@
use std::time::SystemTime;
use matrix_sdk::{
deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
room::Room as MatrixRoom,
ruma::{
api::client::push::get_notifications::v3::Notification,
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
serde::Raw,
MilliSecondsSinceUnixEpoch,
OwnedRoomId,
RoomId,
},
Client,
@ -25,21 +23,6 @@ const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
Some(iamb) => iamb,
};
/// Handle for an open notification that should be closed when the user views it.
pub struct NotificationHandle(
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
Option<notify_rust::NotificationHandle>,
);
impl Drop for NotificationHandle {
fn drop(&mut self) {
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
if let Some(handle) = self.0.take() {
handle.close();
}
}
}
pub async fn register_notifications(
client: &Client,
settings: &ApplicationSettings,
@ -70,10 +53,7 @@ pub async fn register_notifications(
return;
}
let room_id = room.room_id().to_owned();
match notification.event {
RawAnySyncOrStrippedTimelineEvent::Sync(e) => {
match parse_full_notification(e, room, show_message).await {
match parse_notification(notification, room, show_message).await {
Ok((summary, body, server_ts)) => {
if server_ts < startup_ts {
return;
@ -83,90 +63,41 @@ pub async fn register_notifications(
return;
}
send_notification(
&notify_via,
&summary,
body.as_deref(),
room_id,
&store,
)
.await;
match notify_via {
#[cfg(feature = "desktop")]
NotifyVia::Desktop => send_notification_desktop(summary, body),
NotifyVia::Bell => send_notification_bell(&store).await,
}
},
Err(err) => {
tracing::error!("Failed to extract notification data: {err}")
},
}
},
// Stripped events may be dropped silently because they're
// only relevant if we're not in a room, and we presumably
// don't want notifications for rooms we're not in.
RawAnySyncOrStrippedTimelineEvent::Stripped(_) => (),
}
}
})
.await;
}
async fn send_notification(
via: &NotifyVia,
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
store: &AsyncProgramStore,
) {
#[cfg(feature = "desktop")]
if via.desktop {
send_notification_desktop(summary, body, room_id, store).await;
}
#[cfg(not(feature = "desktop"))]
{
let _ = (summary, body, IAMB_XDG_NAME);
}
if via.bell {
send_notification_bell(store).await;
}
}
async fn send_notification_bell(store: &AsyncProgramStore) {
let mut locked = store.lock().await;
locked.application.ring_bell = true;
}
#[cfg(feature = "desktop")]
async fn send_notification_desktop(
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
_store: &AsyncProgramStore,
) {
fn send_notification_desktop(summary: String, body: Option<String>) {
let mut desktop_notification = notify_rust::Notification::new();
desktop_notification
.summary(summary)
.summary(&summary)
.appname(IAMB_XDG_NAME)
.icon(IAMB_XDG_NAME)
.action("default", "default");
#[cfg(all(unix, not(target_os = "macos")))]
desktop_notification.urgency(notify_rust::Urgency::Normal);
if let Some(body) = body {
desktop_notification.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)));
},
if let Err(err) = desktop_notification.show() {
tracing::error!("Failed to send notification: {err}")
}
}
@ -224,12 +155,12 @@ async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
is_focused(&locked) && is_open(&mut locked, room_id)
}
pub async fn parse_full_notification(
event: Raw<AnySyncTimelineEvent>,
pub async fn parse_notification(
notification: Notification,
room: MatrixRoom,
show_body: bool,
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
let event = event.deserialize().map_err(IambError::from)?;
let event = notification.event.deserialize().map_err(IambError::from)?;
let server_ts = event.origin_server_ts();
@ -241,19 +172,19 @@ pub async fn parse_full_notification(
.and_then(|m| m.display_name())
.unwrap_or_else(|| sender_id.localpart());
let summary = if let Some(room_name) = room.cached_display_name() {
if room.is_direct().await.map_err(IambError::from)? && sender_name == room_name.to_string()
{
sender_name.to_string()
} else {
let summary = if let Ok(room_name) = room.display_name().await {
format!("{sender_name} in {room_name}")
}
} else {
sender_name.to_string()
};
let body = if show_body {
event_notification_body(&event, sender_name).map(truncate)
event_notification_body(
&event,
sender_name,
room.is_direct().await.map_err(IambError::from)?,
)
.map(truncate)
} else {
None
};
@ -261,7 +192,11 @@ pub async fn parse_full_notification(
return Ok((summary, body, server_ts));
}
pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str) -> Option<String> {
pub fn event_notification_body(
event: &AnySyncTimelineEvent,
sender_name: &str,
is_direct: bool,
) -> Option<String> {
let AnySyncTimelineEvent::MessageLike(event) = event else {
return None;
};
@ -272,7 +207,10 @@ pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str)
MessageType::Audio(_) => {
format!("{sender_name} sent an audio file.")
},
MessageType::Emote(content) => content.body,
MessageType::Emote(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::File(_) => {
format!("{sender_name} sent a file.")
},
@ -282,9 +220,22 @@ pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str)
MessageType::Location(_) => {
format!("{sender_name} sent their location.")
},
MessageType::Notice(content) => content.body,
MessageType::ServerNotice(content) => content.body,
MessageType::Text(content) => content.body,
MessageType::Notice(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::ServerNotice(content) => {
let message = &content.body;
format!("{sender_name}: {message}")
},
MessageType::Text(content) => {
if is_direct {
content.body
} else {
let message = &content.body;
format!("{sender_name}: {message}")
}
},
MessageType::Video(_) => {
format!("{sender_name} sent a video.")
},
@ -303,7 +254,7 @@ pub fn event_notification_body(event: &AnySyncTimelineEvent, sender_name: &str)
}
fn truncate(s: String) -> String {
static MAX_LENGTH: usize = 5000;
static MAX_LENGTH: usize = 100;
if s.graphemes(true).count() > MAX_LENGTH {
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
truncated + "..."

View file

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

View file

@ -137,7 +137,7 @@ pub fn mock_keys() -> HashMap<OwnedEventId, EventLocation> {
}
pub fn mock_messages() -> Messages {
let mut messages = Messages::main();
let mut messages = Messages::default();
messages.insert(MSG1_KEY.clone(), mock_message1());
messages.insert(MSG2_KEY.clone(), mock_message2());
@ -171,14 +171,12 @@ pub fn mock_tunables() -> TunableValues {
default_room: None,
log_level: Level::INFO,
message_shortcode_display: false,
normal_after_send: true,
reaction_display: true,
reaction_shortcode_display: false,
read_receipt_send: true,
read_receipt_display: true,
request_timeout: 120,
sort: SortOverrides::default().values(),
state_event_display: true,
typing_notice_send: true,
typing_notice_display: true,
users: vec![(TEST_USER5.clone(), UserDisplayTunables {
@ -190,17 +188,14 @@ pub fn mock_tunables() -> TunableValues {
open_command: None,
external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username,
username_display_regex: Some(String::from(".*")),
message_user_color: false,
mouse: Default::default(),
notifications: Notifications {
enabled: false,
via: NotifyVia::default(),
via: NotifyVia::Desktop,
show_message: true,
},
image_preview: None,
user_gutter_width: 30,
tabstop: 4,
}
}

View file

@ -5,7 +5,7 @@
//!
//! Additionally, some of the iamb commands delegate behaviour to the current UI element. For
//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState],
//! where we have the message bar and room ID easily accessible and resettable.
//! where we have the message bar and room ID easily accesible and resetable.
use std::cmp::{Ord, Ordering, PartialOrd};
use std::fmt::{self, Display};
use std::ops::Deref;
@ -23,7 +23,6 @@ use matrix_sdk::{
RoomAliasId,
RoomId,
},
RoomState as MatrixRoomState,
};
use ratatui::{
@ -76,13 +75,11 @@ use crate::base::{
SortFieldRoom,
SortFieldUser,
SortOrder,
SpaceAction,
UnreadInfo,
};
use self::{room::RoomState, welcome::WelcomeState};
use crate::message::MessageTimeStamp;
use feruca::Collator;
pub mod room;
pub mod welcome;
@ -171,12 +168,7 @@ fn user_cmp(a: &MemberItem, b: &MemberItem, field: &SortFieldUser) -> Ordering {
}
}
fn room_cmp<T: RoomLikeItem>(
a: &T,
b: &T,
field: &SortFieldRoom,
collator: &mut Collator,
) -> Ordering {
fn room_cmp<T: RoomLikeItem>(a: &T, b: &T, field: &SortFieldRoom) -> Ordering {
match field {
SortFieldRoom::Favorite => {
let fava = a.has_tag(TagName::Favorite);
@ -192,7 +184,7 @@ fn room_cmp<T: RoomLikeItem>(
// If a has LowPriority and b doesn't, it should sort later in room list.
lowa.cmp(&lowb)
},
SortFieldRoom::Name => collator.collate(a.name(), b.name()),
SortFieldRoom::Name => a.name().cmp(b.name()),
SortFieldRoom::Alias => some_cmp(a.alias(), b.alias(), Ord::cmp),
SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()),
SortFieldRoom::Unread => {
@ -203,10 +195,6 @@ fn room_cmp<T: RoomLikeItem>(
// sort larger timestamps towards the top.
some_cmp(a.recent_ts(), b.recent_ts(), |a, b| b.cmp(a))
},
SortFieldRoom::Invite => {
// sort invites before other rooms.
b.is_invite().cmp(&a.is_invite())
},
}
}
@ -215,10 +203,9 @@ fn room_fields_cmp<T: RoomLikeItem>(
a: &T,
b: &T,
fields: &[SortColumn<SortFieldRoom>],
collator: &mut Collator,
) -> Ordering {
for SortColumn(field, order) in fields {
match (room_cmp(a, b, field, collator), order) {
match (room_cmp(a, b, field), order) {
(Ordering::Equal, _) => continue,
(o, SortOrder::Ascending) => return o,
(o, SortOrder::Descending) => return o.reverse(),
@ -226,7 +213,7 @@ fn room_fields_cmp<T: RoomLikeItem>(
}
// Break ties on ascending room id.
room_cmp(a, b, &SortFieldRoom::RoomId, collator)
room_cmp(a, b, &SortFieldRoom::RoomId)
}
fn user_fields_cmp(
@ -286,7 +273,6 @@ trait RoomLikeItem {
fn recent_ts(&self) -> Option<&MessageTimeStamp>;
fn alias(&self) -> Option<&RoomAliasId>;
fn name(&self) -> &str;
fn is_invite(&self) -> bool;
}
#[inline]
@ -368,19 +354,6 @@ impl IambWindow {
}
}
pub async fn space_command(
&mut self,
act: SpaceAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
if let IambWindow::Room(w) = self {
w.space_command(act, ctx, store).await
} else {
return Err(IambError::NoSelectedRoom.into());
}
}
pub async fn room_command(
&mut self,
act: RoomAction,
@ -523,8 +496,7 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room_info| DirectItem::new(room_info, store))
.collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.dms;
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
state.set(items);
@ -569,8 +541,7 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room_info| RoomItem::new(room_info, store))
.collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.rooms;
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
state.set(items);
@ -601,8 +572,7 @@ impl WindowOps<IambInfo> for IambWindow {
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));
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
state.set(items);
@ -635,8 +605,7 @@ impl WindowOps<IambInfo> for IambWindow {
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));
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
state.set(items);
@ -656,8 +625,7 @@ impl WindowOps<IambInfo> for IambWindow {
.map(|room| SpaceItem::new(room, store))
.collect::<Vec<_>>();
let fields = &store.application.settings.tunables.sort.spaces;
let collator = &mut store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
state.set(items);
@ -946,10 +914,6 @@ impl RoomLikeItem for GenericChatItem {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for GenericChatItem {
@ -1060,15 +1024,11 @@ impl RoomLikeItem for RoomItem {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for RoomItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.name)
write!(f, ":verify request {}", self.name)
}
}
@ -1164,10 +1124,6 @@ impl RoomLikeItem for DirectItem {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for DirectItem {
@ -1267,15 +1223,11 @@ impl RoomLikeItem for SpaceItem {
// XXX: this needs to check whether the space contains rooms with unread messages
false
}
fn is_invite(&self) -> bool {
self.room().state() == MatrixRoomState::Invited
}
}
impl Display for SpaceItem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.name)
write!(f, ":verify request {}", self.room_id())
}
}
@ -1604,7 +1556,6 @@ mod tests {
alias: Option<OwnedRoomAliasId>,
name: &'static str,
unread: UnreadInfo,
invite: bool,
}
impl RoomLikeItem for &TestRoomItem {
@ -1631,16 +1582,10 @@ mod tests {
fn is_unread(&self) -> bool {
self.unread.is_unread()
}
fn is_invite(&self) -> bool {
self.invite
}
}
#[test]
fn test_sort_rooms() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com");
let room1 = TestRoomItem {
@ -1649,7 +1594,6 @@ mod tests {
alias: Some(room_alias_id!("#room1:example.com").to_owned()),
name: "Z",
unread: UnreadInfo::default(),
invite: false,
};
let room2 = TestRoomItem {
@ -1658,7 +1602,6 @@ mod tests {
alias: Some(room_alias_id!("#a:example.com").to_owned()),
name: "Unnamed Room",
unread: UnreadInfo::default(),
invite: false,
};
let room3 = TestRoomItem {
@ -1667,19 +1610,18 @@ mod tests {
alias: None,
name: "Cool Room",
unread: UnreadInfo::default(),
invite: false,
};
// Sort by Name ascending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Ascending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
assert_eq!(rooms, vec![&room3, &room2, &room1]);
// Sort by Name descending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Descending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
assert_eq!(rooms, vec![&room1, &room2, &room3]);
// Sort by Favorite and Alias before Name to show order matters.
@ -1689,7 +1631,7 @@ mod tests {
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
assert_eq!(rooms, vec![&room1, &room2, &room3]);
// Now flip order of Favorite with Descending
@ -1699,14 +1641,12 @@ mod tests {
SortColumn(SortFieldRoom::Alias, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
assert_eq!(rooms, vec![&room2, &room3, &room1]);
}
#[test]
fn test_sort_room_recents() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com");
let room1 = TestRoomItem {
@ -1715,7 +1655,6 @@ mod tests {
alias: None,
name: "Room 1",
unread: UnreadInfo { unread: false, latest: None },
invite: false,
};
let room2 = TestRoomItem {
@ -1727,7 +1666,6 @@ mod tests {
unread: false,
latest: Some(MessageTimeStamp::OriginServer(40u32.into())),
},
invite: false,
};
let room3 = TestRoomItem {
@ -1739,71 +1677,18 @@ mod tests {
unread: false,
latest: Some(MessageTimeStamp::OriginServer(20u32.into())),
},
invite: false,
};
// Sort by Recent ascending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Ascending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
assert_eq!(rooms, vec![&room2, &room3, &room1]);
// Sort by Recent descending.
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Descending)];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields));
assert_eq!(rooms, vec![&room1, &room3, &room2]);
}
#[test]
fn test_sort_room_invites() {
let mut collator = Collator::default();
let collator = &mut collator;
let server = server_name!("example.com");
let room1 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 1",
unread: UnreadInfo::default(),
invite: false,
};
let room2 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
tags: vec![],
alias: None,
name: "Old room 2",
unread: UnreadInfo::default(),
invite: false,
};
let room3 = TestRoomItem {
room_id: RoomId::new(server).to_owned(),
tags: vec![],
alias: None,
name: "New Fancy Room",
unread: UnreadInfo::default(),
invite: true,
};
// Sort invites first
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[
SortColumn(SortFieldRoom::Invite, SortOrder::Ascending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room3, &room1, &room2]);
// Sort invites after
let mut rooms = vec![&room1, &room2, &room3];
let fields = &[
SortColumn(SortFieldRoom::Invite, SortOrder::Descending),
SortColumn(SortFieldRoom::Name, SortOrder::Ascending),
];
rooms.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
assert_eq!(rooms, vec![&room1, &room2, &room3]);
}
}

View file

@ -14,7 +14,7 @@ use url::Url;
use matrix_sdk::{
attachment::AttachmentConfig,
media::{MediaFormat, MediaRequestParameters},
media::{MediaFormat, MediaRequest},
room::Room as MatrixRoom,
ruma::{
events::reaction::ReactionEventContent,
@ -86,14 +86,7 @@ use crate::base::{
SendAction,
};
use crate::message::{
text_to_message,
Message,
MessageEvent,
MessageKey,
MessageTimeStamp,
TreeGenState,
};
use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp};
use crate::worker::Requester;
use super::scrollback::{Scrollback, ScrollbackState};
@ -233,14 +226,10 @@ impl ChatState {
let links = if let Some(html) = &msg.html {
html.get_links()
} else if let Ok(url) = Url::parse(&msg.event.body()) {
vec![('0', url)]
} else {
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()
vec![]
};
if links.is_empty() {
@ -287,7 +276,7 @@ impl ChatState {
}
if !filename.exists() || flags.contains(DownloadFlags::FORCE) {
let req = MediaRequestParameters { source, format: MediaFormat::File };
let req = MediaRequest { source, format: MediaFormat::File };
let bytes =
media.get_media_content(&req, true).await.map_err(IambError::from)?;
@ -391,7 +380,6 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => {
let msg = "Cannot react to a redacted message";
let err = UIError::Failure(msg.into());
@ -429,7 +417,6 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => {
let msg = "Cannot redact already redacted message";
let err = UIError::Failure(msg.into());
@ -477,7 +464,6 @@ impl ChatState {
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::State(ev) => ev.event_id().to_owned(),
MessageEvent::Redacted(_) => {
let msg = "Cannot unreact to a redacted message";
let err = UIError::Failure(msg.into());
@ -610,7 +596,7 @@ impl ChatState {
let dynimage = image::DynamicImage::ImageRgba8(imagebuf);
let bytes = Vec::<u8>::new();
let mut buff = std::io::Cursor::new(bytes);
dynimage.write_to(&mut buff, image::ImageFormat::Png)?;
dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?;
Ok(buff.into_inner())
})
.map_err(IambError::from)?;
@ -620,7 +606,7 @@ impl ChatState {
let config = AttachmentConfig::new();
let resp = room
.send_attachment(name, &mime, bytes, config)
.send_attachment(name.as_ref(), &mime, bytes, config)
.await
.map_err(IambError::from)?;
@ -649,7 +635,10 @@ impl ChatState {
}
pub fn focus_toggle(&mut self) {
self.focus.toggle();
self.focus = match self.focus {
RoomFocus::Scrollback => RoomFocus::MessageBar,
RoomFocus::MessageBar => RoomFocus::Scrollback,
};
}
pub fn room(&self) -> &MatrixRoom {
@ -660,14 +649,6 @@ impl ChatState {
&self.room_id
}
pub fn auto_toggle_focus(
&mut self,
act: &EditorAction,
ctx: &ProgramContext,
) -> Option<EditorAction> {
auto_toggle_focus(&mut self.focus, act, ctx, &self.scrollback, &mut self.tbox)
}
pub fn typing_notice(
&self,
act: &EditorAction,
@ -770,15 +751,8 @@ impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
// Check whether we should automatically switch between the message bar
// or message scrollback, and use an adjusted action if we do so.
let adjusted = self.auto_toggle_focus(act, ctx);
let act = adjusted.as_ref().unwrap_or(act);
// Send typing notice if needed.
self.typing_notice(act, ctx, store);
// And now we can finally run the editor command.
match delegate!(self, w => w.editor_command(act, ctx, store)) {
res @ Ok(_) => res,
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, thread, focus)))
@ -875,16 +849,16 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
fn recall(
&mut self,
filter: &RecallFilter,
dir: &MoveDir1D,
count: &Count,
prefixed: bool,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let count = ctx.resolve(count);
let rope = self.tbox.get();
let text = self.sent.recall(&rope, &mut self.sent_scrollback, filter, *dir, count);
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count);
if let Some(text) = text {
self.tbox.set_text(text);
@ -908,7 +882,9 @@ impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
match act {
PromptAction::Submit => self.submit(ctx, store),
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
PromptAction::Recall(filter, dir, count) => self.recall(filter, dir, count, ctx, store),
PromptAction::Recall(dir, count, prefixed) => {
self.recall(dir, count, *prefixed, ctx, store)
},
}
}
}
@ -930,7 +906,7 @@ impl<'a> Chat<'a> {
}
}
impl StatefulWidget for Chat<'_> {
impl<'a> StatefulWidget for Chat<'a> {
type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
@ -1014,158 +990,3 @@ fn cmd(open_command: &Vec<String>) -> Option<Command> {
}
None
}
pub fn auto_toggle_focus(
focus: &mut RoomFocus,
act: &EditorAction,
ctx: &ProgramContext,
scrollback: &ScrollbackState,
tbox: &mut TextBoxState<IambInfo>,
) -> Option<EditorAction> {
let is_insert = ctx.get_insert_style().is_some();
match (focus, act) {
(f @ RoomFocus::Scrollback, _) if is_insert => {
// Insert mode commands should switch focus.
f.toggle();
None
},
(f @ RoomFocus::Scrollback, EditorAction::InsertText(_)) => {
// Pasting or otherwise inserting text should switch.
f.toggle();
None
},
(
f @ RoomFocus::Scrollback,
EditorAction::Edit(
op,
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Next), count),
),
) if ctx.resolve(op).is_motion() => {
let count = ctx.resolve(count);
if count > 0 && scrollback.is_latest() {
// Trying to move down a line when already at the end of room history should
// switch.
f.toggle();
// And decrement the count for the action.
let count = count.saturating_sub(1).into();
let target = EditTarget::Motion(mov.clone(), count);
let dec = EditorAction::Edit(op.clone(), target);
Some(dec)
} else {
None
}
},
(
f @ RoomFocus::MessageBar,
EditorAction::Edit(
op,
EditTarget::Motion(mov @ MoveType::Line(MoveDir1D::Previous), count),
),
) if !is_insert && ctx.resolve(op).is_motion() => {
let count = ctx.resolve(count);
if count > 0 && tbox.get_cursor().y == 0 {
// Trying to move up a line when already at the top of the msgbar should
// switch as long as we're not in Insert mode.
f.toggle();
// And decrement the count for the action.
let count = count.saturating_sub(1).into();
let target = EditTarget::Motion(mov.clone(), count);
let dec = EditorAction::Edit(op.clone(), target);
Some(dec)
} else {
None
}
},
(RoomFocus::Scrollback, _) | (RoomFocus::MessageBar, _) => {
// Do not switch.
None
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use modalkit::actions::{EditAction, InsertTextAction};
use crate::tests::{mock_store, TEST_ROOM1_ID};
macro_rules! move_line {
($dir: expr, $count: expr) => {
EditorAction::Edit(
EditAction::Motion.into(),
EditTarget::Motion(MoveType::Line($dir), $count.into()),
)
};
}
#[tokio::test]
async fn test_auto_focus() {
let mut store = mock_store().await;
let ctx = ProgramContext::default();
let room_id = TEST_ROOM1_ID.clone();
let scrollback = ScrollbackState::new(room_id.clone(), None);
let id = IambBufferId::Room(room_id, None, RoomFocus::MessageBar);
let ebuf = store.load_buffer(id);
let mut tbox = TextBoxState::new(ebuf);
// Start out focused on the scrollback.
let mut focused = RoomFocus::Scrollback;
// Inserting text toggles:
let act = EditorAction::InsertText(InsertTextAction::Type(
Char::from('a').into(),
MoveDir1D::Next,
1.into(),
));
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert!(res.is_none());
// Going down in message bar doesn't toggle:
let act = move_line!(MoveDir1D::Next, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert!(res.is_none());
// But going up will:
let act = move_line!(MoveDir1D::Previous, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::Scrollback);
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 0)));
// Going up in scrollback doesn't toggle:
let act = move_line!(MoveDir1D::Previous, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::Scrollback);
assert_eq!(res, None);
// And then go back down:
let act = move_line!(MoveDir1D::Next, 1);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 0)));
// Go up 2 will go up 1 in scrollback:
let act = move_line!(MoveDir1D::Previous, 2);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::Scrollback);
assert_eq!(res, Some(move_line!(MoveDir1D::Previous, 1)));
// Go down 3 will go down 2 in messagebar:
let act = move_line!(MoveDir1D::Next, 3);
let res = auto_toggle_focus(&mut focused, &act, &ctx, &scrollback, &mut tbox);
assert_eq!(focused, RoomFocus::MessageBar);
assert_eq!(res, Some(move_line!(MoveDir1D::Next, 2)));
}
}

View file

@ -26,7 +26,7 @@ use matrix_sdk::{
OwnedUserId,
RoomId,
},
RoomDisplayName,
DisplayName,
RoomState as MatrixRoomState,
};
@ -66,7 +66,6 @@ use crate::base::{
RoomAction,
RoomField,
SendAction,
SpaceAction,
};
use self::chat::ChatState;
@ -140,7 +139,7 @@ impl RoomState {
pub fn new(
room: MatrixRoom,
thread: Option<OwnedEventId>,
name: RoomDisplayName,
name: DisplayName,
tags: Option<Tags>,
store: &mut ProgramStore,
) -> Self {
@ -215,18 +214,6 @@ impl RoomState {
}
}
pub async fn space_command(
&mut self,
act: SpaceAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Space(space) => space.space_command(act, ctx, store).await,
RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()),
}
}
pub async fn send_command(
&mut self,
act: SendAction,
@ -419,7 +406,7 @@ impl RoomState {
// Try creating the room alias on the server.
let alias_create_req =
CreateAliasRequest::new(orai.clone(), room.room_id().into());
if let Err(e) = client.send(alias_create_req).await {
if let Err(e) = client.send(alias_create_req, None).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
@ -460,7 +447,7 @@ impl RoomState {
// If the room alias does not exist on the server, create it
let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into());
if let Err(e) = client.send(alias_create_req).await {
if let Err(e) = client.send(alias_create_req, None).await {
if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() {
// Ignore when it already exists.
} else {
@ -477,9 +464,6 @@ impl RoomState {
RoomField::Aliases => {
// This never happens, aliases is only used for showing
},
RoomField::Id => {
// This never happens, id is only used for showing
},
}
Ok(vec![])
@ -535,7 +519,7 @@ impl RoomState {
.application
.worker
.client
.send(del_req)
.send(del_req, None)
.await
.map_err(IambError::from)?;
},
@ -568,16 +552,13 @@ impl RoomState {
.application
.worker
.client
.send(del_req)
.send(del_req, None)
.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![])
@ -591,12 +572,7 @@ impl RoomState {
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}")
format!("Room history visibility: {visibility}")
},
RoomField::Name => {
match room.name() {

View file

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

View file

@ -2,14 +2,11 @@
use std::ops::{Deref, DerefMut};
use std::time::{Duration, Instant};
use matrix_sdk::ruma::events::space::child::SpaceChildEventContent;
use matrix_sdk::ruma::events::StateEventType;
use matrix_sdk::{
room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId},
};
use modalkit::prelude::{EditInfo, InfoMessage};
use ratatui::{
buffer::Buffer,
layout::Rect,
@ -25,18 +22,9 @@ use modalkit_ratatui::{
WindowOps,
};
use crate::base::{
IambBufferId,
IambError,
IambInfo,
IambResult,
ProgramContext,
ProgramStore,
RoomFocus,
SpaceAction,
};
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem};
use crate::windows::{room_fields_cmp, RoomItem};
const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
@ -80,71 +68,6 @@ impl SpaceState {
last_fetch: self.last_fetch,
}
}
pub async fn space_command(
&mut self,
act: SpaceAction,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match act {
SpaceAction::SetChild(child_id, order, suggested) => {
if !self
.room
.can_user_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
.await
.map_err(IambError::from)?
{
return Err(IambError::InsufficientPermission.into());
}
let via = self.room.route().await.map_err(IambError::from)?;
let mut ev = SpaceChildEventContent::new(via);
ev.order = order;
ev.suggested = suggested;
let _ = self
.room
.send_state_event_for_key(&child_id, ev)
.await
.map_err(IambError::from)?;
Ok(InfoMessage::from("Space updated").into())
},
SpaceAction::RemoveChild => {
let space = self.list.get().ok_or(IambError::NoSelectedRoomOrSpaceItem)?;
if !self
.room
.can_user_send_state(
&store.application.settings.profile.user_id,
StateEventType::SpaceChild,
)
.await
.map_err(IambError::from)?
{
return Err(IambError::InsufficientPermission.into());
}
let ev = SpaceChildEventContent::new(vec![]);
let event_id = self
.room
.send_state_event_for_key(&space.room_id().to_owned(), ev)
.await
.map_err(IambError::from)?;
// Fix for element (see https://github.com/element-hq/element-web/issues/29606)
let _ = self
.room
.redact(&event_id.event_id, Some("workaround for element bug"), None)
.await
.map_err(IambError::from)?;
Ok(InfoMessage::from("Room removed").into())
},
}
}
}
impl TerminalCursor for SpaceState {
@ -184,7 +107,7 @@ impl<'a> Space<'a> {
}
}
impl StatefulWidget for Space<'_> {
impl<'a> StatefulWidget for Space<'a> {
type State = SpaceState;
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
@ -214,8 +137,7 @@ impl StatefulWidget for Space<'_> {
})
.collect::<Vec<_>>();
let fields = &self.store.application.settings.tunables.sort.rooms;
let collator = &mut self.store.application.collator;
items.sort_by(|a, b| room_fields_cmp(a, b, fields, collator));
items.sort_by(|a, b| room_fields_cmp(a, b, fields));
state.list.set(items);
state.last_fetch = Some(Instant::now());

View file

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