iamb/src/base.rs

2025 lines
61 KiB
Rust
Raw Normal View History

2023-10-06 22:35:27 -07:00
//! # Common types and utilities
//!
//! The types defined here get used throughout iamb.
use std::borrow::Cow;
use std::collections::hash_map::IntoIter;
use std::collections::{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;
2024-02-27 21:21:05 -08:00
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,
2024-03-02 15:00:29 -08:00
room::Room as MatrixRoom,
2023-01-19 16:05:02 -08:00
ruma::{
events::{
reaction::ReactionEvent,
2024-03-09 00:47:05 -08:00
relation::{Replacement, Thread},
room::encrypted::RoomEncryptedEvent,
room::message::{
OriginalRoomMessageEvent,
Relation,
RoomMessageEvent,
RoomMessageEventContent,
2024-03-02 15:00:29 -08:00
RoomMessageEventContentWithoutRelation,
},
room::redaction::{OriginalSyncRoomRedactionEvent, SyncRoomRedactionEvent},
tag::{TagName, Tags},
MessageLikeEvent,
2023-01-19 16:05:02 -08:00
},
presence::PresenceState,
2023-01-19 16:05:02 -08:00
EventId,
OwnedEventId,
OwnedRoomId,
OwnedUserId,
RoomId,
RoomVersionId,
UserId,
2023-01-19 16:05:02 -08:00
},
2024-03-02 15:00:29 -08:00
RoomState as MatrixRoomState,
};
use modalkit::{
actions::Action,
editing::{
application::{
ApplicationAction,
ApplicationContentId,
ApplicationError,
ApplicationInfo,
ApplicationStore,
ApplicationWindowId,
},
completion::{complete_path, CompletionMap},
context::EditContext,
cursor::Cursor,
rope::EditRope,
store::Store,
},
env::vim::{
command::{CommandContext, CommandDescription, VimCommand, VimCommandMachine},
keybindings::VimMachine,
},
errors::{UIError, UIResult},
2024-02-27 21:21:05 -08:00
key::TerminalKey,
keybindings::SequenceStatus,
prelude::{CommandType, WordStyle},
};
use crate::config::ImagePreviewProtocolValues;
use crate::message::ImageStatus;
use crate::preview::{source_from_event, spawn_insert_preview};
use crate::{
2023-01-19 16:05:02 -08:00
message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages},
worker::Requester,
ApplicationSettings,
};
2023-10-06 22:35:27 -07:00
/// The set of characters used in different Matrix IDs.
pub const MATRIX_ID_WORD: WordStyle = WordStyle::CharSet(is_mxid_char);
/// Find the boundaries for a Matrix username, room alias, or room ID.
///
/// Technically "[" and "]" should be here since IPv6 addresses are allowed
/// in the server name, but in practice that should be uncommon, and people
/// can just use `gf` and friends in Visual mode instead.
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);
2023-10-06 22:35:27 -07:00
/// Empty type used solely to implement [ApplicationInfo].
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IambInfo {}
2023-10-06 22:35:27 -07:00
/// An action taken against an ongoing verification request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VerifyAction {
2023-10-06 22:35:27 -07:00
/// Accept a verification request.
Accept,
2023-10-06 22:35:27 -07:00
/// Cancel an in-progress verification.
Cancel,
2023-10-06 22:35:27 -07:00
/// Confirm an in-progress verification.
Confirm,
2023-10-06 22:35:27 -07:00
/// Reject an in-progress verification due to mismatched Emoji.
Mismatch,
}
2023-10-06 22:35:27 -07:00
/// An action taken against the currently selected message.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MessageAction {
2023-01-19 16:05:02 -08:00
/// Cance the current reply or edit.
///
/// The [bool] argument indicates whether to skip confirmation for clearing the message bar.
Cancel(bool),
2023-01-19 16:05:02 -08:00
/// Download an attachment to the given path.
///
/// The second argument controls whether to overwrite any already existing file at the
/// destination path, or to open the attachment after downloading.
Download(Option<String>, DownloadFlags),
2023-01-19 16:05:02 -08:00
/// Edit a sent message.
Edit,
/// React to a message with an Emoji.
React(String),
/// Redact a message, with an optional reason.
2023-04-28 16:52:33 -07:00
///
/// The [bool] argument indicates whether to skip confirmation.
Redact(Option<String>, bool),
2023-01-19 16:05:02 -08:00
/// Reply to a message.
2023-01-12 21:20:32 -08:00
Reply,
/// Unreact to a message.
///
/// If no specific Emoji to remove to is specified, then all reactions from the user on the
/// message are removed.
Unreact(Option<String>),
}
2023-10-06 22:35:27 -07:00
/// The type of room being created.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CreateRoomType {
/// A direct message room.
Direct(OwnedUserId),
/// A standard chat room.
Room,
/// A Matrix space.
Space,
}
bitflags::bitflags! {
2023-10-06 22:35:27 -07:00
/// Available options for newly created rooms.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CreateRoomFlags: u32 {
2023-10-06 22:35:27 -07:00
/// No flags specified.
const NONE = 0b00000000;
/// Make the room public.
const PUBLIC = 0b00000001;
/// Encrypt this room.
const ENCRYPTED = 0b00000010;
}
}
bitflags::bitflags! {
2023-10-06 22:35:27 -07:00
/// Available options when downloading files.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DownloadFlags: u32 {
2023-10-06 22:35:27 -07:00
/// No flags specified.
const NONE = 0b00000000;
/// Overwrite file if it already exists.
const FORCE = 0b00000001;
/// Open file after downloading.
const OPEN = 0b00000010;
}
}
/// Fields that rooms and spaces can be sorted by.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SortFieldRoom {
/// Sort rooms by whether they have the Favorite tag.
Favorite,
/// Sort rooms by whether they have the Low Priority tag.
LowPriority,
/// Sort rooms by their room name.
Name,
/// Sort rooms by their canonical room alias.
Alias,
/// Sort rooms by their Matrix room identifier.
RoomId,
/// Sort rooms by whether they have unread messages.
Unread,
/// Sort rooms by the timestamps of their most recent messages.
Recent,
}
/// Fields that users can be sorted by.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SortFieldUser {
PowerLevel,
UserId,
LocalPart,
Server,
}
/// Whether to use the default sort direction for a field, or to reverse it.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SortOrder {
Ascending,
Descending,
}
/// One of the columns to sort on.
#[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)
}
}
/// [serde] visitor for deserializing [SortColumn] for rooms and spaces.
struct SortRoomVisitor;
impl<'de> Visitor<'de> 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,
_ => {
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)
}
}
/// [serde] visitor for deserializing [SortColumn] for users.
struct SortUserVisitor;
impl<'de> Visitor<'de> 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))
}
}
2023-10-06 22:35:27 -07:00
/// A room property.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RoomField {
2023-10-06 22:35:27 -07:00
/// The room name.
Name,
2023-10-06 22:35:27 -07:00
/// A room tag.
Tag(TagName),
2023-10-06 22:35:27 -07:00
/// The room topic.
Topic,
}
2023-10-06 22:35:27 -07:00
/// An action that operates on a focused room.
2023-01-04 12:51:33 -08:00
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RoomAction {
2023-10-06 22:35:27 -07:00
/// Accept an invitation to join this room.
InviteAccept,
2023-10-06 22:35:27 -07:00
/// Reject an invitation to join this room.
InviteReject,
2023-10-06 22:35:27 -07:00
/// Invite a user to this room.
InviteSend(OwnedUserId),
2023-10-06 22:35:27 -07:00
/// Leave this room.
2023-04-28 16:52:33 -07:00
Leave(bool),
2023-10-06 22:35:27 -07:00
/// Open the members window.
2024-02-27 21:21:05 -08:00
Members(Box<CommandContext>),
2023-10-06 22:35:27 -07:00
/// Set whether a room is a direct message.
SetDirect(bool),
2023-10-06 22:35:27 -07:00
/// Set a room property.
Set(RoomField, String),
2023-10-06 22:35:27 -07:00
/// Unset a room property.
Unset(RoomField),
2023-01-04 12:51:33 -08:00
}
2023-10-06 22:35:27 -07:00
/// An action that sends a message to a room.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SendAction {
2023-10-06 22:35:27 -07:00
/// Send the text in the message bar.
Submit,
2023-10-06 22:35:27 -07:00
/// Send text provided from an external editor.
SubmitFromEditor,
2023-10-06 22:35:27 -07:00
/// Upload a file.
Upload(String),
2023-10-06 22:35:27 -07:00
/// Upload the image data.
UploadImage(usize, usize, Cow<'static, [u8]>),
}
2023-10-06 22:35:27 -07:00
/// An action performed against the user's homeserver.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HomeserverAction {
2023-10-06 22:35:27 -07:00
/// Create a new room with an optional localpart.
CreateRoom(Option<String>, CreateRoomType, CreateRoomFlags),
Logout(String, bool),
}
/// An action performed against the user's room keys.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum KeysAction {
/// Export room keys to a file, encrypted with a passphrase.
Export(String, String),
/// Import room keys from a file, encrypted with a passphrase.
Import(String, String),
}
2023-10-06 22:35:27 -07:00
/// An action that the main program loop should.
///
/// See [the commands module][super::commands] for where these are usually created.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IambAction {
2023-10-06 22:35:27 -07:00
/// Perform an action against the homeserver.
Homeserver(HomeserverAction),
2023-10-06 22:35:27 -07:00
/// Perform an action over room keys.
Keys(KeysAction),
2023-10-06 22:35:27 -07:00
/// Perform an action on the currently selected message.
Message(MessageAction),
2023-10-06 22:35:27 -07:00
2023-10-07 18:24:25 -07:00
/// Open a URL.
OpenLink(String),
2023-10-06 22:35:27 -07:00
/// Perform an action on the currently focused room.
2023-01-04 12:51:33 -08:00
Room(RoomAction),
2023-10-06 22:35:27 -07:00
/// Send a message to the currently focused room.
Send(SendAction),
2023-10-06 22:35:27 -07:00
/// Perform an action for an in-progress verification.
Verify(VerifyAction, String),
2023-10-06 22:35:27 -07:00
/// Request a new verification with the specified user.
VerifyRequest(String),
2023-10-06 22:35:27 -07:00
/// Toggle the focus within the focused room.
ToggleScrollbackFocus,
}
impl IambAction {
/// Indicates whether this action will draw over the screen.
pub fn scribbles(&self) -> bool {
2023-09-12 17:17:29 -07:00
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<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 {
2024-02-27 21:21:05 -08:00
fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::Homeserver(..) => SequenceStatus::Break,
IambAction::Keys(..) => SequenceStatus::Break,
IambAction::Message(..) => SequenceStatus::Break,
2023-01-04 12:51:33 -08:00
IambAction::Room(..) => SequenceStatus::Break,
2023-10-07 18:24:25 -07:00
IambAction::OpenLink(..) => SequenceStatus::Break,
IambAction::Send(..) => SequenceStatus::Break,
IambAction::ToggleScrollbackFocus => SequenceStatus::Break,
IambAction::Verify(..) => SequenceStatus::Break,
IambAction::VerifyRequest(..) => SequenceStatus::Break,
}
}
2024-02-27 21:21:05 -08:00
fn is_last_action(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::Homeserver(..) => SequenceStatus::Atom,
IambAction::Keys(..) => SequenceStatus::Atom,
IambAction::Message(..) => SequenceStatus::Atom,
2023-10-07 18:24:25 -07:00
IambAction::OpenLink(..) => SequenceStatus::Atom,
2023-01-04 12:51:33 -08:00
IambAction::Room(..) => SequenceStatus::Atom,
IambAction::Send(..) => SequenceStatus::Atom,
IambAction::ToggleScrollbackFocus => SequenceStatus::Atom,
IambAction::Verify(..) => SequenceStatus::Atom,
IambAction::VerifyRequest(..) => SequenceStatus::Atom,
}
}
2024-02-27 21:21:05 -08:00
fn is_last_selection(&self, _: &EditContext) -> SequenceStatus {
match self {
IambAction::Homeserver(..) => SequenceStatus::Ignore,
IambAction::Keys(..) => SequenceStatus::Ignore,
IambAction::Message(..) => SequenceStatus::Ignore,
2023-01-04 12:51:33 -08:00
IambAction::Room(..) => SequenceStatus::Ignore,
2023-10-07 18:24:25 -07:00
IambAction::OpenLink(..) => SequenceStatus::Ignore,
IambAction::Send(..) => SequenceStatus::Ignore,
IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore,
IambAction::Verify(..) => SequenceStatus::Ignore,
IambAction::VerifyRequest(..) => SequenceStatus::Ignore,
}
}
2024-02-27 21:21:05 -08:00
fn is_switchable(&self, _: &EditContext) -> bool {
match self {
IambAction::Homeserver(..) => false,
IambAction::Message(..) => false,
2023-01-04 12:51:33 -08:00
IambAction::Room(..) => false,
IambAction::Keys(..) => false,
IambAction::Send(..) => false,
2023-10-07 18:24:25 -07:00
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<IambAction> for ProgramAction {
fn from(act: IambAction) -> Self {
Action::Application(act)
}
}
2023-10-06 22:35:27 -07:00
/// Alias for program actions.
pub type ProgramAction = Action<IambInfo>;
2023-10-06 22:35:27 -07:00
/// Alias for program context.
2024-02-27 21:21:05 -08:00
pub type ProgramContext = EditContext;
2023-10-06 22:35:27 -07:00
/// Alias for program keybindings.
pub type Keybindings = VimMachine<TerminalKey, IambInfo>;
2023-10-06 22:35:27 -07:00
/// Alias for a program command.
2024-02-27 21:21:05 -08:00
pub type ProgramCommand = VimCommand<IambInfo>;
2023-10-06 22:35:27 -07:00
/// Alias for mapped program commands.
2024-02-27 21:21:05 -08:00
pub type ProgramCommands = VimCommandMachine<IambInfo>;
2023-10-06 22:35:27 -07:00
/// Alias for program store.
pub type ProgramStore = Store<IambInfo>;
2023-10-06 22:35:27 -07:00
/// Alias for shared program store.
pub type AsyncProgramStore = Arc<AsyncMutex<ProgramStore>>;
2023-10-06 22:35:27 -07:00
/// Alias for an action result.
pub type IambResult<T> = UIResult<T, IambInfo>;
/// Reaction events for some message.
///
/// The event identifier used as a key here is the ID for the reaction, and not for the message
/// it's reacting to.
pub type MessageReactions = HashMap<OwnedEventId, (String, OwnedUserId)>;
2023-10-06 22:35:27 -07:00
/// Errors encountered during application use.
#[derive(thiserror::Error, Debug)]
pub enum IambError {
2023-10-06 22:35:27 -07:00
/// An invalid user identifier was specified.
#[error("Invalid user identifier: {0}")]
InvalidUserId(String),
2023-10-06 22:35:27 -07:00
/// An invalid verification identifier was specified.
#[error("Invalid verification user/device pair: {0}")]
InvalidVerificationId(String),
2023-10-06 22:35:27 -07:00
/// A failure related to the cryptographic store.
#[error("Cryptographic storage error: {0}")]
CryptoStore(#[from] matrix_sdk::encryption::CryptoStoreError),
#[error("Failed to import room keys: {0}")]
FailedKeyImport(#[from] matrix_sdk::encryption::RoomKeyImportError),
2024-03-02 15:00:29 -08:00
/// A failure related to the cryptographic store.
#[error("Cannot export keys from sled: {0}")]
UpgradeSled(#[from] crate::sled_export::SledMigrationError),
2023-10-06 22:35:27 -07:00
/// An HTTP error.
#[error("HTTP client error: {0}")]
Http(#[from] matrix_sdk::HttpError),
2023-10-06 22:35:27 -07:00
/// A failure from the Matrix client.
#[error("Matrix client error: {0}")]
Matrix(#[from] matrix_sdk::Error),
2023-10-06 22:35:27 -07:00
/// A failure in the sled storage.
#[error("Matrix client storage error: {0}")]
Store(#[from] matrix_sdk::StoreError),
2023-10-06 22:35:27 -07:00
/// A failure during serialization or deserialization.
#[error("Serialization/deserialization error: {0}")]
Serde(#[from] serde_json::Error),
2023-10-06 22:35:27 -07:00
/// A failure due to not having a configured download directory.
#[error("No download directory configured")]
NoDownloadDir,
2023-10-06 22:35:27 -07:00
/// A failure due to not having a message with an attachment selected.
#[error("Selected message does not have any attachments")]
NoAttachment,
2023-10-06 22:35:27 -07:00
/// A failure due to not having a message selected.
#[error("No message currently selected")]
NoSelectedMessage,
2023-10-06 22:35:27 -07:00
/// A failure due to not having a room or space selected.
#[error("Current window is not a room or space")]
NoSelectedRoomOrSpace,
2023-10-06 22:35:27 -07:00
/// A failure due to not having a room selected.
#[error("Current window is not a room")]
NoSelectedRoom,
2023-10-06 22:35:27 -07:00
/// A failure due to not having an outstanding room invitation.
#[error("You do not have a current invitation to this room")]
NotInvited,
2023-10-06 22:35:27 -07:00
/// A failure due to not being a joined room member.
#[error("You need to join the room before you can do that")]
NotJoined,
2023-10-06 22:35:27 -07:00
/// An unknown room was specified.
#[error("Unknown room identifier: {0}")]
UnknownRoom(OwnedRoomId),
2023-10-06 22:35:27 -07:00
/// A failure occurred during verification.
#[error("Verification request error: {0}")]
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
2023-10-06 22:35:27 -07:00
/// A failure related to images.
#[error("Image error: {0}")]
Image(#[from] image::ImageError),
2023-10-06 22:35:27 -07:00
/// A failure to access the system's clipboard.
#[error("Could not use system clipboard data")]
Clipboard,
/// An failure during disk/network/ipc/etc. I/O.
#[error("Input/Output error: {0}")]
IOError(#[from] std::io::Error),
/// A failure while trying to show an image preview.
#[error("Preview error: {0}")]
Preview(String),
}
impl From<IambError> for UIError<IambInfo> {
fn from(err: IambError) -> Self {
UIError::Application(err)
}
}
impl ApplicationError for IambError {}
2023-10-06 22:35:27 -07:00
/// Status for tracking how much room scrollback we've fetched.
#[derive(Default)]
pub enum RoomFetchStatus {
2023-10-06 22:35:27 -07:00
/// Room history has been completely fetched.
Done,
2023-10-06 22:35:27 -07:00
/// More room history can be fetched.
HaveMore(String),
2023-10-06 22:35:27 -07:00
/// We have not yet started fetching history for this room.
#[default]
NotStarted,
}
2023-10-06 22:35:27 -07:00
/// Indicates where an [EventId] lives in the [ChatStore].
pub enum EventLocation {
2023-10-06 22:35:27 -07:00
/// The [EventId] belongs to a message.
2024-03-09 00:47:05 -08:00
///
/// If the first argument is [None], then it's part of the main scrollback. When [Some],
/// it specifies which thread it's in reply to.
Message(Option<OwnedEventId>, MessageKey),
2023-10-06 22:35:27 -07:00
/// The [EventId] belongs to a reaction to the given event.
Reaction(OwnedEventId),
}
impl EventLocation {
fn to_message_key(&self) -> Option<&MessageKey> {
2024-03-09 00:47:05 -08:00
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()
}
}
2023-10-06 22:35:27 -07:00
/// Information about room's the user's joined.
#[derive(Default)]
pub struct RoomInfo {
/// The display name for this room.
pub name: Option<String>,
/// The tags placed on this room.
pub tags: Option<Tags>,
2023-01-19 16:05:02 -08:00
/// A map of event IDs to where they are stored in this struct.
pub keys: HashMap<OwnedEventId, EventLocation>,
/// The messages loaded for this room.
messages: Messages,
2023-01-19 16:05:02 -08:00
/// A map of read markers to display on different events.
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`],
/// 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<OwnedUserId, OwnedEventId>,
/// A map of message identifiers to a map of reaction events.
pub reactions: HashMap<OwnedEventId, MessageReactions>,
2024-03-09 00:47:05 -08:00
/// A map of message identifiers to thread replies.
threads: HashMap<OwnedEventId, Messages>,
2024-03-09 00:47:05 -08:00
/// 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,
/// The time that we last fetched scrollback for this room.
pub fetch_last: Option<Instant>,
/// Users currently typing in this room, and when we received notification of them doing so.
pub users_typing: Option<(Instant, Vec<OwnedUserId>)>,
/// The display names for users in this room.
pub display_names: HashMap<OwnedUserId, String>,
/// The last time the room was rendered, used to detect if it is currently open.
pub draw_last: Option<Instant>,
}
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).or_default()
} else {
&mut self.messages
}
}
/// Get the event for the last message in a thread (or the thread root if there are no
/// in-thread replies yet).
///
/// This returns `None` if the event identifier isn't in the room.
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
}
}
2023-10-06 22:35:27 -07:00
/// Get the reactions and their counts for a message.
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();
for (key, _) in reacts.values() {
let count = counts.entry(key.as_str()).or_default();
*count += 1;
}
let mut reactions = counts.into_iter().collect::<Vec<_>>();
reactions.sort();
reactions
} else {
vec![]
}
}
2023-10-06 22:35:27 -07:00
/// Map an event identifier to its [MessageKey].
pub fn get_message_key(&self, event_id: &EventId) -> Option<&MessageKey> {
self.keys.get(event_id)?.to_message_key()
}
2023-10-06 22:35:27 -07:00
/// Get an event for an identifier.
2023-01-19 16:05:02 -08:00
pub fn get_event(&self, event_id: &EventId) -> Option<&Message> {
self.messages.get(self.get_message_key(event_id)?)
}
/// Get an event for an identifier as mutable.
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::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);
},
}
}
2023-10-06 22:35:27 -07:00
/// Insert a reaction to a message.
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;
},
}
2023-01-19 16:05:02 -08:00
}
2023-10-06 22:35:27 -07:00
/// Insert an edit.
2024-03-02 15:00:29 -08:00
pub fn insert_edit(&mut self, msg: Replacement<RoomMessageEventContentWithoutRelation>) {
2023-01-19 16:05:02 -08:00
let event_id = msg.event_id;
2024-03-02 15:00:29 -08:00
let new_msgtype = msg.new_content;
2023-01-19 16:05:02 -08:00
2024-03-09 00:47:05 -08:00
let Some(EventLocation::Message(thread, key)) = self.keys.get(&event_id) else {
2023-01-19 16:05:02 -08:00
return;
};
2024-03-09 00:47:05 -08:00
let source = if let Some(thread) = thread {
self.threads.entry(thread.clone()).or_default()
2023-01-19 16:05:02 -08:00
} else {
2024-03-09 00:47:05 -08:00
&mut self.messages
};
let Some(msg) = source.get_mut(key) else {
2023-01-19 16:05:02 -08:00
return;
};
match &mut msg.event {
MessageEvent::Original(orig) => {
2024-03-02 15:00:29 -08:00
orig.content.apply_replacement(new_msgtype);
2023-01-19 16:05:02 -08:00
},
MessageEvent::Local(_, content) => {
2024-03-02 15:00:29 -08:00
content.apply_replacement(new_msgtype);
2023-01-19 16:05:02 -08:00
},
MessageEvent::Redacted(_) |
MessageEvent::EncryptedOriginal(_) |
MessageEvent::EncryptedRedacted(_) => {
2023-01-19 16:05:02 -08:00
return;
},
}
msg.html = msg.event.html();
2023-01-19 16:05:02 -08:00
}
/// 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.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) => UnreadInfo { unread: false, latest: Some(*ts) },
(None, _) => UnreadInfo::default(),
}
}
/// 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());
2024-03-09 00:47:05 -08:00
self.keys.insert(event_id, EventLocation::Message(None, key.clone()));
self.messages.insert(key, msg.into());
}
2023-10-06 22:35:27 -07:00
/// Insert a new message.
2023-01-19 16:05:02 -08:00
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());
2024-03-09 00:47:05 -08:00
let loc = EventLocation::Message(None, key.clone());
self.keys.insert(event_id, loc);
self.messages.insert_message(key, msg);
}
2023-01-19 16:05:02 -08:00
2024-03-09 00:47:05 -08:00
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_default();
let loc = EventLocation::Message(Some(thread_root), key.clone());
self.keys.insert(event_id, loc);
replies.insert_message(key, msg);
2023-01-19 16:05:02 -08:00
}
2023-10-06 22:35:27 -07:00
/// Insert a new message event.
2023-01-19 16:05:02 -08:00
pub fn insert(&mut self, msg: RoomMessageEvent) {
match msg {
RoomMessageEvent::Original(OriginalRoomMessageEvent {
2024-03-09 00:47:05 -08:00
content: RoomMessageEventContent { relates_to: Some(ref relates_to), .. },
2023-01-19 16:05:02 -08:00
..
2024-03-09 00:47:05 -08:00
}) => {
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),
}
},
2023-01-19 16:05:02 -08:00
_ => self.insert_message(msg),
}
}
/// Insert a new message event, and spawn a task for image-preview if it has an image
/// attachment.
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(),
)
}
}
}
2023-10-06 22:35:27 -07:00
/// Indicates whether we've recently fetched scrollback for this room.
pub fn recently_fetched(&self) -> bool {
self.fetch_last.map_or(false, |i| i.elapsed() < ROOM_FETCH_DEBOUNCE)
}
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() {
self.event_receipts.remove(old_event_id);
}
None
}
pub fn set_receipt(&mut self, user_id: OwnedUserId, event_id: OwnedEventId) {
self.clear_receipt(&user_id);
self.event_receipts
.entry(event_id.clone())
.or_default()
.insert(user_id.clone());
self.user_receipts.insert(user_id, event_id);
}
pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> {
self.user_receipts.get(user_id)
}
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..."),
}
}
2023-10-06 22:35:27 -07:00
/// Update typing information for this room.
pub fn set_typing(&mut self, user_ids: Vec<OwnedUserId>) {
self.users_typing = (Instant::now(), user_ids).into();
}
2023-10-06 22:35:27 -07:00
/// Create a [Rect] that displays what users are typing.
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 area;
}
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;
}
/// Checks if a given user has reacted with the given emoji on the given event
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
}
}
}
2023-10-06 22:35:27 -07:00
/// Generate a [CompletionMap] for Emoji shortcodes.
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_termios() {
Ok(picker) => picker,
Err(e) => {
tracing::error!("Failed to setup image previews: {e}");
return None;
},
};
if let Some(protocol_type) = protocol_type {
picker.protocol_type = protocol_type;
} else {
picker.guess_protocol();
}
Some(picker)
}
/// Windows cannot guess the right protocol, and always needs type and font_size.
#[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
{
// User forced type and font_size: use that.
let mut picker = Picker::new(font_size);
picker.protocol_type = protocol_type;
Some(picker)
} else {
// Guess, but use type if forced.
picker_from_termios(image_preview_protocol.and_then(|p| p.r#type))
}
}
2023-10-06 22:35:27 -07:00
/// Information gathered during server syncs about joined rooms.
2023-07-05 15:25:42 -07:00
#[derive(Default)]
pub struct SyncInfo {
2023-10-06 22:35:27 -07:00
/// Spaces that the user is a member of.
pub spaces: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
2023-10-06 22:35:27 -07:00
/// Rooms that the user is a member of.
2023-07-05 15:25:42 -07:00
pub rooms: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
2023-10-06 22:35:27 -07:00
/// DMs that the user is a member of.
2023-07-05 15:25:42 -07:00
pub dms: Vec<Arc<(MatrixRoom, Option<Tags>)>>,
}
bitflags::bitflags! {
/// Load-needs
#[derive(Debug, Default, PartialEq)]
pub struct Need: u32 {
const EMPTY = 0b00000000;
const MESSAGES = 0b00000001;
const MEMBERS = 0b00000010;
}
}
/// Things that need loading for different rooms.
#[derive(Default)]
pub struct RoomNeeds {
needs: HashMap<OwnedRoomId, Need>,
}
impl RoomNeeds {
/// Mark a room for needing something to be loaded.
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()
}
}
2023-10-06 22:35:27 -07:00
/// The main application state.
pub struct ChatStore {
2023-10-06 22:35:27 -07:00
/// `:`-commands
pub cmds: ProgramCommands,
2023-10-06 22:35:27 -07:00
/// Handle for communicating w/ the worker thread.
pub worker: Requester,
2023-10-06 22:35:27 -07:00
/// Map of joined rooms.
pub rooms: CompletionMap<OwnedRoomId, RoomInfo>,
2023-10-06 22:35:27 -07:00
/// Map of room names.
pub names: CompletionMap<String, OwnedRoomId>,
2023-10-06 22:35:27 -07:00
/// Presence information for other users.
pub presences: CompletionMap<OwnedUserId, PresenceState>,
2023-10-06 22:35:27 -07:00
/// In-progress and completed verifications.
pub verifications: HashMap<String, SasVerification>,
2023-10-06 22:35:27 -07:00
/// Settings for the current profile loaded from config file.
pub settings: ApplicationSettings,
2023-10-06 22:35:27 -07:00
/// Set of rooms that need more messages loaded in their scrollback.
pub need_load: RoomNeeds,
2023-10-06 22:35:27 -07:00
/// [CompletionMap] of Emoji shortcodes.
pub emojis: CompletionMap<String, &'static Emoji>,
2023-10-06 22:35:27 -07:00
/// Information gathered by the background thread.
2023-07-05 15:25:42 -07:00
pub sync_info: SyncInfo,
/// Image preview "protocol" picker.
pub picker: Option<Picker>,
/// Last draw time, used to match with RoomInfo's draw_last.
pub draw_curr: Option<Instant>,
/// Whether to ring the terminal bell on the next redraw.
pub ring_bell: bool,
}
impl ChatStore {
2023-10-06 22:35:27 -07:00
/// Create a new [ChatStore].
pub fn new(worker: Requester, settings: ApplicationSettings) -> Self {
let picker = picker_from_settings(&settings);
ChatStore {
worker,
settings,
picker,
cmds: crate::commands::setup_commands(),
2023-07-05 15:25:42 -07:00
emojis: emoji_map(),
names: Default::default(),
rooms: Default::default(),
presences: Default::default(),
verifications: Default::default(),
need_load: Default::default(),
2023-07-05 15:25:42 -07:00
sync_info: Default::default(),
draw_curr: None,
ring_bell: false,
}
}
2023-10-06 22:35:27 -07:00
/// Get a joined room.
2024-03-02 15:00:29 -08:00
pub fn get_joined_room(&self, room_id: &RoomId) -> Option<MatrixRoom> {
let Some(room) = self.worker.client.get_room(room_id) else {
return None;
};
if room.state() == MatrixRoomState::Joined {
Some(room)
} else {
None
}
}
2023-10-06 22:35:27 -07:00
/// Get the title for a room.
2023-01-04 12:51:33 -08:00
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())
}
2023-10-06 22:35:27 -07:00
/// Get the [RoomInfo] for a given room identifier.
pub fn get_room_info(&mut self, room_id: OwnedRoomId) -> &mut RoomInfo {
self.rooms.get_or_default(room_id)
}
2023-10-06 22:35:27 -07:00
/// Set the name for a room.
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();
}
2023-10-06 22:35:27 -07:00
/// Insert a new E2EE verification.
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 {}
2023-10-06 22:35:27 -07:00
/// Identified used to track window content.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum IambId {
2024-03-09 00:47:05 -08:00
/// A Matrix room, with an optional thread to show.
Room(OwnedRoomId, Option<OwnedEventId>),
/// The `:dms` window.
DirectList,
/// The `:members` window for a given Matrix room.
2023-01-04 12:51:33 -08:00
MemberList(OwnedRoomId),
/// The `:rooms` window.
RoomList,
/// The `:spaces` window.
SpaceList,
/// The `:verify` window.
VerifyList,
/// The `:welcome` window.
Welcome,
/// The `:chats` window.
ChatList,
}
impl Display for IambId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
2024-03-09 00:47:05 -08:00
IambId::Room(room_id, None) => {
write!(f, "iamb://room/{room_id}")
},
2024-03-09 00:47:05 -08:00
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"),
}
}
}
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)
}
}
2023-10-06 22:35:27 -07:00
/// [serde] visitor for deserializing [IambId].
struct IambIdVisitor;
impl<'de> Visitor<'de> 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"));
};
2024-03-09 00:47:05 -08:00
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(s) => Err(E::custom(format!("{s:?} is not a valid window"))),
None => Err(E::custom("Invalid iamb window URL")),
}
}
}
2023-10-06 22:35:27 -07:00
/// Which part of the room window's UI is focused.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RoomFocus {
2023-10-06 22:35:27 -07:00
/// The scrollback for a room window is focused.
Scrollback,
2023-10-06 22:35:27 -07:00
/// The message bar for a room window is focused.
MessageBar,
}
impl RoomFocus {
2023-10-06 22:35:27 -07:00
/// Whether this is [RoomFocus::Scrollback].
pub fn is_scrollback(&self) -> bool {
matches!(self, RoomFocus::Scrollback)
}
2023-10-06 22:35:27 -07:00
/// Whether this is [RoomFocus::MessageBar].
pub fn is_msgbar(&self) -> bool {
matches!(self, RoomFocus::MessageBar)
}
}
2023-10-06 22:35:27 -07:00
/// Identifiers used to track where a mark was placed.
///
/// While this is the "buffer identifier" for the mark,
/// not all of these are necessarily actual buffers.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum IambBufferId {
2023-10-06 22:35:27 -07:00
/// The command bar buffer.
Command(CommandType),
2023-10-06 22:35:27 -07:00
/// The message buffer or a specific message in a room.
2024-03-09 00:47:05 -08:00
Room(OwnedRoomId, Option<OwnedEventId>, RoomFocus),
2023-10-06 22:35:27 -07:00
/// The `:dms` window.
DirectList,
2023-10-06 22:35:27 -07:00
/// The `:members` window for a room.
2023-01-04 12:51:33 -08:00
MemberList(OwnedRoomId),
2023-10-06 22:35:27 -07:00
/// The `:rooms` window.
RoomList,
2023-10-06 22:35:27 -07:00
/// The `:spaces` window.
SpaceList,
2023-10-06 22:35:27 -07:00
/// The `:verify` window.
VerifyList,
2023-10-06 22:35:27 -07:00
/// The buffer for the `:rooms` window.
Welcome,
/// The `:chats` window.
ChatList,
}
impl IambBufferId {
2023-10-06 22:35:27 -07:00
/// Get the identifier for the window that contains this buffer.
pub fn to_window(&self) -> Option<IambId> {
2024-03-09 00:47:05 -08:00
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,
};
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 complete(
text: &EditRope,
cursor: &mut Cursor,
content: &IambBufferId,
store: &mut ProgramStore,
) -> Vec<String> {
match content {
IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store),
IambBufferId::Command(CommandType::Search) => vec![],
2024-03-09 00:47:05 -08:00
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![],
}
}
fn content_of_command(ct: CommandType) -> IambBufferId {
IambBufferId::Command(ct)
}
}
2023-10-06 22:35:27 -07:00
/// Tab completion for user IDs.
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()
.map(|i| i.to_string())
.collect()
}
2023-10-06 22:35:27 -07:00
/// Tab completion within the message bar.
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);
let id = Cow::from(&id);
match id.chars().next() {
// Complete room aliases.
Some('#') => {
return store.application.names.complete(id.as_ref());
},
// Complete room identifiers.
Some('!') => {
return store
.application
.rooms
.complete(id.as_ref())
.into_iter()
.map(|i| i.to_string())
.collect();
},
// Complete Emoji shortcodes.
Some(':') => {
let list = store.application.emojis.complete(&id[1..]);
let iter = list.into_iter().take(200).map(|s| format!(":{}:", s));
return iter.collect();
},
// Complete usernames for @ and empty strings.
Some('@') | None => {
return store
.application
.presences
.complete(id.as_ref())
.into_iter()
.map(|i| i.to_string())
.collect();
},
// Unknown sigil.
Some(_) => return vec![],
}
}
2023-10-06 22:35:27 -07:00
/// Tab completion for Matrix identifiers (usernames, room aliases, etc.)
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.application.names.complete(id.as_ref());
if !list.is_empty() {
return list;
}
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()
.map(|i| i.to_string())
.collect()
}
2023-10-06 22:35:27 -07:00
/// Tab completion for Emoji shortcode names.
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.application.emojis.complete(sc.as_ref())
}
2023-10-06 22:35:27 -07:00
/// Tab completion for command names.
fn complete_cmdname(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
// Complete command name and set cursor position.
let _ = text.get_prefix_word_mut(cursor, &WordStyle::Little);
store.application.cmds.complete_name(desc.command.as_str())
}
2023-10-06 22:35:27 -07:00
/// Tab completion for command arguments.
fn complete_cmdarg(
desc: CommandDescription,
text: &EditRope,
cursor: &mut Cursor,
store: &ProgramStore,
) -> Vec<String> {
let cmd = match store.application.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![],
}
}
2023-10-06 22:35:27 -07:00
/// Tab completion for commands.
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() {
complete_cmdname(desc, text, cursor, store)
} else {
// Complete command argument.
complete_cmdarg(desc, text, cursor, store)
}
},
// Can't parse command text, so return zero completions.
Err(_) => vec![],
}
}
2023-10-06 22:35:27 -07:00
/// Tab completion for the command bar.
fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec<String> {
let eo = text.cursor_to_offset(cursor);
2024-02-27 21:21:05 -08:00
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::*;
2024-02-27 21:21:05 -08:00
use ratatui::style::Color;
#[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(),
];
// Nothing set.
assert_eq!(info.users_typing, None);
assert_eq!(info.get_typing_spans(&settings), Line::from(vec![]));
// Empty typing list.
info.set_typing(users0);
assert!(info.users_typing.is_some());
assert_eq!(info.get_typing_spans(&settings), Line::from(vec![]));
// Single user typing.
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...")
])
);
// Two users 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...")
])
);
// Four users typing.
info.set_typing(users4);
assert!(info.users_typing.is_some());
assert_eq!(info.get_typing_spans(&settings), Line::from("Several people are typing..."));
// Five users typing.
info.set_typing(users5);
assert!(info.users_typing.is_some());
assert_eq!(info.get_typing_spans(&settings), Line::from("Many people are typing..."));
// Test that USER5 gets rendered using the configured color and name.
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 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 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);
}
}