15 Commits

Author SHA1 Message Date
Ulyssa
a2590b6bbb Release v0.0.7 (#68) 2023-03-22 21:28:34 -07:00
Ulyssa
725ebb9fd6 Redacted messages should have their HTML removed (#67) 2023-03-22 21:25:37 -07:00
jahway603
ca395097e1 Update README.md to include Arch Linux package (#66) 2023-03-22 20:13:25 -07:00
Ulyssa
e98d58a8cc Emote messages should always show sender (#65) 2023-03-21 14:02:42 -07:00
Ulyssa
e6cdd02f22 HTML self-closing tags are getting parsed incorrectly (#63) 2023-03-20 17:53:55 -07:00
Ulyssa
0bc4ff07b0 Lazy load room state events on initial sync (#62) 2023-03-20 16:17:59 -07:00
Ulyssa
14fe916d94 Allow log level to be configured (#58) 2023-03-13 16:43:08 -07:00
Ulyssa
db35581d07 Indicate when an encrypted room event has been redacted (#59) 2023-03-13 16:43:04 -07:00
Ulyssa
7c1c62897a Show events that couldn't be decrypted (#57) 2023-03-13 15:18:53 -07:00
Ulyssa
61897ea6f2 Fetch scrollback history independently of main loop (#39) 2023-03-13 10:46:26 -07:00
Ulyssa
6a0722795a Fix empty message check when sending (#56) 2023-03-13 09:26:49 -07:00
Ulyssa
f3bbc6ad9f Support configuring client request timeout (#54) 2023-03-12 15:43:13 -07:00
Ulyssa
2dd8c0fddf Link to iamb space in README (#55) 2023-03-10 18:08:42 -08:00
Pavlo Rudy
a786369b14 Create release profile with LTO (#52) 2023-03-10 16:41:32 -08:00
pin
066f60ad32 Add NetBSD install instructions (#51) 2023-03-09 09:27:40 -08:00
12 changed files with 387 additions and 192 deletions

6
Cargo.lock generated
View File

@@ -1308,7 +1308,7 @@ dependencies = [
[[package]]
name = "iamb"
version = "0.0.6"
version = "0.0.7"
dependencies = [
"bitflags",
"chrono",
@@ -1882,9 +1882,9 @@ dependencies = [
[[package]]
name = "modalkit"
version = "0.0.13"
version = "0.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "038bb42efcd659fb123708bf8e4ea12ca9023f07d44514f9358b9334ea7ba80f"
checksum = "5c48c7d7e6d764a09435b43a7e4d342ba2d2e026626ca773b16a5ba34b90b933"
dependencies = [
"anymap2",
"arboard",

View File

@@ -1,6 +1,6 @@
[package]
name = "iamb"
version = "0.0.6"
version = "0.0.7"
edition = "2018"
authors = ["Ulyssa <git@ulyssa.dev>"]
repository = "https://github.com/ulyssa/iamb"
@@ -39,7 +39,7 @@ unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]}
[dependencies.modalkit]
version = "0.0.13"
version = "0.0.14"
[dependencies.matrix-sdk]
version = "0.6"
@@ -52,3 +52,7 @@ features = ["macros", "net", "rt-multi-thread", "sync", "time"]
[dev-dependencies]
lazy_static = "1.4.0"
[profile.release]
lto = true
incremental = false

View File

@@ -2,6 +2,7 @@
[![Build Status](https://github.com/ulyssa/iamb/workflows/CI/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+)
[![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)](https://crates.io/crates/iamb)
[![#iamb:0x.badd.cafe](https://img.shields.io/badge/matrix-%23iamb:0x.badd.cafe-blue)](https://matrix.to/#/#iamb:0x.badd.cafe)
[![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)](https://crates.io/crates/iamb)
## About
@@ -26,6 +27,22 @@ Install Rust and Cargo, and then run:
cargo install --locked iamb
```
### NetBSD
On NetBSD a package is available from the official repositories. To install it simply run:
```
pkgin install iamb
```
### Arch Linux
On Arch Linux a package is available in the Arch User Repositories (AUR). To install it simply run with your favorite AUR helper:
```
paru iamb-git
```
## Configuration
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:

View File

@@ -7,7 +7,6 @@ use std::time::{Duration, Instant};
use emojis::Emoji;
use tokio::sync::Mutex as AsyncMutex;
use tracing::warn;
use matrix_sdk::{
encryption::verification::SasVerification,
@@ -15,6 +14,7 @@ use matrix_sdk::{
ruma::{
events::{
reaction::ReactionEvent,
room::encrypted::RoomEncryptedEvent,
room::message::{
OriginalRoomMessageEvent,
Relation,
@@ -23,7 +23,6 @@ use matrix_sdk::{
RoomMessageEventContent,
},
tag::{TagName, Tags},
AnyMessageLikeEvent,
MessageLikeEvent,
},
presence::PresenceState,
@@ -412,6 +411,9 @@ pub struct RoomInfo {
/// A map of message identifiers to a map of reaction events.
pub reactions: HashMap<OwnedEventId, MessageReactions>,
/// Whether the scrollback for this room is currently being fetched.
pub fetching: bool,
/// Where to continue fetching from when we continue loading scrollback history.
pub fetch_id: RoomFetchStatus,
@@ -489,7 +491,9 @@ impl RoomInfo {
MessageEvent::Local(_, content) => {
*content = new_content;
},
MessageEvent::Redacted(_) => {
MessageEvent::Redacted(_) |
MessageEvent::EncryptedOriginal(_) |
MessageEvent::EncryptedRedacted(_) => {
return;
},
}
@@ -497,6 +501,15 @@ impl RoomInfo {
msg.html = msg.event.html();
}
/// Inserts events that couldn't be decrypted into the scrollback.
pub fn insert_encrypted(&mut self, msg: RoomEncryptedEvent) {
let event_id = msg.event_id().to_owned();
let key = (msg.origin_server_ts().into(), event_id.clone());
self.keys.insert(event_id, EventLocation::Message(key.clone()));
self.messages.insert(key, msg.into());
}
pub fn insert_message(&mut self, msg: RoomMessageEvent) {
let event_id = msg.event_id().to_owned();
let key = (msg.origin_server_ts().into(), event_id.clone());
@@ -520,7 +533,7 @@ impl RoomInfo {
}
}
fn recently_fetched(&self) -> bool {
pub fn recently_fetched(&self) -> bool {
self.fetch_last.map_or(false, |i| i.elapsed() < ROOM_FETCH_DEBOUNCE)
}
@@ -668,61 +681,6 @@ impl ChatStore {
self.need_load.insert(room_id);
}
pub fn load_older(&mut self, limit: u32) {
let ChatStore { need_load, presences, rooms, worker, .. } = self;
for room_id in std::mem::take(need_load).into_iter() {
let info = rooms.get_or_default(room_id.clone());
if info.recently_fetched() {
need_load.insert(room_id);
continue;
} else {
info.fetch_last = Instant::now().into();
}
let fetch_id = match &info.fetch_id {
RoomFetchStatus::Done => continue,
RoomFetchStatus::HaveMore(fetch_id) => Some(fetch_id.clone()),
RoomFetchStatus::NotStarted => None,
};
let res = worker.load_older(room_id.clone(), fetch_id, limit);
match res {
Ok((fetch_id, msgs)) => {
for msg in msgs.into_iter() {
let sender = msg.sender().to_owned();
let _ = presences.get_or_default(sender);
match msg {
AnyMessageLikeEvent::RoomMessage(msg) => {
info.insert(msg);
},
AnyMessageLikeEvent::Reaction(ev) => {
info.insert_reaction(ev);
},
_ => continue,
}
}
info.fetch_id =
fetch_id.map_or(RoomFetchStatus::Done, RoomFetchStatus::HaveMore);
},
Err(e) => {
warn!(
room_id = room_id.as_str(),
err = e.to_string(),
"Failed to load older messages"
);
// Wait and try again.
need_load.insert(room_id);
},
}
}
}
pub fn get_room_info(&mut self, room_id: OwnedRoomId) -> &mut RoomInfo {
self.rooms.get_or_default(room_id)
}

View File

@@ -11,6 +11,7 @@ use std::process;
use clap::Parser;
use matrix_sdk::ruma::{OwnedUserId, UserId};
use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer};
use tracing::Level;
use url::Url;
use modalkit::tui::{
@@ -25,6 +26,8 @@ macro_rules! usage {
}
}
const DEFAULT_REQ_TIMEOUT: u64 = 120;
const COLORS: [Color; 13] = [
Color::Blue,
Color::Cyan,
@@ -106,6 +109,47 @@ pub enum ConfigError {
Invalid(#[from] serde_json::Error),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LogLevel(pub Level);
pub struct LogLevelVisitor;
impl From<LogLevel> for Level {
fn from(level: LogLevel) -> Level {
level.0
}
}
impl<'de> Visitor<'de> for LogLevelVisitor {
type Value = LogLevel;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid log level (e.g. \"warn\" or \"debug\")")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: SerdeError,
{
match value {
"info" => Ok(LogLevel(Level::INFO)),
"debug" => Ok(LogLevel(Level::DEBUG)),
"warn" => Ok(LogLevel(Level::WARN)),
"error" => Ok(LogLevel(Level::ERROR)),
"trace" => Ok(LogLevel(Level::TRACE)),
_ => Err(E::custom("Could not parse log level")),
}
}
}
impl<'de> Deserialize<'de> for LogLevel {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(LogLevelVisitor)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UserColor(pub Color);
pub struct UserColorVisitor;
@@ -178,10 +222,12 @@ fn merge_users(a: Option<UserOverrides>, b: Option<UserOverrides>) -> Option<Use
#[derive(Clone)]
pub struct TunableValues {
pub log_level: Level,
pub reaction_display: bool,
pub reaction_shortcode_display: bool,
pub read_receipt_send: bool,
pub read_receipt_display: bool,
pub request_timeout: u64,
pub typing_notice_send: bool,
pub typing_notice_display: bool,
pub users: UserOverrides,
@@ -190,10 +236,12 @@ pub struct TunableValues {
#[derive(Clone, Default, Deserialize)]
pub struct Tunables {
pub log_level: Option<LogLevel>,
pub reaction_display: Option<bool>,
pub reaction_shortcode_display: Option<bool>,
pub read_receipt_send: Option<bool>,
pub read_receipt_display: Option<bool>,
pub request_timeout: Option<u64>,
pub typing_notice_send: Option<bool>,
pub typing_notice_display: Option<bool>,
pub users: Option<UserOverrides>,
@@ -203,12 +251,14 @@ pub struct Tunables {
impl Tunables {
fn merge(self, other: Self) -> Self {
Tunables {
log_level: self.log_level.or(other.log_level),
reaction_display: self.reaction_display.or(other.reaction_display),
reaction_shortcode_display: self
.reaction_shortcode_display
.or(other.reaction_shortcode_display),
read_receipt_send: self.read_receipt_send.or(other.read_receipt_send),
read_receipt_display: self.read_receipt_display.or(other.read_receipt_display),
request_timeout: self.request_timeout.or(other.request_timeout),
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_users(self.users, other.users),
@@ -218,10 +268,12 @@ impl Tunables {
fn values(self) -> TunableValues {
TunableValues {
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
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),
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(),

View File

@@ -15,7 +15,6 @@ use std::time::Duration;
use clap::Parser;
use tokio::sync::Mutex as AsyncMutex;
use tracing::{self, Level};
use tracing_subscriber::FmtSubscriber;
use matrix_sdk::ruma::OwnedUserId;
@@ -101,14 +100,6 @@ use modalkit::{
},
};
const MIN_MSG_LOAD: u32 = 50;
fn msg_load_req(area: Rect) -> u32 {
let n = area.height as u32;
n.max(MIN_MSG_LOAD)
}
struct Application {
store: AsyncProgramStore,
worker: Requester,
@@ -190,8 +181,6 @@ impl Application {
}
f.set_cursor(cx, cy);
}
store.application.load_older(msg_load_req(area));
})?;
Ok(())
@@ -544,7 +533,7 @@ fn main() -> IambResult<()> {
let subscriber = FmtSubscriber::builder()
.with_writer(appender)
.with_max_level(Level::INFO)
.with_max_level(settings.tunables.log_level)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");

View File

@@ -619,7 +619,7 @@ pub fn parse_matrix_html(s: &str) -> StyleTree {
let dom = parse_fragment(
RcDom::default(),
ParseOpts::default(),
QualName::new(None, ns!(), local_name!("div")),
QualName::new(None, ns!(html), local_name!("body")),
vec![],
)
.one(StrTendril::from(s));
@@ -1147,4 +1147,15 @@ pub mod tests {
])
);
}
#[test]
fn test_self_closing() {
let s = "Hello<br>World<br>Goodbye";
let tree = parse_matrix_html(s);
let text = tree.to_text(7, Style::default(), true);
assert_eq!(text.lines.len(), 3);
assert_eq!(text.lines[0], Spans(vec![Span::raw("Hello"), Span::raw(" "),]));
assert_eq!(text.lines[1], Spans(vec![Span::raw("World"), Span::raw(" "),]));
assert_eq!(text.lines[2], Spans(vec![Span::raw("Goodbye")]),);
}
}

View File

@@ -12,6 +12,11 @@ use unicode_width::UnicodeWidthStr;
use matrix_sdk::ruma::{
events::{
room::{
encrypted::{
OriginalRoomEncryptedEvent,
RedactedRoomEncryptedEvent,
RoomEncryptedEvent,
},
message::{
FormattedBody,
MessageFormat,
@@ -26,6 +31,7 @@ use matrix_sdk::ruma::{
},
AnyMessageLikeEvent,
Redact,
RedactedUnsigned,
},
EventId,
MilliSecondsSinceUnixEpoch,
@@ -318,6 +324,8 @@ impl PartialOrd for MessageCursor {
#[derive(Clone)]
pub enum MessageEvent {
EncryptedOriginal(Box<OriginalRoomEncryptedEvent>),
EncryptedRedacted(Box<RedactedRoomEncryptedEvent>),
Original(Box<OriginalRoomMessageEvent>),
Redacted(Box<RedactedRoomMessageEvent>),
Local(OwnedEventId, Box<RoomMessageEventContent>),
@@ -326,35 +334,45 @@ pub enum MessageEvent {
impl MessageEvent {
pub fn event_id(&self) -> &EventId {
match self {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.as_ref(),
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::Local(event_id, _) => event_id.as_ref(),
}
}
pub fn content(&self) -> Option<&RoomMessageEventContent> {
match self {
MessageEvent::EncryptedOriginal(_) => None,
MessageEvent::Original(ev) => Some(&ev.content),
MessageEvent::EncryptedRedacted(_) => None,
MessageEvent::Redacted(_) => None,
MessageEvent::Local(_, content) => Some(content),
}
}
pub fn is_emote(&self) -> bool {
matches!(
self.content(),
Some(RoomMessageEventContent { msgtype: MessageType::Emote(_), .. })
)
}
pub fn body(&self) -> Cow<'_, str> {
match self {
MessageEvent::EncryptedOriginal(_) => "[Unable to decrypt message]".into(),
MessageEvent::Original(ev) => body_cow_content(&ev.content),
MessageEvent::Redacted(ev) => {
let reason = ev
.unsigned
.redacted_because
.as_ref()
.and_then(|e| e.as_original())
.and_then(|r| r.content.reason.as_ref());
if let Some(r) = reason {
Cow::Owned(format!("[Redacted: {r:?}]"))
} else {
Cow::Borrowed("[Redacted]")
}
},
MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned),
MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned),
MessageEvent::Local(_, content) => body_cow_content(content),
}
}
pub fn html(&self) -> Option<StyleTree> {
let content = match self {
MessageEvent::EncryptedOriginal(_) => return None,
MessageEvent::EncryptedRedacted(_) => return None,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
MessageEvent::Local(_, content) => content,
@@ -371,8 +389,10 @@ impl MessageEvent {
}
}
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
match self {
MessageEvent::EncryptedOriginal(_) => return,
MessageEvent::EncryptedRedacted(_) => return,
MessageEvent::Redacted(_) => return,
MessageEvent::Local(_, _) => return,
MessageEvent::Original(ev) => {
@@ -411,6 +431,20 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> {
Cow::Borrowed(s)
}
fn body_cow_reason(unsigned: &RedactedUnsigned) -> Cow<'_, str> {
let reason = unsigned
.redacted_because
.as_ref()
.and_then(|e| e.as_original())
.and_then(|r| r.content.reason.as_ref());
if let Some(r) = reason {
Cow::Owned(format!("[Redacted: {r:?}]"))
} else {
Cow::Borrowed("[Redacted]")
}
}
enum MessageColumns {
/// Four columns: sender, message, timestamp, read receipts.
Four,
@@ -548,6 +582,8 @@ impl Message {
pub fn reply_to(&self) -> Option<OwnedEventId> {
let content = match &self.event {
MessageEvent::EncryptedOriginal(_) => return None,
MessageEvent::EncryptedRedacted(_) => return None,
MessageEvent::Local(_, content) => content,
MessageEvent::Original(ev) => &ev.content,
MessageEvent::Redacted(_) => return None,
@@ -752,7 +788,10 @@ impl Message {
settings: &ApplicationSettings,
) -> Option<Span> {
if let Some(prev) = prev {
if self.sender == prev.sender && self.timestamp.same_day(&prev.timestamp) {
if self.sender == prev.sender &&
self.timestamp.same_day(&prev.timestamp) &&
!self.event.is_emote()
{
return None;
}
}
@@ -769,6 +808,24 @@ impl Message {
Span::styled(sender, style).into()
}
pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) {
self.event.redact(redaction, version);
self.html = None;
}
}
impl From<RoomEncryptedEvent> for Message {
fn from(event: RoomEncryptedEvent) -> Self {
let timestamp = event.origin_server_ts().into();
let user_id = event.sender().to_owned();
let content = match event {
RoomEncryptedEvent::Original(ev) => MessageEvent::EncryptedOriginal(ev.into()),
RoomEncryptedEvent::Redacted(ev) => MessageEvent::EncryptedRedacted(ev.into()),
};
Message::new(content, user_id, timestamp)
}
}
impl From<OriginalRoomMessageEvent> for Message {

View File

@@ -17,6 +17,7 @@ use matrix_sdk::ruma::{
use lazy_static::lazy_static;
use modalkit::tui::style::{Color, Style};
use tokio::sync::mpsc::unbounded_channel;
use tracing::Level;
use url::Url;
use crate::{
@@ -153,6 +154,7 @@ pub fn mock_room() -> RoomInfo {
read_till: None,
reactions: HashMap::new(),
fetching: false,
fetch_id: RoomFetchStatus::NotStarted,
fetch_last: None,
users_typing: None,
@@ -170,10 +172,12 @@ pub fn mock_dirs() -> DirectoryValues {
pub fn mock_tunables() -> TunableValues {
TunableValues {
default_room: None,
log_level: Level::INFO,
reaction_display: true,
reaction_shortcode_display: false,
read_receipt_send: true,
read_receipt_display: true,
request_timeout: 120,
typing_notice_send: true,
typing_notice_display: true,
users: vec![(TEST_USER5.clone(), UserDisplayTunables {

View File

@@ -1,4 +1,5 @@
use std::cmp::{Ord, Ordering, PartialOrd};
use std::time::{Duration, Instant};
use matrix_sdk::{
encryption::verification::{format_emojis, SasVerification},
@@ -77,6 +78,8 @@ use self::{room::RoomState, welcome::WelcomeState};
pub mod room;
pub mod welcome;
const MEMBER_FETCH_DEBOUNCE: Duration = Duration::from_secs(5);
#[inline]
fn bold_style() -> Style {
Style::default().add_modifier(StyleModifier::BOLD)
@@ -211,7 +214,7 @@ macro_rules! delegate {
match $s {
IambWindow::Room($id) => $e,
IambWindow::DirectList($id) => $e,
IambWindow::MemberList($id, _) => $e,
IambWindow::MemberList($id, _, _) => $e,
IambWindow::RoomList($id) => $e,
IambWindow::SpaceList($id) => $e,
IambWindow::VerifyList($id) => $e,
@@ -222,7 +225,7 @@ macro_rules! delegate {
pub enum IambWindow {
DirectList(DirectListState),
MemberList(MemberListState, OwnedRoomId),
MemberList(MemberListState, OwnedRoomId, Option<Instant>),
Room(RoomState),
VerifyList(VerifyListState),
RoomList(RoomListState),
@@ -392,10 +395,18 @@ impl WindowOps<IambInfo> for IambWindow {
.focus(focused)
.render(area, buf, state);
},
IambWindow::MemberList(state, room_id) => {
if let Ok(mems) = store.application.worker.members(room_id.clone()) {
let items = mems.into_iter().map(MemberItem::new);
state.set(items.collect());
IambWindow::MemberList(state, room_id, last_fetch) => {
let need_fetch = match last_fetch {
Some(i) => i.elapsed() >= MEMBER_FETCH_DEBOUNCE,
None => true,
};
if need_fetch {
if let Ok(mems) = store.application.worker.members(room_id.clone()) {
let items = mems.into_iter().map(MemberItem::new);
state.set(items.collect());
*last_fetch = Some(Instant::now());
}
}
List::new(store)
@@ -456,8 +467,8 @@ impl WindowOps<IambInfo> for IambWindow {
match self {
IambWindow::Room(w) => w.dup(store).into(),
IambWindow::DirectList(w) => w.dup(store).into(),
IambWindow::MemberList(w, room_id) => {
IambWindow::MemberList(w.dup(store), room_id.clone())
IambWindow::MemberList(w, room_id, last_fetch) => {
IambWindow::MemberList(w.dup(store), room_id.clone(), *last_fetch)
},
IambWindow::RoomList(w) => w.dup(store).into(),
IambWindow::SpaceList(w) => w.dup(store).into(),
@@ -497,7 +508,7 @@ impl Window<IambInfo> for IambWindow {
match self {
IambWindow::Room(room) => IambId::Room(room.id().to_owned()),
IambWindow::DirectList(_) => IambId::DirectList,
IambWindow::MemberList(_, room_id) => IambId::MemberList(room_id.clone()),
IambWindow::MemberList(_, room_id, _) => IambId::MemberList(room_id.clone()),
IambWindow::RoomList(_) => IambId::RoomList,
IambWindow::SpaceList(_) => IambId::SpaceList,
IambWindow::VerifyList(_) => IambId::VerifyList,
@@ -518,7 +529,7 @@ impl Window<IambInfo> for IambWindow {
Spans::from(title)
},
IambWindow::MemberList(_, room_id) => {
IambWindow::MemberList(_, room_id, _) => {
let title = store.application.get_room_title(room_id.as_ref());
Spans(vec![bold_span("Room Members: "), title.into()])
@@ -535,7 +546,7 @@ impl Window<IambInfo> for IambWindow {
IambWindow::Welcome(_) => bold_spans("Welcome to iamb"),
IambWindow::Room(w) => w.get_title(store),
IambWindow::MemberList(_, room_id) => {
IambWindow::MemberList(_, room_id, _) => {
let title = store.application.get_room_title(room_id.as_ref());
Spans(vec![bold_span("Room Members: "), title.into()])
@@ -559,7 +570,7 @@ impl Window<IambInfo> for IambWindow {
IambId::MemberList(room_id) => {
let id = IambBufferId::MemberList(room_id.clone());
let list = MemberListState::new(id, vec![]);
let win = IambWindow::MemberList(list, room_id);
let win = IambWindow::MemberList(list, room_id, None);
return Ok(win);
},

View File

@@ -294,6 +294,8 @@ impl ChatState {
MessageAction::React(emoji) => {
let room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => {
@@ -313,6 +315,8 @@ impl ChatState {
MessageAction::Redact(reason) => {
let room = self.get_joined(&store.application.worker)?;
let event_id = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(),
MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(),
MessageEvent::Original(ev) => ev.event_id.clone(),
MessageEvent::Local(event_id, _) => event_id.clone(),
MessageEvent::Redacted(_) => {
@@ -338,6 +342,8 @@ impl ChatState {
MessageAction::Unreact(emoji) => {
let room = self.get_joined(&store.application.worker)?;
let event_id: &EventId = match &msg.event {
MessageEvent::EncryptedOriginal(ev) => ev.event_id.as_ref(),
MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(),
MessageEvent::Original(ev) => ev.event_id.as_ref(),
MessageEvent::Local(event_id, _) => event_id.as_ref(),
MessageEvent::Redacted(_) => {
@@ -395,13 +401,13 @@ impl ChatState {
let (event_id, msg) = match act {
SendAction::Submit => {
let msg = self.tbox.get_text();
let msg = self.tbox.get();
if msg.is_empty() {
if msg.is_blank() {
return Ok(None);
}
let msg = TextMessageEventContent::markdown(msg);
let msg = TextMessageEventContent::markdown(msg.to_string());
let msg = MessageType::Text(msg);
let mut msg = RoomMessageEventContent::new(msg);

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::convert::TryFrom;
use std::fmt::{Debug, Formatter};
use std::fs::File;
@@ -5,21 +6,22 @@ use std::io::BufWriter;
use std::str::FromStr;
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use gethostname::gethostname;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
use tracing::error;
use tracing::{error, warn};
use matrix_sdk::{
config::{RequestConfig, StoreConfig, SyncSettings},
config::{RequestConfig, SyncSettings},
encryption::verification::{SasVerification, Verification},
event_handler::Ctx,
reqwest,
room::{Invited, Messages, MessagesOptions, Room as MatrixRoom, RoomMember},
ruma::{
api::client::{
filter::{FilterDefinition, LazyLoadOptions, RoomEventFilter, RoomFilter},
room::create_room::v3::{CreationContent, Request as CreateRoomRequest, RoomPreset},
room::Visibility,
space::get_hierarchy::v1::Request as SpaceHierarchyRequest,
@@ -44,6 +46,7 @@ use matrix_sdk::{
tag::Tags,
typing::SyncTypingEvent,
AnyInitialStateEvent,
AnyMessageLikeEvent,
AnyTimelineEvent,
EmptyStateKey,
InitialStateEvent,
@@ -56,6 +59,7 @@ use matrix_sdk::{
OwnedRoomId,
OwnedRoomOrAliasId,
OwnedUserId,
RoomId,
RoomVersionId,
},
Client,
@@ -68,12 +72,14 @@ use modalkit::editing::action::{EditInfo, InfoMessage, UIError};
use crate::{
base::{
AsyncProgramStore,
ChatStore,
CreateRoomFlags,
CreateRoomType,
EventLocation,
IambError,
IambResult,
Receipts,
RoomFetchStatus,
VerifyAction,
},
message::MessageFetchResult,
@@ -82,7 +88,7 @@ use crate::{
const IAMB_DEVICE_NAME: &str = "iamb";
const IAMB_USER_AGENT: &str = "iamb";
const REQ_TIMEOUT: Duration = Duration::from_secs(60);
const MIN_MSG_LOAD: u32 = 50;
fn initial_devname() -> String {
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
@@ -159,6 +165,116 @@ pub async fn create_room(
return Ok(resp.room_id);
}
async fn load_plan(store: &AsyncProgramStore) -> HashMap<OwnedRoomId, Option<String>> {
let mut locked = store.lock().await;
let ChatStore { need_load, rooms, .. } = &mut locked.application;
let mut plan = HashMap::new();
for room_id in std::mem::take(need_load).into_iter() {
let info = rooms.get_or_default(room_id.clone());
if info.recently_fetched() || info.fetching {
need_load.insert(room_id);
continue;
} else {
info.fetch_last = Instant::now().into();
info.fetching = true;
}
let fetch_id = match &info.fetch_id {
RoomFetchStatus::Done => continue,
RoomFetchStatus::HaveMore(fetch_id) => Some(fetch_id.clone()),
RoomFetchStatus::NotStarted => None,
};
plan.insert(room_id, fetch_id);
}
return plan;
}
async fn load_older_one(
client: Client,
room_id: &RoomId,
fetch_id: Option<String>,
limit: u32,
) -> MessageFetchResult {
if let Some(room) = client.get_room(room_id) {
let mut opts = match &fetch_id {
Some(id) => MessagesOptions::backward().from(id.as_str()),
None => MessagesOptions::backward(),
};
opts.limit = limit.into();
let Messages { end, chunk, .. } = room.messages(opts).await.map_err(IambError::from)?;
let msgs = chunk.into_iter().filter_map(|ev| {
match ev.event.deserialize() {
Ok(AnyTimelineEvent::MessageLike(msg)) => Some(msg),
Ok(AnyTimelineEvent::State(_)) => None,
Err(_) => None,
}
});
Ok((end, msgs.collect()))
} else {
Err(IambError::UnknownRoom(room_id.to_owned()).into())
}
}
async fn load_insert(room_id: OwnedRoomId, res: MessageFetchResult, store: AsyncProgramStore) {
let mut locked = store.lock().await;
let ChatStore { need_load, presences, rooms, .. } = &mut locked.application;
let info = rooms.get_or_default(room_id.clone());
info.fetching = false;
match res {
Ok((fetch_id, msgs)) => {
for msg in msgs.into_iter() {
let sender = msg.sender().to_owned();
let _ = presences.get_or_default(sender);
match msg {
AnyMessageLikeEvent::RoomEncrypted(msg) => {
info.insert_encrypted(msg);
},
AnyMessageLikeEvent::RoomMessage(msg) => {
info.insert(msg);
},
AnyMessageLikeEvent::Reaction(ev) => {
info.insert_reaction(ev);
},
_ => continue,
}
}
info.fetch_id = fetch_id.map_or(RoomFetchStatus::Done, RoomFetchStatus::HaveMore);
},
Err(e) => {
warn!(room_id = room_id.as_str(), err = e.to_string(), "Failed to load older messages");
// Wait and try again.
need_load.insert(room_id);
},
}
}
async fn load_older(client: &Client, store: &AsyncProgramStore) {
let limit = MIN_MSG_LOAD;
let plan = load_plan(store).await;
// Fetch each room separately, so they don't block each other.
for (room_id, fetch_id) in plan.into_iter() {
let client = client.clone();
let store = store.clone();
tokio::spawn(async move {
let res = load_older_one(client, room_id.as_ref(), fetch_id, limit).await;
load_insert(room_id, res, store).await;
});
}
}
#[derive(Debug)]
pub enum LoginStyle {
SessionRestore(Session),
@@ -217,7 +333,6 @@ pub enum WorkerTask {
ActiveRooms(ClientReply<Vec<FetchedRoom>>),
DirectMessages(ClientReply<Vec<FetchedRoom>>),
Init(AsyncProgramStore, ClientReply<()>),
LoadOlder(OwnedRoomId, Option<String>, u32, ClientReply<MessageFetchResult>),
Login(LoginStyle, ClientReply<IambResult<EditInfo>>),
GetInviter(Invited, ClientReply<IambResult<Option<RoomMember>>>),
GetRoom(OwnedRoomId, ClientReply<IambResult<FetchedRoom>>),
@@ -247,14 +362,6 @@ impl Debug for WorkerTask {
.field(&format_args!("_"))
.finish()
},
WorkerTask::LoadOlder(room_id, from, n, _) => {
f.debug_tuple("WorkerTask::LoadOlder")
.field(room_id)
.field(from)
.field(n)
.field(&format_args!("_"))
.finish()
},
WorkerTask::Login(style, _) => {
f.debug_tuple("WorkerTask::Login")
.field(style)
@@ -326,21 +433,6 @@ impl Requester {
return response.recv();
}
pub fn load_older(
&self,
room_id: OwnedRoomId,
fetch_id: Option<String>,
limit: u32,
) -> MessageFetchResult {
let (reply, response) = oneshot();
self.tx
.send(WorkerTask::LoadOlder(room_id, fetch_id, limit, reply))
.unwrap();
return response.recv();
}
pub fn login(&self, style: LoginStyle) -> IambResult<EditInfo> {
let (reply, response) = oneshot();
@@ -438,8 +530,9 @@ pub struct ClientWorker {
initialized: bool,
settings: ApplicationSettings,
client: Client,
sync_handle: Option<JoinHandle<()>>,
load_handle: Option<JoinHandle<()>>,
rcpt_handle: Option<JoinHandle<()>>,
sync_handle: Option<JoinHandle<()>>,
}
impl ClientWorker {
@@ -447,28 +540,27 @@ impl ClientWorker {
let (tx, rx) = unbounded_channel();
let account = &settings.profile;
// Set up a custom client that only uses HTTP/1.
//
// During my testing, I kept stumbling across something weird with sync and HTTP/2 that
// will need to be revisited in the future.
let req_timeout = Duration::from_secs(settings.tunables.request_timeout);
// Set up the HTTP client.
let http = reqwest::Client::builder()
.user_agent(IAMB_USER_AGENT)
.timeout(Duration::from_secs(30))
.timeout(req_timeout)
.pool_idle_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(10))
.http1_only()
.build()
.unwrap();
let req_config = RequestConfig::new().timeout(req_timeout).retry_timeout(req_timeout);
// Set up the Matrix client for the selected profile.
let client = Client::builder()
.http_client(Arc::new(http))
.homeserver_url(account.url.clone())
.store_config(StoreConfig::default())
.sled_store(settings.matrix_dir.as_path(), None)
.expect("Failed to setup up sled store for Matrix SDK")
.request_config(RequestConfig::new().timeout(REQ_TIMEOUT).retry_timeout(REQ_TIMEOUT))
.request_config(req_config)
.build()
.await
.expect("Failed to instantiate Matrix client");
@@ -477,8 +569,9 @@ impl ClientWorker {
initialized: false,
settings,
client: client.clone(),
sync_handle: None,
load_handle: None,
rcpt_handle: None,
sync_handle: None,
};
tokio::spawn(async move {
@@ -536,10 +629,6 @@ impl ClientWorker {
assert!(self.initialized);
reply.send(self.active_rooms().await);
},
WorkerTask::LoadOlder(room_id, fetch_id, limit, reply) => {
assert!(self.initialized);
reply.send(self.load_older(room_id, fetch_id, limit).await);
},
WorkerTask::Login(style, reply) => {
assert!(self.initialized);
reply.send(self.login_and_sync(style).await);
@@ -685,7 +774,7 @@ impl ClientWorker {
Some(EventLocation::Message(key)) => {
if let Some(msg) = info.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.event.redact(ev, room_version);
msg.redact(ev, room_version);
}
},
Some(EventLocation::Reaction(event_id)) => {
@@ -812,17 +901,34 @@ impl ClientWorker {
},
);
let client = self.client.clone();
self.rcpt_handle = tokio::spawn({
let store = store.clone();
let client = self.client.clone();
self.rcpt_handle = tokio::spawn(async move {
// Update the displayed read receipts ever 5 seconds.
let mut interval = tokio::time::interval(Duration::from_secs(5));
async move {
// Update the displayed read receipts every 5 seconds.
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
loop {
interval.tick().await;
let receipts = update_receipts(&client).await;
store.lock().await.application.set_receipts(receipts).await;
let receipts = update_receipts(&client).await;
store.lock().await.application.set_receipts(receipts).await;
}
}
})
.into();
self.load_handle = tokio::spawn({
let client = self.client.clone();
async move {
// Load older messages every 2 seconds.
let mut interval = tokio::time::interval(Duration::from_secs(2));
loop {
interval.tick().await;
load_older(&client, &store).await;
}
}
})
.into();
@@ -861,10 +967,19 @@ impl ClientWorker {
self.sync_handle = Some(handle);
self.client
.sync_once(SyncSettings::default())
.await
.map_err(IambError::from)?;
// Perform an initial, lazily-loaded sync.
let mut room = RoomEventFilter::default();
room.lazy_load_options = LazyLoadOptions::Enabled { include_redundant_members: false };
let mut room_ev = RoomFilter::default();
room_ev.state = room;
let mut filter = FilterDefinition::default();
filter.room = room_ev;
let settings = SyncSettings::new().filter(filter.into());
self.client.sync_once(settings).await.map_err(IambError::from)?;
Ok(Some(InfoMessage::from("Successfully logged in!")))
}
@@ -992,35 +1107,6 @@ impl ClientWorker {
return rooms;
}
async fn load_older(
&mut self,
room_id: OwnedRoomId,
fetch_id: Option<String>,
limit: u32,
) -> MessageFetchResult {
if let Some(room) = self.client.get_room(room_id.as_ref()) {
let mut opts = match &fetch_id {
Some(id) => MessagesOptions::backward().from(id.as_str()),
None => MessagesOptions::backward(),
};
opts.limit = limit.into();
let Messages { end, chunk, .. } = room.messages(opts).await.map_err(IambError::from)?;
let msgs = chunk.into_iter().filter_map(|ev| {
match ev.event.deserialize() {
Ok(AnyTimelineEvent::MessageLike(msg)) => Some(msg),
Ok(AnyTimelineEvent::State(_)) => None,
Err(_) => None,
}
});
Ok((end, msgs.collect()))
} else {
Err(IambError::UnknownRoom(room_id).into())
}
}
async fn members(&mut self, room_id: OwnedRoomId) -> IambResult<Vec<RoomMember>> {
if let Some(room) = self.client.get_room(room_id.as_ref()) {
Ok(room.active_members().await.map_err(IambError::from)?)