use std::borrow::Cow;
use std::collections::hash_map::IntoIter;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::convert::TryFrom;
use std::fmt::{self, Display};
use std::hash::Hash;
use std::str::FromStr;
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},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use ratatui_image::picker::{Picker, ProtocolType};
use serde::{
de::Error as SerdeError,
de::Visitor,
Deserialize,
Deserializer,
Serialize,
Serializer,
};
use tokio::sync::Mutex as AsyncMutex;
use url::Url;
use matrix_sdk::{
encryption::verification::SasVerification,
room::Room as MatrixRoom,
ruma::{
events::{
reaction::ReactionEvent,
relation::{Replacement, Thread},
room::encrypted::RoomEncryptedEvent,
room::message::{
OriginalRoomMessageEvent,
Relation,
RoomMessageEvent,
RoomMessageEventContent,
RoomMessageEventContentWithoutRelation,
},
room::redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent},
tag::{TagName, Tags},
AnySyncStateEvent,
MessageLikeEvent,
},
presence::PresenceState,
EventId,
OwnedEventId,
OwnedRoomId,
OwnedUserId,
RoomId,
RoomVersionId,
UserId,
},
RoomState as MatrixRoomState,
};
use modalkit::{
actions::Action,
editing::{
application::{
ApplicationAction,
ApplicationContentId,
ApplicationError,
ApplicationInfo,
ApplicationStore,
ApplicationWindowId,
},
completion::{complete_path, Completer, CompletionMap},
context::EditContext,
cursor::Cursor,
rope::EditRope,
store::Store,
},
env::vim::{
command::{CommandContext, CommandDescription, VimCommand, VimCommandMachine},
keybindings::VimMachine,
},
errors::{UIError, UIResult},
key::TerminalKey,
keybindings::SequenceStatus,
prelude::{CommandType, WordStyle},
};
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},
worker::Requester,
ApplicationSettings,
};
pub const MATRIX_ID_WORD: WordStyle = WordStyle::CharSet(is_mxid_char);
fn is_mxid_char(c: char) -> bool {
return c >= 'a' && c <= 'z' ||
c >= 'A' && c <= 'Z' ||
c >= '0' && c <= '9' ||
":-./@_#!".contains(c);
}
const ROOM_FETCH_DEBOUNCE: Duration = Duration::from_secs(2);
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IambInfo {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VerifyAction {
Accept,
Cancel,
Confirm,
Mismatch,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MessageAction {
Cancel(bool),
Download(Option<String>, DownloadFlags),
Edit,
React(String, bool),
Redact(Option<String>, bool),
Reply,
Unreact(Option<String>, bool),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SpaceAction {
SetChild(OwnedRoomId, Option<String>, bool),
RemoveChild,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CreateRoomType {
Direct(OwnedUserId),
Room,
Space,
}
bitflags::bitflags! {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CreateRoomFlags: u32 {
const NONE = 0b00000000;
const PUBLIC = 0b00000001;
const ENCRYPTED = 0b00000010;
}
}
bitflags::bitflags! {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DownloadFlags: u32 {
const NONE = 0b00000000;
const FORCE = 0b00000001;
const OPEN = 0b00000010;
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SortFieldRoom {
Favorite,
LowPriority,
Name,
Alias,
RoomId,
Unread,
Recent,
Invite,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SortFieldUser {
PowerLevel,
UserId,
LocalPart,
Server,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SortOrder {
Ascending,
Descending,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SortColumn<T>(pub T, pub SortOrder);
impl<'de> Deserialize<'de> for SortColumn<SortFieldRoom> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(SortRoomVisitor)
}
}
struct SortRoomVisitor;
impl Visitor<'_> for SortRoomVisitor {
type Value = SortColumn<SortFieldRoom>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid field for sorting rooms")
}
fn visit_str<E>(self, mut value: &str) -> Result<Self::Value, E>
where
E: SerdeError,
{
if value.is_empty() {
return Err(E::custom("Invalid sort field"));
}
let order = if value.starts_with('~') {
value = &value[1..];
SortOrder::Descending
} else {
SortOrder::Ascending
};
let field = match value {
"favorite" => SortFieldRoom::Favorite,
"lowpriority" => SortFieldRoom::LowPriority,
"recent" => SortFieldRoom::Recent,
"unread" => SortFieldRoom::Unread,
"name" => SortFieldRoom::Name,
"alias" => SortFieldRoom::Alias,
"id" => SortFieldRoom::RoomId,
"invite" => SortFieldRoom::Invite,
_ => {
let msg = format!("Unknown sort field: {value:?}");
return Err(E::custom(msg));
},
};
Ok(SortColumn(field, order))
}
}
impl<'de> Deserialize<'de> for SortColumn<SortFieldUser> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(SortUserVisitor)
}
}
struct SortUserVisitor;
impl Visitor<'_> for SortUserVisitor {
type Value = SortColumn<SortFieldUser>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid field for sorting rooms")
}
fn visit_str<E>(self, mut value: &str) -> Result<Self::Value, E>
where
E: SerdeError,
{
if value.is_empty() {
return Err(E::custom("Invalid field for sorting users"));
}
let order = if value.starts_with('~') {
value = &value[1..];
SortOrder::Descending
} else {
SortOrder::Ascending
};
let field = match value {
"id" => SortFieldUser::UserId,
"localpart" => SortFieldUser::LocalPart,
"server" => SortFieldUser::Server,
"power" => SortFieldUser::PowerLevel,
_ => {
let msg = format!("Unknown sort field: {value:?}");
return Err(E::custom(msg));
},
};
Ok(SortColumn(field, order))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RoomField {
History,
Name,
Id,
Tag(TagName),
Topic,
NotificationMode,
Aliases,
Alias(String),
CanonicalAlias,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MemberUpdateAction {
Ban,
Kick,
Unban,
}
impl Display for MemberUpdateAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MemberUpdateAction::Ban => write!(f, "ban"),
MemberUpdateAction::Kick => write!(f, "kick"),
MemberUpdateAction::Unban => write!(f, "unban"),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RoomAction {
InviteAccept,
InviteReject,
InviteSend(OwnedUserId),
Leave(bool),
MemberUpdate(MemberUpdateAction, String, Option<String>, bool),
Members(Box<CommandContext>),
SetDirect(bool),
Set(RoomField, String),
Unset(RoomField),
Show(RoomField),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SendAction {
Submit,
SubmitFromEditor,
Upload(String),
UploadImage(usize, usize, Cow<'static, [u8]>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HomeserverAction {
CreateRoom(Option<String>, CreateRoomType, CreateRoomFlags),
Logout(String, bool),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum KeysAction {
Export(String, String),
Import(String, String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IambAction {
Homeserver(HomeserverAction),
Keys(KeysAction),
Message(MessageAction),
Space(SpaceAction),
OpenLink(String),
Room(RoomAction),
Send(SendAction),
Verify(VerifyAction, String),
VerifyRequest(String),
ToggleScrollbackFocus,
ClearUnreads,
}
impl IambAction {
pub fn scribbles(&self) -> bool {
matches!(self, IambAction::Send(SendAction::SubmitFromEditor))
}
}
impl From<HomeserverAction> for IambAction {
fn from(act: HomeserverAction) -> Self {
IambAction::Homeserver(act)
}
}
impl From<MessageAction> for IambAction {
fn from(act: MessageAction) -> Self {
IambAction::Message(act)
}
}
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)
}
}
impl From<SendAction> for IambAction {
fn from(act: SendAction) -> Self {
IambAction::Send(act)
}
}
impl ApplicationAction for IambAction {
fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::ClearUnreads => SequenceStatus::Break,
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,
IambAction::ToggleScrollbackFocus => SequenceStatus::Break,
IambAction::Verify(..) => SequenceStatus::Break,
IambAction::VerifyRequest(..) => SequenceStatus::Break,
}
}
fn is_last_action(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::ClearUnreads => SequenceStatus::Atom,
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,
IambAction::ToggleScrollbackFocus => SequenceStatus::Atom,
IambAction::Verify(..) => SequenceStatus::Atom,
IambAction::VerifyRequest(..) => SequenceStatus::Atom,
}
}
fn is_last_selection(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::ClearUnreads => SequenceStatus::Ignore,
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,
IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore,
IambAction::Verify(..) => SequenceStatus::Ignore,
IambAction::VerifyRequest(..) => SequenceStatus::Ignore,
}
}
fn is_switchable(&self, _: &EditContext) -> bool {
match self {
IambAction::ClearUnreads => false,
IambAction::Homeserver(..) => false,
IambAction::Message(..) => false,
IambAction::Space(..) => false,
IambAction::Room(..) => false,
IambAction::Keys(..) => false,
IambAction::Send(..) => false,
IambAction::OpenLink(..) => false,
IambAction::ToggleScrollbackFocus => false,
IambAction::Verify(..) => false,
IambAction::VerifyRequest(..) => false,
}
}
}
impl From<RoomAction> for ProgramAction {
fn from(act: RoomAction) -> Self {
IambAction::from(act).into()
}
}
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)
}
}
pub type ProgramAction = Action<IambInfo>;
pub type ProgramContext = EditContext;
pub type Keybindings = VimMachine<TerminalKey, IambInfo>;
pub type ProgramCommand = VimCommand<IambInfo>;
pub type ProgramCommands = VimCommandMachine<IambInfo>;
pub type ProgramStore = Store<IambInfo>;
pub type AsyncProgramStore = Arc<AsyncMutex<ProgramStore>>;
pub type IambResult<T> = UIResult<T, IambInfo>;
pub type MessageReactions = HashMap<OwnedEventId, (String, OwnedUserId)>;
#[derive(thiserror::Error, Debug)]
pub enum IambError {
#[error("Invalid history visibility setting: {0}")]
InvalidHistoryVisibility(String),
#[error("Invalid notification level: {0}")]
InvalidNotificationLevel(String),
#[error("Invalid user identifier: {0}")]
InvalidUserId(String),
#[error("Invalid room alias: {0}")]
InvalidRoomAlias(String),
#[error("Invalid verification user/device pair: {0}")]
InvalidVerificationId(String),
#[error("Cryptographic storage error: {0}")]
CryptoStore(#[from] matrix_sdk::encryption::CryptoStoreError),
#[error("Failed to import room keys: {0}")]
FailedKeyImport(#[from] matrix_sdk::encryption::RoomKeyImportError),
#[error("Cannot export keys from sled: {0}")]
UpgradeSled(#[from] crate::sled_export::SledMigrationError),
#[error("HTTP client error: {0}")]
Http(#[from] matrix_sdk::HttpError),
#[error("Matrix client error: {0}")]
Matrix(#[from] matrix_sdk::Error),
#[error("Matrix client storage error: {0}")]
Store(#[from] matrix_sdk::StoreError),
#[error("Serialization/deserialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("No download directory configured")]
NoDownloadDir,
#[error("Selected message does not have any attachments")]
NoAttachment,
#[error("No message currently selected")]
NoSelectedMessage,
#[error("Current window is not a room or space")]
NoSelectedRoomOrSpace,
#[error("No room or space currently selected in list")]
NoSelectedRoomOrSpaceItem,
#[error("Current window is not a room")]
NoSelectedRoom,
#[error("Current window is not a space")]
NoSelectedSpace,
#[error("You do not have the permission to do that")]
InsufficientPermission,
#[error("You do not have a current invitation to this room")]
NotInvited,
#[error("You need to join the room before you can do that")]
NotJoined,
#[error("Unknown room identifier: {0}")]
UnknownRoom(OwnedRoomId),
#[error("Invalid room alias id: {0}")]
InvalidRoomAliasId(#[from] matrix_sdk::ruma::IdParseError),
#[error("Verification request error: {0}")]
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
#[error("Notification setting error: {0}")]
NotificationSettingError(#[from] matrix_sdk::NotificationSettingsError),
#[error("Image error: {0}")]
Image(#[from] image::ImageError),
#[error("Could not use system clipboard data")]
Clipboard,
#[error("Input/Output error: {0}")]
IOError(#[from] std::io::Error),
#[error("Preview error: {0}")]
Preview(String),
}
impl From<IambError> for UIError<IambInfo> {
fn from(err: IambError) -> Self {
UIError::Application(err)
}
}
impl ApplicationError for IambError {}
#[derive(Default)]
pub enum RoomFetchStatus {
Done,
HaveMore(String),
#[default]
NotStarted,
}
pub enum EventLocation {
Message(Option<OwnedEventId>, MessageKey),
Reaction(OwnedEventId),
State(MessageKey),
}
impl EventLocation {
fn to_message_key(&self) -> Option<&MessageKey> {
if let EventLocation::Message(_, key) = self {
Some(key)
} else {
None
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct UnreadInfo {
pub(crate) unread: bool,
pub(crate) latest: Option<MessageTimeStamp>,
}
impl UnreadInfo {
pub fn is_unread(&self) -> bool {
self.unread
}
pub fn latest(&self) -> Option<&MessageTimeStamp> {
self.latest.as_ref()
}
}
pub struct RoomInfo {
pub name: Option<String>,
pub tags: Option<Tags>,
pub keys: HashMap<OwnedEventId, EventLocation>,
messages: Messages,
pub event_receipts: HashMap<ReceiptThread, HashMap<OwnedEventId, HashSet<OwnedUserId>>>,
pub user_receipts: HashMap<ReceiptThread, HashMap<OwnedUserId, OwnedEventId>>,
pub reactions: HashMap<OwnedEventId, MessageReactions>,
threads: HashMap<OwnedEventId, Messages>,
pub fetching: bool,
pub fetch_id: RoomFetchStatus,
pub fetch_last: Option<Instant>,
pub users_typing: Option<(Instant, Vec<OwnedUserId>)>,
pub display_names: HashMap<OwnedUserId, String>,
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 {
self.threads.get(thread_root)
} else {
Some(&self.messages)
}
}
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))
} else {
&mut self.messages
}
}
pub fn get_thread_last<'a>(
&'a self,
thread_root: &OwnedEventId,
) -> Option<&'a OriginalRoomMessageEvent> {
let last = self.threads.get(thread_root).and_then(|t| Some(t.last_key_value()?.1));
let msg = if let Some(last) = last {
&last.event
} else if let EventLocation::Message(_, key) = self.keys.get(thread_root)? {
let msg = self.messages.get(key)?;
&msg.event
} else {
return None;
};
if let MessageEvent::Original(ev) = &msg {
Some(ev)
} else {
None
}
}
pub fn get_reactions(&self, event_id: &EventId) -> Vec<(&str, usize)> {
if let Some(reacts) = self.reactions.get(event_id) {
let mut counts = HashMap::new();
let mut seen_user_reactions = BTreeSet::new();
for (key, user) in reacts.values() {
if !seen_user_reactions.contains(&(key, user)) {
seen_user_reactions.insert((key, user));
let count = counts.entry(key.as_str()).or_default();
*count += 1;
}
}
let mut reactions = counts.into_iter().collect::<Vec<_>>();
reactions.sort();
reactions
} else {
vec![]
}
}
pub fn get_message_key(&self, event_id: &EventId) -> Option<&MessageKey> {
self.keys.get(event_id)?.to_message_key()
}
pub fn get_event(&self, event_id: &EventId) -> Option<&Message> {
self.messages.get(self.get_message_key(event_id)?)
}
pub fn get_event_mut(&mut self, event_id: &EventId) -> Option<&mut Message> {
self.messages.get_mut(self.keys.get(event_id)?.to_message_key()?)
}
pub fn redact(&mut self, ev: OriginalSyncRoomRedactionEvent, room_version: &RoomVersionId) {
let Some(redacts) = &ev.redacts else {
return;
};
match self.keys.get(redacts) {
None => return,
Some(EventLocation::State(key)) => {
if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, room_version);
}
},
Some(EventLocation::Message(None, key)) => {
if let Some(msg) = self.messages.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, room_version);
}
},
Some(EventLocation::Message(Some(root), key)) => {
if let Some(thread) = self.threads.get_mut(root) {
if let Some(msg) = thread.get_mut(key) {
let ev = SyncRoomRedactionEvent::Original(ev);
msg.redact(ev, room_version);
}
}
},
Some(EventLocation::Reaction(event_id)) => {
if let Some(reactions) = self.reactions.get_mut(event_id) {
reactions.remove(redacts);
}
self.keys.remove(redacts);
},
}
}
pub fn insert_reaction(&mut self, react: ReactionEvent) {
match react {
MessageLikeEvent::Original(react) => {
let rel_id = react.content.relates_to.event_id;
let key = react.content.relates_to.key;
let message = self.reactions.entry(rel_id.clone()).or_default();
let event_id = react.event_id;
let user_id = react.sender;
message.insert(event_id.clone(), (key, user_id));
let loc = EventLocation::Reaction(rel_id);
self.keys.insert(event_id, loc);
},
MessageLikeEvent::Redacted(_) => {
return;
},
}
}
pub fn insert_edit(&mut self, msg: Replacement<RoomMessageEventContentWithoutRelation>) {
let event_id = msg.event_id;
let new_msgtype = msg.new_content;
let Some(EventLocation::Message(thread, key)) = self.keys.get(&event_id) else {
return;
};
let source = if let Some(thread) = thread {
self.threads
.entry(thread.clone())
.or_insert_with(|| Messages::thread(thread.clone()))
} else {
&mut self.messages
};
let Some(msg) = source.get_mut(key) else {
return;
};
match &mut msg.event {
MessageEvent::Original(orig) => {
orig.content.apply_replacement(new_msgtype);
},
MessageEvent::Local(_, content) => {
content.apply_replacement(new_msgtype);
},
MessageEvent::Redacted(_) |
MessageEvent::State(_) |
MessageEvent::EncryptedOriginal(_) |
MessageEvent::EncryptedRedacted(_) => {
return;
},
}
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);
}
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));
match (last_message, last_receipt) {
(Some(((ts, recent), _)), Some(last_read)) => {
UnreadInfo { unread: last_read != recent, latest: Some(*ts) }
},
(Some(((ts, _), _)), None) => {
UnreadInfo { unread: true, latest: Some(*ts) }
},
(None, _) => UnreadInfo::default(),
}
}
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(None, 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());
let loc = EventLocation::Message(None, key.clone());
self.keys.insert(event_id, loc);
self.messages.insert_message(key, msg);
}
fn insert_thread(&mut self, msg: RoomMessageEvent, thread_root: OwnedEventId) {
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 loc = EventLocation::Message(Some(thread_root), key.clone());
self.keys.insert(event_id, loc);
replies.insert_message(key, msg);
}
pub fn insert(&mut self, msg: RoomMessageEvent) {
match msg {
RoomMessageEvent::Original(OriginalRoomMessageEvent {
content: RoomMessageEventContent { relates_to: Some(ref relates_to), .. },
..
}) => {
match relates_to {
Relation::Replacement(repl) => self.insert_edit(repl.clone()),
Relation::Thread(Thread { event_id, .. }) => {
let event_id = event_id.clone();
self.insert_thread(msg, event_id);
},
Relation::Reply { .. } => self.insert_message(msg),
_ => self.insert_message(msg),
}
},
_ => self.insert_message(msg),
}
}
pub fn insert_with_preview(
&mut self,
room_id: OwnedRoomId,
store: AsyncProgramStore,
picker: Option<Picker>,
ev: RoomMessageEvent,
settings: &mut ApplicationSettings,
media: matrix_sdk::Media,
) {
let source = picker.and_then(|_| source_from_event(&ev));
self.insert(ev);
if let Some((event_id, source)) = source {
if let (Some(msg), Some(image_preview)) =
(self.get_event_mut(&event_id), &settings.tunables.image_preview)
{
msg.image_preview = ImageStatus::Downloading(image_preview.size.clone());
spawn_insert_preview(
store,
room_id,
event_id,
source,
media,
settings.dirs.image_previews.clone(),
)
}
}
}
pub fn recently_fetched(&self) -> bool {
self.fetch_last.is_some_and(|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)?;
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);
}
None
}
pub fn set_receipt(
&mut self,
thread: ReceiptThread,
user_id: OwnedUserId,
event_id: OwnedEventId,
) {
self.clear_receipt(&thread, &user_id);
self.event_receipts
.entry(thread.clone())
.or_default()
.entry(event_id.clone())
.or_default()
.insert(user_id.clone());
self.user_receipts.entry(thread).or_default().insert(user_id, event_id);
}
pub fn fully_read(&mut self, user_id: &UserId) {
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());
}
}
pub fn receipts<'a>(
&'a self,
user_id: &'a UserId,
) -> impl Iterator<Item = (&'a ReceiptThread, &'a OwnedEventId)> + 'a {
self.user_receipts
.iter()
.filter_map(move |(t, rs)| rs.get(user_id).map(|r| (t, r)))
}
fn get_typers(&self) -> &[OwnedUserId] {
if let Some((t, users)) = &self.users_typing {
if t.elapsed() < Duration::from_secs(4) {
return users.as_ref();
} else {
return &[];
}
} else {
return &[];
}
}
fn get_typing_spans<'a>(&'a self, settings: &'a ApplicationSettings) -> Line<'a> {
let typers = self.get_typers();
let n = typers.len();
match n {
0 => Line::from(vec![]),
1 => {
let user = settings.get_user_span(typers[0].as_ref(), self);
Line::from(vec![user, Span::from(" is typing...")])
},
2 => {
let user1 = settings.get_user_span(typers[0].as_ref(), self);
let user2 = settings.get_user_span(typers[1].as_ref(), self);
Line::from(vec![
user1,
Span::raw(" and "),
user2,
Span::from(" are typing..."),
])
},
n if n < 5 => Line::from("Several people are typing..."),
_ => Line::from("Many people are typing..."),
}
}
pub fn set_typing(&mut self, user_ids: Vec<OwnedUserId>) {
self.users_typing = (Instant::now(), user_ids).into();
}
pub fn render_typing(
&mut self,
area: Rect,
buf: &mut Buffer,
settings: &ApplicationSettings,
) -> Rect {
if area.height <= 2 || area.width <= 20 {
return area;
}
if !settings.tunables.typing_notice_display {
return Rect::new(area.x, area.y, area.width, area.height - 1);
}
let top = Rect::new(area.x, area.y, area.width, area.height - 1);
let bar = Rect::new(area.x, area.y + top.height, area.width, 1);
Paragraph::new(self.get_typing_spans(settings))
.alignment(Alignment::Center)
.render(bar, buf);
return top;
}
pub fn user_reactions_contains(
&mut self,
user_id: &UserId,
event_id: &EventId,
emoji: &str,
) -> bool {
if let Some(reactions) = self.reactions.get(event_id) {
reactions
.values()
.any(|(annotation, user)| annotation == emoji && user == user_id)
} else {
false
}
}
}
fn emoji_map() -> CompletionMap<String, &'static Emoji> {
let mut emojis = CompletionMap::default();
for emoji in emojis::iter() {
for shortcode in emoji.shortcodes() {
emojis.insert(shortcode.to_string(), emoji);
}
}
return emojis;
}
#[cfg(unix)]
fn picker_from_termios(protocol_type: Option<ProtocolType>) -> Option<Picker> {
let mut picker = match Picker::from_query_stdio() {
Ok(picker) => picker,
Err(e) => {
tracing::error!("Failed to setup image previews: {e}");
return None;
},
};
if let Some(protocol_type) = protocol_type {
picker.set_protocol_type(protocol_type);
}
Some(picker)
}
#[cfg(windows)]
fn picker_from_termios(_: Option<ProtocolType>) -> Option<Picker> {
tracing::error!("\"image_preview\" requires \"protocol\" with \"type\" and \"font_size\" options on Windows.");
None
}
fn picker_from_settings(settings: &ApplicationSettings) -> Option<Picker> {
let image_preview = settings.tunables.image_preview.as_ref()?;
let image_preview_protocol = image_preview.protocol.as_ref();
if let Some(&ImagePreviewProtocolValues {
r#type: Some(protocol_type),
font_size: Some(font_size),
}) = image_preview_protocol
{
let mut picker = Picker::from_fontsize(font_size);
picker.set_protocol_type(protocol_type);
Some(picker)
} else {
picker_from_termios(image_preview_protocol.and_then(|p| p.r#type))
}
}
#[derive(Default)]
pub struct SyncInfo {
pub spaces: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
pub rooms: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
pub dms: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
}
impl SyncInfo {
pub fn rooms(&self) -> impl Iterator<Item = &RoomId> {
self.rooms.iter().map(|r| r.0.room_id())
}
pub fn dms(&self) -> impl Iterator<Item = &RoomId> {
self.dms.iter().map(|r| r.0.room_id())
}
pub fn chats(&self) -> impl Iterator<Item = &RoomId> {
self.rooms().chain(self.dms())
}
}
bitflags::bitflags! {
#[derive(Debug, Default, PartialEq)]
pub struct Need: u32 {
const EMPTY = 0b00000000;
const MESSAGES = 0b00000001;
const MEMBERS = 0b00000010;
}
}
#[derive(Default)]
pub struct RoomNeeds {
needs: HashMap<OwnedRoomId, Need>,
}
impl RoomNeeds {
pub fn insert(&mut self, room_id: OwnedRoomId, need: Need) {
self.needs.entry(room_id).or_default().insert(need);
}
pub fn rooms(&self) -> usize {
self.needs.len()
}
}
impl IntoIterator for RoomNeeds {
type Item = (OwnedRoomId, Need);
type IntoIter = IntoIter<OwnedRoomId, Need>;
fn into_iter(self) -> Self::IntoIter {
self.needs.into_iter()
}
}
pub struct ChatStore {
pub cmds: ProgramCommands,
pub worker: Requester,
pub rooms: CompletionMap<OwnedRoomId, RoomInfo>,
pub names: CompletionMap<String, OwnedRoomId>,
pub presences: CompletionMap<OwnedUserId, PresenceState>,
pub verifications: HashMap<String, SasVerification>,
pub settings: ApplicationSettings,
pub need_load: RoomNeeds,
pub emojis: CompletionMap<String, &'static Emoji>,
pub sync_info: SyncInfo,
pub picker: Option<Picker>,
pub draw_curr: Option<Instant>,
pub ring_bell: bool,
pub focused: bool,
pub collator: feruca::Collator,
pub open_notifications: HashMap<OwnedRoomId, Vec<NotificationHandle>>,
}
impl ChatStore {
pub fn new(worker: Requester, settings: ApplicationSettings) -> Self {
let picker = picker_from_settings(&settings);
ChatStore {
worker,
settings,
picker,
cmds: crate::commands::setup_commands(),
emojis: emoji_map(),
collator: Default::default(),
names: Default::default(),
rooms: Default::default(),
presences: Default::default(),
verifications: Default::default(),
need_load: Default::default(),
sync_info: Default::default(),
draw_curr: None,
ring_bell: false,
focused: true,
open_notifications: Default::default(),
}
}
pub fn get_joined_room(&self, room_id: &RoomId) -> Option<MatrixRoom> {
let room = self.worker.client.get_room(room_id)?;
if room.state() == MatrixRoomState::Joined {
Some(room)
} else {
None
}
}
pub fn get_room_title(&self, room_id: &RoomId) -> String {
self.rooms
.get(room_id)
.and_then(|i| i.name.as_ref())
.map(String::from)
.unwrap_or_else(|| "Untitled Matrix Room".to_string())
}
pub fn get_room_info(&mut self, room_id: OwnedRoomId) -> &mut RoomInfo {
self.rooms.get_or_default(room_id)
}
pub fn set_room_name(&mut self, room_id: &RoomId, name: &str) {
self.rooms.get_or_default(room_id.to_owned()).name = name.to_string().into();
}
pub fn insert_sas(&mut self, sas: SasVerification) {
let key = format!("{}/{}", sas.other_user_id(), sas.other_device().device_id());
self.verifications.insert(key, sas);
}
}
impl ApplicationStore for ChatStore {}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum IambId {
Room(OwnedRoomId, Option<OwnedEventId>),
DirectList,
MemberList(OwnedRoomId),
RoomList,
SpaceList,
VerifyList,
Welcome,
ChatList,
UnreadList,
}
impl Display for IambId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IambId::Room(room_id, None) => {
write!(f, "iamb://room/{room_id}")
},
IambId::Room(room_id, Some(thread)) => {
write!(f, "iamb://room/{room_id}/threads/{thread}")
},
IambId::MemberList(room_id) => {
write!(f, "iamb://members/{room_id}")
},
IambId::DirectList => f.write_str("iamb://dms"),
IambId::RoomList => f.write_str("iamb://rooms"),
IambId::SpaceList => f.write_str("iamb://spaces"),
IambId::VerifyList => f.write_str("iamb://verify"),
IambId::Welcome => f.write_str("iamb://welcome"),
IambId::ChatList => f.write_str("iamb://chats"),
IambId::UnreadList => f.write_str("iamb://unreads"),
}
}
}
impl ApplicationWindowId for IambId {}
impl Serialize for IambId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for IambId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(IambIdVisitor)
}
}
struct IambIdVisitor;
impl Visitor<'_> for IambIdVisitor {
type Value = IambId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid window URL")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: SerdeError,
{
let Ok(url) = Url::parse(value) else {
return Err(E::custom("Invalid iamb window URL"));
};
if url.scheme() != "iamb" {
return Err(E::custom("Invalid iamb window URL"));
}
match url.domain() {
Some("room") => {
let Some(path) = url.path_segments() else {
return Err(E::custom("Invalid members window URL"));
};
match *path.collect::<Vec<_>>().as_slice() {
[room_id] => {
let Ok(room_id) = OwnedRoomId::try_from(room_id) else {
return Err(E::custom("Invalid room identifier"));
};
Ok(IambId::Room(room_id, None))
},
[room_id, "threads", thread_root] => {
let Ok(room_id) = OwnedRoomId::try_from(room_id) else {
return Err(E::custom("Invalid room identifier"));
};
let Ok(thread_root) = OwnedEventId::try_from(thread_root) else {
return Err(E::custom("Invalid thread root identifier"));
};
Ok(IambId::Room(room_id, Some(thread_root)))
},
_ => return Err(E::custom("Invalid members window URL")),
}
},
Some("members") => {
let Some(path) = url.path_segments() else {
return Err(E::custom("Invalid members window URL"));
};
let &[room_id] = path.collect::<Vec<_>>().as_slice() else {
return Err(E::custom("Invalid members window URL"));
};
let Ok(room_id) = OwnedRoomId::try_from(room_id) else {
return Err(E::custom("Invalid room identifier"));
};
Ok(IambId::MemberList(room_id))
},
Some("dms") => {
if url.path() != "" {
return Err(E::custom("iamb://dms takes no path"));
}
Ok(IambId::DirectList)
},
Some("rooms") => {
if url.path() != "" {
return Err(E::custom("iamb://rooms takes no path"));
}
Ok(IambId::RoomList)
},
Some("spaces") => {
if url.path() != "" {
return Err(E::custom("iamb://spaces takes no path"));
}
Ok(IambId::SpaceList)
},
Some("verify") => {
if url.path() != "" {
return Err(E::custom("iamb://verify takes no path"));
}
Ok(IambId::VerifyList)
},
Some("welcome") => {
if url.path() != "" {
return Err(E::custom("iamb://welcome takes no path"));
}
Ok(IambId::Welcome)
},
Some("chats") => {
if url.path() != "" {
return Err(E::custom("iamb://chats takes no path"));
}
Ok(IambId::ChatList)
},
Some("unreads") => {
if url.path() != "" {
return Err(E::custom("iamb://unreads takes no path"));
}
Ok(IambId::UnreadList)
},
Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))),
None => Err(E::custom("Invalid iamb window URL")),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RoomFocus {
Scrollback,
MessageBar,
}
impl RoomFocus {
pub fn is_scrollback(&self) -> bool {
matches!(self, RoomFocus::Scrollback)
}
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,
};
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum IambBufferId {
Command(CommandType),
Room(OwnedRoomId, Option<OwnedEventId>, RoomFocus),
DirectList,
MemberList(OwnedRoomId),
RoomList,
SpaceList,
VerifyList,
Welcome,
ChatList,
UnreadList,
}
impl IambBufferId {
pub fn to_window(&self) -> Option<IambId> {
let id = match self {
IambBufferId::Command(_) => return None,
IambBufferId::Room(room, thread, _) => IambId::Room(room.clone(), thread.clone()),
IambBufferId::DirectList => IambId::DirectList,
IambBufferId::MemberList(room) => IambId::MemberList(room.clone()),
IambBufferId::RoomList => IambId::RoomList,
IambBufferId::SpaceList => IambId::SpaceList,
IambBufferId::VerifyList => IambId::VerifyList,
IambBufferId::Welcome => IambId::Welcome,
IambBufferId::ChatList => IambId::ChatList,
IambBufferId::UnreadList => IambId::UnreadList,
};
Some(id)
}
}
impl ApplicationContentId for IambBufferId {}
impl ApplicationInfo for IambInfo {
type Error = IambError;
type Store = ChatStore;
type Action = IambAction;
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,
) -> Vec<String> {
match content {
IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store),
IambBufferId::Command(CommandType::Search) => vec![],
IambBufferId::Room(_, _, RoomFocus::MessageBar) => complete_msgbar(text, cursor, store),
IambBufferId::Room(_, _, RoomFocus::Scrollback) => vec![],
IambBufferId::DirectList => vec![],
IambBufferId::MemberList(_) => vec![],
IambBufferId::RoomList => vec![],
IambBufferId::SpaceList => vec![],
IambBufferId::VerifyList => vec![],
IambBufferId::Welcome => vec![],
IambBufferId::ChatList => vec![],
IambBufferId::UnreadList => vec![],
}
}
}
fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);
store
.presences
.complete(id.as_ref())
.into_iter()
.map(|i| i.to_string())
.collect()
}
fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);
match id.chars().next() {
Some('#') => {
return store.names.complete(id.as_ref());
},
Some('!') => {
return store
.rooms
.complete(id.as_ref())
.into_iter()
.map(|i| i.to_string())
.collect();
},
Some(':') => {
let list = store.emojis.complete(&id[1..]);
let iter = list.into_iter().take(200).map(|s| format!(":{}:", s));
return iter.collect();
},
Some('@') | None => {
return store
.presences
.complete(id.as_ref())
.into_iter()
.map(|i| i.to_string())
.collect();
},
Some(_) => return vec![],
}
}
fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);
let list = store.names.complete(id.as_ref());
if !list.is_empty() {
return list;
}
let list = store.presences.complete(id.as_ref());
if !list.is_empty() {
return list.into_iter().map(|i| i.to_string()).collect();
}
store
.rooms
.complete(id.as_ref())
.into_iter()
.map(|i| i.to_string())
.collect()
}
fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let sc = text.get_prefix_word_mut(cursor, &WordStyle::Little);
let sc = sc.unwrap_or_else(EditRope::empty);
let sc = Cow::from(&sc);
store.emojis.complete(sc.as_ref())
}
fn complete_cmdname(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ChatStore,
) -> Vec<String> {
let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little);
store.cmds.complete_name(desc.command.as_str())
}
fn complete_cmdarg(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ChatStore,
) -> Vec<String> {
let cmd = match store.cmds.get(desc.command.as_str()) {
Ok(cmd) => cmd,
Err(_) => return vec![],
};
match cmd.name.as_str() {
"cancel" | "dms" | "edit" | "redact" | "reply" => vec![],
"members" | "rooms" | "spaces" | "welcome" => vec![],
"download" | "keys" | "open" | "upload" => complete_path(text, cursor),
"react" | "unreact" => complete_emoji(text, cursor, store),
"invite" => complete_users(text, cursor, store),
"join" | "split" | "vsplit" | "tabedit" => complete_matrix_names(text, cursor, store),
"room" => vec![],
"verify" => vec![],
"vertical" | "horizontal" | "aboveleft" | "belowright" | "tab" => {
complete_cmd(desc.arg.text.as_str(), text, cursor, store)
},
_ => vec![],
}
}
fn complete_cmd(cmd: &str, text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
match CommandDescription::from_str(cmd) {
Ok(desc) => {
if desc.arg.untrimmed.is_empty() {
complete_cmdname(desc, text, cursor, store)
} else {
complete_cmdarg(desc, text, cursor, store)
}
},
Err(_) => vec![],
}
}
fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let eo = text.cursor_to_offset(cursor);
let slice = text.slice(..eo);
let cow = Cow::from(&slice);
complete_cmd(cow.as_ref(), text, cursor, store)
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::config::user_style_from_color;
use crate::tests::*;
use matrix_sdk::ruma::{
events::{reaction::ReactionEventContent, relation::Annotation, MessageLikeUnsigned},
owned_event_id,
owned_room_id,
owned_user_id,
MilliSecondsSinceUnixEpoch,
};
use pretty_assertions::assert_eq;
use ratatui::style::Color;
#[test]
fn multiple_identical_reactions() {
let mut info = RoomInfo::default();
let content = ReactionEventContent::new(Annotation::new(
owned_event_id!("$my_reaction"),
"🏠".to_owned(),
));
for i in 0..3 {
let event_id = format!("$house_{}", i);
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
event_id: OwnedEventId::from_str(&event_id).unwrap(),
sender: owned_user_id!("@foo:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
room_id: owned_room_id!("!foo:example.org"),
unsigned: MessageLikeUnsigned::new(),
},
));
}
let content = ReactionEventContent::new(Annotation::new(
owned_event_id!("$my_reaction"),
"🙂".to_owned(),
));
for i in 0..2 {
let event_id = format!("$smile_{}", i);
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
event_id: OwnedEventId::from_str(&event_id).unwrap(),
sender: owned_user_id!("@foo:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
room_id: owned_room_id!("!foo:example.org"),
unsigned: MessageLikeUnsigned::new(),
},
));
}
for i in 2..4 {
let event_id = format!("$smile_{}", i);
info.insert_reaction(MessageLikeEvent::Original(
matrix_sdk::ruma::events::OriginalMessageLikeEvent {
content: content.clone(),
event_id: OwnedEventId::from_str(&event_id).unwrap(),
sender: owned_user_id!("@bar:example.org"),
origin_server_ts: MilliSecondsSinceUnixEpoch::now(),
room_id: owned_room_id!("!foo:example.org"),
unsigned: MessageLikeUnsigned::new(),
},
));
}
assert_eq!(info.get_reactions(&owned_event_id!("$my_reaction")), vec![
("🏠", 1),
("🙂", 2)
]);
}
#[test]
fn test_typing_spans() {
let mut info = RoomInfo::default();
let settings = mock_settings();
let users0 = vec![];
let users1 = vec![TEST_USER1.clone()];
let users2 = vec![TEST_USER1.clone(), TEST_USER2.clone()];
let users4 = vec![
TEST_USER1.clone(),
TEST_USER2.clone(),
TEST_USER3.clone(),
TEST_USER4.clone(),
];
let users5 = vec![
TEST_USER1.clone(),
TEST_USER2.clone(),
TEST_USER3.clone(),
TEST_USER4.clone(),
TEST_USER5.clone(),
];
assert_eq!(info.users_typing, None);
assert_eq!(info.get_typing_spans(&settings), Line::from(vec![]));
info.set_typing(users0);
assert!(info.users_typing.is_some());
assert_eq!(info.get_typing_spans(&settings), Line::from(vec![]));
info.set_typing(users1);
assert!(info.users_typing.is_some());
assert_eq!(
info.get_typing_spans(&settings),
Line::from(vec![
Span::styled("@user1:example.com", user_style("@user1:example.com")),
Span::from(" is typing...")
])
);
info.set_typing(users2);
assert!(info.users_typing.is_some());
assert_eq!(
info.get_typing_spans(&settings),
Line::from(vec![
Span::styled("@user1:example.com", user_style("@user1:example.com")),
Span::raw(" and "),
Span::styled("@user2:example.com", user_style("@user2:example.com")),
Span::raw(" are typing...")
])
);
info.set_typing(users4);
assert!(info.users_typing.is_some());
assert_eq!(info.get_typing_spans(&settings), Line::from("Several people are typing..."));
info.set_typing(users5);
assert!(info.users_typing.is_some());
assert_eq!(info.get_typing_spans(&settings), Line::from("Many people are typing..."));
info.set_typing(vec![TEST_USER5.clone()]);
assert!(info.users_typing.is_some());
assert_eq!(
info.get_typing_spans(&settings),
Line::from(vec![
Span::styled("USER 5", user_style_from_color(Color::Black)),
Span::from(" is typing...")
])
);
}
#[test]
fn test_need_load() {
let room_id = TEST_ROOM1_ID.clone();
let mut need_load = RoomNeeds::default();
need_load.insert(room_id.clone(), Need::MESSAGES);
need_load.insert(room_id.clone(), Need::MEMBERS);
assert_eq!(need_load.into_iter().collect::<Vec<(OwnedRoomId, Need)>>(), vec![(
room_id,
Need::MESSAGES | Need::MEMBERS,
)],);
}
#[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);
let res = complete_msgbar(&text, &mut cursor, &store);
assert_eq!(res, vec![":walking:", ":walking_man:", ":walking_woman:"]);
assert_eq!(cursor, Cursor::new(0, 17));
let text = EditRope::from("hello @user1 ");
let mut cursor = Cursor::new(0, 12);
let res = complete_msgbar(&text, &mut cursor, &store);
assert_eq!(res, vec!["@user1:example.com"]);
assert_eq!(cursor, Cursor::new(0, 6));
let text = EditRope::from("see #room ");
let mut cursor = Cursor::new(0, 9);
let res = complete_msgbar(&text, &mut cursor, &store);
assert_eq!(res, vec!["#room1:example.com"]);
assert_eq!(cursor, Cursor::new(0, 4));
}
#[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",
"@user3:example.com",
"@user4:example.com",
"@user5:example.com",
];
let text = EditRope::from("invite ");
let mut cursor = Cursor::new(0, 7);
let id = text
.get_prefix_word_mut(&mut cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
assert_eq!(id.to_string(), "");
assert_eq!(cursor, Cursor::new(0, 7));
let text = EditRope::from("invite ");
let mut cursor = Cursor::new(0, 7);
let res = complete_cmdbar(&text, &mut cursor, &store);
assert_eq!(res, users);
let text = EditRope::from("invite ignored");
let mut cursor = Cursor::new(0, 7);
let res = complete_cmdbar(&text, &mut cursor, &store);
assert_eq!(res, users);
let text = EditRope::from("invite @user1ignored");
let mut cursor = Cursor::new(0, 13);
let res = complete_cmdbar(&text, &mut cursor, &store);
assert_eq!(res, vec!["@user1:example.com"]);
let text = EditRope::from("abo hor");
let mut cursor = Cursor::new(0, 7);
let res = complete_cmdbar(&text, &mut cursor, &store);
assert_eq!(res, vec!["horizontal"]);
let text = EditRope::from("abo hor inv");
let mut cursor = Cursor::new(0, 11);
let res = complete_cmdbar(&text, &mut cursor, &store);
assert_eq!(res, vec!["invite"]);
let text = EditRope::from("abo hor invite \n");
let mut cursor = Cursor::new(0, 15);
let res = complete_cmdbar(&text, &mut cursor, &store);
assert_eq!(res, users);
}
}