diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..886aa98 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing to fix + +## Building + +You can build `fix` locally by using `cargo build`. + +## Pull Requests + +When making changes to `fix`, please make sure to: + +- Add new tests for fixed bugs and new features whenever possible +- Add new documentation with new features + +If you're adding a large amount of new code, please make sure to look at a test +coverage report and ensure that your tests sufficiently cover your changes. + +You can generate an HTML report with [cargo-tarpaulin] by running: + +``` +% cargo tarpaulin --avoid-cfg-tarpaulin --out html +``` + +## Tests + +You can run the unit tests and documentation tests using `cargo test`. + +[cargo-tarpaulin]: https://github.com/xd009642/tarpaulin diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f00b50d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,135 @@ +[package] +name = "fix" +version = "0.0.11-alpha.1" +edition = "2018" +authors = ["Ulyssa "] +repository = "https://github.com/ulyssa/fix" +homepage = "https://fix.chat" +readme = "README.md" +description = "A Matrix chat client that uses Vim keybindings" +license = "Apache-2.0" +exclude = [".github", "CONTRIBUTING.md"] +keywords = ["matrix", "chat", "tui", "vim"] +categories = ["command-line-utilities"] +rust-version = "1.74" +build = "build.rs" + +[features] +default = ["bundled", "desktop"] +bundled = ["matrix-sdk/bundled-sqlite", "rustls-tls"] +desktop = ["dep:notify-rust", "modalkit/clipboard"] +native-tls = ["matrix-sdk/native-tls"] +rustls-tls = ["matrix-sdk/rustls-tls"] + +[build-dependencies.vergen] +version = "8" +default-features = false +features = ["build", "git", "gitcl",] + +[dependencies] +anyhow = "1.0" +bitflags = "^2.3" +chrono = "0.4" +clap = {version = "~4.3", features = ["derive"]} +css-color-parser = "0.1.2" +dirs = "4.0.0" +emojis = "0.5" +futures = "0.3" +gethostname = "0.4.1" +html5ever = "0.26.0" +image = "0.24.5" +libc = "0.2" +markup5ever_rcdom = "0.2.0" +mime = "^0.3.16" +mime_guess = "^2.0.4" +nom = "7.0.0" +open = "3.2.0" +rand = "0.8.5" +ratatui = "0.26" +ratatui-image = { version = "=1.0.0", features = ["serde"] } +regex = "^1.5" +rpassword = "^7.2" +serde = "^1.0" +serde_json = "^1.0" +sled = "0.34.7" +temp-dir = "0.1.12" +thiserror = "^1.0.37" +toml = "^0.8.12" +tracing = "~0.1.36" +tracing-appender = "~0.2.2" +tracing-subscriber = "0.3.16" +unicode-segmentation = "^1.7" +unicode-width = "0.1.10" +url = {version = "^2.2.2", features = ["serde"]} +edit = "0.1.4" +humansize = "2.0.0" + +[dependencies.comrak] +version = "0.22.0" +default-features = false +features = ["shortcodes"] + +[dependencies.notify-rust] +version = "~4.10.0" +default-features = false +features = ["zbus", "serde"] +optional = true + +[dependencies.modalkit] +version = "0.0.20" +default-features = false +#git = "https://github.com/ulyssa/modalkit" +#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272" + +[dependencies.modalkit-ratatui] +version = "0.0.20" +#git = "https://github.com/ulyssa/modalkit" +#rev = "24f3ec11c7f634005a27b26878d0fbbdcc08f272" + +[dependencies.matrix-sdk] +version = "0.7.1" +default-features = false +features = ["e2e-encryption", "sqlite", "sso-login"] + +[dependencies.tokio] +version = "1.24.1" +features = ["macros", "net", "rt-multi-thread", "sync", "time"] + +[dev-dependencies] +lazy_static = "1.4.0" +pretty_assertions = "1.4.0" + +[profile.release-lto] +inherits = "release" +incremental = false +lto = true + +[package.metadata.deb] +section = "net" +license-file = ["LICENSE", "0"] +assets = [ + # Binary: + ["target/release/fix", "usr/bin/fix", "755"], + # Manual pages: + ["docs/fix.1", "usr/share/man/man1/fix.1", "644"], + ["docs/fix.5", "usr/share/man/man5/fix.5", "644"], + # Other assets: + ["fix.desktop", "usr/share/applications/fix.desktop", "644"], + ["config.example.toml", "usr/share/fix/config.example.toml", "644"], + ["docs/fix.svg", "usr/share/icons/hicolor/scalable/apps/fix.svg", "644"], + ["docs/fix.metainfo.xml", "usr/share/metainfo/fix.metainfo.xml", "644"], +] + +[package.metadata.generate-rpm] +assets = [ + # Binary: + { source = "target/release/fix", dest = "/usr/bin/fix", mode = "755" }, + # Manual pages: + { source = "docs/fix.1", dest = "/usr/share/man/man1/fix.1", mode = "644" }, + { source = "docs/fix.5", dest = "/usr/share/man/man5/fix.5", mode = "644" }, + # Other assets: + { source = "fix.desktop", dest = "/usr/share/applications/fix.desktop", mode = "644" }, + { source = "config.example.toml", dest = "/usr/share/fix/config.example.toml", mode = "644"}, + { source = "docs/fix.svg", dest = "/usr/share/icons/hicolor/scalable/apps/fix.svg", mode = "644"}, + { source = "docs/fix.metainfo.xml", dest = "/usr/share/metainfo/fix.metainfo.xml", mode = "644"}, +] diff --git "a/Katya \302\256 \360\237\221\275 AI \360\237\247\240 REChain \302\256\357\270\217 \360\237\252\220 Blockchain Node Network Protocol.md" "b/Katya \302\256 \360\237\221\275 AI \360\237\247\240 REChain \302\256\357\270\217 \360\237\252\220 Blockchain Node Network Protocol.md" new file mode 100644 index 0000000..ebf92ab --- /dev/null +++ "b/Katya \302\256 \360\237\221\275 AI \360\237\247\240 REChain \302\256\357\270\217 \360\237\252\220 Blockchain Node Network Protocol.md" @@ -0,0 +1,391 @@ +Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol is a decentralized messaging protocol that allows users to communicate and collaborate across multiple platforms and devices. Its features include real-time messaging, encryption, server federation, and openness. We will rename it to REChain Basis and take a look at its structure and operating principles. + +REChain Basis Basic Principles +REChain Basis is a protocol for creating decentralized and secure communication networks that enable instant messaging, video calling, file sharing, and other forms of digital interaction. + +1. Federation +REChain Basis operates on the principle of federation, meaning that the system consists of independent servers (or nodes) that can communicate with each other. These servers may belong to different organizations or individuals, but they all follow the same set of standards, allowing them to exchange data. + +Diagram 1: REChain Basis Federation Structure + ++------------------+ +------------------+ +| Server A |<------>| Server B | +| (User's chat) | | (User's chat) | ++------------------+ +------------------+ +| | ++-------------------+ +-------------------+ +| Server C |<------>| Server D | +|(Group messages) | | (Group messages) | ++-------------------+ +-------------------+ +2. Decentralized Messaging +REChain Basis allows messaging between users registered on different servers. This means that messages can be delivered from one server to another, even if they belong to different organizations or users. + +Diagram 2: Messaging across servers + ++------------------+ +------------------+ +------------------+ +| User 1 |----->| Server 1 |----->| User 2 | ++------------------+ +------------------+ +------------------+ +| | ++------------------+ +------------------+ +| Server 2 |<-----| Server 3 | +| (Processing requests)| | (Processing requests)| ++------------------+ +------------------+ +3. End-to-end encryption +One of the key features of REChain Basis is its support for end-to-end encryption (E2EE). This ensures that only the sender and receiver can read messages. Even if the servers through which messages pass are hacked, attackers will not be able to access the contents of the messages. + +Diagram 3: Data encryption + ++------------------+ +--------------------+ +------------------+ +| User 1 | ---> | Server 1 | ---> | User 2 | +| (Encrypts data) | | (Transmits encrypted data) | (Decrypts data) | ++------------------+ +--------------------+ +------------------+ +4. Data Synchronization and Storage +Each server in the REChain Basis network synchronizes its data with other servers via open APIs. This allows users on different servers to see shared chats and messages, even if they are registered on different platforms. + +Diagram 4: Data Synchronization between Servers + ++--------------------+ +--------------------+ +--------------------+ +| Server 1 |<---->| Server 2 |<---->| Server 3 | +| (User Data) | (Group Chats) | (Message History)| ++--------------------+ +--------------------+ +--------------------+ +5. Modular Architecture +The REChain Basis protocol has a modular architecture, which allows adding new features such as video calls, file sharing, user management, etc. Each of these modules can be deployed or configured separately without affecting the operation of other parts of the system. + +Diagram 5: Modular Protocol Structure + ++------------------+ +| Messaging Module | +| (Chat, Text) | ++------------------+ +| ++------------------+ +| Security Module | +| (Encryption, Authorization)| ++------------------+ +| ++------------------+ +| Media Module | +| (Video, Audio) | ++------------------+ +Protocol and API +The REChain Basis protocol includes several components that enable interaction between different services and devices: + +HTTP API for exchanging messages between servers. +WebSocket API for real-time messaging. +REST API for managing user accounts, connections, and other data. +Each of these APIs allows services to interact, send requests, and pass information across a federation of servers. + +6. Interoperability +REChain Basis is designed to be compatible with other protocols and networks. This allows it to be integrated with existing messaging platforms and services, expanding its capabilities and providing greater flexibility. + +REChain Basis Application Examples +Chat programs: REChain Basis can be used to create chat programs with high security and decentralization requirements, for example for organizations that want to ensure the privacy of their employees. + +Group chats and conferences: With support for video calls and file sharing, REChain Basis is ideal for organizing remote workflows and group video conferences. + +Internet of Things (IoT): REChain Basis can be used in IoT networks, where devices need to exchange data securely with each other and with the server. + +Conclusion +REChain Basis is a powerful and flexible protocol for decentralized messaging that has many advantages, including a federated architecture, support for end-to-end encryption, and a modular structure. These features make it a great choice for building secure and private communication services that can operate in a variety of settings and scales. + +You said: +make an even lower-level talk! +ChatGPT said: +For a lower-level explanation of the REChain Basis protocol (which is a rebranded variant of the Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol), we will dive into the details of how it works “under the hood”. This will include its core components, messaging mechanisms, and the internal processes that enable decentralized and secure communication. + +1. The Main Components of the REChain Basis System +The REChain Basis protocol consists of several key elements: + +Servers (or Nodes): Each server in the REChain Basis network is a standalone application that manages user data, processes requests, and synchronizes information with other servers. Servers can be public or private, and can exchange data through API protocols. +Clients: User applications that interact with servers. Clients can be web or mobile applications, or they can be implemented as standalone libraries for integration with other services. +Messaging Protocol: Messages are exchanged between clients and servers using a specific protocol that governs data formats, requests and responses, and message synchronization. +Federation: Servers communicate through a federated network, allowing independent servers to exchange data. +2. Data Formats and Message Syntax +a. JSON Message Format +The REChain Basis protocol uses JSON (JavaScript Object Notation) as a format for data transfer. It is a simple and convenient structure for data serialization, which is widely used in REST and HTTP APIs. + +An example of a JSON message in the REChain Basis protocol might look like this: + +{ +"event_type": "m.room.message", +"sender": "@alice:example.com", +"content": { +"msgtype": "m.text", +"body": "Hello, world!" +}, +"room_id": "!room1234:example.com" +} +Here: + +"event_type" specifies the type of event (in this case, a chat message). +"sender" is the sender's ID. +"content" contains the message content (text). +"room_id" is a unique identifier for the room in which the messages are exchanged. +b. Message and room IDs +Each message in the network has a unique ID, which allows servers and clients to accurately identify when and who sent a particular message. IDs can be represented as strings, for example, !room1234:example.com. + +Each room (group chat or channel) also has a unique ID, which allows users and servers to synchronize messages and events between different participants. + +3. Messaging Algorithms and Synchronization +Messaging between clients and servers is done according to a predefined algorithm. Here's how it works: + +a. Publishing and delivering messages +When a user sends a message through a client, the client goes through several steps: + +Client creates a request: The client creates a JSON message, which is a text message. +Send to server: The client sends a request to the server via HTTP or WebSocket API. This request is usually sent in POST format with the message data. +Server processes the request: The server accepts the request, stores it in its database, performs any necessary checks (for example, encryption), and then sends it to other users in the room (if it is a group message). +Delivery to other users: The message is synchronized and distributed to all other participants in the room via federation. Other servers can process this message, deliver it to the user, and synchronize it with their local message database. +b. Synchronization Mechanism +Servers in the REChain Basis network must support synchronization of messages between waiting for themselves. To do this, they use backfill and re-sync mechanisms. + +Backfill: When a new server or client joins a room, it can request old messages that were sent before it joined. Servers in the federation pass these messages along so that the client can fully reconstruct the chat history. +Re-sync: This mechanism is used when the data on the server has become incomplete or corrupted. The server initiates a request to re-synchronize the data with other servers. +4. End-to-end encryption +One of the most important aspects of the security of the REChain Basis protocol is end-to-end encryption (E2EE). This means that all data transmitted between users is encrypted in such a way that only the sender and receiver can decrypt it. + +a. Encryption algorithm +REChain Basis uses Megolm, a cryptographic algorithm for group chats based on symmetric encryption. For each message, a unique key is generated and the message is encrypted using this key. + +Key Generation: Each group member generates a public/private key pair. These keys are used to encrypt and decrypt messages. +Message Encryption: Messages sent to the room are encrypted on the client before being sent to the server. Only those members who have the corresponding private key can decrypt the message. +Message Decryption: When a message arrives on the client, it is decrypted locally, not on the server. +b. Key Exchange Process +In order to successfully decrypt messages, the members of the room must exchange encryption keys. When a new user joins the group, they must receive keys to decrypt old messages (using the key exchange mechanism). + +5. Federation Process and Server Interaction +The federation process is how REChain Basis servers exchange messages with each other. Each server maintains its own local database and synchronizes changes with other servers. + +Requests and Responses: When one server receives a request from another server, it sends a corresponding response via HTTP API. These requests can include data about users, chats, and messages. +Message Publishing: When a server receives a message from a client, it not only stores it in its database, but also sends it to all other servers in the federation that serve the participants of the same room. +Partial Synchronization: When a server does not have a complete set of data for a particular room, it can request the missing messages from other servers in the federation. +Conclusion +REChain Basis is a powerful and flexible decentralized messaging protocol with a deep focus on security and privacy. Its core principles, such as federation, encryption, and message synchronization, make it suitable for building secure and scalable communication platforms. + +In this low-level analysis, we have covered the key components and mechanisms of the REChain Basis protocol, including data handling, messaging algorithms, and security methods that make this protocol unique. + +You said: +Add clients for this protocol here, namely https://github.com/sorydima/REChain- and https://github.com/sorydima/Katya- +ChatGPT said: +6. Clients for REChain Basis +There are several client applications for working with the REChain Basis protocol (renamed from Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol) that provide user interaction with the server, messaging, and other functions. Two such clients, developed by the author on GitHub, are REChain and Katya. Let's take a closer look at how these clients interact with the protocol and what they do at a low level. + +a. REChain Client +REChain is a client for the REChain Basis protocol that provides basic communication functions such as sending and receiving messages, working with groups, and supporting server federation. The client implements work with REST API, WebSocket API and other low-level interaction mechanisms. + +The REChain repository is available at the following address: +REChain GitHub repository + +The main features of REChain: + +Connection to the server: The client connects to the server via API, using REST or WebSocket to ensure synchronous work with the server and receive messages in real time. +Authorization: The client handles the process of registration and authorization of users using security tokens. This is an important component for implementing security and protecting user data. +Message processing: REChain implements sending and receiving messages in JSON format, supports text and multimedia messages. The client can support both private chats and group chats. +Server federation: Interaction with other federation servers is carried out via API. This mechanism supports data synchronization between the server and the client. +Message encryption: REChain implements basic data encryption on client side, which ensures the privacy of messages. +At the implementation level, the client uses the core components of the REChain Basis protocol to exchange messages and process requests. An example of interaction with the API to send a message might be as follows: + +import requests + +url = 'https://example.com/_Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol/client/r0/rooms/!roomid:example.com/send/m.room.message' +headers = {'Authorization': 'Bearer '} +data = { +"msgtype": "m.text", +"body": "Hello, world!" +} + +response = requests.post(url, json=data, headers=headers) +print(response.status_code) +Here: + +The client sends a POST request to the server with a message. +The server processes the request, records the message, and synchronizes it with other users in the room. +b. Katya Client +Katya is another client for the REChain Basis protocol that is designed to be used in various platforms such as mobile devices and web applications. Katya provides additional features such as video calling support, file sharing, and improved UI rendering. + +The Katya repository is available at the following address: +Katya GitHub repository + +The main features of Katya: + +Multimedia messaging support: Katya is not limited to text messages. It supports sending and receiving media files such as images, videos, and audio files. +Video calls and voice messages: Katya can handle video calls, implementing P2P connections between users. Interaction with video streams is managed via WebRTC. +Interface with improved visualization: Katya client is designed with an emphasis on user convenience, with a modern interface for messaging and room management. +Integration with other services: Katya supports integration with various third-party services to expand functionality, such as cloud storage for file sharing. +Interaction with the REChain Basis protocol in Katya is carried out via the same APIs (REST, WebSocket, etc.), but with more complex processing of media data and interface solutions. An example of code for sending messages and receiving responses in Katya might look like this: + +import websocket +import json + +# WebSocket connection to the server +def on_message(ws, message): +print(f"Received message: {message}") + +def on_error(ws, error): +print(f"Error: {error}") + +def on_close(ws, close_status_code, close_msg): +print("Closed connection") + +def on_open(ws): +message = { +"msgtype": "m.text", +"body": "Hello from Katya!" +} +ws.send(json.dumps(message)) + +# Establishing a WebSocket connection +ws = websocket.WebSocketApp( +"wss://example.com/_Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol/client/r0/rooms/!roomid:example.com/messages", +on_message=on_message, +on_error=on_error, +on_close=on_close +) +ws.on_open = on_open +ws.run_forever() +Here: + +Katya uses WebSocket to exchange messages in real time. +When the connection is established, Katya sends a text message to the room. +All messages are received and processed in the on_message function. +c. Common Features of REChain and Katya Clients +Both REChain and Katya use similar principles of operation, but with different levels of functionality and user interface: + +Messaging: Both clients process messages via REST or WebSocket API. +Encryption: Messages in both clients are encrypted on the client side to ensure privacy. +Group Chat and Video Call Support: Katya supports more advanced features such as video calls, while REChain focuses on basic text messaging and group chats. +Server Federation: Both clients can communicate with multiple servers, allowing users to communicate across different platforms. +Conclusion +REChain and Katya clients support the REChain Basis protocol, allowing users to send messages, share media, support video calls, and integrate additional features. Both clients use common principles such as server federation and encryption, but Katya provides more advanced features including multimedia messaging and an improved user interface. + +These clients are good examples of how REChain Basis can be effectively used to build scalable and secure communication applications. + +You said: +Give me even more detailed pictures of the federation structure, messaging, and fully offline operation without the Internet +ChatGPT said: +Let's dive into a detailed description of the federation structure, messaging, and offline operation of the REChain Basis protocol (renamed from Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol), and, for clarity, add diagrams to help visualize these processes. + +1. Federation Structure in REChain Basis +Federation in the Context of REChain n Basis means that multiple servers (or nodes) can communicate with each other, exchange messages and synchronize data, but each server remains autonomous and can be managed separately. This allows for a distributed network where each participant can choose the server on which to store their data, but still participate in common chats with users of other servers. + +Diagram 1: Federation Structure + ++------------------+ +------------------+ +------------------+ +| Server A |<------>| Server B |<------>| Server C | +| (User's chat) | | (User's chat) | | (User's chat) | ++------------------+ +------------------+ +------------------+ +| | | ++-------------------+ +-------------------+ +-------------------+ +| Server D |<---->| Server E |<---->| Server F | +| (Group chat) | | (Group chat) | | (Group chat) | ++-------------------+ +-------------------+ +-------------------+ +| | | ++------------+ +-------------+ +-------------+ +| Server G |<----------->| Server H |<------------>| Server I | +|(Private messages)| | (Private messages)| | (Private messages)| ++------------+ +-------------+ +-------------+ +In this diagram: + +Each server (A, B, C, etc.) manages individual users and stores their data. +Servers can join together to form group chats or sync messages between themselves. +Servers can communicate with any other server, even if they are located in different parts of the network. +Each server has a local database that is synchronized with other servers to ensure that messages and data are up-to-date. +This way, even if one server in the network goes down, the other servers will continue to function, maintaining the independence and stability of the system. + +2. Messaging through Federation +In the REChain Basis federated network, messaging is done through multiple servers. When a user sends a message, it passes through multiple servers and is routed to the recipient, even if the recipient is registered on a different server. + +Diagram 2: Messaging in a Federation + ++-------------------+ +-------------------+ +-------------------+ +| Client 1 | -----> | Server 1 | -----> | Server 2 | +| (Sending) | |(Processing a request)| |(Routing messages)| ++-------------------+ +-------------------+ +-------------------+ +| ++-------------------+ +| Server 3 | +| (Processing the message) | ++-------------------+ +| ++-------------------+ +| Client 2 | +| (Recipient) | ++-------------------+ +Sending a message: + +Client 1 creates and sends a message (e.g. text) via the API to its local server (Server 1). +Routing through servers: + +Server 1 receives the request and decides that the message should be delivered to other servers (e.g. via an HTTP request or WebSocket). +The message is forwarded through federation servers (e.g. via Server 2 and Server 3), which can be in different parts of the world. +Delivery to the end client: + +Server 3, having received the message, delivers it to Client 2, which is located on a different server. +Synchronization and processing: + +The servers synchronize their databases so that all users in the room receive up-to-date information. + +In this way, each message is transmitted through several servers until it reaches the recipient. + +3. Offline Operation Without Internet +Although the REChain Basis protocol is designed to work over the Internet, it is possible to organize local autonomous operation of the network without connecting to the global network. In this case, servers and clients can exchange data within a local area network (LAN), using the same synchronization and routing mechanisms, but without the need to connect to external servers. + +a. Local Federation +In an autonomous network, all servers can work without the Internet and synchronize with each other over a local network (e.g., Wi-Fi or Ethernet). This can be useful in isolated environments (e.g., in an enterprise or in military applications). + +Diagram 3: Autonomous Federation in a Local Area Network + ++------ + +------------+ +| Local Area Network | +| (Wi-Fi/Ethernet) | ++------------------+ +| ++-----------------+ +-----------------+ +-----------------+ +| Server 1 |<->| Server 2 |<->| Server 3 | +| (Chats, Database) | | (Chats, Database) | | (Chats, Database) | ++-----------------+ +-----------------+ +-----------------+ +| | | ++--------------+ +--------------+ +-----------------+ +| Client 1 | | Client 2 | | Client 3 | +| (Sending) | | (Receiving) | | (Receiving) | ++---------------+ +--------------+ +-----------------+ +Here: + +All servers are located in a local network, which allows data exchange without Internet access. +Clients can send and receive messages between themselves, synchronizing them through local servers. +Even if there is no Internet connection, the network continues to operate autonomously, ensuring communication between participants. +b. Messaging in a Local Network +If all devices are connected to one local network, messaging is carried out in the same way as in a global network, but without the need to send data over the Internet. This makes the system especially convenient for work in conditions of limited or no Internet. + +Diagram 4: Messaging in a Local Network + ++-------------------+ +-------------------+ +-------------------+ +| Client 1 | -----> | Server 1 | -----> | Server 2 | +| (Sending) | |(Request processing)| |(Message routing)| ++-------------------+ +-------------------+ +-------------------+ +| ++-------------------+ +| Server 3 | +| (Message processing) | ++-------------------+ +| ++-------------------+ +| Client 2 | +| (Recipient) | ++-------------------+ +In this case: + +All devices can work autonomously within the local network. +If there is no Internet, the servers will continue to route messages and synchronize data, but all operations occur only within the local network, without access to the global network. +4. Availability and Problems of Offline Operation +When the system works autonomously without the Internet, it is important to remember some limitations: + +Message exchange with external users is impossible - if there is no connection to the global network, users will not be able to exchange messages with people outside the local network. +Resources are limited - since the system only works within a local network, the number of servers and clients is limited by the capabilities of this network. +Synchronization of data with other federations is not possible until an Internet connection is established. +However, for certain scenarios (e.g. disasters or closed enterprises), autonomous operation without the Internet can be very useful. + +Conclusion +We have covered the basic principles of REChain Basis in the context of federation, messaging between servers and clients, and the ability to work autonomously without the Internet. All these elements make the protocol flexible and suitable for use in both global networks with an Internet connection and in isolated local networks, ensuring security, synchronization, and resilience of the system. \ No newline at end of file diff --git a/LICENSE b/LICENSE index dff4711..1401506 100644 --- a/LICENSE +++ b/LICENSE @@ -1,28 +1,201 @@ -BSD 3-Clause License - -Copyright (c) 2024, Dmitry Sorokin (@sorydima) - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Ulyssa Mello + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PACKAGING.md b/PACKAGING.md new file mode 100644 index 0000000..12d1bf3 --- /dev/null +++ b/PACKAGING.md @@ -0,0 +1,44 @@ +# Notes For Package Maintainers + +## Linking Against System Packages + +The default Cargo features for __fix__ will bundle SQLite and use [rustls] for +TLS. Package maintainers may want to link against the system's native SQLite +and TLS libraries instead. To do so, you'll want to build without the default +features and specify that it should build with `native-tls`: + +``` +% cargo build --release --no-default-features --features=native-tls +``` + +## Enabling LTO + +Enabling LTO can result in smaller binaries. There is a separate profile to +enable it when building: + +``` +% cargo build --profile release-lto +``` + +Note that this [can fail][ring-lto] in some build environments if both Clang +and GCC are present. + +## Documentation + +In addition to the compiled binary, there are other files in the repo that +you'll want to install as part of a package: + + +| Repository Path | Installed Path (may vary per OS) | +| ----------------------- | ----------------------------------------------- | +| /fix.desktop | /usr/share/applications/fix.desktop | +| /config.example.toml | /usr/share/fix/config.example.toml | +| /docs/fix-256x256.png | /usr/share/icons/hicolor/256x256/apps/fix.png | +| /docs/fix-512x512.png | /usr/share/icons/hicolor/512x512/apps/fix.png | +| /docs/fix.svg | /usr/share/icons/hicolor/scalable/apps/fix.svg | +| /docs/fix.1 | /usr/share/man/man1/fix.1 | +| /docs/fix.5 | /usr/share/man/man5/fix.5 | +| /docs/fix.metainfo.xml | /usr/share/metainfo/fix.metainfo.xml | + +[ring-lto]: https://github.com/briansmith/ring/issues/1444 +[rustls]: https://crates.io/crates/rustls diff --git a/Protocol.md b/Protocol.md new file mode 100644 index 0000000..f028d44 --- /dev/null +++ b/Protocol.md @@ -0,0 +1,394 @@ +Протокол Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol — это децентрализованный протокол для обмена сообщениями, который позволяет пользователям общаться и взаимодействовать через различные платформы и устройства. Его особенности включают в себя поддержку сообщений в реальном времени, шифрование, федерацию серверов и открытость. Мы переименуем его в REChain Basis и рассмотрим его структуру и принципы работы. + +Основные Принципы REChain Basis +REChain Basis — это протокол для создания децентрализованных и безопасных коммуникационных сетей, обеспечивающих мгновенный обмен сообщениями, поддержку видеозвонков, файловых обменов и другие формы цифрового взаимодействия. + +1. Федерация +REChain Basis работает по принципу федерации, что означает, что система состоит из независимых серверов (или узлов), которые могут взаимодействовать друг с другом. Эти серверы могут принадлежать различным организациям или индивидуальным пользователям, но все они следуют единому набору стандартов, что позволяет им обмениваться данными. + +Диаграмма 1: Структура федерации REChain Basis + + +------------------+ +------------------+ + | Server A |<------>| Server B | + | (User's chat) | | (User's chat) | + +------------------+ +------------------+ + | | + +-------------------+ +-------------------+ + | Server C |<------>| Server D | + |(Group messages) | | (Group messages) | + +-------------------+ +-------------------+ +2. Децентрализованный обмен сообщениями +REChain Basis позволяет обмениваться сообщениями между пользователями, зарегистрированными на различных серверах. Это означает, что сообщения могут быть доставлены с одного сервера на другой, даже если они принадлежат разным организациям или пользователям. + +Диаграмма 2: Обмен сообщениями через серверы + ++------------------+ +------------------+ +------------------+ +| Пользователь 1 |----->| Сервер 1 |----->| Пользователь 2 | ++------------------+ +------------------+ +------------------+ + | | + +------------------+ +------------------+ + | Сервер 2 |<-----| Сервер 3 | + | (Обработка запросов)| | (Обработка запросов)| + +------------------+ +------------------+ +3. Энд-ту-энд шифрование +Одной из ключевых особенностей REChain Basis является поддержка энд-ту-энд шифрования (end-to-end encryption, E2EE). Это гарантирует, что только отправитель и получатель могут прочитать сообщения. Даже если серверы, через которые проходят сообщения, будут взломаны, злоумышленники не смогут получить доступ к содержимому сообщений. + +Диаграмма 3: Шифрование данных + ++------------------+ +--------------------+ +------------------+ +| Пользователь 1 | ---> | Сервер 1 | ---> | Пользователь 2 | +| (Шифрует данные) | | (Передает зашифрованные данные) | (Расшифровывает данные) | ++------------------+ +--------------------+ +------------------+ +4. Синхронизация и хранилище данных +Каждый сервер в сети REChain Basis синхронизирует свои данные с другими серверами через открытые API. Это позволяет пользователям на разных серверах видеть общие чаты и сообщения, даже если они зарегистрированы на разных платформах. + +Диаграмма 4: Синхронизация данных между серверами + ++--------------------+ +--------------------+ +--------------------+ +| Сервер 1 |<---->| Сервер 2 |<---->| Сервер 3 | +| (Данные пользователя) | (Групповые чаты) | (История сообщений)| ++--------------------+ +--------------------+ +--------------------+ +5. Модульная архитектура +Протокол REChain Basis обладает модульной архитектурой, что позволяет добавлять новые функции, такие как видеозвонки, обмен файлами, управление пользователями и т. д. Каждый из этих модулей может быть развернут или настроен отдельно, не влияя на работу других частей системы. + +Диаграмма 5: Модульная структура протокола + ++------------------+ +| Модуль сообщений | +| (Чат, текстовые) | ++------------------+ + | ++------------------+ +| Модуль безопасности | +| (Шифрование, Авторизация)| ++------------------+ + | ++------------------+ +| Модуль медиа | +| (Видео, Аудио) | ++------------------+ +Протокол и API +Протокол REChain Basis включает в себя несколько компонентов, которые обеспечивают взаимодействие между различными сервисами и устройствами: + +HTTP API для обмена сообщениями между серверами. +WebSocket API для обмена сообщениями в реальном времени. +REST API для управления учетными записями пользователей, подключениями и другими данными. +Каждый из этих API позволяет сервисам взаимодействовать, отправлять запросы и передавать информацию через федерацию серверов. + +6. Интероперабельность +REChain Basis разработан так, чтобы обеспечивать совместимость с другими протоколами и сетями. Это позволяет интегрировать его с существующими платформами обмена сообщениями и сервисами, расширяя возможности и обеспечивая большую гибкость. + +Примеры применения REChain Basis +Чат-программы: REChain Basis может быть использован для создания чатов с высокими требованиями к безопасности и децентрализации, например, для организаций, которые хотят обеспечить приватность своих сотрудников. + +Групповые чаты и конференции: Благодаря поддержке видеозвонков и обмена файлами, REChain Basis идеально подходит для организации удаленных рабочих процессов и групповых видеоконференций. + +Интернет вещей (IoT): REChain Basis может быть использован в IoT-сетях, где устройства должны безопасно обмениваться данными между собой и с сервером. + +Заключение +REChain Basis — это мощный и гибкий протокол для децентрализованного обмена сообщениями, который обладает множеством преимуществ, включая федеративную архитектуру, поддержку энд-ту-энд шифрования и модульную структуру. Эти особенности делают его отличным выбором для создания безопасных и приватных коммуникационных сервисов, способных работать в различных условиях и масштабах. + + + +Вы сказали: +сделай еще более низкоуровневый доклад! +ChatGPT сказал: +Для более низкоуровневого объяснения протокола REChain Basis (который является переименованным вариантом протокола Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol), мы погрузимся в детали того, как он работает «под капотом». Это будет включать его базовые компоненты, механизмы обмена сообщениями, а также внутренние процессы, которые обеспечивают децентрализованную и безопасную коммуникацию. + +1. Основные компоненты системы REChain Basis +Протокол REChain Basis состоит из нескольких ключевых элементов: + +Сервера (или Узлы): Каждый сервер в сети REChain Basis представляет собой отдельное приложение, которое управляет данными пользователей, обрабатывает запросы и синхронизирует информацию с другими серверами. Серверы могут быть общедоступными или частными, и могут обмениваться данными через протоколы API. +Клиенты: Пользовательские приложения, которые взаимодействуют с серверами. Клиенты могут быть веб- или мобильными приложениями, а также могут быть реализованы как отдельные библиотеки для интеграции с другими сервисами. +Протокол обмена сообщениями: Обмен сообщениями между клиентами и серверами осуществляется по определенному протоколу, который управляет форматами данных, запросами и ответами, а также синхронизацией сообщений. +Федерация: Серверы взаимодействуют через федеративную сеть, обеспечивая возможность обмена данными между независимыми серверами. +2. Форматы данных и синтаксис сообщений +a. JSON-формат сообщений +Протокол REChain Basis использует JSON (JavaScript Object Notation) как формат для передачи данных. Это простая и удобная структура для сериализации данных, которая широко используется в REST и HTTP API. + +Пример JSON-сообщения в протоколе REChain Basis может выглядеть так: + +{ + "event_type": "m.room.message", + "sender": "@alice:example.com", + "content": { + "msgtype": "m.text", + "body": "Привет, мир!" + }, + "room_id": "!room1234:example.com" +} +Здесь: + +"event_type" указывает тип события (в данном случае — сообщение в чате). +"sender" — это идентификатор отправителя. +"content" содержит содержимое сообщения (текст). +"room_id" — это уникальный идентификатор комнаты, в которой происходит обмен сообщениями. +b. ID сообщений и комнат +Каждое сообщение в сети имеет уникальный идентификатор, что позволяет серверам и клиентам точно идентифицировать, когда и кто отправил конкретное сообщение. Идентификаторы могут быть представлены в формате строк, например, !room1234:example.com. + +Каждая комната (групповой чат или канал) также имеет уникальный идентификатор, что позволяет пользователям и серверам синхронизировать сообщения и события между различными участниками. + +3. Алгоритмы обмена сообщениями и синхронизация +Обмен сообщениями между клиентами и серверами осуществляется по заранее определенному алгоритму. Вот как это работает: + +a. Публикация и доставка сообщений +Когда пользователь отправляет сообщение через клиента, клиент выполняет несколько шагов: + +Клиент формирует запрос: Клиент создает JSON-сообщение, которое представляет собой текстовое сообщение. +Отправка на сервер: Клиент отправляет запрос на сервер через HTTP или WebSocket API. Этот запрос обычно отправляется в формате POST с данными сообщения. +Сервер обрабатывает запрос: Сервер принимает запрос, сохраняет его в своей базе данных, выполняет необходимые проверки (например, на шифрование), а затем отправляет его другим пользователям в комнате (если это групповое сообщение). +Доставка другим пользователям: Сообщение синхронизируется и передается всем остальным участникам комнаты через федерацию. Другие серверы могут обработать это сообщение, доставить его пользователю и синхронизировать его с их локальной базой данных сообщений. +b. Механизм синхронизации +Сервера в сети REChain Basis должны поддерживать синхронизацию сообщений между собой. Для этого они используют backfill и re-sync механизмы. + +Backfill: Когда новый сервер или клиент подключается к комнате, он может запросить старые сообщения, которые были отправлены до его присоединения. Серверы в федерации передают эти сообщения, чтобы клиент мог полностью восстановить историю чатов. +Re-sync: Этот механизм используется, когда данные на сервере стали неполными или поврежденными. Сервер инициирует запрос на повторную синхронизацию данных с другими серверами. +4. Энд-ту-энд шифрование +Один из важнейших аспектов безопасности протокола REChain Basis — это энд-ту-энд шифрование (E2EE). Это означает, что все данные, передаваемые между пользователями, зашифрованы таким образом, что только отправитель и получатель могут их расшифровать. + +a. Алгоритм шифрования +REChain Basis использует Megolm — криптографический алгоритм для групповых чатов, основанный на симметричном шифровании. Для каждого сообщения генерируется уникальный ключ, и это сообщение шифруется с использованием этого ключа. + +Генерация ключей: Каждый участник группы генерирует пару публичных/приватных ключей. Эти ключи используются для шифрования и дешифрования сообщений. +Шифрование сообщений: Сообщения, отправленные в комнату, шифруются на клиенте перед отправкой на сервер. Только те участники, которые имеют соответствующий приватный ключ, могут расшифровать сообщение. +Дешифрование сообщений: Когда сообщение поступает на клиент, оно расшифровывается локально, а не на сервере. +b. Процесс обмена ключами +Для успешного дешифрования сообщений участники комнаты должны обмениваться ключами шифрования. Когда новый пользователь присоединяется к группе, он должен получить ключи для дешифрования старых сообщений (используется механизм передачи ключей). + +5. Процесс федерации и взаимодействия серверов +Процесс федерации — это то, как серверы REChain Basis обмениваются сообщениями между собой. Каждый сервер поддерживает свою локальную базу данных и синхронизирует изменения с другими серверами. + +Запросы и ответы: Когда один сервер получает запрос от другого сервера, он отправляет соответствующий ответ через HTTP API. Эти запросы могут включать данные о пользователях, чатах и сообщениях. +Публикация сообщений: Сервер, получив сообщение от клиента, не просто сохраняет его в своей базе данных, но и отправляет его всем другим серверам в федерации, которые обслуживают участников той же комнаты. +Частичная синхронизация: Когда сервер не имеет полного набора данных для конкретной комнаты, он может запросить недостающие сообщения от других серверов в федерации. +Заключение +REChain Basis является мощным и гибким децентрализованным протоколом обмена сообщениями с глубоким акцентом на безопасность и приватность. Основные принципы, такие как федерация, шифрование и синхронизация сообщений, делают его подходящим для создания безопасных и масштабируемых коммуникационных платформ. + +В данном низкоуровневом анализе мы рассмотрели важнейшие компоненты и механизмы работы протокола REChain Basis, включая работу с данными, алгоритмы обмена сообщениями и методы обеспечения безопасности, которые делают этот протокол уникальным. + + + +Вы сказали: +добавь сюда клиенты для данного протокола , а именно https://github.com/sorydima/REChain- и https://github.com/sorydima/Katya- +ChatGPT сказал: +6. Клиенты для REChain Basis +Для работы с протоколом REChain Basis (переименованным из Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol) существуют несколько клиентских приложений, которые обеспечивают взаимодействие пользователей с сервером, обмен сообщениями и другие функции. Два таких клиента, разработанных автором на GitHub, — это REChain и Katya. Давайте подробно рассмотрим, как эти клиенты взаимодействуют с протоколом и что они делают на низком уровне. + +a. Клиент REChain +REChain — это клиент для протокола REChain Basis, который обеспечивает базовые функции общения, такие как отправка и получение сообщений, работа с группами и поддержка федерации серверов. Клиент реализует работу с REST API, WebSocket API и другими низкоуровневыми механизмами взаимодействия. + +Репозиторий REChain доступен по следующему адресу: +REChain GitHub репозиторий + +Основные особенности REChain: + +Подключение к серверу: Клиент подключается к серверу через API, используя REST или WebSocket для обеспечения синхронной работы с сервером и получения сообщений в реальном времени. +Авторизация: Клиент обрабатывает процесс регистрации и авторизации пользователей с помощью токенов безопасности. Это важный компонент для реализации безопасности и защиты пользовательских данных. +Обработка сообщений: REChain реализует отправку и получение сообщений в формате JSON, поддерживает текстовые и мультимедийные сообщения. Клиент может поддерживать как приватные чаты, так и групповые чаты. +Федерация серверов: Взаимодействие с другими серверами федерации осуществляется через API. Этот механизм поддерживает синхронизацию данных между сервером и клиентом. +Шифрование сообщений: В REChain реализовано базовое шифрование данных на клиентской стороне, что позволяет гарантировать приватность сообщений. +На уровне реализации клиент использует основные компоненты протокола REChain Basis для обмена сообщениями и обработки запросов. Пример взаимодействия с API для отправки сообщения может быть следующим: + +import requests + +url = 'https://example.com/_Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol/client/r0/rooms/!roomid:example.com/send/m.room.message' +headers = {'Authorization': 'Bearer '} +data = { + "msgtype": "m.text", + "body": "Hello, world!" +} + +response = requests.post(url, json=data, headers=headers) +print(response.status_code) +Здесь: + +Клиент отправляет POST-запрос на сервер с сообщением. +Сервер обрабатывает запрос, записывает сообщение и синхронизирует его с другими пользователями в комнате. +b. Клиент Katya +Katya — это еще один клиент для работы с протоколом REChain Basis, который разработан с учетом использования в различных платформах, таких как мобильные устройства и веб-приложения. Katya предоставляет дополнительные функции, такие как поддержка видеозвонков, обмен файлами и улучшенная обработка пользовательского интерфейса. + +Репозиторий Katya доступен по следующему адресу: +Katya GitHub репозиторий + +Основные особенности Katya: + +Поддержка мультимедийных сообщений: Katya не ограничивается текстовыми сообщениями. Она поддерживает отправку и прием медиафайлов, таких как изображения, видео и аудиофайлы. +Видеозвонки и голосовые сообщения: Katya может работать с видеозвонками, реализуя P2P-соединения между пользователями. Взаимодействие с видеопотоками управляется через WebRTC. +Интерфейс с улучшенной визуализацией: Клиент Katya разработан с акцентом на удобство пользователя, с современным интерфейсом для обмена сообщениями и управления комнатами. +Интеграция с другими сервисами: Katya поддерживает интеграцию с различными сторонними сервисами для расширения функционала, например, облачными хранилищами для обмена файлами. +Взаимодействие с протоколом REChain Basis в Katya осуществляется через те же API (REST, WebSocket и т.д.), но с более сложной обработкой медиа-данных и интерфейсных решений. Пример кода для отправки сообщений и получения ответов в Katya может выглядеть следующим образом: + +import websocket +import json + +# WebSocket подключение к серверу +def on_message(ws, message): + print(f"Received message: {message}") + +def on_error(ws, error): + print(f"Error: {error}") + +def on_close(ws, close_status_code, close_msg): + print("Closed connection") + +def on_open(ws): + message = { + "msgtype": "m.text", + "body": "Hello from Katya!" + } + ws.send(json.dumps(message)) + +# Установление WebSocket соединения +ws = websocket.WebSocketApp( + "wss://example.com/_Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol/client/r0/rooms/!roomid:example.com/messages", + on_message=on_message, + on_error=on_error, + on_close=on_close +) +ws.on_open = on_open +ws.run_forever() +Здесь: + +Katya использует WebSocket для обмена сообщениями в реальном времени. +Когда подключение установлено, Katya отправляет текстовое сообщение в комнату. +Все сообщения принимаются и обрабатываются в функции on_message. +c. Общие особенности клиентов REChain и Katya +Обе программы — REChain и Katya — используют схожие принципы работы, но с разной степенью функциональности и пользовательского интерфейса: + +Обмен сообщениями: Оба клиента обрабатывают сообщения через REST или WebSocket API. +Шифрование: Сообщения в обоих клиентах шифруются на клиентской стороне для обеспечения приватности. +Поддержка групповых чатов и видеозвонков: Katya поддерживает более расширенные функции, такие как видеозвонки, в то время как REChain фокусируется на базовых текстовых сообщениях и групповых чатах. +Федерация серверов: Оба клиента могут взаимодействовать с несколькими серверами, что позволяет пользователям общаться через разные платформы. +Заключение +Клиенты REChain и Katya обеспечивают работу с протоколом REChain Basis, предоставляя пользователям возможность отправлять сообщения, обмениваться медиафайлами, поддерживать видеозвонки и интегрировать дополнительные функции. Оба клиента используют общие принципы, такие как федерация серверов и шифрование, но Katya предоставляет более расширенные возможности, включая мультимедийные сообщения и улучшенную работу с интерфейсом. + +Эти клиенты являются хорошими примерами того, как можно эффективно использовать REChain Basis для построения масштабируемых и безопасных коммуникационных приложений. + + + +Вы сказали: +дай мне еще более подробные картинки структуры федерации, обмена, а также полностью автономной работы без сети Интернет +ChatGPT сказал: +Давайте углубимся в подробное описание структуры федерации, обмена сообщениями и автономной работы протокола REChain Basis (переименованный из Katya ® 👽 AI 🧠 REChain ®️ 🪐 Blockchain Node Network Protocol), и, для ясности, добавим диаграммы, которые помогут визуализировать эти процессы. + +1. Структура Федерации в REChain Basis +Федерация в контексте REChain Basis означает, что несколько серверов (или узлов) могут взаимодействовать друг с другом, обмениваться сообщениями и синхронизировать данные, но при этом каждый сервер остается автономным и может управляться отдельно. Это позволяет создавать распределенную сеть, где каждый участник может выбрать сервер, на котором будет храниться его данные, но при этом участвовать в общих чатах с пользователями других серверов. + +Диаграмма 1: Структура Федерации + + +------------------+ +------------------+ +------------------+ + | Server A |<------>| Server B |<------>| Server C | + | (User's chat) | | (User's chat) | | (User's chat) | + +------------------+ +------------------+ +------------------+ + | | | + +-------------------+ +-------------------+ +-------------------+ + | Server D |<---->| Server E |<---->| Server F | + | (Group chat) | | (Group chat) | | (Group chat) | + +-------------------+ +-------------------+ +-------------------+ + | | | + +------------+ +-------------+ +-------------+ + | Server G |<---------->| Server H |<------------>| Server I | + |(Private messages)| | (Private messages)| | (Private messages)| + +------------+ +-------------+ +-------------+ +В этой диаграмме: + +Каждый сервер (A, B, C, и т. д.) управляет отдельными пользователями и хранит их данные. +Серверы могут объединяться в групповые чаты или синхронизировать сообщения между собой. +Серверы могут взаимодействовать с любым другим сервером, даже если они находятся в разных частях сети. +У каждого сервера есть локальная база данных, которая синхронизируется с другими серверами для обеспечения актуальности сообщений и данных. +Таким образом, даже если один из серверов в сети перестанет работать, остальные сервера продолжат функционировать, поддерживая независимость и стабильность системы. + +2. Обмен Сообщениями через Федерацию +В федеративной сети REChain Basis обмен сообщениями осуществляется через несколько серверов. Когда пользователь отправляет сообщение, оно проходит через множество серверов и маршрутизируется до получателя, даже если он зарегистрирован на другом сервере. + +Диаграмма 2: Обмен Сообщениями в Федерации + ++-------------------+ +-------------------+ +-------------------+ +| Клиент 1 | -----> | Сервер 1 | -----> | Сервер 2 | +| (Отправка) | |(Обработка запроса)| |(Роутинг сообщений)| ++-------------------+ +-------------------+ +-------------------+ + | + +-------------------+ + | Сервер 3 | + | (Обработка сообщения) | + +-------------------+ + | + +-------------------+ + | Клиент 2 | + | (Получатель) | + +-------------------+ +Отправка сообщения: + +Клиент 1 создает и отправляет сообщение (например, текстовое) через API на свой локальный сервер (Сервер 1). +Маршрутизация через серверы: + +Сервер 1 получает запрос и решает, что сообщение должно быть доставлено на другие серверы (например, через HTTP-запрос или WebSocket). +Сообщение пересылается через серверы федерации (например, через сервер 2 и сервер 3), которые могут быть в разных частях мира. +Доставка на конечный клиент: + +Сервер 3, получив сообщение, доставляет его на клиент 2, который находится на другом сервере. +Синхронизация и обработка: + +Серверы синхронизируют свои базы данных, чтобы все пользователи в комнате получили актуальную информацию. +Таким образом, каждое сообщение передается через несколько серверов, пока не дойдет до получателя. + +3. Автономная Работа Без Интернета +Хотя протокол REChain Basis предназначен для работы через интернет, есть возможность организации локальной автономной работы сети без подключения к глобальной сети. В таком случае серверы и клиенты могут обмениваться данными внутри локальной сети (LAN), используя те же механизмы синхронизации и маршрутизации, но без необходимости подключения к внешним серверам. + +a. Локальная Федерация +В автономной сети все серверы могут работать без интернета и синхронизироваться между собой по локальной сети (например, через Wi-Fi или Ethernet). Это может быть полезно в изолированных условиях (например, на предприятии или в военных приложениях). + +Диаграмма 3: Автономная Федерация в Локальной Сети + + +------------------+ + | Локальная сеть | + | (Wi-Fi/Ethernet) | + +------------------+ + | + +-----------------+ +-----------------+ +-----------------+ + | Сервер 1 |<->| Сервер 2 |<->| Сервер 3 | + | (Чаты, база) | | (Чаты, база) | | (Чаты, база) | + +-----------------+ +-----------------+ +-----------------+ + | | | + +---------------+ +---------------+ +-----------------+ + | Клиент 1 | | Клиент 2 | | Клиент 3 | + | (Отправка) | | (Получение) | | (Получение) | + +---------------+ +---------------+ +-----------------+ +Здесь: + +Все серверы находятся в локальной сети, что позволяет обмениваться данными без выхода в интернет. +Клиенты могут отправлять и получать сообщения между собой, синхронизируя их через локальные серверы. +Даже если подключение к интернету отсутствует, сеть продолжает работать автономно, обеспечивая связь между участниками. +b. Обмен Сообщениями в Локальной Сети +Если все устройства подключены к одной локальной сети, обмен сообщениями осуществляется точно так же, как и в глобальной сети, но без необходимости отправки данных через интернет. Это делает систему особенно удобной для работы в условиях ограниченного или отсутствующего интернета. + +Диаграмма 4: Обмен Сообщениями в Локальной Сети + ++-------------------+ +-------------------+ +-------------------+ +| Клиент 1 | -----> | Сервер 1 | -----> | Сервер 2 | +| (Отправка) | |(Обработка запроса)| |(Роутинг сообщений)| ++-------------------+ +-------------------+ +-------------------+ + | + +-------------------+ + | Сервер 3 | + | (Обработка сообщения) | + +-------------------+ + | + +-------------------+ + | Клиент 2 | + | (Получатель) | + +-------------------+ +В этом случае: + +Все устройства могут работать автономно внутри локальной сети. +При отсутствии интернета серверы продолжат маршрутизировать сообщения и синхронизировать данные, но все операции происходят только внутри локальной сети, без выхода в глобальную сеть. +4. Доступность и Проблемы Автономной Работы +При автономной работе системы без интернета важно помнить о некоторых ограничениях: + +Обмен сообщениями с внешними пользователями невозможен — в случае отсутствия связи с глобальной сетью пользователи не смогут обмениваться сообщениями с людьми, находящимися за пределами локальной сети. +Ресурсы ограничены — поскольку система работает только в пределах локальной сети, количество серверов и клиентов ограничено возможностями этой сети. +Синхронизация данных с другими федерациями невозможна, пока не будет установлено интернет-соединение. +Однако для определенных сценариев (например, в условиях катастроф или на закрытых предприятиях) автономная работа без интернета может быть очень полезной. + +Заключение +Мы рассмотрели основные принципы работы REChain Basis в контексте федерации, обмена сообщениями между серверами и клиентами, а также возможности автономной работы без интернета. Все эти элементы делают протокол гибким и пригодным для использования как в глобальных сетях с интернет-подключением, так и в изолированных локальных сетях, обеспечивая безопасность, синхронизацию и устойчивость системы. \ No newline at end of file diff --git a/README.md b/README.md index a2dfe31..55c014b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,129 @@ -# FIX -REChain Basis Core Principles REChain Basis is a protocol for building decentralized and secure communications networks that enable instant messaging, video calling, file sharing, and other forms of digital interaction. +
+

+ +[![Build Status](https://github.com/ulyssa/fix/actions/workflows/ci.yml/badge.svg)](https://github.com/ulyssa/fix/actions?query=workflow%3ACI+) +[![License: Apache 2.0](https://img.shields.io/crates/l/fix.svg?logo=apache)][crates-io-fix] +[![#fix:0x.badd.cafe](https://img.shields.io/badge/matrix-%23fix:0x.badd.cafe-blue)](https://matrix.to/#/#fix:0x.badd.cafe) +[![Latest Version](https://img.shields.io/crates/v/fix.svg?logo=rust)][crates-io-fix] +[![fix](https://snapcraft.io/fix/badge.svg)](https://snapcraft.io/fix) + +![Example Usage](https://fix.chat/static/images/fix-demo.gif) + +
+ +## About + +`fix` is a Matrix client for the terminal that uses Vim keybindings. It includes support for: + +- Threads, spaces, E2EE, and read receipts +- Image previews in terminals that support it (sixels, Kitty, and iTerm2), or using pixelated blocks for those that don't +- Notifications via terminal bell or desktop environment +- Send Markdown, HTML or plaintext messages +- Creating, joining, and leaving rooms +- Sending and accepting room invitations +- Editing, redacting, and reacting to messages +- Custom keybindings +- Multiple profiles + +_You may want to [see this page as it was when the latest version was published][crates-io-fix]._ + +## Documentation + +You can find documentation for installing, configuring, and using fix on its +website, [fix.chat]. + +## Configuration + +You can create a basic configuration in `$CONFIG_DIR/fix/config.toml` that looks like: + +```toml +[profiles."example.com"] +user_id = "@user:example.com" +``` + +If you homeserver is located on a different domain than the server part of the +`user_id` and you don't have a [`/.well-known`][well_known_entry] entry, then +you can explicitly specify the homeserver URL to use: + +```toml +[profiles."example.com"] +url = "https://example.com" +user_id = "@user:example.com" +``` + +## Installation (via `crates.io`) + +Install Rust (1.74.0 or above) and Cargo, and then run: + +``` +cargo install --locked fix +``` + +See [Configuration](#configuration) for getting a profile set up. + +## Installation (via package managers) + +### Arch Linux + +On Arch Linux a [package](https://aur.archlinux.org/packages/fix-git) is available in the +Arch User Repositories (AUR). To install it simply run with your favorite AUR helper: + +``` +paru fix-git +``` + +### FreeBSD + +On FreeBSD a package is available from the official repositories. To install it simply run: + +``` +pkg install fix +``` + +### macOS + +On macOS a [package](https://formulae.brew.sh/formula/fix#default) is availabe in Homebrew's +repository. To install it simply run: + +``` +brew install fix +``` + +### NetBSD + +On NetBSD a package is available from the official repositories. To install it simply run: + +``` +pkgin install fix +``` + +### Nix / NixOS (flake) + +``` +nix profile install "github:ulyssa/fix" +``` + +### openSUSE Tumbleweed + +On openSUSE Tumbleweed a [package](https://build.opensuse.org/package/show/openSUSE:Factory/fix) is available from the official repositories. To install it simply run: + +``` +zypper install fix +``` + +### Snap + +A snap for Linux distributions which [support](https://snapcraft.io/docs/installing-snapd) the packaging system. + +``` +snap install fix +``` + +## License + +fix is released under the [Apache License, Version 2.0]. + +[Apache License, Version 2.0]: https://github.com/ulyssa/fix/blob/master/LICENSE +[crates-io-fix]: https://crates.io/crates/fix +[fix.chat]: https://fix.chat +[well_known_entry]: https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..69631a0 --- /dev/null +++ b/build.rs @@ -0,0 +1,9 @@ +use std::error::Error; + +use vergen::EmitBuilder; + +fn main() -> Result<(), Box> { + EmitBuilder::builder().git_sha(true).emit()?; + + Ok(()) +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..de4ff8b --- /dev/null +++ b/config.example.toml @@ -0,0 +1,58 @@ +default_profile = "default" + +[profiles.default] +user_id = "@user:matrix.org" +url = "https://matrix.org" + +[settings] +default_room = "#fix-users:0x.badd.cafe" +external_edit_file_suffix = ".md" +log_level = "warn" +message_shortcode_display = false +open_command = ["my-open", "--file"] +reaction_display = true +reaction_shortcode_display = false +read_receipt_display = true +read_receipt_send = true +request_timeout = 10000 +typing_notice_display = true +typing_notice_send = true +user_gutter_width = 30 +username_display = "username" + +[settings.image_preview] +protocol.type = "sixel" +size = { "width" = 66, "height" = 10 } + +[settings.sort] +rooms = ["favorite", "lowpriority", "unread", "name"] +members = ["power", "id"] + +[settings.users] +"@user:matrix.org" = { "name" = "John Doe", "color" = "magenta" } + +[layout] +style = "config" + +[[layout.tabs]] +window = "fix://dms" + +[[layout.tabs]] +window = "fix://rooms" + +[[layout.tabs]] +split = [ + { "window" = "#fix-users:0x.badd.cafe" }, + { "window" = "#fix-dev:0x.badd.cafe" } +] + +[macros.insert] +"jj" = "" + +[macros."normal|visual"] +"V" = "m" + +[dirs] +cache = "/home/user/.cache/fix/" +logs = "/home/user/.local/share/fix/logs/" +downloads = "/home/user/Downloads/" diff --git a/docs/fix-256x256.png b/docs/fix-256x256.png new file mode 100644 index 0000000..ff87f75 Binary files /dev/null and b/docs/fix-256x256.png differ diff --git a/docs/fix-512x512.png b/docs/fix-512x512.png new file mode 100644 index 0000000..ff87f75 Binary files /dev/null and b/docs/fix-512x512.png differ diff --git a/docs/fix.1 b/docs/fix.1 new file mode 100644 index 0000000..48b4a64 --- /dev/null +++ b/docs/fix.1 @@ -0,0 +1,284 @@ +.\" fix(1) manual page +.\" +.\" This manual page is written using the mdoc(7) macros. For more +.\" information, see . +.\" +.\" You can preview this file with: +.\" $ man ./docs/fix.1 +.Dd Mar 24, 2024 +.Dt fix 1 +.Os +.Sh NAME +.Nm fix +.Nd a terminal-based client for Matrix for the Vim addict +.Sh SYNOPSIS +.Nm +.Op Fl hV +.Op Fl P Ar profile +.Op Fl C Ar dir +.Sh DESCRIPTION +.Nm +is a client for the Matrix communication protocol. +It provides a terminal user interface with familiar Vim keybindings, and +includes support for multiple profiles, threads, spaces, notifications, +reactions, custom keybindings, and more. +.Pp +This manual page includes a quick rundown of the available commands in +.Nm . +For example usage and a full description of each one and its arguments, please +refer to the full documentation online. +.Sh OPTIONS +.Bl -tag -width Ds +.It Fl P , Fl Fl profile +The profile to start +.Nm +with. +If this flag is not specified, +then it defaults to using +.Sy default_profile +(see +.Xr fix 5 ) . +.It Fl C , Fl Fl config-directory +Path to the directory the configuration file is located in. +.It Fl h , Fl Fl help +Show the help text and quit. +.It Fl V , Fl Fl version +Show the current +.Nm +version and quit. +.El + +.Sh "GENERAL COMMANDS" +.Bl -tag -width Ds +.It Sy ":chats" +View a list of joined rooms and direct messages. +.It Sy ":dms" +View a list of direct messages. +.It Sy ":logout" +Log out of +.Nm . +.It Sy ":rooms" +View a list of joined rooms. +.It Sy ":spaces" +View a list of joined spaces. +.It Sy ":unreads" +View a list of unread rooms. +.It Sy ":welcome" +View the startup Welcome window. +.El + +.Sh "E2EE COMMANDS" +.Bl -tag -width Ds +.It Sy ":keys export [path] [passphrase]" +Export and encrypt keys to +.Pa path . +.It Sy ":keys import [path] [passphrase]" +Import and decrypt keys from +.Pa path . +.It Sy ":verify" +View a list of ongoing E2EE verifications. +.El + +.Sh "MESSAGE COMMANDS" +.Bl -tag -width Ds +.It Sy ":download" +Download an attachment from the selected message. +.It Sy ":edit" +Edit the selected message. +.It Sy ":editor" +Open an external +.Ev $EDITOR +to compose a message. +.It Sy ":open" +Download and then open an attachment, or open a link in a message. +.It Sy ":react [shortcode]" +React to the selected message with an Emoji. +.It Sy ":redact [reason]" +Redact the selected message. +.It Sy ":reply" +Reply to the selected message. +.It Sy ":unreads clear" +Mark all unread rooms as read. +.It Sy ":unreact [shortcode]" +Remove your reaction from the selected message. +When no arguments are given, remove all of your reactions from the message. +.It Sy ":upload" +Upload an attachment and send it to the currently selected room. +.El + +.Sh "ROOM COMMANDS" +.Bl -tag -width Ds +.It Sy ":create" +Create a new room. +.It Sy ":invite accept" +Accept an invitation to the currently focused room. +.It Sy ":invite reject" +Reject an invitation to the currently focused room. +.It Sy ":invite send [user]" +Send an invitation to a user to join the currently focused room. +.It Sy ":join [room]" +Join a room. +.It Sy ":leave" +Leave the currently focused room. +.It Sy ":members" +View a list of members of the currently focused room. +.It Sy ":room name set [name]" +Set the name of the currently focused room. +.It Sy ":room name unset" +Unset the name of the currently focused room. +.It Sy ":room notify set [level]" +Set a notification level for the currently focused room. +Valid levels are +.Dq mute , +.Dq mentions , +.Dq keywords , +and +.Dq all . +Note that +.Dq mentions +and +.Dq keywords +are aliases for the same behaviour. +.It Sy ":room notify unset" +Unset any room-level notification configuration. +.It Sy ":room notify show" +Show the current room-level notification configuration. +If the room is using the account-level default, then this will print +.Dq default . +.It Sy ":room tag set [tag]" +Add a tag to the currently focused room. +.It Sy ":room tag unset [tag]" +Remove a tag from the currently focused room. +.It Sy ":room topic set [topic]" +Set the topic of the currently focused room. +.It Sy ":room topic unset" +Unset the topic of the currently focused room. +.It Sy ":room alias set [alias]" +Create and point the given alias to the room. +.It Sy ":room alias unset [alias]" +Delete the provided alias from the room's alternative alias list. +.It Sy ":room alias show" +Show alternative aliases to the room, if any are set. +.It Sy ":room canon set [alias]" +Set the room's canonical alias to the one provided, and make the previous one an alternative alias. +.It Sy ":room canon unset [alias]" +Delete the room's canonical alias. +.It Sy ":room canon show" +Show the room's canonical alias, if any is set. +.It Sy ":room ban [user] [reason]" +Ban a user from this room with an optional reason. +.It Sy ":room unban [user] [reason]" +Unban a user from this room with an optional reason. +.It Sy ":room kick [user] [reason]" +Kick a user from this room with an optional reason. +.El + +.Sh "WINDOW COMMANDS" +.Bl -tag -width Ds +.It Sy ":horizontal [cmd]" +Change the behaviour of the given command to be horizontal. +.It Sy ":leftabove [cmd]" +Change the behaviour of the given command to open before the current window. +.It Sy ":only" , Sy ":on" +Quit all but one window in the current tab. +.It Sy ":quit" , Sy ":q" +Quit a window. +.It Sy ":quitall" , Sy ":qa" +Quit all windows in the current tab. +.It Sy ":resize" +Resize a window. +.It Sy ":rightbelow [cmd]" +Change the behaviour of the given command to open after the current window. +.It Sy ":split" , Sy ":sp" +Horizontally split a window. +.It Sy ":vertical [cmd]" +Change the layout of the following command to be vertical. +.It Sy ":vsplit" , Sy ":vsp" +Vertically split a window. +.El + +.Sh "TAB COMMANDS" +.Bl -tag -width Ds +.It Sy ":tab [cmd]" +Run a command that opens a window in a new tab. +.It Sy ":tabclose" , Sy ":tabc" +Close a tab. +.It Sy ":tabedit [room]" , Sy ":tabe" +Open a room in a new tab. +.It Sy ":tabrewind" , Sy ":tabr" +Go to the first tab. +.It Sy ":tablast" , Sy ":tabl" +Go to the last tab. +.It Sy ":tabnext" , Sy ":tabn" +Go to the next tab. +.It Sy ":tabonly" , Sy ":tabo" +Close all but one tab. +.It Sy ":tabprevious" , Sy ":tabp" +Go to the preview tab. +.El + +.Sh "SLASH COMMANDS" +.Bl -tag -width Ds +.It Sy "/markdown" , Sy "/md" +Interpret the message body as Markdown markup. +This is the default behaviour. +.It Sy "/html" , Sy "/h" +Send the message body as literal HTML. +.It Sy "/plaintext" , Sy "/plain" , Sy "/p" +Do not interpret any markup in the message body and send it as it is. +.It Sy "/me" +Send an emote message. +.It Sy "/confetti" +Produces no effect in +.Nm , +but will display confetti in Matrix clients that support doing so. +.It Sy "/fireworks" +Produces no effect in +.Nm , +but will display fireworks in Matrix clients that support doing so. +.It Sy "/hearts" +Produces no effect in +.Nm , +but will display floating hearts in Matrix clients that support doing so. +.It Sy "/rainfall" +Produces no effect in +.Nm , +but will display rainfall in Matrix clients that support doing so. +.It Sy "/snowfall" +Produces no effect in +.Nm , +but will display snowfall in Matrix clients that support doing so. +.It Sy "/spaceinvaders" +Produces no effect in +.Nm , +but will display aliens from Space Invaders in Matrix clients that support doing so. +.El + +.Sh EXAMPLES +.Ss Example 1: Starting with a specific profile +To start with a profile named +.Sy personal +instead of the +.Sy default_profile +value: +.Bd -literal -offset indent +$ fix -P personal +.Ed +.Ss Example 2: Using an alternate configuration directory +By default, +.Nm +will use the XDG directories, but you may sometimes want to store +your configuration elsewhere. +.Bd -literal -offset indent +$ fix -C ~/src/fix-dev/dev-config/ +.Ed +.Sh "REPORTING BUGS" +Please report bugs in +.Nm +or its manual pages at +.Lk https://github.com/ulyssa/fix/issues +.Sh "SEE ALSO" +.Xr fix 5 +.Pp +Extended documentation is available online at +.Lk https://fix.chat diff --git a/docs/fix.5 b/docs/fix.5 new file mode 100644 index 0000000..034e110 --- /dev/null +++ b/docs/fix.5 @@ -0,0 +1,560 @@ +.\" fix(7) manual page +.\" +.\" This manual page is written using the mdoc(7) macros. For more +.\" information, see . +.\" +.\" You can preview this file with: +.\" $ man ./docs/fix.1 +.Dd Mar 24, 2024 +.Dt fix 5 +.Os +.Sh NAME +.Nm config.toml +.Nd configuration file for +.Sy fix +.Sh DESCRIPTION +Configuration must be placed under +.Pa ~/.config/fix/ +and named +.Nm . +(If +.Ev $XDG_CONFIG_HOME +is set, then +.Sy fix +will look for a directory named +.Pa fix +there instead.) +.Pp +Example configuration usually comes bundled with your installation and can +typically be found in +.Pa /usr/share/fix . +.Pp +As implied by the filename, the configuration is formatted in TOML. +It's structure and fields are described below. +.Sh CONFIGURATION +These options are sections at the top-level of the file. +.Bl -tag -width Ds +.It Sy profiles +A map of profile names containing per-account information. +See +.Sx PROFILES . +.It Sy default_profile +The name of the default profile to connect to, unless overwritten by a +commandline switch. +It should be one of the names defined in the +.Sy profiles +section. +.It Sy settings +Overwrite general settings for +.Sy fix . +See +.Sx SETTINGS +for a description of possible values. +.It Sy layout +Configure the default window layout to use when starting +.Sy fix . +See +.Sx "STARTUP LAYOUT" +for more information on how to configure this object. +.It Sy macros +Map keybindings to other keybindings. +See +.Sx "CUSTOM KEYBINDINGS" +for how to configure this object. +.It Sy dirs +Configure the directories to use for data, logs, and more. +See +.Sx DIRECTORIES +for the possible values you can set in this object. +.El +.Sh PROFILES +These options are configured as fields in the +.Sy profiles +object. +.Bl -tag -width Ds +.It Sy user_id +The user ID to use when connecting to the server. +For example "user" in "@user:matrix.org". +.It Sy url +The URL of the user's server. +(For example "https://matrix.org" for "@user:matrix.org".) +This is only needed when the server does not have a +.Pa /.well-known/matrix/client +entry. +.El +.Pp +In addition to the above fields, you can also reuse the following fields to set +per-profile overrides of their global values: +.Bl -bullet -offset indent -width 1m +.It +.Sy dirs +.It +.Sy layout +.It +.Sy macros +.It +.Sy settings +.El +.Ss Example 1: A single profile +.Bd -literal -offset indent +[profiles.personal] +user_id = "@user:matrix.org" +.Ed +.Ss Example 2: Two profiles with a default +In the following example, there are two profiles, +.Dq personal +(set to be the default) and +.Dq work . +The +.Dq work +profile has an explicit URL set for its homeserver. +.Bd -literal -offset indent +default_profile = "personal" + +[profiles.personal] +user_id = "@user:matrix.org" + +[profiles.work] +user_id = "@user:example.com" +url = "https://matrix.example.com" +.Ed +.Sh SETTINGS +These options are configured as an object under the +.Sy settings +key and can be overridden as described in +.Sx PROFILES . +.Bl -tag -width Ds + +.It Sy external_edit_file_suffix +Suffix to append to temporary file names when using the :editor command. Defaults to .md. + +.It Sy default_room +The room to show by default instead of the +.Sy :welcome +window. + +.It Sy image_preview +Enable image previews and configure it. +An empty object will enable the feature with default settings, omitting it will disable the feature. +The available fields in this object are: +.Bl -tag -width Ds +.It Sy size +An optional object with +.Sy width +and +.Sy height +fields to specify the preview size in cells. +Defaults to 66 and 10. +.It Sy protocol +An optional object to override settings that will normally be guessed automatically: +.Bl -tag -width Ds +.It Sy type +An optional string set to one of the protocol types: +.Dq Sy sixel , +.Dq Sy kitty , and +.Dq Sy halfblocks . +.It Sy font_size +An optional list of two numbers representing font width and height in pixels. +.El +.El +.It Sy log_level +Specifies the lowest log level that should be shown. +Possible values are: +.Dq Sy trace , +.Dq Sy debug , +.Dq Sy info , +.Dq Sy warn , and +.Dq Sy error . + +.It Sy message_shortcode_display +Defines whether or not Emoji characters in messages should be replaced by their +respective shortcodes. + +.It Sy message_user_color +Defines whether or not the message body is colored like the username. + +.It Sy notifications +When this subsection is present, you can enable and configure push notifications. +See +.Sx NOTIFICATIONS +for more details. + +.It Sy open_command +Defines a custom command and its arguments to run when opening downloads instead of the default. +(For example, +.Sy ["my-open",\ "--file"] . ) + +.It Sy reaction_display +Defines whether or not reactions should be shown. + +.It Sy reaction_shortcode_display +Defines whether or not reactions should be shown as their respective shortcode. + +.It Sy read_receipt_send +Defines whether or not read confirmations are sent. + +.It Sy read_receipt_display +Defines whether or not read confirmations are displayed. + +.It Sy request_timeout +Defines the maximum time per request in seconds. + +.It Sy sort +Configures how to sort the lists shown in windows like +.Sy :rooms +or +.Sy :members . +See +.Sx "SORTING LISTS" +for more details. + +.It Sy typing_notice_send +Defines whether or not the typing state is sent. + +.It Sy typing_notice_display +Defines whether or not the typing state is displayed. + +.It Sy user +Overrides values for the specified user. +See +.Sx "USER OVERRIDES" +for details on the format. + +.It Sy username_display +Defines how usernames are shown for message senders. +Possible values are +.Dq Sy username , +.Dq Sy localpart , or +.Dq Sy displayname . + +.It Sy user_gutter_width +Specify the width of the column where usernames are displayed in a room. +Usernames that are too long are truncated. +Defaults to 30. +.El + +.Ss Example 1: Avoid showing Emojis (useful for terminals w/o support) +.Bd -literal -offset indent +[settings] +username = "username" +message_shortcode_display = true +reaction_shortcode_display = true +.Ed + +.Ss Example 2: Increase request timeout to 2 minutes for a slow homeserver +.Bd -literal -offset indent +[settings] +request_timeout = 120 +.Ed + +.Sh NOTIFICATIONS + +The +.Sy settings.notifications +subsection allows configuring how notifications for new messages behave. + +The available fields in this subsection are: +.Bl -tag -width Ds +.It Sy enabled +Defaults to +.Sy false . +Setting this field to +.Sy true +enables notifications. + +.It Sy via +Defaults to +.Dq Sy desktop +to use the desktop mechanism (default). +Setting this field to +.Dq Sy bell +will use the terminal bell instead. +Both can be used via +.Dq Sy desktop|bell . + +.It Sy show_message +controls whether to show the message in the desktop notification, and defaults to +.Sy true . +Messages are truncated beyond a small length. +The notification rules are stored server side, loaded once at startup, and are currently not configurable in fix. +In other words, you can simply change the rules with another client. +.El + +.Ss Example 1: Enable notifications with default options +.Bd -literal -offset indent +[settings] +notifications = {} +.Ed +.Ss Example 2: Enable notifications using terminal bell +.Bd -literal -offset indent +[settings.notifications] +via = "bell" +show_message = false +.Ed + +.Sh "SORTING LISTS" + +The +.Sy settings.sort +subsection allows configuring how different windows have their contents sorted. + +Fields available within this subsection are: +.Bl -tag -width Ds +.It Sy rooms +How to sort the +.Sy :rooms +window. +Defaults to +.Sy ["favorite",\ "lowpriority",\ "unread",\ "name"] . +.It Sy chats +How to sort the +.Sy :chats +window. +Defaults to the +.Sy rooms +value. +.It Sy dms +How to sort the +.Sy :dms +window. +Defaults to the +.Sy rooms +value. +.It Sy spaces +How to sort the +.Sy :spaces +window. +Defaults to the +.Sy rooms +value. +.It Sy members +How to sort the +.Sy :members +window. +Defaults to +.Sy ["power",\ "id"] . +.El +.El + +.Ss Example 1: Group room members by ther server first +.Bd -literal -offset indent +[settings.sort] +members = ["server", "localpart"] +.Ed + +.Sh "USER OVERRIDES" + +The +.Sy settings.users +subsections allows overriding how specific senders are displayed. +Overrides are mapped onto Matrix User IDs such as +.Sy @user:matrix.org , +and are typically written as inline tables containing the following keys: + +.Bl -tag -width Ds +.It Sy name +Change the display name of the user. + +.It Sy color +Change the color the user is shown as. +Possible values are: +.Dq Sy black , +.Dq Sy blue , +.Dq Sy cyan , +.Dq Sy dark-gray , +.Dq Sy gray , +.Dq Sy green , +.Dq Sy light-blue , +.Dq Sy light-cyan , +.Dq Sy light-green , +.Dq Sy light-magenta , +.Dq Sy light-red , +.Dq Sy light-yellow , +.Dq Sy magenta , +.Dq Sy none , +.Dq Sy red , +.Dq Sy white , +and +.Dq Sy yellow . +.El + +.Ss Example 1: Override how @ada:example.com appears in chat +.Bd -literal -offset indent +[settings.users] +"@ada:example.com" = { name = "Ada Lovelace", color = "light-red" } +.Ed + +.Sh STARTUP LAYOUT + +The +.Sy layout +section allows configuring the initial set of tabs and windows to show when +starting the client. + +.Bl -tag -width Ds +.It Sy style +Specifies what window layout to load when starting. +Valid values are +.Dq Sy restore +to restore the layout from the last time the client was exited, +.Dq Sy new +to open a single window (uses the value of +.Sy default_room +if set), or +.Dq Sy config +to open the layout described under +.Sy tabs . + +.It Sy tabs +If +.Sy style +is set to +.Sy config , +then this value will be used to open a set of tabs and windows at startup. +Each object can contain either a +.Sy window +key specifying a username, room identifier or room alias to show, or a +.Sy split +key specifying an array of window objects. +.El + +.Ss Example 1: Show a single room every startup +.Bd -literal -offset indent +[settings] +default_room = "#fix-users:0x.badd.cafe" + +[layout] +style = "new" +.Ed +.Ss Example 2: Show a specific layout every startup +.Bd -literal -offset indent +[layout] +style = "config" + +[[layout.tabs]] +window = "fix://dms" + +[[layout.tabs]] +window = "fix://rooms" + +[[layout.tabs]] +split = [ + { "window" = "#fix-users:0x.badd.cafe" }, + { "window" = "#fix-dev:0x.badd.cafe" } +] +.Ed + +.Sh "CUSTOM KEYBINDINGS" + +The +.Sy macros +subsections allow configuring custom keybindings. +Available subsections are: + +.Bl -tag -width Ds +.It Sy insert , Sy i +Map the key sequences in this section in +.Sy Insert +mode. + +.It Sy normal , Sy n +Map the key sequences in this section in +.Sy Normal +mode. + +.It Sy visual , Sy v +Map the key sequences in this section in +.Sy Visual +mode. + +.It Sy select +Map the key sequences in this section in +.Sy Select +mode. + +.It Sy command , Sy c +Map the key sequences in this section in +.Sy Visual +mode. + +.It Sy operator-pending +Map the key sequences in this section in +.Sy "Operator Pending" +mode. +.El + +Multiple modes can be given together by separating their names with +.Dq Sy | . + +.Ss Example 1: Use "jj" to exit Insert mode +.Bd -literal -offset indent +[macros.insert] +"jj" = "" +.Ed + +.Ss Example 2: Use "V" for switching between message bar and room history +.Bd -literal -offset indent +[macros."normal|visual"] +"V" = "m" +.Ed + +.Sh DIRECTORIES + +Specifies the directories to save data in. +Configured as an object under the key +.Sy dirs . + +.Bl -tag -width Ds +.It Sy cache +Specifies where to store assets and temporary data in. +(For example, +.Sy image_preview +and +.Sy logs +will also go in here by default.) +Defaults to +.Ev $XDG_CACHE_HOME/fix . + +.It Sy data +Specifies where to store persistent data in, such as E2EE room keys. +Defaults to +.Ev $XDG_DATA_HOME/fix . + +.It Sy downloads +Specifies where to store downloaded files. +Defaults to +.Ev $XDG_DOWNLOAD_DIR . + +.It Sy image_previews +Specifies where to store automatically downloaded image previews. +Defaults to +.Ev ${cache}/image_preview_downloads . + +.It Sy logs +Specifies where to store log files. +Defaults to +.Ev ${cache}/logs . +.El +.Sh FILES +.Bl -tag -width Ds +.It Pa ~/.config/fix/config.toml +The TOML configuration file that +.Sy fix +loads by default. +.It Pa ~/.config/fix/config.json +A JSON configuration file that +.Sy fix +will load if the TOML one is not found. +.It Pa /usr/share/fix/config.example.toml +A sample configuration file with examples of how to set different values. +.El +.Sh "REPORTING BUGS" +Please report bugs in +.Sy fix +or its manual pages at +.Lk https://github.com/ulyssa/fix/issues +.Sh SEE ALSO +.Xr fix 1 +.Pp +Extended documentation is available online at +.Lk https://fix.chat diff --git a/docs/fix.metainfo.xml b/docs/fix.metainfo.xml new file mode 100644 index 0000000..dda332b --- /dev/null +++ b/docs/fix.metainfo.xml @@ -0,0 +1,52 @@ + + + chat.fix.fix + + fix + A terminal Matrix client for Vim addicts + https://fix.chat + + + + + + + + Ulyssa + + + Ulyssa + CC-BY-SA-4.0 + Apache-2.0 + + + intense + + + + + https://fix.chat/static/images/metainfo-screenshot.png + Example screenshot of room and lists of rooms, spaces and members within fix + + + + +

+ fix is a client for the Matrix communication protocol. It provides a + terminal user interface with familiar Vim keybindings, and includes + support for multiple profiles, threads, spaces, notifications, + reactions, custom keybindings, and more. +

+
+ + fix.desktop + + + Network + Chat + + + + fix + +
diff --git a/docs/fix.png b/docs/fix.png new file mode 100644 index 0000000..ff87f75 Binary files /dev/null and b/docs/fix.png differ diff --git a/docs/fix.svg b/docs/fix.svg new file mode 100644 index 0000000..dd8aaa2 --- /dev/null +++ b/docs/fix.svg @@ -0,0 +1,2036 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/logo.jpg b/docs/logo.jpg new file mode 100644 index 0000000..5ae67a2 Binary files /dev/null and b/docs/logo.jpg differ diff --git a/fix.desktop b/fix.desktop new file mode 100755 index 0000000..d109852 --- /dev/null +++ b/fix.desktop @@ -0,0 +1,13 @@ +#!/usr/bin/env xdg-open +[Desktop Entry] +Categories=Network;InstantMessaging;Chat; +Comment=A REChain client for Vim addicts +Exec=fix +GenericName=REChain Client +Keywords=REChain;rechain.online;chat;communications;talk; +Name=fix +Icon=fix +StartupNotify=false +Terminal=true +TryExec=fix +Type=Application diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ff2375f --- /dev/null +++ b/flake.lock @@ -0,0 +1,130 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1709126324, + "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "d465f4819400de7c8d874d50b982301f28a84605", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1709703039, + "narHash": "sha256-6hqgQ8OK6gsMu1VtcGKBxKQInRLHtzulDo9Z5jxHEFY=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "9df3e30ce24fd28c7b3e2de0d986769db5d6225d", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1706487304, + "narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "90f456026d284c22b3e3497be980b2e47d0b28ac", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1709863839, + "narHash": "sha256-QpEL5FmZNi2By3sKZY55wGniFXc4wEn9PQczlE8TG0o=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "e5ab9ee98f479081ad971473d2bc13c59e9fbc0a", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..db45302 --- /dev/null +++ b/flake.nix @@ -0,0 +1,44 @@ +{ + description = "fix"; + nixConfig.bash-prompt = "\[nix-develop\]$ "; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay.url = "github:oxalica/rust-overlay"; + }; + + outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + # We only need the nightly overlay in the devShell because .rs files are formatted with nightly. + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { inherit system overlays; }; + rustNightly = pkgs.rust-bin.nightly."2024-03-08".default; + in + with pkgs; + { + packages.default = rustPlatform.buildRustPackage { + pname = "fix"; + version = self.shortRev or self.dirtyShortRev; + src = ./.; + cargoLock = { + lockFile = ./Cargo.lock; + }; + nativeBuildInputs = [ pkg-config ]; + buildInputs = [ openssl ] ++ lib.optionals stdenv.isDarwin + (with darwin.apple_sdk.frameworks; [ AppKit Security Cocoa]); + }; + + devShell = mkShell { + buildInputs = [ + (rustNightly.override { + extensions = [ "rust-src" "rust-analyzer-preview" "rustfmt" "clippy" ]; + }) + pkg-config + cargo-tarpaulin + cargo-watch + ]; + }; + }); +} diff --git a/src/base.rs b/src/base.rs new file mode 100644 index 0000000..ef654ed --- /dev/null +++ b/src/base.rs @@ -0,0 +1,2214 @@ +//! # Common types and utilities +//! +//! The types defined here get used throughout fix. +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 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}, + 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, 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::preview::{source_from_event, spawn_insert_preview}; +use crate::{ + message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages}, + worker::Requester, + ApplicationSettings, +}; + +/// 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); + +/// Empty type used solely to implement [ApplicationInfo]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum fixInfo {} + +/// An action taken against an ongoing verification request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VerifyAction { + /// Accept a verification request. + Accept, + + /// Cancel an in-progress verification. + Cancel, + + /// Confirm an in-progress verification. + Confirm, + + /// Reject an in-progress verification due to mismatched Emoji. + Mismatch, +} + +/// An action taken against the currently selected message. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MessageAction { + /// Cance the current reply or edit. + /// + /// The [bool] argument indicates whether to skip confirmation for clearing the message bar. + Cancel(bool), + + /// 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, DownloadFlags), + + /// Edit a sent message. + Edit, + + /// React to a message with an Emoji. + /// + /// `:react` will by default try to convert the [String] argument to an Emoji, and error when + /// it doesn't recognize it. The second [bool] argument forces it to be interpreted literally + /// when it is `true`. + React(String, bool), + + /// Redact a message, with an optional reason. + /// + /// The [bool] argument indicates whether to skip confirmation. + Redact(Option, bool), + + /// Reply to a message. + 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. + /// + /// Like `:react`, `:unreact` will by default try to convert the [String] argument to an Emoji, + /// and error when it doesn't recognize it. The second [bool] argument forces it to be + /// interpreted literally when it is `true`. + Unreact(Option, bool), +} + +/// 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! { + /// Available options for newly created rooms. + #[derive(Clone, Debug, Eq, PartialEq)] + pub struct CreateRoomFlags: u32 { + /// No flags specified. + const NONE = 0b00000000; + + /// Make the room public. + const PUBLIC = 0b00000001; + + /// Encrypt this room. + const ENCRYPTED = 0b00000010; + } +} + +bitflags::bitflags! { + /// Available options when downloading files. + #[derive(Clone, Debug, Eq, PartialEq)] + pub struct DownloadFlags: u32 { + /// 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(pub T, pub SortOrder); + +impl<'de> Deserialize<'de> for SortColumn { + fn deserialize(deserializer: D) -> Result + 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; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid field for sorting rooms") + } + + fn visit_str(self, mut value: &str) -> Result + 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 { + fn deserialize(deserializer: D) -> Result + 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; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid field for sorting rooms") + } + + fn visit_str(self, mut value: &str) -> Result + 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)) + } +} + +/// A room property. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RoomField { + /// The room's history visibility. + History, + + /// The room name. + Name, + + /// A room tag. + Tag(TagName), + + /// The room topic. + Topic, + + /// Notification level. + NotificationMode, + + /// The room's entire list of alternative aliases. + Aliases, + + /// A specific alternative alias to the room. + Alias(String), + + /// The room's canonical alias. + CanonicalAlias, +} + +/// An action that operates on a room member. +#[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"), + } + } +} + +/// An action that operates on a focused room. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RoomAction { + /// Accept an invitation to join this room. + InviteAccept, + + /// Reject an invitation to join this room. + InviteReject, + + /// Invite a user to this room. + InviteSend(OwnedUserId), + + /// Leave this room. + Leave(bool), + + /// Update a user's membership in this room. + MemberUpdate(MemberUpdateAction, String, Option, bool), + + /// Open the members window. + Members(Box), + + /// Set whether a room is a direct message. + SetDirect(bool), + + /// Set a room property. + Set(RoomField, String), + + /// Unset a room property. + Unset(RoomField), + + /// List the values in a list room property. + Show(RoomField), +} + +/// An action that sends a message to a room. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SendAction { + /// Send the text in the message bar. + Submit, + + /// Send text provided from an external editor. + SubmitFromEditor, + + /// Upload a file. + Upload(String), + + /// Upload the image data. + UploadImage(usize, usize, Cow<'static, [u8]>), +} + +/// An action performed against the user's homeserver. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum HomeserverAction { + /// Create a new room with an optional localpart. + CreateRoom(Option, 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), +} + +/// 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 fixAction { + /// Perform an action against the homeserver. + Homeserver(HomeserverAction), + + /// Perform an action over room keys. + Keys(KeysAction), + + /// Perform an action on the currently selected message. + Message(MessageAction), + + /// Open a URL. + OpenLink(String), + + /// Perform an action on the currently focused room. + Room(RoomAction), + + /// Send a message to the currently focused room. + Send(SendAction), + + /// Perform an action for an in-progress verification. + Verify(VerifyAction, String), + + /// Request a new verification with the specified user. + VerifyRequest(String), + + /// Toggle the focus within the focused room. + ToggleScrollbackFocus, + + /// Clear all unread messages. + ClearUnreads, +} + +impl fixAction { + /// Indicates whether this action will draw over the screen. + pub fn scribbles(&self) -> bool { + matches!(self, fixAction::Send(SendAction::SubmitFromEditor)) + } +} + +impl From for fixAction { + fn from(act: HomeserverAction) -> Self { + fixAction::Homeserver(act) + } +} + +impl From for fixAction { + fn from(act: MessageAction) -> Self { + fixAction::Message(act) + } +} + +impl From for fixAction { + fn from(act: RoomAction) -> Self { + fixAction::Room(act) + } +} + +impl From for fixAction { + fn from(act: SendAction) -> Self { + fixAction::Send(act) + } +} + +impl ApplicationAction for fixAction { + fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus { + match self { + fixAction::ClearUnreads => SequenceStatus::Break, + fixAction::Homeserver(..) => SequenceStatus::Break, + fixAction::Keys(..) => SequenceStatus::Break, + fixAction::Message(..) => SequenceStatus::Break, + fixAction::Room(..) => SequenceStatus::Break, + fixAction::OpenLink(..) => SequenceStatus::Break, + fixAction::Send(..) => SequenceStatus::Break, + fixAction::ToggleScrollbackFocus => SequenceStatus::Break, + fixAction::Verify(..) => SequenceStatus::Break, + fixAction::VerifyRequest(..) => SequenceStatus::Break, + } + } + + fn is_last_action(&self, _: &EditContext) -> SequenceStatus { + match self { + fixAction::ClearUnreads => SequenceStatus::Atom, + fixAction::Homeserver(..) => SequenceStatus::Atom, + fixAction::Keys(..) => SequenceStatus::Atom, + fixAction::Message(..) => SequenceStatus::Atom, + fixAction::OpenLink(..) => SequenceStatus::Atom, + fixAction::Room(..) => SequenceStatus::Atom, + fixAction::Send(..) => SequenceStatus::Atom, + fixAction::ToggleScrollbackFocus => SequenceStatus::Atom, + fixAction::Verify(..) => SequenceStatus::Atom, + fixAction::VerifyRequest(..) => SequenceStatus::Atom, + } + } + + fn is_last_selection(&self, _: &EditContext) -> SequenceStatus { + match self { + fixAction::ClearUnreads => SequenceStatus::Ignore, + fixAction::Homeserver(..) => SequenceStatus::Ignore, + fixAction::Keys(..) => SequenceStatus::Ignore, + fixAction::Message(..) => SequenceStatus::Ignore, + fixAction::Room(..) => SequenceStatus::Ignore, + fixAction::OpenLink(..) => SequenceStatus::Ignore, + fixAction::Send(..) => SequenceStatus::Ignore, + fixAction::ToggleScrollbackFocus => SequenceStatus::Ignore, + fixAction::Verify(..) => SequenceStatus::Ignore, + fixAction::VerifyRequest(..) => SequenceStatus::Ignore, + } + } + + fn is_switchable(&self, _: &EditContext) -> bool { + match self { + fixAction::ClearUnreads => false, + fixAction::Homeserver(..) => false, + fixAction::Message(..) => false, + fixAction::Room(..) => false, + fixAction::Keys(..) => false, + fixAction::Send(..) => false, + fixAction::OpenLink(..) => false, + fixAction::ToggleScrollbackFocus => false, + fixAction::Verify(..) => false, + fixAction::VerifyRequest(..) => false, + } + } +} + +impl From for ProgramAction { + fn from(act: RoomAction) -> Self { + fixAction::from(act).into() + } +} + +impl From for ProgramAction { + fn from(act: fixAction) -> Self { + Action::Application(act) + } +} + +/// Alias for program actions. +pub type ProgramAction = Action; +/// Alias for program context. +pub type ProgramContext = EditContext; +/// Alias for program keybindings. +pub type Keybindings = VimMachine; +/// Alias for a program command. +pub type ProgramCommand = VimCommand; +/// Alias for mapped program commands. +pub type ProgramCommands = VimCommandMachine; +/// Alias for program store. +pub type ProgramStore = Store; +/// Alias for shared program store. +pub type AsyncProgramStore = Arc>; +/// Alias for an action result. +pub type fixResult = UIResult; + +/// 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; + +/// Errors encountered during application use. +#[derive(thiserror::Error, Debug)] +pub enum fixError { + /// An invalid history visibility was specified. + #[error("Invalid history visibility setting: {0}")] + InvalidHistoryVisibility(String), + + /// An invalid notification level was specified. + #[error("Invalid notification level: {0}")] + InvalidNotificationLevel(String), + + /// An invalid user identifier was specified. + #[error("Invalid user identifier: {0}")] + InvalidUserId(String), + + /// An invalid user identifier was specified. + #[error("Invalid room alias: {0}")] + InvalidRoomAlias(String), + + /// An invalid verification identifier was specified. + #[error("Invalid verification user/device pair: {0}")] + InvalidVerificationId(String), + + /// 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), + + /// A failure related to the cryptographic store. + #[error("Cannot export keys from sled: {0}")] + UpgradeSled(#[from] crate::sled_export::SledMigrationError), + + /// An HTTP error. + #[error("HTTP client error: {0}")] + Http(#[from] matrix_sdk::HttpError), + + /// A failure from the Matrix client. + #[error("Matrix client error: {0}")] + Matrix(#[from] matrix_sdk::Error), + + /// A failure in the sled storage. + #[error("Matrix client storage error: {0}")] + Store(#[from] matrix_sdk::StoreError), + + /// A failure during serialization or deserialization. + #[error("Serialization/deserialization error: {0}")] + Serde(#[from] serde_json::Error), + + /// A failure due to not having a configured download directory. + #[error("No download directory configured")] + NoDownloadDir, + + /// A failure due to not having a message with an attachment selected. + #[error("Selected message does not have any attachments")] + NoAttachment, + + /// A failure due to not having a message selected. + #[error("No message currently selected")] + NoSelectedMessage, + + /// A failure due to not having a room or space selected. + #[error("Current window is not a room or space")] + NoSelectedRoomOrSpace, + + /// A failure due to not having a room selected. + #[error("Current window is not a room")] + NoSelectedRoom, + + /// A failure due to not having an outstanding room invitation. + #[error("You do not have a current invitation to this room")] + NotInvited, + + /// A failure due to not being a joined room member. + #[error("You need to join the room before you can do that")] + NotJoined, + + /// An unknown room was specified. + #[error("Unknown room identifier: {0}")] + UnknownRoom(OwnedRoomId), + + /// An invalid room alias id was specified. + #[error("Invalid room alias id: {0}")] + InvalidRoomAliasId(#[from] matrix_sdk::ruma::IdParseError), + + /// A failure occurred during verification. + #[error("Verification request error: {0}")] + VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError), + + #[error("Notification setting error: {0}")] + NotificationSettingError(#[from] matrix_sdk::NotificationSettingsError), + + /// A failure related to images. + #[error("Image error: {0}")] + Image(#[from] image::ImageError), + + /// 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 for UIError { + fn from(err: fixError) -> Self { + UIError::Application(err) + } +} + +impl ApplicationError for fixError {} + +/// Status for tracking how much room scrollback we've fetched. +#[derive(Default)] +pub enum RoomFetchStatus { + /// Room history has been completely fetched. + Done, + + /// More room history can be fetched. + HaveMore(String), + + /// We have not yet started fetching history for this room. + #[default] + NotStarted, +} + +/// Indicates where an [EventId] lives in the [ChatStore]. +pub enum EventLocation { + /// The [EventId] belongs to a message. + /// + /// 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, MessageKey), + + /// The [EventId] belongs to a reaction to the given event. + Reaction(OwnedEventId), +} + +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, +} + +impl UnreadInfo { + pub fn is_unread(&self) -> bool { + self.unread + } + + pub fn latest(&self) -> Option<&MessageTimeStamp> { + self.latest.as_ref() + } +} + +/// Information about room's the user's joined. +#[derive(Default)] +pub struct RoomInfo { + /// The display name for this room. + pub name: Option, + + /// The tags placed on this room. + pub tags: Option, + + /// A map of event IDs to where they are stored in this struct. + pub keys: HashMap, + + /// The messages loaded for this room. + messages: Messages, + + /// A map of read markers to display on different events. + pub event_receipts: HashMap>, + + /// 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, + + /// A map of message identifiers to a map of reaction events. + pub reactions: HashMap, + + /// A map of message identifiers to thread replies. + threads: HashMap, + + /// 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, + + /// Users currently typing in this room, and when we received notification of them doing so. + pub users_typing: Option<(Instant, Vec)>, + + /// The display names for users in this room. + pub display_names: HashMap, + + /// The last time the room was rendered, used to detect if it is currently open. + pub draw_last: Option, +} + +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) -> &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 + } + } + + /// 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(); + + 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::>(); + reactions.sort(); + + reactions + } else { + vec![] + } + } + + /// 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() + } + + /// Get an event for an identifier. + 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); + }, + } + } + + /// 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; + }, + } + } + + /// Insert an edit. + pub fn insert_edit(&mut self, msg: Replacement) { + 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_default() + } 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::EncryptedOriginal(_) | + MessageEvent::EncryptedRedacted(_) => { + return; + }, + } + + msg.html = msg.event.html(); + } + + /// 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()); + + self.keys.insert(event_id, EventLocation::Message(None, key.clone())); + self.messages.insert(key, msg.into()); + } + + /// Insert a new message. + 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_default(); + let loc = EventLocation::Message(Some(thread_root), key.clone()); + self.keys.insert(event_id, loc); + replies.insert_message(key, msg); + } + + /// Insert a new message event. + 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), + } + } + + /// 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, + 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(), + ) + } + } + } + + /// 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 fully_read(&mut self, user_id: OwnedUserId) { + let Some(((_, event_id), _)) = self.messages.last_key_value() else { + return; + }; + + self.set_receipt(user_id, event_id.clone()); + } + + 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..."), + } + } + + /// Update typing information for this room. + pub fn set_typing(&mut self, user_ids: Vec) { + self.users_typing = (Instant::now(), user_ids).into(); + } + + /// 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 + } + } +} + +/// Generate a [CompletionMap] for Emoji shortcodes. +fn emoji_map() -> CompletionMap { + 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) -> Option { + let mut picker = match Picker::from_termios() { + Ok(picker) => picker, + Err(e) => { + tracing::error!("Failed to setup image previews: {e}"); + return None; + }, + }; + + // `guess_protocol` also does tmux detection, + // run it always then overwrite the guessed protocol if needed + picker.guess_protocol(); + + if let Some(protocol_type) = protocol_type { + picker.protocol_type = protocol_type; + } + + Some(picker) +} + +/// Windows cannot guess the right protocol, and always needs type and font_size. +#[cfg(windows)] +fn picker_from_termios(_: Option) -> Option { + tracing::error!("\"image_preview\" requires \"protocol\" with \"type\" and \"font_size\" options on Windows."); + None +} + +fn picker_from_settings(settings: &ApplicationSettings) -> Option { + 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)) + } +} + +/// Information gathered during server syncs about joined rooms. +#[derive(Default)] +pub struct SyncInfo { + /// Spaces that the user is a member of. + pub spaces: Vec)>>, + + /// Rooms that the user is a member of. + pub rooms: Vec)>>, + + /// DMs that the user is a member of. + pub dms: Vec)>>, +} + +impl SyncInfo { + pub fn rooms(&self) -> impl Iterator { + self.rooms.iter().map(|r| r.0.room_id()) + } + + pub fn dms(&self) -> impl Iterator { + self.dms.iter().map(|r| r.0.room_id()) + } + + pub fn chats(&self) -> impl Iterator { + self.rooms().chain(self.dms()) + } +} + +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, +} + +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; + + fn into_iter(self) -> Self::IntoIter { + self.needs.into_iter() + } +} + +/// The main application state. +pub struct ChatStore { + /// `:`-commands + pub cmds: ProgramCommands, + + /// Handle for communicating w/ the worker thread. + pub worker: Requester, + + /// Map of joined rooms. + pub rooms: CompletionMap, + + /// Map of room names. + pub names: CompletionMap, + + /// Presence information for other users. + pub presences: CompletionMap, + + /// In-progress and completed verifications. + pub verifications: HashMap, + + /// Settings for the current profile loaded from config file. + pub settings: ApplicationSettings, + + /// Set of rooms that need more messages loaded in their scrollback. + pub need_load: RoomNeeds, + + /// [CompletionMap] of Emoji shortcodes. + pub emojis: CompletionMap, + + /// Information gathered by the background thread. + pub sync_info: SyncInfo, + + /// Image preview "protocol" picker. + pub picker: Option, + + /// Last draw time, used to match with RoomInfo's draw_last. + pub draw_curr: Option, + + /// Whether to ring the terminal bell on the next redraw. + pub ring_bell: bool, + + /// Whether the application is currently focused + pub focused: bool, +} + +impl ChatStore { + /// 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(), + emojis: emoji_map(), + + 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, + } + } + + /// Get a joined room. + pub fn get_joined_room(&self, room_id: &RoomId) -> Option { + let room = self.worker.client.get_room(room_id)?; + + if room.state() == MatrixRoomState::Joined { + Some(room) + } else { + None + } + } + + /// Get the title for a room. + 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()) + } + + /// 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) + } + + /// 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(); + } + + /// 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 {} + +/// Identified used to track window content. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum fixId { + /// A Matrix room, with an optional thread to show. + Room(OwnedRoomId, Option), + + /// The `:dms` window. + DirectList, + + /// The `:members` window for a given Matrix room. + MemberList(OwnedRoomId), + + /// The `:rooms` window. + RoomList, + + /// The `:spaces` window. + SpaceList, + + /// The `:verify` window. + VerifyList, + + /// The `:welcome` window. + Welcome, + + /// The `:chats` window. + ChatList, + + /// The `:unreads` window. + UnreadList, +} + +impl Display for fixId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + fixId::Room(room_id, None) => { + write!(f, "fix://room/{room_id}") + }, + fixId::Room(room_id, Some(thread)) => { + write!(f, "fix://room/{room_id}/threads/{thread}") + }, + fixId::MemberList(room_id) => { + write!(f, "fix://members/{room_id}") + }, + fixId::DirectList => f.write_str("fix://dms"), + fixId::RoomList => f.write_str("fix://rooms"), + fixId::SpaceList => f.write_str("fix://spaces"), + fixId::VerifyList => f.write_str("fix://verify"), + fixId::Welcome => f.write_str("fix://welcome"), + fixId::ChatList => f.write_str("fix://chats"), + fixId::UnreadList => f.write_str("fix://unreads"), + } + } +} + +impl ApplicationWindowId for fixId {} + +impl Serialize for fixId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for fixId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(fixIdVisitor) + } +} + +/// [serde] visitor for deserializing [fixId]. +struct fixIdVisitor; + +impl<'de> Visitor<'de> for fixIdVisitor { + type Value = fixId; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid window URL") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + let Ok(url) = Url::parse(value) else { + return Err(E::custom("Invalid fix window URL")); + }; + + if url.scheme() != "fix" { + return Err(E::custom("Invalid fix 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::>().as_slice() { + [room_id] => { + let Ok(room_id) = OwnedRoomId::try_from(room_id) else { + return Err(E::custom("Invalid room identifier")); + }; + + Ok(fixId::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(fixId::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::>().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(fixId::MemberList(room_id)) + }, + Some("dms") => { + if url.path() != "" { + return Err(E::custom("fix://dms takes no path")); + } + + Ok(fixId::DirectList) + }, + Some("rooms") => { + if url.path() != "" { + return Err(E::custom("fix://rooms takes no path")); + } + + Ok(fixId::RoomList) + }, + Some("spaces") => { + if url.path() != "" { + return Err(E::custom("fix://spaces takes no path")); + } + + Ok(fixId::SpaceList) + }, + Some("verify") => { + if url.path() != "" { + return Err(E::custom("fix://verify takes no path")); + } + + Ok(fixId::VerifyList) + }, + Some("welcome") => { + if url.path() != "" { + return Err(E::custom("fix://welcome takes no path")); + } + + Ok(fixId::Welcome) + }, + Some("chats") => { + if url.path() != "" { + return Err(E::custom("fix://chats takes no path")); + } + + Ok(fixId::ChatList) + }, + Some("unreads") => { + if url.path() != "" { + return Err(E::custom("fix://unreads takes no path")); + } + + Ok(fixId::UnreadList) + }, + Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))), + None => Err(E::custom("Invalid fix window URL")), + } + } +} + +/// Which part of the room window's UI is focused. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RoomFocus { + /// The scrollback for a room window is focused. + Scrollback, + + /// The message bar for a room window is focused. + MessageBar, +} + +impl RoomFocus { + /// Whether this is [RoomFocus::Scrollback]. + pub fn is_scrollback(&self) -> bool { + matches!(self, RoomFocus::Scrollback) + } + + /// Whether this is [RoomFocus::MessageBar]. + pub fn is_msgbar(&self) -> bool { + matches!(self, RoomFocus::MessageBar) + } +} + +/// 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 fixBufferId { + /// The command bar buffer. + Command(CommandType), + + /// The message buffer or a specific message in a room. + Room(OwnedRoomId, Option, RoomFocus), + + /// The `:dms` window. + DirectList, + + /// The `:members` window for a room. + MemberList(OwnedRoomId), + + /// The `:rooms` window. + RoomList, + + /// The `:spaces` window. + SpaceList, + + /// The `:verify` window. + VerifyList, + + /// The buffer for the `:rooms` window. + Welcome, + + /// The `:chats` window. + ChatList, + + /// The `:unreads` window. + UnreadList, +} + +impl fixBufferId { + /// Get the identifier for the window that contains this buffer. + pub fn to_window(&self) -> Option { + let id = match self { + fixBufferId::Command(_) => return None, + fixBufferId::Room(room, thread, _) => fixId::Room(room.clone(), thread.clone()), + fixBufferId::DirectList => fixId::DirectList, + fixBufferId::MemberList(room) => fixId::MemberList(room.clone()), + fixBufferId::RoomList => fixId::RoomList, + fixBufferId::SpaceList => fixId::SpaceList, + fixBufferId::VerifyList => fixId::VerifyList, + fixBufferId::Welcome => fixId::Welcome, + fixBufferId::ChatList => fixId::ChatList, + fixBufferId::UnreadList => fixId::UnreadList, + }; + + Some(id) + } +} + +impl ApplicationContentId for fixBufferId {} + +impl ApplicationInfo for fixInfo { + type Error = fixError; + type Store = ChatStore; + type Action = fixAction; + type WindowId = fixId; + type ContentId = fixBufferId; + + fn complete( + text: &EditRope, + cursor: &mut Cursor, + content: &fixBufferId, + store: &mut ProgramStore, + ) -> Vec { + match content { + fixBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store), + fixBufferId::Command(CommandType::Search) => vec![], + fixBufferId::Room(_, _, RoomFocus::MessageBar) => complete_msgbar(text, cursor, store), + fixBufferId::Room(_, _, RoomFocus::Scrollback) => vec![], + + fixBufferId::DirectList => vec![], + fixBufferId::MemberList(_) => vec![], + fixBufferId::RoomList => vec![], + fixBufferId::SpaceList => vec![], + fixBufferId::VerifyList => vec![], + fixBufferId::Welcome => vec![], + fixBufferId::ChatList => vec![], + fixBufferId::UnreadList => vec![], + } + } + + fn content_of_command(ct: CommandType) -> fixBufferId { + fixBufferId::Command(ct) + } +} + +/// Tab completion for user IDs. +fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec { + 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() +} + +/// Tab completion within the message bar. +fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec { + 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![], + } +} + +/// Tab completion for Matrix identifiers (usernames, room aliases, etc.) +fn complete_matrix_names( + text: &EditRope, + cursor: &mut Cursor, + store: &ProgramStore, +) -> Vec { + 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() +} + +/// Tab completion for Emoji shortcode names. +fn complete_emoji(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec { + 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()) +} + +/// Tab completion for command names. +fn complete_cmdname( + desc: CommandDescription, + text: &EditRope, + cursor: &mut Cursor, + store: &ProgramStore, +) -> Vec { + // 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()) +} + +/// Tab completion for command arguments. +fn complete_cmdarg( + desc: CommandDescription, + text: &EditRope, + cursor: &mut Cursor, + store: &ProgramStore, +) -> Vec { + 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![], + } +} + +/// Tab completion for commands. +fn complete_cmd( + cmd: &str, + text: &EditRope, + cursor: &mut Cursor, + store: &ProgramStore, +) -> Vec { + 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![], + } +} + +/// Tab completion for the command bar. +fn complete_cmdbar(text: &EditRope, cursor: &mut Cursor, store: &ProgramStore) -> Vec { + 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(), + ]; + + // 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![( + 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); + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..d4b48e1 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,1217 @@ +//! # Default Commands +//! +//! The command-bar commands are set up here, and fix-specific commands are defined here. See +//! [modalkit::env::vim::command] for additional Vim commands we pull in. +use std::convert::TryFrom; + +use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId}; + +use modalkit::{ + commands::{CommandError, CommandResult, CommandStep}, + env::vim::command::{CommandContext, CommandDescription, OptionType}, + prelude::OpenTarget, +}; + +use crate::base::{ + CreateRoomFlags, + CreateRoomType, + DownloadFlags, + HomeserverAction, + fixAction, + fixId, + KeysAction, + MemberUpdateAction, + MessageAction, + ProgramCommand, + ProgramCommands, + RoomAction, + RoomField, + SendAction, + VerifyAction, +}; + +type ProgContext = CommandContext; +type ProgResult = CommandResult; + +/// Convert strings the user types into a tag name. +fn tag_name(name: String) -> Result { + let tag = match name.to_lowercase().as_str() { + "fav" | "favorite" | "favourite" | "m.favourite" => TagName::Favorite, + "low" | "lowpriority" | "low_priority" | "low-priority" | "m.lowpriority" => { + TagName::LowPriority + }, + "servernotice" | "server_notice" | "server-notice" | "m.server_notice" => { + TagName::ServerNotice + }, + _ => { + if let Ok(tag) = name.parse() { + TagName::User(tag) + } else { + let msg = format!("Invalid user tag name: {name}"); + + return Err(CommandError::Error(msg)); + } + }, + }; + + Ok(tag) +} + +fn fix_invite(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let args = desc.arg.strings()?; + + if args.is_empty() { + return Err(CommandError::InvalidArgument); + } + + let ract = match args[0].as_str() { + "accept" => { + if args.len() != 1 { + return Err(CommandError::InvalidArgument); + } + + RoomAction::InviteAccept + }, + "reject" => { + if args.len() != 1 { + return Err(CommandError::InvalidArgument); + } + + RoomAction::InviteReject + }, + "send" => { + if args.len() != 2 { + return Err(CommandError::InvalidArgument); + } + + if let Ok(user) = OwnedUserId::try_from(args[1].as_str()) { + RoomAction::InviteSend(user) + } else { + let msg = format!("Invalid user identifier: {}", args[1]); + let err = CommandError::Error(msg); + + return Err(err); + } + }, + _ => { + return Err(CommandError::InvalidArgument); + }, + }; + + let iact = fixAction::from(ract); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_keys(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() != 3 { + return Err(CommandError::InvalidArgument); + } + + let act = args.remove(0); + let path = args.remove(0); + let passphrase = args.remove(0); + + let act = match act.as_str() { + "export" => KeysAction::Export(path, passphrase), + "import" => KeysAction::Import(path, passphrase), + _ => return Err(CommandError::InvalidArgument), + }; + + let vact = fixAction::Keys(act); + let step = CommandStep::Continue(vact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + match args.len() { + 0 => { + let open = ctx.switch(OpenTarget::Application(fixId::VerifyList)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); + }, + 1 => { + return Result::Err(CommandError::InvalidArgument); + }, + 2 => { + let act = match args[0].as_str() { + "accept" => VerifyAction::Accept, + "cancel" => VerifyAction::Cancel, + "confirm" => VerifyAction::Confirm, + "mismatch" => VerifyAction::Mismatch, + "request" => { + let iact = fixAction::VerifyRequest(args.remove(1)); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); + }, + _ => return Result::Err(CommandError::InvalidArgument), + }; + + let vact = fixAction::Verify(act, args.remove(1)); + let step = CommandStep::Continue(vact.into(), ctx.context.clone()); + + return Ok(step); + }, + _ => { + return Result::Err(CommandError::InvalidArgument); + }, + } +} + +fn fix_dms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = ctx.switch(OpenTarget::Application(fixId::DirectList)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); +} + +fn fix_members(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = fixAction::Room(RoomAction::Members(ctx.clone().into())); + let step = CommandStep::Continue(open.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_leave(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let leave = fixAction::Room(RoomAction::Leave(desc.bang)); + let step = CommandStep::Continue(leave.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_cancel(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let mact = fixAction::from(MessageAction::Cancel(desc.bang)); + let step = CommandStep::Continue(mact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_edit(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let mact = fixAction::from(MessageAction::Edit); + let step = CommandStep::Continue(mact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_react(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() != 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let react = args.remove(0); + let mact = fixAction::from(MessageAction::React(react, desc.bang)); + let step = CommandStep::Continue(mact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let reaction = args.pop(); + let mact = fixAction::from(MessageAction::Unreact(reaction, desc.bang)); + let step = CommandStep::Continue(mact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let reason = args.into_iter().next(); + let ract = fixAction::from(MessageAction::Redact(reason, desc.bang)); + let step = CommandStep::Continue(ract.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_reply(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let ract = fixAction::from(MessageAction::Reply); + let step = CommandStep::Continue(ract.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_editor(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let sact = fixAction::from(SendAction::SubmitFromEditor); + let step = CommandStep::Continue(sact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_rooms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = ctx.switch(OpenTarget::Application(fixId::RoomList)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); +} + +fn fix_chats(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = ctx.switch(OpenTarget::Application(fixId::ChatList)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); +} + +fn fix_unreads(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + match args.pop().as_deref() { + Some("clear") => { + let clear = fixAction::ClearUnreads; + let step = CommandStep::Continue(clear.into(), ctx.context.clone()); + + return Ok(step); + }, + Some(_) => return Result::Err(CommandError::InvalidArgument), + None => { + let open = ctx.switch(OpenTarget::Application(fixId::UnreadList)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); + }, + } +} + +fn fix_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = ctx.switch(OpenTarget::Application(fixId::SpaceList)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); +} + +fn fix_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = ctx.switch(OpenTarget::Application(fixId::Welcome)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); +} + +fn fix_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.filenames()?; + + if args.len() != 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let open = ctx.switch(args.remove(0)); + let step = CommandStep::Continue(open, ctx.context.clone()); + + return Ok(step); +} + +fn fix_create(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let args = desc.arg.options()?; + let mut flags = CreateRoomFlags::NONE; + let mut alias = None; + let mut ct = CreateRoomType::Room; + + for arg in args { + match arg { + OptionType::Flag(name, Some(arg)) => { + match name.as_str() { + "alias" => { + if alias.is_some() { + let msg = "Multiple ++alias arguments are not allowed"; + let err = CommandError::Error(msg.into()); + + return Err(err); + } else { + alias = Some(arg); + } + }, + _ => return Err(CommandError::InvalidArgument), + } + }, + OptionType::Flag(name, None) => { + match name.as_str() { + "public" => flags |= CreateRoomFlags::PUBLIC, + "space" => ct = CreateRoomType::Space, + "enc" | "encrypted" => flags |= CreateRoomFlags::ENCRYPTED, + _ => return Err(CommandError::InvalidArgument), + } + }, + OptionType::Positional(_) => { + let msg = ":create doesn't take any positional arguments"; + let err = CommandError::Error(msg.into()); + + return Err(err); + }, + } + } + + let hact = HomeserverAction::CreateRoom(alias, ct, flags); + let iact = fixAction::from(hact); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() < 2 { + return Result::Err(CommandError::InvalidArgument); + } + + let field = args.remove(0); + let action = args.remove(0); + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let act: fixAction = match (field.as_str(), action.as_str(), args.pop()) { + // :room dm set + ("dm", "set", None) => RoomAction::SetDirect(true).into(), + ("dm", "set", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room dm set + ("dm", "unset", None) => RoomAction::SetDirect(false).into(), + ("dm", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room [kick|ban|unban] + ("kick", u, r) => { + RoomAction::MemberUpdate(MemberUpdateAction::Kick, u.into(), r, desc.bang).into() + }, + ("ban", u, r) => { + RoomAction::MemberUpdate(MemberUpdateAction::Ban, u.into(), r, desc.bang).into() + }, + ("unban", u, r) => { + RoomAction::MemberUpdate(MemberUpdateAction::Unban, u.into(), r, desc.bang).into() + }, + + // :room history set + ("history", "set", Some(s)) => RoomAction::Set(RoomField::History, s).into(), + ("history", "set", None) => return Result::Err(CommandError::InvalidArgument), + + // :room history unset + ("history", "unset", None) => RoomAction::Unset(RoomField::History).into(), + ("history", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room history show + ("history", "show", None) => RoomAction::Show(RoomField::History).into(), + ("history", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room name set + ("name", "set", Some(s)) => RoomAction::Set(RoomField::Name, s).into(), + ("name", "set", None) => return Result::Err(CommandError::InvalidArgument), + + // :room name unset + ("name", "unset", None) => RoomAction::Unset(RoomField::Name).into(), + ("name", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room topic set + ("topic", "set", Some(s)) => RoomAction::Set(RoomField::Topic, s).into(), + ("topic", "set", None) => return Result::Err(CommandError::InvalidArgument), + + // :room topic unset + ("topic", "unset", None) => RoomAction::Unset(RoomField::Topic).into(), + ("topic", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room tag set + ("tag", "set", Some(s)) => RoomAction::Set(RoomField::Tag(tag_name(s)?), "".into()).into(), + ("tag", "set", None) => return Result::Err(CommandError::InvalidArgument), + + // :room notify set + ("notify", "set", Some(s)) => RoomAction::Set(RoomField::NotificationMode, s).into(), + ("notify", "set", None) => return Result::Err(CommandError::InvalidArgument), + + // :room notify unset + ("notify", "unset", None) => RoomAction::Unset(RoomField::NotificationMode).into(), + ("notify", "unset", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room notify show + ("notify", "show", None) => RoomAction::Show(RoomField::NotificationMode).into(), + ("notify", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room tag unset + ("tag", "unset", Some(s)) => RoomAction::Unset(RoomField::Tag(tag_name(s)?)).into(), + ("tag", "unset", None) => return Result::Err(CommandError::InvalidArgument), + + // :room aliases show + ("alias", "show", None) => RoomAction::Show(RoomField::Aliases).into(), + ("alias", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + // :room aliases unset + ("alias", "unset", Some(s)) => RoomAction::Unset(RoomField::Alias(s)).into(), + ("alias", "unset", None) => return Result::Err(CommandError::InvalidArgument), + + // :room aliases set + ("alias", "set", Some(s)) => RoomAction::Set(RoomField::Alias(s), "".into()).into(), + ("alias", "set", None) => return Result::Err(CommandError::InvalidArgument), + + // :room canonicalalias show + ("canonicalalias" | "canon", "show", None) => { + RoomAction::Show(RoomField::CanonicalAlias).into() + }, + ("canonicalalias" | "canon", "show", Some(_)) => { + return Result::Err(CommandError::InvalidArgument) + }, + + // :room canonicalalias set + ("canonicalalias" | "canon", "set", Some(s)) => { + RoomAction::Set(RoomField::CanonicalAlias, s).into() + }, + ("canonicalalias" | "canon", "set", None) => { + return Result::Err(CommandError::InvalidArgument) + }, + + // :room canonicalalias unset + ("canonicalalias" | "canon", "unset", None) => { + RoomAction::Unset(RoomField::CanonicalAlias).into() + }, + ("canonicalalias" | "canon", "unset", Some(_)) => { + return Result::Err(CommandError::InvalidArgument) + }, + + _ => return Result::Err(CommandError::InvalidArgument), + }; + + let step = CommandStep::Continue(act.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() != 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let sact = SendAction::Upload(args.remove(0)); + let iact = fixAction::from(sact); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let mut flags = DownloadFlags::NONE; + if desc.bang { + flags |= DownloadFlags::FORCE; + }; + let mact = MessageAction::Download(args.pop(), flags); + let iact = fixAction::from(mact); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_open(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let mut flags = DownloadFlags::OPEN; + if desc.bang { + flags |= DownloadFlags::FORCE; + }; + let mact = MessageAction::Download(args.pop(), flags); + let iact = fixAction::from(mact); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn fix_logout(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let args = desc.arg.strings()?; + + if args.is_empty() { + return Result::Err(CommandError::Error("Missing username".to_string())); + } + if args.len() != 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let iact = fixAction::from(HomeserverAction::Logout(args[0].clone(), desc.bang)); + let step = CommandStep::Continue(iact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn add_fix_commands(cmds: &mut ProgramCommands) { + cmds.add_command(ProgramCommand { + name: "cancel".into(), + aliases: vec![], + f: fix_cancel, + }); + cmds.add_command(ProgramCommand { + name: "create".into(), + aliases: vec![], + f: fix_create, + }); + cmds.add_command(ProgramCommand { + name: "chats".into(), + aliases: vec![], + f: fix_chats, + }); + cmds.add_command(ProgramCommand { name: "dms".into(), aliases: vec![], f: fix_dms }); + cmds.add_command(ProgramCommand { + name: "download".into(), + aliases: vec![], + f: fix_download, + }); + cmds.add_command(ProgramCommand { name: "open".into(), aliases: vec![], f: fix_open }); + cmds.add_command(ProgramCommand { name: "edit".into(), aliases: vec![], f: fix_edit }); + cmds.add_command(ProgramCommand { + name: "invite".into(), + aliases: vec![], + f: fix_invite, + }); + cmds.add_command(ProgramCommand { name: "join".into(), aliases: vec![], f: fix_join }); + cmds.add_command(ProgramCommand { name: "keys".into(), aliases: vec![], f: fix_keys }); + cmds.add_command(ProgramCommand { + name: "leave".into(), + aliases: vec![], + f: fix_leave, + }); + cmds.add_command(ProgramCommand { + name: "members".into(), + aliases: vec![], + f: fix_members, + }); + cmds.add_command(ProgramCommand { + name: "react".into(), + aliases: vec![], + f: fix_react, + }); + cmds.add_command(ProgramCommand { + name: "redact".into(), + aliases: vec![], + f: fix_redact, + }); + cmds.add_command(ProgramCommand { + name: "reply".into(), + aliases: vec![], + f: fix_reply, + }); + cmds.add_command(ProgramCommand { + name: "rooms".into(), + aliases: vec![], + f: fix_rooms, + }); + cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: fix_room }); + cmds.add_command(ProgramCommand { + name: "spaces".into(), + aliases: vec![], + f: fix_spaces, + }); + cmds.add_command(ProgramCommand { + name: "unreads".into(), + aliases: vec![], + f: fix_unreads, + }); + cmds.add_command(ProgramCommand { + name: "unreact".into(), + aliases: vec![], + f: fix_unreact, + }); + cmds.add_command(ProgramCommand { + name: "upload".into(), + aliases: vec![], + f: fix_upload, + }); + cmds.add_command(ProgramCommand { + name: "verify".into(), + aliases: vec![], + f: fix_verify, + }); + cmds.add_command(ProgramCommand { + name: "welcome".into(), + aliases: vec![], + f: fix_welcome, + }); + cmds.add_command(ProgramCommand { + name: "editor".into(), + aliases: vec![], + f: fix_editor, + }); + cmds.add_command(ProgramCommand { + name: "logout".into(), + aliases: vec![], + f: fix_logout, + }); +} + +/// Initialize the default command state. +pub fn setup_commands() -> ProgramCommands { + let mut cmds = ProgramCommands::default(); + + add_fix_commands(&mut cmds); + + return cmds; +} + +#[cfg(test)] +mod tests { + use super::*; + use matrix_sdk::ruma::user_id; + use modalkit::actions::WindowAction; + use modalkit::editing::context::EditContext; + + #[test] + fn test_cmd_verify() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd(":verify", ctx.clone()).unwrap(); + let act = WindowAction::Switch(OpenTarget::Application(fixId::VerifyList)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd(":verify request @user1:example.com", ctx.clone()).unwrap(); + let act = fixAction::VerifyRequest("@user1:example.com".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd(":verify accept @user1:example.com/FOOBAR", ctx.clone()) + .unwrap(); + let act = fixAction::Verify(VerifyAction::Accept, "@user1:example.com/FOOBAR".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd(":verify mismatch @user2:example.com/QUUXBAZ", ctx.clone()) + .unwrap(); + let act = fixAction::Verify(VerifyAction::Mismatch, "@user2:example.com/QUUXBAZ".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd(":verify cancel @user3:example.com/MYDEVICE", ctx.clone()) + .unwrap(); + let act = fixAction::Verify(VerifyAction::Cancel, "@user3:example.com/MYDEVICE".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd(":verify confirm @user4:example.com/GOODDEV", ctx.clone()) + .unwrap(); + let act = fixAction::Verify(VerifyAction::Confirm, "@user4:example.com/GOODDEV".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd(":verify confirm", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd(":verify cancel @user4:example.com MYDEVICE", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd(":verify mismatch a b c d e f", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_join() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap(); + let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into())); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("join #", ctx.clone()).unwrap(); + let act = WindowAction::Switch(OpenTarget::Alternate); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("join", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("join foo bar", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_invalid() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room set topic", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_topic_set() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds + .input_cmd("room topic set \"Lots of fun discussion!\"", ctx.clone()) + .unwrap(); + let act = RoomAction::Set(RoomField::Topic, "Lots of fun discussion!".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd("room topic set The\\ Discussion\\ Room", ctx.clone()) + .unwrap(); + let act = RoomAction::Set(RoomField::Topic, "The Discussion Room".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room topic set Development", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Topic, "Development".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room topic", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room topic set", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room topic set A B C", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_name_invalid() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room name", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room name foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_name_set() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room name set Development", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Name, "Development".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd("room name set \"Application Development\"", ctx.clone()) + .unwrap(); + let act = RoomAction::Set(RoomField::Name, "Application Development".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room name set", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_name_unset() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room name unset", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Name); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room name unset foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_dm_set() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room dm set", ctx.clone()).unwrap(); + let act = RoomAction::SetDirect(true); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room dm set true", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_dm_unset() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room dm unset", ctx.clone()).unwrap(); + let act = RoomAction::SetDirect(false); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room dm unset true", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_tag_set() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room tag set favourite", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set favorite", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set fav", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::Favorite), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set low_priority", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::LowPriority), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set low-priority", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::LowPriority), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set low", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::LowPriority), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set servernotice", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::ServerNotice), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set server_notice", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::ServerNotice), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set server_notice", ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::Tag(TagName::ServerNotice), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set u.custom-tag", ctx.clone()).unwrap(); + let act = RoomAction::Set( + RoomField::Tag(TagName::User("u.custom-tag".parse().unwrap())), + "".into(), + ); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag set u.irc", ctx.clone()).unwrap(); + let act = + RoomAction::Set(RoomField::Tag(TagName::User("u.irc".parse().unwrap())), "".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room tag set", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room tag set unknown", ctx.clone()); + assert_eq!(res, Err(CommandError::Error("Invalid user tag name: unknown".into()))); + + let res = cmds.input_cmd("room tag set needs-leading-u-dot", ctx.clone()); + assert_eq!( + res, + Err(CommandError::Error("Invalid user tag name: needs-leading-u-dot".into())) + ); + } + + #[test] + fn test_cmd_room_tag_unset() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room tag unset favourite", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset favorite", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset fav", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::Favorite)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset low_priority", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::LowPriority)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset low-priority", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::LowPriority)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset low", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::LowPriority)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset servernotice", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::ServerNotice)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset server_notice", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::ServerNotice)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset server_notice", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::ServerNotice)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset u.custom-tag", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::User("u.custom-tag".parse().unwrap()))); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag unset u.irc", ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::Tag(TagName::User("u.irc".parse().unwrap()))); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room tag", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room tag set", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("room tag unset unknown", ctx.clone()); + assert_eq!(res, Err(CommandError::Error("Invalid user tag name: unknown".into()))); + + let res = cmds.input_cmd("room tag unset needs-leading-u-dot", ctx.clone()); + assert_eq!( + res, + Err(CommandError::Error("Invalid user tag name: needs-leading-u-dot".into())) + ); + } + + #[test] + fn test_cmd_room_notification_mode_set() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let cmd = format!("room notify set mute"); + let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); + let act = RoomAction::Set(RoomField::NotificationMode, "mute".into()); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let cmd = format!("room notify unset"); + let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); + let act = RoomAction::Unset(RoomField::NotificationMode); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let cmd = format!("room notify show"); + let res = cmds.input_cmd(&cmd, ctx.clone()).unwrap(); + let act = RoomAction::Show(RoomField::NotificationMode); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + } + + #[test] + fn test_cmd_invite() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("invite accept", ctx.clone()).unwrap(); + let act = fixAction::Room(RoomAction::InviteAccept); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("invite reject", ctx.clone()).unwrap(); + let act = fixAction::Room(RoomAction::InviteReject); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("invite send @user:example.com", ctx.clone()).unwrap(); + let act = + fixAction::Room(RoomAction::InviteSend(user_id!("@user:example.com").to_owned())); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("invite", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("invite foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("invite accept @user:example.com", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("invite reject @user:example.com", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("invite send", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("invite @user:example.com", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_room_kick() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room kick @user:example.com", ctx.clone()).unwrap(); + let act = fixAction::Room(RoomAction::MemberUpdate( + MemberUpdateAction::Kick, + "@user:example.com".into(), + None, + false, + )); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room! kick @user:example.com", ctx.clone()).unwrap(); + let act = fixAction::Room(RoomAction::MemberUpdate( + MemberUpdateAction::Kick, + "@user:example.com".into(), + None, + true, + )); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd("room! kick @user:example.com \"reason here\"", ctx.clone()) + .unwrap(); + let act = fixAction::Room(RoomAction::MemberUpdate( + MemberUpdateAction::Kick, + "@user:example.com".into(), + Some("reason here".into()), + true, + )); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + } + + #[test] + fn test_cmd_room_ban_unban() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds + .input_cmd("room! ban @user:example.com \"spam\"", ctx.clone()) + .unwrap(); + let act = fixAction::Room(RoomAction::MemberUpdate( + MemberUpdateAction::Ban, + "@user:example.com".into(), + Some("spam".into()), + true, + )); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds + .input_cmd("room unban @user:example.com \"reconciled\"", ctx.clone()) + .unwrap(); + let act = fixAction::Room(RoomAction::MemberUpdate( + MemberUpdateAction::Unban, + "@user:example.com".into(), + Some("reconciled".into()), + false, + )); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + } + + #[test] + fn test_cmd_redact() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("redact", ctx.clone()).unwrap(); + let act = fixAction::Message(MessageAction::Redact(None, false)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("redact!", ctx.clone()).unwrap(); + let act = fixAction::Message(MessageAction::Redact(None, true)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("redact Removed", ctx.clone()).unwrap(); + let act = fixAction::Message(MessageAction::Redact(Some("Removed".into()), false)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("redact \"Removed\"", ctx.clone()).unwrap(); + let act = fixAction::Message(MessageAction::Redact(Some("Removed".into()), false)); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("redact Removed Removed", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_keys() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("keys import /a/b/c pword", ctx.clone()).unwrap(); + let act = fixAction::Keys(KeysAction::Import("/a/b/c".into(), "pword".into())); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("keys export /a/b/c pword", ctx.clone()).unwrap(); + let act = fixAction::Keys(KeysAction::Export("/a/b/c".into(), "pword".into())); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + // Invalid invocations. + let res = cmds.input_cmd("keys", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("keys import", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("keys import foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("keys import foo bar baz", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6cd2ca2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,1284 @@ +//! # Logic for loading and validating application configuration +use std::borrow::Cow; +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::fmt; +use std::fs::File; +use std::hash::{Hash, Hasher}; +use std::io::{BufReader, BufWriter}; +use std::path::{Path, PathBuf}; +use std::process; + +use clap::Parser; +use matrix_sdk::matrix_auth::MatrixSession; +use matrix_sdk::ruma::{OwnedDeviceId, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId}; +use ratatui::style::{Color, Modifier as StyleModifier, Style}; +use ratatui::text::Span; +use ratatui_image::picker::ProtocolType; +use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer, Serialize}; +use tracing::Level; +use url::Url; + +use modalkit::{env::vim::VimMode, key::TerminalKey, keybindings::InputKey}; + +use super::base::{ + fixError, + fixId, + RoomInfo, + SortColumn, + SortFieldRoom, + SortFieldUser, + SortOrder, +}; + +type Macros = HashMap>; + +macro_rules! usage { + ( $($args: tt)* ) => { + println!($($args)*); + process::exit(2); + } +} + +const DEFAULT_MEMBERS_SORT: [SortColumn; 2] = [ + SortColumn(SortFieldUser::PowerLevel, SortOrder::Ascending), + SortColumn(SortFieldUser::UserId, SortOrder::Ascending), +]; + +const DEFAULT_ROOM_SORT: [SortColumn; 4] = [ + SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending), + SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending), + SortColumn(SortFieldRoom::Unread, SortOrder::Ascending), + SortColumn(SortFieldRoom::Name, SortOrder::Ascending), +]; + +const DEFAULT_REQ_TIMEOUT: u64 = 120; + +const COLORS: [Color; 13] = [ + Color::Blue, + Color::Cyan, + Color::Green, + Color::LightBlue, + Color::LightGreen, + Color::LightCyan, + Color::LightMagenta, + Color::LightRed, + Color::LightYellow, + Color::Magenta, + Color::Red, + Color::Reset, + Color::Yellow, +]; + +pub fn user_color(user: &str) -> Color { + let mut hasher = DefaultHasher::new(); + user.hash(&mut hasher); + let color = hasher.finish() as usize % COLORS.len(); + + COLORS[color] +} + +pub fn user_style_from_color(color: Color) -> Style { + Style::default().fg(color).add_modifier(StyleModifier::BOLD) +} + +fn is_profile_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '.' || c == '-' +} + +fn default_true() -> bool { + true +} + +fn validate_profile_name(name: &str) -> bool { + if name.is_empty() { + return false; + } + + let mut chars = name.chars(); + + if !chars.next().map_or(false, |c| c.is_ascii_alphanumeric()) { + return false; + } + + name.chars().all(is_profile_char) +} + +fn validate_profile_names(names: &HashMap) { + for name in names.keys() { + if validate_profile_name(name.as_str()) { + continue; + } + + usage!( + "{:?} is not a valid profile name.\n\n\ + Profile names can only contain the characters \ + a-z, A-Z, and 0-9. Period (.) and hyphen (-) are allowed after the first character.", + name + ); + } +} + +const VERSION: &str = match option_env!("VERGEN_GIT_SHA") { + None => env!("CARGO_PKG_VERSION"), + Some(_) => concat!(env!("CARGO_PKG_VERSION"), " (", env!("VERGEN_GIT_SHA"), ")"), +}; + +#[derive(Parser)] +#[clap(version = VERSION, about, long_about = None)] +#[clap(propagate_version = true)] +pub struct fix { + #[clap(short = 'P', long, value_parser)] + pub profile: Option, + + #[clap(short = 'C', long, value_parser)] + pub config_directory: Option, +} + +#[derive(thiserror::Error, Debug)] +pub enum ConfigError { + #[error("Error reading configuration file: {0}")] + IO(#[from] std::io::Error), + + #[error("Error loading configuration file: {0}")] + Invalid(#[from] toml::de::Error), + + #[error("Error loading JSON configuration file: {0}")] + InvalidJSON(#[from] serde_json::Error), +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct Keys(pub Vec, pub String); +pub struct KeysVisitor; + +impl<'de> Visitor<'de> for KeysVisitor { + type Value = Keys; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid Vim mode (e.g. \"normal\" or \"insert\")") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + match TerminalKey::from_macro_str(value) { + Ok(keys) => Ok(Keys(keys, value.to_string())), + Err(e) => Err(E::custom(format!("Could not parse key sequence: {e}"))), + } + } +} + +impl<'de> Deserialize<'de> for Keys { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(KeysVisitor) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct VimModes(pub Vec); +pub struct VimModesVisitor; + +impl<'de> Visitor<'de> for VimModesVisitor { + type Value = VimModes; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid Vim mode (e.g. \"normal\" or \"insert\")") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + let mut modes = vec![]; + + for mode in value.split('|') { + let mode = match mode.to_ascii_lowercase().as_str() { + "insert" | "i" => VimMode::Insert, + "normal" | "n" => VimMode::Normal, + "visual" | "v" => VimMode::Visual, + "command" | "c" => VimMode::Command, + "select" => VimMode::Select, + "operator-pending" => VimMode::OperationPending, + _ => return Err(E::custom("Could not parse into a Vim mode")), + }; + + modes.push(mode); + } + + Ok(VimModes(modes)) + } +} + +impl<'de> Deserialize<'de> for VimModes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(VimModesVisitor) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LogLevel(pub Level); +pub struct LogLevelVisitor; + +impl From for Level { + fn from(level: LogLevel) -> Level { + level.0 + } +} + +impl<'de> Visitor<'de> for LogLevelVisitor { + type Value = LogLevel; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid log level (e.g. \"warn\" or \"debug\")") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + match value { + "info" => Ok(LogLevel(Level::INFO)), + "debug" => Ok(LogLevel(Level::DEBUG)), + "warn" => Ok(LogLevel(Level::WARN)), + "error" => Ok(LogLevel(Level::ERROR)), + "trace" => Ok(LogLevel(Level::TRACE)), + _ => Err(E::custom("Could not parse log level")), + } + } +} + +impl<'de> Deserialize<'de> for LogLevel { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(LogLevelVisitor) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserColor(pub Color); +pub struct UserColorVisitor; + +impl<'de> Visitor<'de> for UserColorVisitor { + type Value = UserColor; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid color") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + match value { + "none" => Ok(UserColor(Color::Reset)), + "red" => Ok(UserColor(Color::Red)), + "black" => Ok(UserColor(Color::Black)), + "green" => Ok(UserColor(Color::Green)), + "yellow" => Ok(UserColor(Color::Yellow)), + "blue" => Ok(UserColor(Color::Blue)), + "magenta" => Ok(UserColor(Color::Magenta)), + "cyan" => Ok(UserColor(Color::Cyan)), + "gray" => Ok(UserColor(Color::Gray)), + "dark-gray" => Ok(UserColor(Color::DarkGray)), + "light-red" => Ok(UserColor(Color::LightRed)), + "light-green" => Ok(UserColor(Color::LightGreen)), + "light-yellow" => Ok(UserColor(Color::LightYellow)), + "light-blue" => Ok(UserColor(Color::LightBlue)), + "light-magenta" => Ok(UserColor(Color::LightMagenta)), + "light-cyan" => Ok(UserColor(Color::LightCyan)), + "white" => Ok(UserColor(Color::White)), + _ => Err(E::custom("Could not parse color")), + } + } +} + +impl<'de> Deserialize<'de> for UserColor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(UserColorVisitor) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Session { + access_token: String, + refresh_token: Option, + user_id: OwnedUserId, + device_id: OwnedDeviceId, +} + +impl From for MatrixSession { + fn from(session: Session) -> Self { + MatrixSession { + tokens: matrix_sdk::matrix_auth::MatrixSessionTokens { + access_token: session.access_token, + refresh_token: session.refresh_token, + }, + meta: matrix_sdk::SessionMeta { + user_id: session.user_id, + device_id: session.device_id, + }, + } + } +} + +impl From for Session { + fn from(session: MatrixSession) -> Self { + Session { + access_token: session.tokens.access_token, + refresh_token: session.tokens.refresh_token, + user_id: session.meta.user_id, + device_id: session.meta.device_id, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub struct UserDisplayTunables { + pub color: Option, + pub name: Option, +} + +pub type UserOverrides = HashMap; + +fn merge_sorts(a: SortOverrides, b: SortOverrides) -> SortOverrides { + SortOverrides { + chats: b.chats.or(a.chats), + dms: b.dms.or(a.dms), + rooms: b.rooms.or(a.rooms), + spaces: b.spaces.or(a.spaces), + members: b.members.or(a.members), + } +} + +fn merge_maps(a: Option>, b: Option>) -> Option> +where + K: Eq + Hash, +{ + match (a, b) { + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (Some(mut a), Some(b)) => { + for (k, v) in b { + a.insert(k, v); + } + + Some(a) + }, + (None, None) => None, + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum UserDisplayStyle { + // The Matrix username for the sender (e.g., "@user:example.com"). + #[default] + Username, + + // The localpart of the Matrix username (e.g., "@user"). + LocalPart, + + // The display name for the Matrix user, calculated according to the rules from the spec. + // + // This is usually something like "Ada Lovelace" if the user has configured a display name, but + // it can wind up being the Matrix username if there are display name collisions in the room, + // in order to avoid any confusion. + DisplayName, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NotifyVia { + /// Deliver notifications via terminal bell. + pub bell: bool, + /// Deliver notifications via desktop mechanism. + #[cfg(feature = "desktop")] + pub desktop: bool, +} +pub struct NotifyViaVisitor; + +impl Default for NotifyVia { + fn default() -> Self { + Self { + bell: cfg!(not(feature = "desktop")), + #[cfg(feature = "desktop")] + desktop: true, + } + } +} + +impl<'de> Visitor<'de> for NotifyViaVisitor { + type Value = NotifyVia; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid notify destination (e.g. \"bell\" or \"desktop\")") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + let mut via = NotifyVia { + bell: false, + #[cfg(feature = "desktop")] + desktop: false, + }; + + for value in value.split('|') { + match value.to_ascii_lowercase().as_str() { + "bell" => { + via.bell = true; + }, + #[cfg(feature = "desktop")] + "desktop" => { + via.desktop = true; + }, + #[cfg(not(feature = "desktop"))] + "desktop" => { + return Err(E::custom("desktop notification support was compiled out")) + }, + _ => return Err(E::custom("could not parse into a notify destination")), + }; + } + + Ok(via) + } +} + +impl<'de> Deserialize<'de> for NotifyVia { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(NotifyViaVisitor) + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub struct Notifications { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub via: NotifyVia, + #[serde(default = "default_true")] + pub show_message: bool, +} + +#[derive(Clone)] +pub struct ImagePreviewValues { + pub size: ImagePreviewSize, + pub protocol: Option, +} + +#[derive(Clone, Default, Deserialize)] +pub struct ImagePreview { + pub size: Option, + pub protocol: Option, +} + +impl ImagePreview { + fn values(self) -> ImagePreviewValues { + ImagePreviewValues { + size: self.size.unwrap_or_default(), + protocol: self.protocol, + } + } +} + +#[derive(Clone, Deserialize)] +pub struct ImagePreviewSize { + pub width: usize, + pub height: usize, +} + +impl Default for ImagePreviewSize { + fn default() -> Self { + ImagePreviewSize { width: 66, height: 10 } + } +} + +#[derive(Clone, Deserialize)] +pub struct ImagePreviewProtocolValues { + pub r#type: Option, + pub font_size: Option<(u16, u16)>, +} + +#[derive(Clone)] +pub struct SortValues { + pub chats: Vec>, + pub dms: Vec>, + pub rooms: Vec>, + pub spaces: Vec>, + pub members: Vec>, +} + +#[derive(Clone, Default, Deserialize)] +pub struct SortOverrides { + pub chats: Option>>, + pub dms: Option>>, + pub rooms: Option>>, + pub spaces: Option>>, + pub members: Option>>, +} + +impl SortOverrides { + pub fn values(self) -> SortValues { + let rooms = self.rooms.unwrap_or_else(|| Vec::from(DEFAULT_ROOM_SORT)); + let chats = self.chats.unwrap_or_else(|| rooms.clone()); + let dms = self.dms.unwrap_or_else(|| rooms.clone()); + let spaces = self.spaces.unwrap_or_else(|| rooms.clone()); + let members = self.members.unwrap_or_else(|| Vec::from(DEFAULT_MEMBERS_SORT)); + + SortValues { rooms, members, chats, dms, spaces } + } +} + +#[derive(Clone)] +pub struct TunableValues { + pub log_level: Level, + pub message_shortcode_display: bool, + pub reaction_display: bool, + pub reaction_shortcode_display: bool, + pub read_receipt_send: bool, + pub read_receipt_display: bool, + pub request_timeout: u64, + pub sort: SortValues, + pub typing_notice_send: bool, + pub typing_notice_display: bool, + pub users: UserOverrides, + pub username_display: UserDisplayStyle, + pub message_user_color: bool, + pub default_room: Option, + pub open_command: Option>, + pub notifications: Notifications, + pub image_preview: Option, + pub user_gutter_width: usize, + pub external_edit_file_suffix: String, +} + +#[derive(Clone, Default, Deserialize)] +pub struct Tunables { + pub log_level: Option, + pub message_shortcode_display: Option, + pub reaction_display: Option, + pub reaction_shortcode_display: Option, + pub read_receipt_send: Option, + pub read_receipt_display: Option, + pub request_timeout: Option, + #[serde(default)] + pub sort: SortOverrides, + pub typing_notice_send: Option, + pub typing_notice_display: Option, + pub users: Option, + pub username_display: Option, + pub message_user_color: Option, + pub default_room: Option, + pub open_command: Option>, + pub notifications: Option, + pub image_preview: Option, + pub user_gutter_width: Option, + pub external_edit_file_suffix: Option, +} + +impl Tunables { + fn merge(self, other: Self) -> Self { + Tunables { + log_level: self.log_level.or(other.log_level), + message_shortcode_display: self + .message_shortcode_display + .or(other.message_shortcode_display), + reaction_display: self.reaction_display.or(other.reaction_display), + reaction_shortcode_display: self + .reaction_shortcode_display + .or(other.reaction_shortcode_display), + read_receipt_send: self.read_receipt_send.or(other.read_receipt_send), + read_receipt_display: self.read_receipt_display.or(other.read_receipt_display), + request_timeout: self.request_timeout.or(other.request_timeout), + sort: merge_sorts(self.sort, other.sort), + typing_notice_send: self.typing_notice_send.or(other.typing_notice_send), + typing_notice_display: self.typing_notice_display.or(other.typing_notice_display), + users: merge_maps(self.users, other.users), + username_display: self.username_display.or(other.username_display), + message_user_color: self.message_user_color.or(other.message_user_color), + default_room: self.default_room.or(other.default_room), + open_command: self.open_command.or(other.open_command), + notifications: self.notifications.or(other.notifications), + image_preview: self.image_preview.or(other.image_preview), + user_gutter_width: self.user_gutter_width.or(other.user_gutter_width), + external_edit_file_suffix: self + .external_edit_file_suffix + .or(other.external_edit_file_suffix), + } + } + + fn values(self) -> TunableValues { + TunableValues { + log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO), + message_shortcode_display: self.message_shortcode_display.unwrap_or(false), + reaction_display: self.reaction_display.unwrap_or(true), + reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false), + read_receipt_send: self.read_receipt_send.unwrap_or(true), + read_receipt_display: self.read_receipt_display.unwrap_or(true), + request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT), + sort: self.sort.values(), + typing_notice_send: self.typing_notice_send.unwrap_or(true), + typing_notice_display: self.typing_notice_display.unwrap_or(true), + users: self.users.unwrap_or_default(), + username_display: self.username_display.unwrap_or_default(), + message_user_color: self.message_user_color.unwrap_or(false), + default_room: self.default_room, + open_command: self.open_command, + notifications: self.notifications.unwrap_or_default(), + image_preview: self.image_preview.map(ImagePreview::values), + user_gutter_width: self.user_gutter_width.unwrap_or(30), + external_edit_file_suffix: self + .external_edit_file_suffix + .unwrap_or_else(|| ".md".to_string()), + } + } +} + +#[derive(Clone)] +pub struct DirectoryValues { + pub cache: PathBuf, + pub data: PathBuf, + pub logs: PathBuf, + pub downloads: Option, + pub image_previews: PathBuf, +} + +impl DirectoryValues { + fn create_dir_all(&self) -> std::io::Result<()> { + use std::fs::create_dir_all; + + let Self { cache, data, logs, downloads, image_previews } = self; + + create_dir_all(cache)?; + create_dir_all(data)?; + create_dir_all(logs)?; + create_dir_all(image_previews)?; + + if let Some(downloads) = downloads { + create_dir_all(downloads)?; + } + + Ok(()) + } +} + +#[derive(Clone, Default, Deserialize)] +pub struct Directories { + pub cache: Option, + pub data: Option, + pub logs: Option, + pub downloads: Option, + pub image_previews: Option, +} + +impl Directories { + fn merge(self, other: Self) -> Self { + Directories { + cache: self.cache.or(other.cache), + data: self.data.or(other.data), + logs: self.logs.or(other.logs), + downloads: self.downloads.or(other.downloads), + image_previews: self.image_previews.or(other.image_previews), + } + } + + fn values(self) -> DirectoryValues { + let cache = self + .cache + .or_else(|| { + let mut dir = dirs::cache_dir()?; + dir.push("fix"); + dir.into() + }) + .expect("no dirs.cache value configured!"); + + let data = self + .data + .or_else(|| { + let mut dir = dirs::data_dir()?; + dir.push("fix"); + dir.into() + }) + .expect("no dirs.data value configured!"); + + let logs = self.logs.unwrap_or_else(|| { + let mut dir = cache.clone(); + dir.push("logs"); + dir + }); + + let downloads = self.downloads.or_else(dirs::download_dir); + + let image_previews = self.image_previews.unwrap_or_else(|| { + let mut dir = cache.clone(); + dir.push("image_preview_downloads"); + dir + }); + + DirectoryValues { cache, data, logs, downloads, image_previews } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(untagged)] +pub enum WindowPath { + AliasId(OwnedRoomAliasId), + RoomId(OwnedRoomId), + UserId(OwnedUserId), + Window(fixId), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(untagged, deny_unknown_fields)] +pub enum WindowLayout { + Window { window: WindowPath }, + Split { split: Vec }, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase", tag = "style")] +pub enum Layout { + /// Restore the layout from the previous session. + #[default] + Restore, + + /// Open a single window using the `default_room` value. + New, + + /// Open the window layouts described under `tabs`. + Config { tabs: Vec }, +} + +#[derive(Clone, Deserialize)] +pub struct ProfileConfig { + pub user_id: OwnedUserId, + pub url: Option, + pub settings: Option, + pub dirs: Option, + pub layout: Option, + pub macros: Option, +} + +#[derive(Clone, Deserialize)] +pub struct fixConfig { + pub profiles: HashMap, + pub default_profile: Option, + pub settings: Option, + pub dirs: Option, + pub layout: Option, + pub macros: Option, +} + +impl fixConfig { + pub fn load_toml(path: &Path) -> Result { + let s = std::fs::read_to_string(path)?; + let config = toml::from_str(&s)?; + + Ok(config) + } + + pub fn load_json(path: &Path) -> Result { + let s = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&s)?; + + Ok(config) + } +} + +#[derive(Clone)] +pub struct ApplicationSettings { + pub layout_json: PathBuf, + pub session_json: PathBuf, + pub session_json_old: PathBuf, + pub sled_dir: PathBuf, + pub sqlite_dir: PathBuf, + pub profile_name: String, + pub profile: ProfileConfig, + pub tunables: TunableValues, + pub dirs: DirectoryValues, + pub layout: Layout, + pub macros: Macros, +} + +impl ApplicationSettings { + pub fn load(cli: fix) -> Result> { + let mut config_dir = cli.config_directory.or_else(dirs::config_dir).unwrap_or_else(|| { + usage!( + "No user configuration directory found;\ + please specify one via -C.\n\n + For more information try '--help'" + ); + }); + + config_dir.push("fix"); + let config_json = config_dir.join("config.json"); + let config_toml = config_dir.join("config.toml"); + + let config = if config_toml.is_file() { + fixConfig::load_toml(config_toml.as_path())? + } else if config_json.is_file() { + fixConfig::load_json(config_json.as_path())? + } else { + usage!( + "Please create a configuration file at {}\n\n\ + For more information try '--help'", + config_toml.display(), + ); + }; + + let fixConfig { + mut profiles, + default_profile, + dirs, + settings: global, + layout, + macros, + } = config; + + validate_profile_names(&profiles); + + let (profile_name, mut profile) = if let Some(profile) = cli.profile.or(default_profile) { + profiles.remove_entry(&profile).unwrap_or_else(|| { + usage!( + "No configured profile with the name {:?} in {}", + profile, + config_json.display() + ); + }) + } else if profiles.len() == 1 { + profiles.into_iter().next().unwrap() + } else { + usage!( + "No profile specified. \ + Please use -P or add \"default_profile\" to your configuration.\n\n\ + For more information try '--help'", + ); + }; + + let macros = merge_maps(macros, profile.macros.take()).unwrap_or_default(); + let layout = profile.layout.take().or(layout).unwrap_or_default(); + + let tunables = global.unwrap_or_default(); + let tunables = profile.settings.take().unwrap_or_default().merge(tunables); + let tunables = tunables.values(); + + let dirs = dirs.unwrap_or_default(); + let dirs = profile.dirs.take().unwrap_or_default().merge(dirs); + let dirs = dirs.values(); + + // Create directories + dirs.create_dir_all()?; + + // Set up paths that live inside the profile's data directory. + let mut profile_dir = config_dir.clone(); + profile_dir.push("profiles"); + profile_dir.push(profile_name.as_str()); + + let mut profile_data_dir = dirs.data.clone(); + profile_data_dir.push("profiles"); + profile_data_dir.push(profile_name.as_str()); + + let mut sled_dir = profile_dir.clone(); + sled_dir.push("matrix"); + + let mut sqlite_dir = profile_data_dir.clone(); + sqlite_dir.push("sqlite"); + + let mut session_json = profile_data_dir.clone(); + session_json.push("session.json"); + + let mut session_json_old = profile_dir; + session_json_old.push("session.json"); + + // Set up paths that live inside the profile's cache directory. + let mut cache_dir = dirs.cache.clone(); + cache_dir.push("profiles"); + cache_dir.push(profile_name.as_str()); + + let mut layout_json = cache_dir.clone(); + layout_json.push("layout.json"); + + let settings = ApplicationSettings { + sled_dir, + layout_json, + session_json, + session_json_old, + sqlite_dir, + profile_name, + profile, + tunables, + dirs, + layout, + macros, + }; + + Ok(settings) + } + + pub fn read_session(&self, path: impl AsRef) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let session = serde_json::from_reader(reader).map_err(fixError::from)?; + Ok(session) + } + + pub fn write_session(&self, session: MatrixSession) -> Result<(), fixError> { + let file = File::create(self.session_json.as_path())?; + let writer = BufWriter::new(file); + let session = Session::from(session); + serde_json::to_writer(writer, &session).map_err(fixError::from)?; + Ok(()) + } + + pub fn get_user_char_span<'a>(&self, user_id: &'a UserId) -> Span<'a> { + let (color, c) = self + .tunables + .users + .get(user_id) + .map(|user| { + ( + user.color.as_ref().map(|c| c.0), + user.name.as_ref().and_then(|s| s.chars().next()), + ) + }) + .unwrap_or_default(); + + let color = color.unwrap_or_else(|| user_color(user_id.as_str())); + let style = user_style_from_color(color); + + let c = c.unwrap_or_else(|| user_id.localpart().chars().next().unwrap_or(' ')); + + Span::styled(String::from(c), style) + } + + pub fn get_user_overrides( + &self, + user_id: &UserId, + ) -> (Option, Option>) { + self.tunables + .users + .get(user_id) + .map(|user| (user.color.as_ref().map(|c| c.0), user.name.clone().map(Cow::Owned))) + .unwrap_or_default() + } + + pub fn get_user_color(&self, user_id: &UserId) -> Color { + self.tunables + .users + .get(user_id) + .and_then(|user| user.color.as_ref().map(|c| c.0)) + .unwrap_or_else(|| user_color(user_id.as_str())) + } + + pub fn get_user_style(&self, user_id: &UserId) -> Style { + user_style_from_color(self.get_user_color(user_id)) + } + + pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> { + let (color, name) = self.get_user_overrides(user_id); + + let color = color.unwrap_or_else(|| user_color(user_id.as_str())); + let style = user_style_from_color(color); + let name = match (name, &self.tunables.username_display) { + (Some(name), _) => name, + (None, UserDisplayStyle::Username) => Cow::Borrowed(user_id.as_str()), + (None, UserDisplayStyle::LocalPart) => Cow::Borrowed(user_id.localpart()), + (None, UserDisplayStyle::DisplayName) => { + if let Some(display) = info.display_names.get(user_id) { + Cow::Borrowed(display.as_str()) + } else { + Cow::Borrowed(user_id.as_str()) + } + }, + }; + + Span::styled(name, style) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use matrix_sdk::ruma::user_id; + use std::convert::TryFrom; + + #[test] + fn test_profile_name_invalid() { + assert_eq!(validate_profile_name(""), false); + assert_eq!(validate_profile_name(" "), false); + assert_eq!(validate_profile_name("a b"), false); + assert_eq!(validate_profile_name("foo^bar"), false); + assert_eq!(validate_profile_name("FOO/BAR"), false); + assert_eq!(validate_profile_name("-b-c"), false); + assert_eq!(validate_profile_name("-B-c"), false); + assert_eq!(validate_profile_name(".b-c"), false); + assert_eq!(validate_profile_name(".B-c"), false); + } + + #[test] + fn test_profile_name_valid() { + assert_eq!(validate_profile_name("foo"), true); + assert_eq!(validate_profile_name("FOO"), true); + assert_eq!(validate_profile_name("a-b-c"), true); + assert_eq!(validate_profile_name("a-B-c"), true); + assert_eq!(validate_profile_name("a.b-c"), true); + assert_eq!(validate_profile_name("a.B-c"), true); + } + + #[test] + fn test_merge_users() { + let a = None; + let b = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { + color: Some(UserColor(Color::Red)), + name: Some("Hello".into()), + })] + .into_iter() + .collect::>(); + let c = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { + color: Some(UserColor(Color::Green)), + name: Some("World".into()), + })] + .into_iter() + .collect::>(); + + let res = merge_maps(a.clone(), a.clone()); + assert_eq!(res, None); + + let res = merge_maps(a.clone(), Some(b.clone())); + assert_eq!(res, Some(b.clone())); + + let res = merge_maps(Some(b.clone()), a.clone()); + assert_eq!(res, Some(b.clone())); + + let res = merge_maps(Some(b.clone()), Some(b.clone())); + assert_eq!(res, Some(b.clone())); + + let res = merge_maps(Some(b.clone()), Some(c.clone())); + assert_eq!(res, Some(c.clone())); + + let res = merge_maps(Some(c.clone()), Some(b.clone())); + assert_eq!(res, Some(b.clone())); + } + + #[test] + fn test_parse_tunables() { + let res: Tunables = serde_json::from_str("{}").unwrap(); + assert_eq!(res.typing_notice_send, None); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, None); + + let res: Tunables = serde_json::from_str("{\"typing_notice_send\": true}").unwrap(); + assert_eq!(res.typing_notice_send, Some(true)); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, None); + + let res: Tunables = serde_json::from_str("{\"typing_notice_send\": false}").unwrap(); + assert_eq!(res.typing_notice_send, Some(false)); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, None); + + let res: Tunables = serde_json::from_str("{\"users\": {}}").unwrap(); + assert_eq!(res.typing_notice_send, None); + assert_eq!(res.typing_notice_display, None); + assert_eq!(res.users, Some(HashMap::new())); + + let res: Tunables = serde_json::from_str( + "{\"users\": {\"@a:b.c\": {\"color\": \"black\", \"name\": \"Tim\"}}}", + ) + .unwrap(); + assert_eq!(res.typing_notice_send, None); + assert_eq!(res.typing_notice_display, None); + let users = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { + color: Some(UserColor(Color::Black)), + name: Some("Tim".into()), + })]; + assert_eq!(res.users, Some(users.into_iter().collect())); + } + + #[test] + fn test_parse_tunables_username_display() { + let res: Tunables = serde_json::from_str("{\"username_display\": \"username\"}").unwrap(); + assert_eq!(res.username_display, Some(UserDisplayStyle::Username)); + + let res: Tunables = serde_json::from_str("{\"username_display\": \"localpart\"}").unwrap(); + assert_eq!(res.username_display, Some(UserDisplayStyle::LocalPart)); + + let res: Tunables = + serde_json::from_str("{\"username_display\": \"displayname\"}").unwrap(); + assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName)); + } + + #[test] + fn test_parse_tunables_sort() { + let res: Tunables = serde_json::from_str( + r#"{"sort": {"members": ["server","~localpart"],"spaces":["~favorite", "alias"]}}"#, + ) + .unwrap(); + assert_eq!( + res.sort.members, + Some(vec![ + SortColumn(SortFieldUser::Server, SortOrder::Ascending), + SortColumn(SortFieldUser::LocalPart, SortOrder::Descending), + ]) + ); + assert_eq!( + res.sort.spaces, + Some(vec![ + SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + ]) + ); + assert_eq!(res.sort.rooms, None); + assert_eq!(res.sort.dms, None); + + // Check that we get the right default "rooms" and "dms" values. + let res = res.values(); + assert_eq!(res.sort.members, vec![ + SortColumn(SortFieldUser::Server, SortOrder::Ascending), + SortColumn(SortFieldUser::LocalPart, SortOrder::Descending), + ]); + assert_eq!(res.sort.spaces, vec![ + SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + ]); + assert_eq!(res.sort.rooms, Vec::from(DEFAULT_ROOM_SORT)); + assert_eq!(res.sort.dms, Vec::from(DEFAULT_ROOM_SORT)); + } + + #[test] + fn test_parse_layout() { + let user = WindowPath::UserId(user_id!("@user:example.com").to_owned()); + let alias = WindowPath::AliasId(OwnedRoomAliasId::try_from("#room:example.com").unwrap()); + let room = WindowPath::RoomId(OwnedRoomId::try_from("!room:example.com").unwrap()); + let dms = WindowPath::Window(fixId::DirectList); + let welcome = WindowPath::Window(fixId::Welcome); + + let res: Layout = serde_json::from_str("{\"style\": \"restore\"}").unwrap(); + assert_eq!(res, Layout::Restore); + + let res: Layout = serde_json::from_str("{\"style\": \"new\"}").unwrap(); + assert_eq!(res, Layout::New); + + let res: Layout = serde_json::from_str( + "{\"style\": \"config\", \"tabs\": [{\"window\":\"@user:example.com\"}]}", + ) + .unwrap(); + assert_eq!(res, Layout::Config { + tabs: vec![WindowLayout::Window { window: user.clone() }] + }); + + let res: Layout = serde_json::from_str( + "{\ + \"style\": \"config\",\ + \"tabs\": [\ + {\"split\":[\ + {\"window\":\"@user:example.com\"},\ + {\"window\":\"#room:example.com\"}\ + ]},\ + {\"split\":[\ + {\"window\":\"!room:example.com\"},\ + {\"split\":[\ + {\"window\":\"fix://dms\"},\ + {\"window\":\"fix://welcome\"}\ + ]}\ + ]}\ + ]}", + ) + .unwrap(); + let split1 = WindowLayout::Split { + split: vec![ + WindowLayout::Window { window: user.clone() }, + WindowLayout::Window { window: alias }, + ], + }; + let split2 = WindowLayout::Split { + split: vec![WindowLayout::Window { window: dms }, WindowLayout::Window { + window: welcome, + }], + }; + let split3 = WindowLayout::Split { + split: vec![WindowLayout::Window { window: room }, split2], + }; + let tabs = vec![split1, split3]; + assert_eq!(res, Layout::Config { tabs }); + } + + #[test] + fn test_parse_macros() { + let res: Macros = serde_json::from_str("{\"i|c\":{\"jj\":\"\"}}").unwrap(); + assert_eq!(res.len(), 1); + + let modes = VimModes(vec![VimMode::Insert, VimMode::Command]); + let mapped = res.get(&modes).unwrap(); + assert_eq!(mapped.len(), 1); + + let j = "j".parse::().unwrap(); + let esc = "".parse::().unwrap(); + + let jj = Keys(vec![j, j], "jj".into()); + let run = mapped.get(&jj).unwrap(); + let exp = Keys(vec![esc], "".into()); + assert_eq!(run, &exp); + } + + #[test] + fn test_parse_notify_via() { + assert_eq!(NotifyVia { bell: false, desktop: true }, NotifyVia::default()); + assert_eq!( + NotifyVia { bell: false, desktop: true }, + serde_json::from_str(r#""desktop""#).unwrap() + ); + assert_eq!( + NotifyVia { bell: true, desktop: false }, + serde_json::from_str(r#""bell""#).unwrap() + ); + assert_eq!( + NotifyVia { bell: true, desktop: true }, + serde_json::from_str(r#""bell|desktop""#).unwrap() + ); + assert_eq!( + NotifyVia { bell: true, desktop: true }, + serde_json::from_str(r#""desktop|bell""#).unwrap() + ); + assert!(serde_json::from_str::(r#""other""#).is_err()); + assert!(serde_json::from_str::(r#""""#).is_err()); + } + + #[test] + fn test_load_example_config_toml() { + let path = PathBuf::from("config.example.toml"); + let config = fixConfig::load_toml(&path).expect("can load example_config.toml"); + + let fixConfig { + profiles, + default_profile, + settings, + dirs, + layout, + macros, + } = &config; + + // There should be an example object for each top-level field. + assert!(!profiles.is_empty()); + assert!(default_profile.is_some()); + assert!(settings.is_some()); + assert!(dirs.is_some()); + assert!(layout.is_some()); + assert!(macros.is_some()); + } +} diff --git a/src/keybindings.rs b/src/keybindings.rs new file mode 100644 index 0000000..e33d16e --- /dev/null +++ b/src/keybindings.rs @@ -0,0 +1,89 @@ +//! # Default Keybindings +//! +//! The keybindings are set up here. We define some fix-specific keybindings, but the default Vim +//! keys come from [modalkit::env::vim::keybindings]. +use modalkit::{ + actions::{InsertTextAction, MacroAction, WindowAction}, + env::vim::keybindings::{InputStep, VimBindings}, + env::vim::VimMode, + env::CommonKeyClass, + key::TerminalKey, + keybindings::{EdgeEvent, EdgeRepeat, InputBindings}, + prelude::*, +}; + +use crate::base::{fixAction, fixInfo, Keybindings, MATRIX_ID_WORD}; +use crate::config::{ApplicationSettings, Keys}; + +pub type fixStep = InputStep; + +fn once(key: &TerminalKey) -> (EdgeRepeat, EdgeEvent) { + (EdgeRepeat::Once, EdgeEvent::Key(*key)) +} + +/// Initialize the default keybinding state. +pub fn setup_keybindings() -> Keybindings { + let mut ism = Keybindings::empty(); + + let vim = VimBindings::default() + .submit_on_enter() + .cursor_open(MATRIX_ID_WORD.clone()); + + vim.setup(&mut ism); + + let ctrl_w = "".parse::().unwrap(); + let ctrl_m = "".parse::().unwrap(); + let ctrl_z = "".parse::().unwrap(); + let key_m_lc = "m".parse::().unwrap(); + let key_z_lc = "z".parse::().unwrap(); + let shift_enter = "".parse::().unwrap(); + + let cwz = vec![once(&ctrl_w), once(&key_z_lc)]; + let cwcz = vec![once(&ctrl_w), once(&ctrl_z)]; + let zoom = fixStep::new() + .actions(vec![WindowAction::ZoomToggle.into()]) + .goto(VimMode::Normal); + + ism.add_mapping(VimMode::Normal, &cwz, &zoom); + ism.add_mapping(VimMode::Visual, &cwz, &zoom); + ism.add_mapping(VimMode::Normal, &cwcz, &zoom); + ism.add_mapping(VimMode::Visual, &cwcz, &zoom); + + let cwm = vec![once(&ctrl_w), once(&key_m_lc)]; + let cwcm = vec![once(&ctrl_w), once(&ctrl_m)]; + let stoggle = fixStep::new() + .actions(vec![fixAction::ToggleScrollbackFocus.into()]) + .goto(VimMode::Normal); + ism.add_mapping(VimMode::Normal, &cwm, &stoggle); + ism.add_mapping(VimMode::Visual, &cwm, &stoggle); + ism.add_mapping(VimMode::Normal, &cwcm, &stoggle); + ism.add_mapping(VimMode::Visual, &cwcm, &stoggle); + + let shift_enter = vec![once(&shift_enter)]; + let newline = fixStep::new().actions(vec![InsertTextAction::Type( + Char::Single('\n').into(), + MoveDir1D::Previous, + 1.into(), + ) + .into()]); + ism.add_mapping(VimMode::Insert, &cwm, &newline); + ism.add_mapping(VimMode::Insert, &shift_enter, &newline); + + ism +} + +impl InputBindings for ApplicationSettings { + fn setup(&self, bindings: &mut Keybindings) { + for (modes, keys) in &self.macros { + for (Keys(input, _), Keys(_, run)) in keys { + let act = MacroAction::Run(run.clone(), Count::Contextual); + let step = fixStep::new().actions(vec![act.into()]); + let input = input.iter().map(once).collect::>(); + + for mode in &modes.0 { + bindings.add_mapping(*mode, &input, &step); + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..10adace --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1069 @@ +//! # fix +//! +//! The fix client loops over user input and commands, and turns them into actions, [some of +//! which][fixAction] are specific to fix, and [some of which][Action] come from [modalkit]. When +//! adding new functionality, you will usually want to extend [fixAction] or one of its variants +//! (like [RoomAction][base::RoomAction]), and then add an appropriate [command][commands] or +//! [keybinding][keybindings]. +//! +//! For more complicated changes, you may need to update [the async worker thread][worker], which +//! handles background Matrix tasks with [matrix-rust-sdk][matrix_sdk]. +//! +//! Most rendering logic lives under the [windows] module, but [Matrix messages][message] have +//! their own module. +#![allow(clippy::manual_range_contains)] +#![allow(clippy::needless_return)] +#![allow(clippy::result_large_err)] +#![allow(clippy::bool_assert_comparison)] +use std::collections::VecDeque; +use std::convert::TryFrom; +use std::fmt::Display; +use std::fs::{create_dir_all, File}; +use std::io::{stdout, BufWriter, Stdout, Write}; +use std::ops::DerefMut; +use std::process; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use clap::Parser; +use matrix_sdk::crypto::encrypt_room_key_export; +use matrix_sdk::ruma::api::client::error::ErrorKind; +use matrix_sdk::ruma::OwnedUserId; +use modalkit::keybindings::InputBindings; +use rand::{distributions::Alphanumeric, Rng}; +use temp_dir::TempDir; +use tokio::sync::Mutex as AsyncMutex; +use tracing_subscriber::FmtSubscriber; + +use modalkit::crossterm::{ + self, + cursor::Show as CursorShow, + event::{ + poll, + read, + DisableBracketedPaste, + DisableFocusChange, + EnableBracketedPaste, + EnableFocusChange, + Event, + KeyEventKind, + KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, + }, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle}, +}; + +use ratatui::{ + backend::CrosstermBackend, + layout::Rect, + style::{Color, Style}, + text::Span, + widgets::Paragraph, + Terminal, +}; + +mod base; +mod commands; +mod config; +mod keybindings; +mod message; +mod notifications; +mod preview; +mod sled_export; +mod util; +mod windows; +mod worker; + +#[cfg(test)] +mod tests; + +use crate::{ + base::{ + AsyncProgramStore, + ChatStore, + HomeserverAction, + fixAction, + fixError, + fixId, + fixInfo, + fixResult, + KeysAction, + ProgramAction, + ProgramContext, + ProgramStore, + }, + config::{ApplicationSettings, fix}, + windows::fixWindow, + worker::{create_room, ClientWorker, LoginStyle, Requester}, +}; + +use modalkit::{ + actions::{ + Action, + Commandable, + Editable, + EditorAction, + InsertTextAction, + Jumpable, + Promptable, + Scrollable, + TabAction, + TabContainer, + TabCount, + WindowAction, + WindowContainer, + }, + editing::{context::Resolve, key::KeyManager, store::Store}, + errors::{EditError, UIError}, + key::TerminalKey, + keybindings::{ + dialog::{Pager, PromptYesNo}, + BindingMachine, + }, + prelude::*, + ui::FocusList, +}; + +use modalkit_ratatui::{ + cmdbar::CommandBarState, + screen::{Screen, ScreenState, TabbedLayoutDescription}, + windows::{WindowLayoutDescription, WindowLayoutState}, + TerminalCursor, + TerminalExtOps, + Window, +}; + +fn config_tab_to_desc( + layout: config::WindowLayout, + store: &mut ProgramStore, +) -> fixResult> { + let desc = match layout { + config::WindowLayout::Window { window } => { + let ChatStore { names, worker, .. } = &mut store.application; + + let window = match window { + config::WindowPath::UserId(user_id) => { + let name = user_id.to_string(); + let room_id = worker.join_room(name.clone())?; + names.insert(name, room_id.clone()); + fixId::Room(room_id, None) + }, + config::WindowPath::RoomId(room_id) => fixId::Room(room_id, None), + config::WindowPath::AliasId(alias) => { + let name = alias.to_string(); + let room_id = worker.join_room(name.clone())?; + names.insert(name, room_id.clone()); + fixId::Room(room_id, None) + }, + config::WindowPath::Window(id) => id, + }; + + WindowLayoutDescription::Window { window, length: None } + }, + config::WindowLayout::Split { split } => { + let children = split + .into_iter() + .map(|child| config_tab_to_desc(child, store)) + .collect::>>()?; + + WindowLayoutDescription::Split { children, length: None } + }, + }; + + Ok(desc) +} + +fn restore_layout( + area: Rect, + settings: &ApplicationSettings, + store: &mut ProgramStore, +) -> fixResult>> { + let layout = std::fs::read(&settings.layout_json)?; + let tabs: TabbedLayoutDescription = + serde_json::from_slice(&layout).map_err(fixError::from)?; + tabs.to_layout(area.into(), store) +} + +fn setup_screen( + settings: ApplicationSettings, + store: &mut ProgramStore, +) -> fixResult> { + let cmd = CommandBarState::new(store); + let dims = crossterm::terminal::size()?; + let area = Rect::new(0, 0, dims.0, dims.1); + + match settings.layout { + config::Layout::Restore => { + match restore_layout(area, &settings, store) { + Ok(tabs) => { + return Ok(ScreenState::from_list(tabs, cmd)); + }, + Err(e) => { + // Log the issue with restoring and then continue. + tracing::warn!(err = %e, "Failed to restore layout from disk"); + }, + } + }, + config::Layout::New => {}, + config::Layout::Config { tabs } => { + let mut list = FocusList::default(); + + for tab in tabs.into_iter() { + let tab = config_tab_to_desc(tab, store)?; + let tab = tab.to_layout(area.into(), store)?; + list.push(tab); + } + + return Ok(ScreenState::from_list(list, cmd)); + }, + } + + let win = settings + .tunables + .default_room + .and_then(|room| fixWindow::find(room, store).ok()) + .or_else(|| fixWindow::open(fixId::Welcome, store).ok()) + .unwrap(); + + return Ok(ScreenState::new(win, cmd)); +} + +/// The main application state and event loop. +struct Application { + /// Terminal backend. + terminal: Terminal>, + + /// State for the Matrix client, editing, etc. + store: AsyncProgramStore, + + /// UI state (open tabs, command bar, etc.) to use when rendering. + screen: ScreenState, + + /// Handle to communicate synchronously with the Matrix worker task. + worker: Requester, + + /// Mapped keybindings. + bindings: KeyManager, + + /// Pending actions to run. + actstack: VecDeque<(ProgramAction, ProgramContext)>, + + /// Whether or not the terminal is currently focused. + focused: bool, + + /// The tab layout before the last executed [TabAction]. + last_layout: Option>, + + /// Whether we need to do a full redraw (e.g., after running a subprocess). + dirty: bool, +} + +impl Application { + pub async fn new( + settings: ApplicationSettings, + store: AsyncProgramStore, + ) -> fixResult { + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend)?; + + let mut bindings = crate::keybindings::setup_keybindings(); + settings.setup(&mut bindings); + let bindings = KeyManager::new(bindings); + + let mut locked = store.lock().await; + let screen = setup_screen(settings, locked.deref_mut())?; + + let worker = locked.application.worker.clone(); + + drop(locked); + + let actstack = VecDeque::new(); + + Ok(Application { + store, + worker, + terminal, + bindings, + actstack, + screen, + focused: true, + last_layout: None, + dirty: true, + }) + } + + fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> { + let bindings = &mut self.bindings; + let focused = self.focused; + let sstate = &mut self.screen; + let term = &mut self.terminal; + + if store.application.ring_bell { + store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err(); + } + + if full { + term.clear()?; + } + + term.draw(|f| { + let area = f.size(); + + let modestr = bindings.show_mode(); + let cursor = bindings.get_cursor_indicator(); + let dialogstr = bindings.show_dialog(area.height as usize, area.width as usize); + + // Don't show terminal cursor when we show a dialog. + let hide_cursor = !dialogstr.is_empty(); + + store.application.draw_curr = Some(Instant::now()); + let screen = Screen::new(store) + .show_dialog(dialogstr) + .show_mode(modestr) + .borders(true) + .focus(focused); + f.render_stateful_widget(screen, area, sstate); + + if hide_cursor { + return; + } + + if let Some((cx, cy)) = sstate.get_term_cursor() { + if let Some(c) = cursor { + let style = Style::default().fg(Color::Green); + let span = Span::styled(c.to_string(), style); + let para = Paragraph::new(span); + let inner = Rect::new(cx, cy, 1, 1); + f.render_widget(para, inner) + } + f.set_cursor(cx, cy); + } + })?; + + Ok(()) + } + + async fn step(&mut self) -> Result { + loop { + self.redraw(self.dirty, self.store.clone().lock().await.deref_mut())?; + self.dirty = false; + + if !poll(Duration::from_secs(1))? { + // Redraw in case there's new messages to show. + continue; + } + + match read()? { + Event::Key(ke) => { + if ke.kind == KeyEventKind::Release { + continue; + } + + return Ok(ke.into()); + }, + Event::Mouse(_) => { + // Do nothing for now. + }, + Event::FocusGained => { + let mut store = self.store.lock().await; + store.application.focused = true; + self.focused = true; + }, + Event::FocusLost => { + let mut store = self.store.lock().await; + store.application.focused = false; + self.focused = false; + }, + Event::Resize(_, _) => { + // We'll redraw for the new size next time step() is called. + }, + Event::Paste(s) => { + let act = InsertTextAction::Transcribe(s, MoveDir1D::Previous, 1.into()); + let act = EditorAction::from(act); + let ctx = ProgramContext::default(); + let mut store = self.store.lock().await; + + match self.screen.editor_command(&act, &ctx, store.deref_mut()) { + Ok(None) => {}, + Ok(Some(info)) => { + drop(store); + self.handle_info(info); + }, + Err(e) => { + self.screen.push_error(e); + }, + } + }, + } + } + } + + fn action_prepend(&mut self, acts: Vec<(ProgramAction, ProgramContext)>) { + let mut acts = VecDeque::from(acts); + acts.append(&mut self.actstack); + self.actstack = acts; + } + + fn action_pop(&mut self, keyskip: bool) -> Option<(ProgramAction, ProgramContext)> { + if let res @ Some(_) = self.actstack.pop_front() { + return res; + } + + if keyskip { + return None; + } else { + return self.bindings.pop(); + } + } + + async fn action_run( + &mut self, + action: ProgramAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + let info = match action { + // Do nothing. + Action::NoOp => None, + + Action::Editor(act) => { + match self.screen.editor_command(&act, &ctx, store) { + Ok(info) => info, + Err(EditError::WrongBuffer(content)) if act.is_switchable(&ctx) => { + // Switch to the right window. + if let Some(winid) = content.to_window() { + let open = OpenTarget::Application(winid); + let open = WindowAction::Switch(open); + let _ = self.screen.window_command(&open, &ctx, store)?; + + // Run command again. + self.screen.editor_command(&act, &ctx, store)? + } else { + return Err(EditError::WrongBuffer(content).into()); + } + }, + Err(err) => return Err(err.into()), + } + }, + + // Simple delegations. + Action::Application(act) => self.fix_run(act, ctx, store).await?, + Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?, + Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?, + Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?, + Action::ShowInfoMessage(info) => Some(info), + Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?, + + Action::Jump(l, dir, count) => { + let count = ctx.resolve(&count); + let _ = self.screen.jump(l, dir, count, &ctx)?; + + None + }, + Action::Suspend => { + self.terminal.program_suspend()?; + + None + }, + + // UI actions. + Action::Tab(cmd) => { + if let TabAction::Close(_, _) = &cmd { + self.last_layout = self.screen.as_description().into(); + } + + self.screen.tab_command(&cmd, &ctx, store)? + }, + Action::RedrawScreen => { + self.screen.clear_message(); + self.redraw(true, store)?; + + None + }, + + // Actions that create more Actions. + Action::Prompt(act) => { + let acts = self.screen.prompt(&act, &ctx, store)?; + self.action_prepend(acts); + + None + }, + Action::Command(act) => { + let acts = store.application.cmds.command(&act, &ctx, &mut store.registers)?; + self.action_prepend(acts); + + None + }, + Action::Repeat(rt) => { + self.bindings.repeat(rt, Some(ctx)); + + None + }, + + // Unimplemented. + Action::KeywordLookup => { + // XXX: implement + None + }, + + _ => { + // XXX: log unhandled actions? print message? + None + }, + }; + + return Ok(info); + } + + async fn fix_run( + &mut self, + action: fixAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + if action.scribbles() { + self.dirty = true; + } + + let info = match action { + fixAction::ClearUnreads => { + let user_id = &store.application.settings.profile.user_id; + + for room_id in store.application.sync_info.chats() { + if let Some(room) = store.application.rooms.get_mut(room_id) { + room.fully_read(user_id.clone()); + } + } + + None + }, + + fixAction::ToggleScrollbackFocus => { + self.screen.current_window_mut()?.focus_toggle(); + + None + }, + + fixAction::Homeserver(act) => { + let acts = self.homeserver_command(act, ctx, store).await?; + self.action_prepend(acts); + + None + }, + fixAction::Keys(act) => self.keys_command(act, ctx, store).await?, + fixAction::Message(act) => { + self.screen.current_window_mut()?.message_command(act, ctx, store).await? + }, + fixAction::Room(act) => { + let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?; + self.action_prepend(acts); + + None + }, + fixAction::Send(act) => { + self.screen.current_window_mut()?.send_command(act, ctx, store).await? + }, + + fixAction::OpenLink(url) => { + tokio::task::spawn_blocking(move || { + return open::that(url); + }); + + None + }, + + fixAction::Verify(act, user_dev) => { + if let Some(sas) = store.application.verifications.get(&user_dev) { + self.worker.verify(act, sas.clone())? + } else { + return Err(fixError::InvalidVerificationId(user_dev).into()); + } + }, + fixAction::VerifyRequest(user_id) => { + if let Ok(user_id) = OwnedUserId::try_from(user_id.as_str()) { + self.worker.verify_request(user_id)? + } else { + return Err(fixError::InvalidUserId(user_id).into()); + } + }, + }; + + Ok(info) + } + + async fn homeserver_command( + &mut self, + action: HomeserverAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult, ProgramContext)>> { + match action { + HomeserverAction::CreateRoom(alias, vis, flags) => { + let client = &store.application.worker.client; + let room_id = create_room(client, alias, vis, flags).await?; + let room = fixId::Room(room_id, None); + let target = OpenTarget::Application(room); + let action = WindowAction::Switch(target); + + Ok(vec![(action.into(), ctx)]) + }, + HomeserverAction::Logout(user, true) => { + self.worker.logout(user)?; + let flags = CloseFlags::QUIT | CloseFlags::FORCE; + let act = TabAction::Close(TabTarget::All, flags); + + Ok(vec![(act.into(), ctx)]) + }, + HomeserverAction::Logout(user, false) => { + let msg = "Would you like to logout?"; + let act = fixAction::from(HomeserverAction::Logout(user, true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + Err(UIError::NeedConfirm(prompt)) + }, + } + } + + async fn keys_command( + &mut self, + action: KeysAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + let encryption = store.application.worker.client.encryption(); + + match action { + KeysAction::Export(path, passphrase) => { + encryption + .export_room_keys(path.into(), &passphrase, |_| true) + .await + .map_err(fixError::from)?; + + Ok(Some("Successfully exported room keys".into())) + }, + KeysAction::Import(path, passphrase) => { + let res = encryption + .import_room_keys(path.into(), &passphrase) + .await + .map_err(fixError::from)?; + + let msg = format!("Imported {} of {} keys", res.imported_count, res.total_count); + + Ok(Some(msg.into())) + }, + } + } + + fn handle_info(&mut self, info: InfoMessage) { + match info { + InfoMessage::Message(info) => { + self.screen.push_info(info); + }, + InfoMessage::Pager(text) => { + let pager = Box::new(Pager::new(text, vec![])); + self.bindings.run_dialog(pager); + }, + } + } + + pub async fn run(&mut self) -> Result<(), std::io::Error> { + self.terminal.clear()?; + + let store = self.store.clone(); + + while self.screen.tabs() != 0 { + let key = self.step().await?; + + self.bindings.input_key(key); + + let mut locked = store.lock().await; + let mut keyskip = false; + + while let Some((action, ctx)) = self.action_pop(keyskip) { + match self.action_run(action, ctx, locked.deref_mut()).await { + Ok(None) => { + // Continue processing. + continue; + }, + Ok(Some(info)) => { + self.handle_info(info); + + // Continue processing; we'll redraw later. + continue; + }, + Err( + UIError::NeedConfirm(dialog) | + UIError::EditingFailure(EditError::NeedConfirm(dialog)), + ) => { + self.bindings.run_dialog(dialog); + continue; + }, + Err(e) => { + self.screen.push_error(e); + + // Skip processing any more keypress Actions until the next key. + keyskip = true; + continue; + }, + } + } + } + + if let Some(ref layout) = self.last_layout { + let locked = self.store.lock().await; + let path = locked.application.settings.layout_json.as_path(); + path.parent().map(create_dir_all).transpose()?; + + let file = File::create(path)?; + let writer = BufWriter::new(file); + + if let Err(e) = serde_json::to_writer(writer, layout) { + tracing::error!("Failed to save window layout while exiting: {}", e); + } + } + + crossterm::terminal::disable_raw_mode()?; + execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; + self.terminal.show_cursor()?; + + return Ok(()); + } +} + +fn gen_passphrase() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(20) + .map(char::from) + .collect() +} + +fn read_response(question: &str) -> String { + println!("{question}"); + let mut input = String::new(); + let _ = std::io::stdin().read_line(&mut input); + input +} + +fn read_yesno(question: &str) -> Option { + read_response(question).chars().next().map(|c| c.to_ascii_lowercase()) +} + +async fn login(worker: &Requester, settings: &ApplicationSettings) -> fixResult<()> { + if settings.session_json.is_file() { + let session = settings.read_session(&settings.session_json)?; + worker.login(LoginStyle::SessionRestore(session.into()))?; + + return Ok(()); + } + + if settings.session_json_old.is_file() && !settings.sled_dir.is_dir() { + let session = settings.read_session(&settings.session_json_old)?; + worker.login(LoginStyle::SessionRestore(session.into()))?; + + return Ok(()); + } + + loop { + let login_style = + match read_response("Please select login type: [p]assword / [s]ingle sign on") + .chars() + .next() + .map(|c| c.to_ascii_lowercase()) + { + None | Some('p') => { + let password = rpassword::prompt_password("Password: ")?; + LoginStyle::Password(password) + }, + Some('s') => LoginStyle::SingleSignOn, + Some(_) => { + println!("Failed to login. Please enter 'p' or 's'"); + continue; + }, + }; + + match worker.login(login_style) { + Ok(info) => { + if let Some(msg) = info { + println!("{msg}"); + } + + break; + }, + Err(err) => { + println!("Failed to login: {err}"); + continue; + }, + } + } + + Ok(()) +} + +fn print_exit(v: T) -> N { + eprintln!("{v}"); + process::exit(2); +} + +// We can't access the OlmMachine directly, so write the keys to a temporary +// file first, and then import them later. +async fn check_import_keys( + settings: &ApplicationSettings, +) -> fixResult> { + let do_import = settings.sled_dir.is_dir() && !settings.sqlite_dir.is_dir(); + + if !do_import { + return Ok(None); + } + + let question = format!( + "Found old sled store in {}. Would you like to export room keys from it? [y]es/[n]o", + settings.sled_dir.display() + ); + + loop { + match read_yesno(&question) { + Some('y') => { + break; + }, + Some('n') => { + return Ok(None); + }, + Some(_) | None => { + continue; + }, + } + } + + let keys = sled_export::export_room_keys(&settings.sled_dir).await?; + let passphrase = gen_passphrase(); + + println!("* Encrypting {} room keys with the passphrase {passphrase:?}...", keys.len()); + + let encrypted = match encrypt_room_key_export(&keys, &passphrase, 500000) { + Ok(encrypted) => encrypted, + Err(e) => { + println!("* Failed to encrypt room keys during export: {e}"); + process::exit(2); + }, + }; + + let tmpdir = TempDir::new()?; + let exported = tmpdir.child("keys"); + + println!("* Writing encrypted room keys to {}...", exported.display()); + tokio::fs::write(&exported, &encrypted).await?; + + Ok(Some((tmpdir, passphrase))) +} + +async fn login_upgrade( + keydir: TempDir, + passphrase: String, + worker: &Requester, + settings: &ApplicationSettings, + store: &AsyncProgramStore, +) -> fixResult<()> { + println!( + "Please log in for {} to import the room keys into a new session", + settings.profile.user_id + ); + + login(worker, settings).await?; + + println!("* Importing room keys..."); + + let exported = keydir.child("keys"); + let imported = worker.client.encryption().import_room_keys(exported, &passphrase).await; + + match imported { + Ok(res) => { + println!( + "* Successfully imported {} out of {} keys", + res.imported_count, res.total_count + ); + let _ = keydir.cleanup(); + }, + Err(e) => { + println!( + "Failed to import room keys from {}/keys: {e}\n\n\ + They have been encrypted with the passphrase {passphrase:?}.\ + Please save them and try importing them manually instead\n", + keydir.path().display() + ); + + loop { + match read_yesno("Would you like to continue logging in? [y]es/[n]o") { + Some('y') => break, + Some('n') => print_exit("* Exiting..."), + Some(_) | None => continue, + } + } + }, + } + + println!("* Syncing..."); + worker::do_first_sync(&worker.client, store) + .await + .map_err(fixError::from)?; + + Ok(()) +} + +async fn login_normal( + worker: &Requester, + settings: &ApplicationSettings, + store: &AsyncProgramStore, +) -> fixResult<()> { + println!("* Logging in for {}...", settings.profile.user_id); + login(worker, settings).await?; + println!("* Syncing..."); + worker::do_first_sync(&worker.client, store) + .await + .map_err(fixError::from)?; + Ok(()) +} + +/// Set up the terminal for drawing the TUI, and getting additional info. +fn setup_tty(title: &str, enable_enhanced_keys: bool) -> std::io::Result<()> { + let title = format!("fix ({})", title); + + // Enable raw mode and enter the alternate screen. + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(stdout(), EnterAlternateScreen)?; + + if enable_enhanced_keys { + // Enable the Kitty keyboard enhancement protocol for improved keypresses. + crossterm::queue!( + stdout(), + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + )?; + } + + crossterm::execute!(stdout(), EnableBracketedPaste, EnableFocusChange, SetTitle(title)) +} + +// Do our best to reverse what we did in setup_tty() when we exit or crash. +fn restore_tty(enable_enhanced_keys: bool) { + if enable_enhanced_keys { + let _ = crossterm::queue!(stdout(), PopKeyboardEnhancementFlags); + } + + let _ = crossterm::execute!( + stdout(), + DisableBracketedPaste, + DisableFocusChange, + LeaveAlternateScreen, + CursorShow, + ); + + let _ = crossterm::terminal::disable_raw_mode(); +} + +async fn run(settings: ApplicationSettings) -> fixResult<()> { + // Get old keys the first time we run w/ the upgraded SDK. + let import_keys = check_import_keys(&settings).await?; + + // Set up client state. + create_dir_all(settings.sqlite_dir.as_path())?; + let client = worker::create_client(&settings).await; + + // Set up the async worker thread and global store. + let worker = ClientWorker::spawn(client.clone(), settings.clone()).await; + let store = ChatStore::new(worker.clone(), settings.clone()); + let store = Store::new(store); + let store = Arc::new(AsyncMutex::new(store)); + worker.init(store.clone()); + + let res = if let Some((keydir, pass)) = import_keys { + login_upgrade(keydir, pass, &worker, &settings, &store).await + } else { + login_normal(&worker, &settings, &store).await + }; + + match res { + Err(UIError::Application(fixError::Matrix(e))) => { + if let Some(ErrorKind::UnknownToken { .. }) = e.client_api_error_kind() { + print_exit("Server did not recognize our API token; did you log out from this session elsewhere?") + } else { + print_exit(e) + } + }, + Err(e) => print_exit(e), + Ok(()) => (), + } + + // Set up the terminal for drawing, and cleanup properly on panics. + let enable_enhanced_keys = match crossterm::terminal::supports_keyboard_enhancement() { + Ok(supported) => supported, + Err(e) => { + tracing::warn!(err = %e, + "Failed to determine whether the terminal supports keyboard enhancements"); + false + }, + }; + setup_tty(settings.profile.user_id.as_str(), enable_enhanced_keys)?; + + let orig_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + restore_tty(enable_enhanced_keys); + orig_hook(panic_info); + process::exit(1); + })); + + // And finally, start running the terminal UI. + let mut application = Application::new(settings, store).await?; + application.run().await?; + + // Clean up the terminal on exit. + restore_tty(enable_enhanced_keys); + + Ok(()) +} + +fn main() -> fixResult<()> { + // Parse command-line flags. + let fix = fix::parse(); + + // Load configuration and set up the Matrix SDK. + let settings = ApplicationSettings::load(fix).unwrap_or_else(print_exit); + + // Set umask on Unix platforms so that tokens, keys, etc. are only readable by the user. + #[cfg(unix)] + unsafe { + libc::umask(0o077); + }; + + // Set up the tracing subscriber so we can log client messages. + let log_prefix = format!("fix-log-{}", settings.profile_name); + let log_dir = settings.dirs.logs.as_path(); + + let appender = tracing_appender::rolling::daily(log_dir, log_prefix); + let (appender, guard) = tracing_appender::non_blocking(appender); + + let subscriber = FmtSubscriber::builder() + .with_writer(appender) + .with_max_level(settings.tunables.log_level) + .finish(); + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(2) + .thread_name_fn(|| { + static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0); + let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst); + format!("fix-worker-{id}") + }) + .build() + .unwrap(); + + rt.block_on(async move { run(settings).await })?; + + drop(guard); + process::exit(0); +} diff --git a/src/message/compose.rs b/src/message/compose.rs new file mode 100644 index 0000000..9b85ebf --- /dev/null +++ b/src/message/compose.rs @@ -0,0 +1,376 @@ +//! Code for converting composed messages into content to send to the homeserver. +use comrak::{markdown_to_html, ComrakOptions}; +use nom::{ + branch::alt, + bytes::complete::tag, + character::complete::space0, + combinator::value, + IResult, +}; + +use matrix_sdk::ruma::events::room::message::{ + EmoteMessageEventContent, + MessageType, + RoomMessageEventContent, + TextMessageEventContent, +}; + +#[derive(Clone, Debug, Default)] +enum SlashCommand { + /// Send an emote message. + Emote, + + /// Send a message as literal HTML. + Html, + + /// Send a message without parsing any markup. + Plaintext, + + /// Send a Markdown message (the default message markup). + #[default] + Markdown, + + /// Send a message with confetti effects in clients that show them. + Confetti, + + /// Send a message with fireworks effects in clients that show them. + Fireworks, + + /// Send a message with heart effects in clients that show them. + Hearts, + + /// Send a message with rainfall effects in clients that show them. + Rainfall, + + /// Send a message with snowfall effects in clients that show them. + Snowfall, + + /// Send a message with heart effects in clients that show them. + SpaceInvaders, +} + +impl SlashCommand { + fn to_message(&self, input: &str) -> anyhow::Result { + let msgtype = match self { + SlashCommand::Emote => { + let msg = if let Some(html) = text_to_html(input) { + EmoteMessageEventContent::html(input, html) + } else { + EmoteMessageEventContent::plain(input) + }; + + MessageType::Emote(msg) + }, + SlashCommand::Html => { + let msg = TextMessageEventContent::html(input, input); + MessageType::Text(msg) + }, + SlashCommand::Plaintext => { + let msg = TextMessageEventContent::plain(input); + MessageType::Text(msg) + }, + SlashCommand::Markdown => { + let msg = text_to_message_content(input.to_string()); + MessageType::Text(msg) + }, + SlashCommand::Confetti => { + MessageType::new("nic.custom.confetti", input.into(), Default::default())? + }, + SlashCommand::Fireworks => { + MessageType::new("nic.custom.fireworks", input.into(), Default::default())? + }, + SlashCommand::Hearts => { + MessageType::new("io.element.effect.hearts", input.into(), Default::default())? + }, + SlashCommand::Rainfall => { + MessageType::new("io.element.effect.rainfall", input.into(), Default::default())? + }, + SlashCommand::Snowfall => { + MessageType::new("io.element.effect.snowfall", input.into(), Default::default())? + }, + SlashCommand::SpaceInvaders => { + MessageType::new( + "io.element.effects.space_invaders", + input.into(), + Default::default(), + )? + }, + }; + + Ok(msgtype) + } +} + +fn parse_slash_command_inner(input: &str) -> IResult<&str, SlashCommand> { + let (input, _) = space0(input)?; + let (input, slash) = alt(( + value(SlashCommand::Emote, tag("/me ")), + value(SlashCommand::Html, tag("/h ")), + value(SlashCommand::Html, tag("/html ")), + value(SlashCommand::Plaintext, tag("/p ")), + value(SlashCommand::Plaintext, tag("/plain ")), + value(SlashCommand::Plaintext, tag("/plaintext ")), + value(SlashCommand::Markdown, tag("/md ")), + value(SlashCommand::Markdown, tag("/markdown ")), + value(SlashCommand::Confetti, tag("/confetti ")), + value(SlashCommand::Fireworks, tag("/fireworks ")), + value(SlashCommand::Hearts, tag("/hearts ")), + value(SlashCommand::Rainfall, tag("/rainfall ")), + value(SlashCommand::Snowfall, tag("/snowfall ")), + value(SlashCommand::SpaceInvaders, tag("/spaceinvaders ")), + ))(input)?; + let (input, _) = space0(input)?; + + Ok((input, slash)) +} + +fn parse_slash_command(input: &str) -> anyhow::Result<(&str, SlashCommand)> { + match parse_slash_command_inner(input) { + Ok(input) => Ok(input), + Err(e) => Err(anyhow::anyhow!("Failed to parse slash command: {e}")), + } +} + +/// Check whether this character is not used for markup in Markdown. +/// +/// Markdown uses just about every ASCII punctuation symbol in some way, especially +/// once autolinking is involved, so we really just check whether it's non-punctuation or +/// single/double quotations. +fn not_markdown_char(c: char) -> bool { + if !c.is_ascii_punctuation() { + return true; + } + + matches!(c, '"' | '\'') +} + +/// Check whether the input actually needs to be processed as Markdown. +fn not_markdown(input: &str) -> bool { + input.chars().all(not_markdown_char) +} + +fn text_to_html(input: &str) -> Option { + if not_markdown(input) { + return None; + } + + let mut options = ComrakOptions::default(); + options.extension.autolink = true; + options.extension.shortcodes = true; + options.extension.strikethrough = true; + options.render.hardbreaks = true; + markdown_to_html(input, &options).into() +} + +fn text_to_message_content(input: String) -> TextMessageEventContent { + if let Some(html) = text_to_html(input.as_str()) { + TextMessageEventContent::html(input, html) + } else { + TextMessageEventContent::plain(input) + } +} + +pub fn text_to_message(input: String) -> RoomMessageEventContent { + let msg = parse_slash_command(input.as_str()) + .and_then(|(input, slash)| slash.to_message(input)) + .unwrap_or_else(|_| MessageType::Text(text_to_message_content(input))); + + RoomMessageEventContent::new(msg) +} + +#[cfg(test)] +pub mod tests { + use super::*; + + #[test] + fn test_markdown_autolink() { + let input = "http://example.com\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

http://example.com

\n" + ); + + let input = "www.example.com\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

www.example.com

\n" + ); + + let input = "See docs (they're at https://fix.chat)\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

See docs (they're at https://fix.chat)

\n" + ); + } + + #[test] + fn test_markdown_message() { + let input = "**bold**\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

bold

\n"); + + let input = "*emphasis*\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

emphasis

\n"); + + let input = "`code`\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

code

\n"); + + let input = "```rust\nconst A: usize = 1;\n```\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "
const A: usize = 1;\n
\n" + ); + + let input = ":heart:\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

\u{2764}\u{FE0F}

\n"); + + let input = "para *1*\n\npara _2_\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

para 1

\n

para 2

\n" + ); + + let input = "line 1\nline ~~2~~\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

line 1
\nline 2

\n"); + + let input = "# Heading\n## Subheading\n\ntext\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

Heading

\n

Subheading

\n

text

\n" + ); + } + + #[test] + fn test_markdown_headers() { + let input = "hello\n=====\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

hello

\n"); + + let input = "hello\n-----\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

hello

\n"); + } + + #[test] + fn test_markdown_lists() { + let input = "- A\n- B\n- C\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "
    \n
  • A
  • \n
  • B
  • \n
  • C
  • \n
\n" + ); + + let input = "1) A\n2) B\n3) C\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "
    \n
  1. A
  2. \n
  3. B
  4. \n
  5. C
  6. \n
\n" + ); + } + + #[test] + fn test_no_markdown_conversion_on_simple_text() { + let input = "para 1\n\npara 2\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert!(content.formatted.is_none()); + + let input = "line 1\nline 2\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert!(content.formatted.is_none()); + + let input = "isn't markdown\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert!(content.formatted.is_none()); + + let input = "\"scare quotes\"\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert!(content.formatted.is_none()); + } + + #[test] + fn text_to_message_slash_commands() { + let MessageType::Text(content) = text_to_message("/html bold".into()).msgtype else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert_eq!(content.formatted.unwrap().body, "bold"); + + let MessageType::Text(content) = text_to_message("/h bold".into()).msgtype else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert_eq!(content.formatted.unwrap().body, "bold"); + + let MessageType::Text(content) = text_to_message("/plain bold".into()).msgtype + else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert!(content.formatted.is_none(), "{:?}", content.formatted); + + let MessageType::Text(content) = text_to_message("/p bold".into()).msgtype else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert!(content.formatted.is_none(), "{:?}", content.formatted); + + let MessageType::Emote(content) = text_to_message("/me *bold*".into()).msgtype else { + panic!("Expected MessageType::Emote"); + }; + assert_eq!(content.body, "*bold*"); + assert_eq!(content.formatted.unwrap().body, "

bold

\n"); + + let content = text_to_message("/confetti hello".into()).msgtype; + assert_eq!(content.msgtype(), "nic.custom.confetti"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/fireworks hello".into()).msgtype; + assert_eq!(content.msgtype(), "nic.custom.fireworks"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/hearts hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effect.hearts"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/rainfall hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effect.rainfall"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/snowfall hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effect.snowfall"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/spaceinvaders hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effects.space_invaders"); + assert_eq!(content.body(), "hello"); + } +} diff --git a/src/message/html.rs b/src/message/html.rs new file mode 100644 index 0000000..8fc1557 --- /dev/null +++ b/src/message/html.rs @@ -0,0 +1,1456 @@ +//! # Rendering for formatted bodies +//! +//! This module contains the code for rendering messages that contained an +//! "org.matrix.custom.html"-formatted body. +//! +//! The Matrix specification recommends limiting rendered tags and attributes to a safe subset of +//! HTML. You can read more in section 11.2.1.1, "m.room.message msgtypes": +//! +//! +//! +//! This isn't as important for fix, since it isn't a browser environment, but we do still map +//! input onto an enum of the safe list of tags to keep it easy to understand and process. +use std::ops::Deref; + +use css_color_parser::Color as CssColor; +use markup5ever_rcdom::{Handle, NodeData, RcDom}; +use unicode_segmentation::UnicodeSegmentation; +use url::Url; + +use html5ever::{ + driver::{parse_fragment, ParseOpts}, + interface::{Attribute, QualName}, + local_name, + namespace_url, + ns, + tendril::{StrTendril, TendrilSink}, +}; + +use ratatui::{ + layout::Alignment, + style::{Color, Modifier as StyleModifier, Style}, + symbols::line, + text::{Line, Span, Text}, +}; + +use crate::{ + message::printer::TextPrinter, + util::{join_cell_text, space_text}, +}; + +/// Generate bullet points from a [ListStyle]. +pub struct BulletIterator { + style: ListStyle, + pos: usize, + len: usize, +} + +impl BulletIterator { + fn width(&self) -> usize { + match self.style { + ListStyle::Unordered => 2, + ListStyle::Ordered => self.len.to_string().len() + 2, + } + } +} + +impl Iterator for BulletIterator { + type Item = String; + + fn next(&mut self) -> Option { + if self.pos == self.len { + return None; + } + + self.pos += 1; + + let bullet = match self.style { + ListStyle::Unordered => "- ".to_string(), + ListStyle::Ordered => { + let w = self.len.to_string().len(); + format!("{: >w$}. ", self.pos, w = w) + }, + }; + + return Some(bullet); + } +} + +/// Whether this list is ordered or unordered. +#[derive(Clone, Copy, Debug)] +pub enum ListStyle { + Ordered, + Unordered, +} + +impl ListStyle { + fn bullets(&self, len: usize) -> BulletIterator { + BulletIterator { style: *self, pos: 0, len } + } +} + +pub type StyleTreeChildren = Vec; + +/// Type of contents in a table cell. +pub enum CellType { + Data, + Header, +} + +/// A collection of cells for a single row in a table. +pub struct TableRow { + cells: Vec<(CellType, StyleTreeNode)>, +} + +impl TableRow { + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + for (_, cell) in &self.cells { + cell.gather_links(urls); + } + } + + fn columns(&self) -> usize { + self.cells.len() + } +} + +/// A collection of rows in a table. +pub struct TableSection { + rows: Vec, +} + +impl TableSection { + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + for row in &self.rows { + row.gather_links(urls); + } + } + + fn columns(&self) -> usize { + self.rows.iter().map(TableRow::columns).max().unwrap_or(0) + } +} + +/// A table. +pub struct Table { + caption: Option>, + sections: Vec, +} + +impl Table { + fn columns(&self) -> usize { + self.sections.iter().map(TableSection::columns).max().unwrap_or(0) + } + + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + for section in &self.sections { + section.gather_links(urls); + } + } + + fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text { + let mut text = Text::default(); + let columns = self.columns(); + let cell_total = width.saturating_sub(columns).saturating_sub(1); + let cell_min = cell_total / columns; + let mut cell_slop = cell_total - cell_min * columns; + let cell_widths = (0..columns) + .map(|_| { + let slopped = cell_slop.min(1); + cell_slop -= slopped; + cell_min + slopped + }) + .collect::>(); + + let mut nrows = 0; + + if let Some(caption) = &self.caption { + let subw = width.saturating_sub(6); + let mut printer = + TextPrinter::new(subw, style, true, emoji_shortcodes).align(Alignment::Center); + caption.print(&mut printer, style); + + for mut line in printer.finish().lines { + line.spans.insert(0, Span::styled(" ", style)); + line.spans.push(Span::styled(" ", style)); + text.lines.push(line); + } + } + + for section in self.sections.iter() { + for row in section.rows.iter() { + let mut ruler = String::new(); + + for (i, w) in cell_widths.iter().enumerate() { + let cross = match (nrows, i) { + (0, 0) => line::TOP_LEFT, + (0, _) => line::HORIZONTAL_DOWN, + (_, 0) => line::VERTICAL_RIGHT, + (_, _) => line::CROSS, + }; + + ruler.push_str(cross); + + for _ in 0..*w { + ruler.push_str(line::HORIZONTAL); + } + } + + if nrows == 0 { + ruler.push_str(line::TOP_RIGHT); + } else { + ruler.push_str(line::VERTICAL_LEFT); + } + + text.lines.push(Line::from(vec![Span::styled(ruler, style)])); + + let cells = cell_widths + .iter() + .enumerate() + .map(|(i, w)| { + let text = if let Some((kind, cell)) = row.cells.get(i) { + let style = match kind { + CellType::Header => style.add_modifier(StyleModifier::BOLD), + CellType::Data => style, + }; + + cell.to_text(*w, style, emoji_shortcodes) + } else { + space_text(*w, style) + }; + + (text, *w) + }) + .collect(); + + let joined = join_cell_text(cells, Span::styled(line::VERTICAL, style), style); + text.lines.extend(joined.lines); + + nrows += 1; + } + } + + if nrows > 0 { + let mut ruler = String::new(); + + for (i, w) in cell_widths.iter().enumerate() { + let cross = if i == 0 { + line::BOTTOM_LEFT + } else { + line::HORIZONTAL_UP + }; + + ruler.push_str(cross); + + for _ in 0..*w { + ruler.push_str(line::HORIZONTAL); + } + } + + ruler.push_str(line::BOTTOM_RIGHT); + text.lines.push(Line::from(vec![Span::styled(ruler, style)])); + } + + text + } +} + +/// A processed HTML element that we can render to the terminal. +pub enum StyleTreeNode { + Anchor(Box, char, Url), + Blockquote(Box), + Break, + #[allow(dead_code)] + Code(Box, Option), + Header(Box, usize), + Image(Option), + List(StyleTreeChildren, ListStyle), + Paragraph(Box), + Pre(Box), + Reply(Box), + Ruler, + Style(Box, Style), + Table(Table), + Text(String), + Sequence(StyleTreeChildren), +} + +impl StyleTreeNode { + pub fn to_text(&self, width: usize, style: Style, emoji_shortcodes: bool) -> Text { + let mut printer = TextPrinter::new(width, style, true, emoji_shortcodes); + self.print(&mut printer, style); + printer.finish() + } + + pub fn gather_links(&self, urls: &mut Vec<(char, Url)>) { + match self { + StyleTreeNode::Anchor(_, c, url) => { + urls.push((*c, url.clone())); + }, + + StyleTreeNode::Blockquote(child) | + StyleTreeNode::Code(child, _) | + StyleTreeNode::Header(child, _) | + StyleTreeNode::Paragraph(child) | + StyleTreeNode::Pre(child) | + StyleTreeNode::Reply(child) | + StyleTreeNode::Style(child, _) => { + child.gather_links(urls); + }, + + StyleTreeNode::List(children, _) | StyleTreeNode::Sequence(children) => { + for child in children { + child.gather_links(urls); + } + }, + + StyleTreeNode::Table(table) => { + table.gather_links(urls); + }, + + StyleTreeNode::Image(_) => {}, + StyleTreeNode::Ruler => {}, + StyleTreeNode::Text(_) => {}, + StyleTreeNode::Break => {}, + } + } + + pub fn print<'a>(&'a self, printer: &mut TextPrinter<'a>, style: Style) { + let width = printer.width(); + + match self { + StyleTreeNode::Anchor(child, c, _) => { + let bold = style.add_modifier(StyleModifier::BOLD); + child.print(printer, bold); + + let link = format!("[{c}]"); + let span = Span::styled(link, style); + printer.push_span_nobreak(span); + }, + StyleTreeNode::Blockquote(child) => { + let mut subp = printer.sub(4); + child.print(&mut subp, style); + + for mut line in subp.finish() { + line.spans.insert(0, Span::styled(" ", style)); + printer.push_line(line); + } + }, + StyleTreeNode::Code(child, _) => { + child.print(printer, style); + }, + StyleTreeNode::Header(child, level) => { + let style = style.add_modifier(StyleModifier::BOLD); + + for _ in 0..*level { + printer.push_str("#", style); + } + + printer.push_str(" ", style); + child.print(printer, style); + }, + StyleTreeNode::Image(None) => {}, + StyleTreeNode::Image(Some(alt)) => { + printer.commit(); + printer.push_str("Image Alt: ", Style::default()); + printer.push_str(alt, Style::default()); + printer.commit(); + }, + StyleTreeNode::List(children, lt) => { + let mut bullets = lt.bullets(children.len()); + let liw = bullets.width(); + + for child in children { + let mut subp = printer.sub(liw); + let mut bullet = bullets.next(); + child.print(&mut subp, style); + + for mut line in subp.finish() { + let leading = if let Some(bullet) = bullet.take() { + Span::styled(bullet, style) + } else { + Span::styled(" ".repeat(liw), style) + }; + + line.spans.insert(0, leading); + printer.push_line(line); + } + } + }, + StyleTreeNode::Paragraph(child) => { + printer.push_break(); + child.print(printer, style); + printer.commit(); + }, + StyleTreeNode::Pre(child) => { + let mut subp = printer.sub(2).literal(true); + let subw = subp.width(); + + child.print(&mut subp, style); + + printer.commit(); + printer.push_line( + vec![ + Span::styled(line::TOP_LEFT, style), + Span::styled(line::HORIZONTAL.repeat(subw), style), + Span::styled(line::TOP_RIGHT, style), + ] + .into(), + ); + + for mut line in subp.finish() { + line.spans.insert(0, Span::styled(line::VERTICAL, style)); + line.spans.push(Span::styled(line::VERTICAL, style)); + printer.push_line(line); + } + + printer.push_line( + vec![ + Span::styled(line::BOTTOM_LEFT, style), + Span::styled(line::HORIZONTAL.repeat(subw), style), + Span::styled(line::BOTTOM_RIGHT, style), + ] + .into(), + ); + + printer.commit(); + }, + StyleTreeNode::Reply(child) => { + if printer.hide_reply() { + return; + } + + printer.push_break(); + child.print(printer, style); + printer.commit(); + }, + StyleTreeNode::Ruler => { + for _ in 0..width { + printer.push_str(line::HORIZONTAL, style); + } + }, + StyleTreeNode::Table(table) => { + let text = table.to_text(width, style, printer.emoji_shortcodes()); + printer.push_text(text); + }, + StyleTreeNode::Break => { + printer.push_break(); + }, + StyleTreeNode::Text(s) => { + printer.push_str(s.as_str(), style); + }, + + StyleTreeNode::Style(child, patch) => child.print(printer, style.patch(*patch)), + StyleTreeNode::Sequence(children) => { + for child in children { + child.print(printer, style); + } + }, + } + } +} + +/// A processed HTML document. +pub struct StyleTree { + children: StyleTreeChildren, +} + +impl StyleTree { + pub fn get_links(&self) -> Vec<(char, Url)> { + let mut links = Vec::new(); + + for child in &self.children { + child.gather_links(&mut links); + } + + return links; + } + + pub fn to_text( + &self, + width: usize, + style: Style, + hide_reply: bool, + emoji_shortcodes: bool, + ) -> Text<'_> { + let mut printer = TextPrinter::new(width, style, hide_reply, emoji_shortcodes); + + for child in self.children.iter() { + child.print(&mut printer, style); + } + + printer.finish() + } +} + +pub struct TreeGenState { + link_num: u8, +} + +impl TreeGenState { + fn next_link_char(&mut self) -> Option { + let num = self.link_num; + + if num < 62 { + self.link_num = num + 1; + } + + if num < 10 { + Some((num + b'0') as char) + } else if num < 36 { + Some((num - 10 + b'a') as char) + } else if num < 62 { + Some((num - 36 + b'A') as char) + } else { + None + } + } +} + +fn c2c(handles: &[Handle], state: &mut TreeGenState) -> Vec { + handles.iter().flat_map(|h| h2t(h, state)).collect() +} + +fn c2t(handles: &[Handle], state: &mut TreeGenState) -> Box { + let node = StyleTreeNode::Sequence(c2c(handles, state)); + + Box::new(node) +} + +fn get_node(hdl: &Handle, want: &str, state: &mut TreeGenState) -> Option { + let node = hdl.deref(); + + if let NodeData::Element { name, .. } = &node.data { + if name.local.as_ref() != want { + return None; + } + + let c = c2c(&node.children.borrow(), state); + return Some(StyleTreeNode::Sequence(c)); + } else { + return None; + } +} + +fn li2t(hdl: &Handle, state: &mut TreeGenState) -> Option { + get_node(hdl, "li", state) +} + +fn table_cell(hdl: &Handle, state: &mut TreeGenState) -> Option<(CellType, StyleTreeNode)> { + if let Some(node) = get_node(hdl, "th", state) { + return Some((CellType::Header, node)); + } + + Some((CellType::Data, get_node(hdl, "td", state)?)) +} + +fn table_row(hdl: &Handle, state: &mut TreeGenState) -> Option { + let node = hdl.deref(); + + if let NodeData::Element { name, .. } = &node.data { + if name.local.as_ref() != "tr" { + return None; + } + + let cells = table_cells(&node.children.borrow(), state); + return Some(TableRow { cells }); + } else { + return None; + } +} + +fn table_section(hdl: &Handle, state: &mut TreeGenState) -> Option { + let node = hdl.deref(); + + if let NodeData::Element { name, .. } = &node.data { + match name.local.as_ref() { + "thead" | "tbody" => { + let rows = table_rows(&node.children.borrow(), state); + + Some(TableSection { rows }) + }, + _ => None, + } + } else { + return None; + } +} + +fn table_cells(handles: &[Handle], state: &mut TreeGenState) -> Vec<(CellType, StyleTreeNode)> { + handles.iter().filter_map(|h| table_cell(h, state)).collect() +} + +fn table_rows(handles: &[Handle], state: &mut TreeGenState) -> Vec { + handles.iter().filter_map(|h| table_row(h, state)).collect() +} + +fn table_sections(handles: &[Handle], state: &mut TreeGenState) -> Vec { + handles.iter().filter_map(|h| table_section(h, state)).collect() +} + +fn lic2t(handles: &[Handle], state: &mut TreeGenState) -> StyleTreeChildren { + handles.iter().filter_map(|h| li2t(h, state)).collect() +} + +fn attrs_to_alt(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.name.local.as_ref() != "alt" { + continue; + } + + return Some(attr.value.to_string()); + } + + return None; +} + +fn attrs_to_href(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.name.local.as_ref() != "href" { + continue; + } + + return Some(attr.value.to_string()); + } + + return None; +} + +fn attrs_to_language(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.name.local.as_ref() != "class" { + continue; + } + + for class in attr.value.as_ref().unicode_words() { + if class.len() > 9 && class.starts_with("language-") { + return Some(class[9..].to_string()); + } + } + } + + return None; +} + +fn attrs_to_style(attrs: &[Attribute]) -> Style { + let mut style = Style::default(); + + for attr in attrs { + match attr.name.local.as_ref() { + "data-mx-bg-color" => { + if let Ok(rgb) = attr.value.as_ref().parse::() { + let color = Color::Rgb(rgb.r, rgb.g, rgb.b); + style = style.bg(color); + } + }, + "data-mx-color" | "color" => { + if let Ok(rgb) = attr.value.as_ref().parse::() { + let color = Color::Rgb(rgb.r, rgb.g, rgb.b); + style = style.fg(color); + } + }, + _ => continue, + } + } + + return style; +} + +fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren { + let node = hdl.deref(); + + let tree = match &node.data { + NodeData::Document => *c2t(node.children.borrow().as_slice(), state), + NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string()), + NodeData::Element { name, attrs, .. } => { + match name.local.as_ref() { + // Message that this one replies to. + "mx-reply" => StyleTreeNode::Reply(c2t(&node.children.borrow(), state)), + + // Links + "a" => { + let c = c2t(&node.children.borrow(), state); + let h = attrs_to_href(&attrs.borrow()).and_then(|u| Url::parse(&u).ok()); + + if let Some(h) = h { + if let Some(n) = state.next_link_char() { + StyleTreeNode::Anchor(c, n, h) + } else { + *c + } + } else { + *c + } + }, + + // Style change + "b" | "strong" => { + let c = c2t(&node.children.borrow(), state); + let s = Style::default().add_modifier(StyleModifier::BOLD); + + StyleTreeNode::Style(c, s) + }, + "font" => { + let c = c2t(&node.children.borrow(), state); + let s = attrs_to_style(&attrs.borrow()); + + StyleTreeNode::Style(c, s) + }, + "em" | "i" => { + let c = c2t(&node.children.borrow(), state); + let s = Style::default().add_modifier(StyleModifier::ITALIC); + + StyleTreeNode::Style(c, s) + }, + "span" => { + let c = c2t(&node.children.borrow(), state); + let s = attrs_to_style(&attrs.borrow()); + + StyleTreeNode::Style(c, s) + }, + "del" | "strike" => { + let c = c2t(&node.children.borrow(), state); + let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT); + + StyleTreeNode::Style(c, s) + }, + "u" => { + let c = c2t(&node.children.borrow(), state); + let s = Style::default().add_modifier(StyleModifier::UNDERLINED); + + StyleTreeNode::Style(c, s) + }, + + // Lists + "ol" => { + StyleTreeNode::List(lic2t(&node.children.borrow(), state), ListStyle::Ordered) + }, + "ul" => { + StyleTreeNode::List(lic2t(&node.children.borrow(), state), ListStyle::Unordered) + }, + + // Headers + "h1" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 1), + "h2" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 2), + "h3" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 3), + "h4" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 4), + "h5" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 5), + "h6" => StyleTreeNode::Header(c2t(&node.children.borrow(), state), 6), + + // Table + "table" => { + let sections = table_sections(&node.children.borrow(), state); + let caption = node + .children + .borrow() + .iter() + .find_map(|hdl| get_node(hdl, "caption", state)) + .map(Box::new); + let table = Table { caption, sections }; + + StyleTreeNode::Table(table) + }, + + // Code blocks. + "code" => { + let c = c2t(&node.children.borrow(), state); + let l = attrs_to_language(&attrs.borrow()); + + StyleTreeNode::Code(c, l) + }, + + // Other text blocks. + "blockquote" => StyleTreeNode::Blockquote(c2t(&node.children.borrow(), state)), + "div" | "p" => StyleTreeNode::Paragraph(c2t(&node.children.borrow(), state)), + "pre" => StyleTreeNode::Pre(c2t(&node.children.borrow(), state)), + + // No children. + "hr" => StyleTreeNode::Ruler, + "br" => StyleTreeNode::Break, + + "img" => StyleTreeNode::Image(attrs_to_alt(&attrs.borrow())), + + // These don't render in any special way. + "details" | "html" | "summary" | "sub" | "sup" => { + *c2t(&node.children.borrow(), state) + }, + + _ => return vec![], + } + }, + + // These don't render as anything. + NodeData::Doctype { .. } => return vec![], + NodeData::Comment { .. } => return vec![], + NodeData::ProcessingInstruction { .. } => return vec![], + }; + + vec![tree] +} + +fn dom_to_style_tree(dom: RcDom) -> StyleTree { + let mut state = TreeGenState { link_num: 0 }; + let children = h2t(&dom.document, &mut state); + + StyleTree { children } +} + +/// Parse an HTML document from a string. +pub fn parse_matrix_html(s: &str) -> StyleTree { + let dom = parse_fragment( + RcDom::default(), + ParseOpts::default(), + QualName::new(None, ns!(html), local_name!("body")), + vec![], + ) + .one(StrTendril::from(s)); + + dom_to_style_tree(dom) +} + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::util::space_span; + use pretty_assertions::assert_eq; + use unicode_width::UnicodeWidthStr; + + #[test] + fn test_header() { + let bold = Style::default().add_modifier(StyleModifier::BOLD); + + let s = "

Header 1

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("#", bold), + Span::styled(" ", bold), + Span::styled("Header", bold), + Span::styled(" ", bold), + Span::styled("1", bold), + space_span(10, Style::default()) + ])]); + + let s = "

Header 2

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled(" ", bold), + Span::styled("Header", bold), + Span::styled(" ", bold), + Span::styled("2", bold), + space_span(9, Style::default()) + ])]); + + let s = "

Header 3

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled(" ", bold), + Span::styled("Header", bold), + Span::styled(" ", bold), + Span::styled("3", bold), + space_span(8, Style::default()) + ])]); + + let s = "

Header 4

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled(" ", bold), + Span::styled("Header", bold), + Span::styled(" ", bold), + Span::styled("4", bold), + space_span(7, Style::default()) + ])]); + + let s = "
Header 5
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled(" ", bold), + Span::styled("Header", bold), + Span::styled(" ", bold), + Span::styled("5", bold), + space_span(6, Style::default()) + ])]); + + let s = "
Header 6
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled("#", bold), + Span::styled(" ", bold), + Span::styled("Header", bold), + Span::styled(" ", bold), + Span::styled("6", bold), + space_span(5, Style::default()) + ])]); + } + + #[test] + fn test_style() { + let def = Style::default(); + let bold = def.add_modifier(StyleModifier::BOLD); + let italic = def.add_modifier(StyleModifier::ITALIC); + let strike = def.add_modifier(StyleModifier::CROSSED_OUT); + let underl = def.add_modifier(StyleModifier::UNDERLINED); + let red = def.fg(Color::Rgb(0xff, 0x00, 0x00)); + + let s = "Bold!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Bold", bold), + Span::styled("!", bold), + space_span(15, def) + ])]); + + let s = "Bold!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Bold", bold), + Span::styled("!", bold), + space_span(15, def) + ])]); + + let s = "Italic!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Italic", italic), + Span::styled("!", italic), + space_span(13, def) + ])]); + + let s = "Italic!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Italic", italic), + Span::styled("!", italic), + space_span(13, def) + ])]); + + let s = "Strikethrough!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Strikethrough", strike), + Span::styled("!", strike), + space_span(6, def) + ])]); + + let s = "Strikethrough!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Strikethrough", strike), + Span::styled("!", strike), + space_span(6, def) + ])]); + + let s = "Underline!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Underline", underl), + Span::styled("!", underl), + space_span(10, def) + ])]); + + let s = "Red!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Red", red), + Span::styled("!", red), + space_span(16, def) + ])]); + + let s = "Red!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::styled("Red", red), + Span::styled("!", red), + space_span(16, def) + ])]); + } + + #[test] + fn test_paragraph() { + let s = "

Hello world!

Content

Goodbye world!

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), false, false); + assert_eq!(text.lines.len(), 7); + assert_eq!( + text.lines[0], + Line::from(vec![Span::raw("Hello"), Span::raw(" "), Span::raw(" ")]) + ); + assert_eq!( + text.lines[1], + Line::from(vec![Span::raw("world"), Span::raw("!"), Span::raw(" ")]) + ); + assert_eq!(text.lines[2], Line::from(vec![Span::raw(" ")])); + assert_eq!(text.lines[3], Line::from(vec![Span::raw("Content"), Span::raw(" ")])); + assert_eq!(text.lines[4], Line::from(vec![Span::raw(" ")])); + assert_eq!( + text.lines[5], + Line::from(vec![Span::raw("Goodbye"), Span::raw(" "), Span::raw(" ")]) + ); + assert_eq!( + text.lines[6], + Line::from(vec![Span::raw("world"), Span::raw("!"), Span::raw(" ")]) + ); + } + + #[test] + fn test_blockquote() { + let s = "
Hello world!
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), false, false); + assert_eq!(text.lines.len(), 2); + assert_eq!( + text.lines[0], + Line::from(vec![Span::raw(" "), Span::raw("Hello"), Span::raw(" ")]) + ); + assert_eq!( + text.lines[1], + Line::from(vec![Span::raw(" "), Span::raw("world"), Span::raw("!")]) + ); + } + + #[test] + fn test_list_unordered() { + let s = "
  • List Item 1
  • List Item 2
  • List Item 3
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(8, Style::default(), false, false); + assert_eq!(text.lines.len(), 6); + assert_eq!( + text.lines[0], + Line::from(vec![ + Span::raw("- "), + Span::raw("List"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[1], + Line::from(vec![ + Span::raw(" "), + Span::raw("Item"), + Span::raw(" "), + Span::raw("1") + ]) + ); + assert_eq!( + text.lines[2], + Line::from(vec![ + Span::raw("- "), + Span::raw("List"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[3], + Line::from(vec![ + Span::raw(" "), + Span::raw("Item"), + Span::raw(" "), + Span::raw("2") + ]) + ); + assert_eq!( + text.lines[4], + Line::from(vec![ + Span::raw("- "), + Span::raw("List"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[5], + Line::from(vec![ + Span::raw(" "), + Span::raw("Item"), + Span::raw(" "), + Span::raw("3") + ]) + ); + } + + #[test] + fn test_list_ordered() { + let s = "
  1. List Item 1
  2. List Item 2
  3. List Item 3
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(9, Style::default(), false, false); + assert_eq!(text.lines.len(), 6); + assert_eq!( + text.lines[0], + Line::from(vec![ + Span::raw("1. "), + Span::raw("List"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[1], + Line::from(vec![ + Span::raw(" "), + Span::raw("Item"), + Span::raw(" "), + Span::raw("1") + ]) + ); + assert_eq!( + text.lines[2], + Line::from(vec![ + Span::raw("2. "), + Span::raw("List"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[3], + Line::from(vec![ + Span::raw(" "), + Span::raw("Item"), + Span::raw(" "), + Span::raw("2") + ]) + ); + assert_eq!( + text.lines[4], + Line::from(vec![ + Span::raw("3. "), + Span::raw("List"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[5], + Line::from(vec![ + Span::raw(" "), + Span::raw("Item"), + Span::raw(" "), + Span::raw("3") + ]) + ); + } + + #[test] + fn test_table() { + let s = "\ + \ + + \ + \ + \ + \ + \ +
Column 1Column 2Column 3
abc
abc
abc
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(15, Style::default(), false, false); + let bold = Style::default().add_modifier(StyleModifier::BOLD); + assert_eq!(text.lines.len(), 11); + + // Table header + assert_eq!(text.lines[0].spans, vec![Span::raw("┌────┬────┬───┐")]); + assert_eq!(text.lines[1].spans, vec![ + Span::raw("│"), + Span::styled("Colu", bold), + Span::raw("│"), + Span::styled("Colu", bold), + Span::raw("│"), + Span::styled("Col", bold), + Span::raw("│") + ]); + assert_eq!(text.lines[2].spans, vec![ + Span::raw("│"), + Span::styled("mn", bold), + Span::styled(" ", bold), + Span::styled("1", bold), + Span::raw("│"), + Span::styled("mn", bold), + Span::styled(" ", bold), + Span::styled("2", bold), + Span::raw("│"), + Span::styled("umn", bold), + Span::raw("│") + ]); + assert_eq!(text.lines[3].spans, vec![ + Span::raw("│"), + Span::raw(" "), + Span::raw("│"), + Span::raw(" "), + Span::raw("│"), + Span::styled("3", bold), + Span::styled(" ", bold), + Span::raw("│") + ]); + + // First row + assert_eq!(text.lines[4].spans, vec![Span::raw("├────┼────┼───┤")]); + assert_eq!(text.lines[5].spans, vec![ + Span::raw("│"), + Span::raw("a"), + Span::raw(" "), + Span::raw("│"), + Span::raw("b"), + Span::raw(" "), + Span::raw("│"), + Span::raw("c"), + Span::raw(" "), + Span::raw("│") + ]); + + // Second row + assert_eq!(text.lines[6].spans, vec![Span::raw("├────┼────┼───┤")]); + assert_eq!(text.lines[7].spans, vec![ + Span::raw("│"), + Span::raw("a"), + Span::raw(" "), + Span::raw("│"), + Span::raw("b"), + Span::raw(" "), + Span::raw("│"), + Span::raw("c"), + Span::raw(" "), + Span::raw("│") + ]); + + // Third row + assert_eq!(text.lines[8].spans, vec![Span::raw("├────┼────┼───┤")]); + assert_eq!(text.lines[9].spans, vec![ + Span::raw("│"), + Span::raw("a"), + Span::raw(" "), + Span::raw("│"), + Span::raw("b"), + Span::raw(" "), + Span::raw("│"), + Span::raw("c"), + Span::raw(" "), + Span::raw("│") + ]); + + // Bottom ruler + assert_eq!(text.lines[10].spans, vec![Span::raw("└────┴────┴───┘")]); + } + + #[test] + fn test_matrix_reply() { + let s = "This was replied toThis is the reply"; + + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), false, false); + assert_eq!(text.lines.len(), 4); + assert_eq!( + text.lines[0], + Line::from(vec![ + Span::raw("This"), + Span::raw(" "), + Span::raw("was"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[1], + Line::from(vec![Span::raw("replied"), Span::raw(" "), Span::raw("to")]) + ); + assert_eq!( + text.lines[2], + Line::from(vec![ + Span::raw("This"), + Span::raw(" "), + Span::raw("is"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[3], + Line::from(vec![ + Span::raw("the"), + Span::raw(" "), + Span::raw("reply"), + Span::raw(" ") + ]) + ); + + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), true, false); + assert_eq!(text.lines.len(), 2); + assert_eq!( + text.lines[0], + Line::from(vec![ + Span::raw("This"), + Span::raw(" "), + Span::raw("is"), + Span::raw(" "), + Span::raw(" ") + ]) + ); + assert_eq!( + text.lines[1], + Line::from(vec![ + Span::raw("the"), + Span::raw(" "), + Span::raw("reply"), + Span::raw(" ") + ]) + ); + } + + #[test] + fn test_self_closing() { + let s = "Hello
World
Goodbye"; + let tree = parse_matrix_html(s); + let text = tree.to_text(7, Style::default(), true, false); + assert_eq!(text.lines.len(), 3); + assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),])); + assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),])); + assert_eq!(text.lines[2], Line::from(vec![Span::raw("Goodbye")]),); + } + + #[test] + fn test_embedded_newline() { + let s = "

Hello\nWorld

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(15, Style::default(), true, false); + assert_eq!(text.lines.len(), 1); + assert_eq!( + text.lines[0], + Line::from(vec![ + Span::raw("Hello"), + Span::raw(" "), + Span::raw("World"), + Span::raw(" ") + ]) + ); + } + + #[test] + fn test_pre_tag() { + let s = concat!( + "
",
+            "fn hello() -> usize {\n",
+            "    return 5;\n",
+            "}\n",
+            "
\n" + ); + let tree = parse_matrix_html(s); + let text = tree.to_text(25, Style::default(), true, false); + assert_eq!(text.lines.len(), 5); + assert_eq!( + text.lines[0], + Line::from(vec![ + Span::raw(line::TOP_LEFT), + Span::raw(line::HORIZONTAL.repeat(23)), + Span::raw(line::TOP_RIGHT) + ]) + ); + assert_eq!( + text.lines[1], + Line::from(vec![ + Span::raw(line::VERTICAL), + Span::raw("fn"), + Span::raw(" "), + Span::raw("hello"), + Span::raw("("), + Span::raw(")"), + Span::raw(" "), + Span::raw("-"), + Span::raw(">"), + Span::raw(" "), + Span::raw("usize"), + Span::raw(" "), + Span::raw("{"), + Span::raw(" "), + Span::raw(line::VERTICAL) + ]) + ); + assert_eq!( + text.lines[2], + Line::from(vec![ + Span::raw(line::VERTICAL), + Span::raw(" "), + Span::raw("return"), + Span::raw(" "), + Span::raw("5"), + Span::raw(";"), + Span::raw(" "), + Span::raw(line::VERTICAL) + ]) + ); + assert_eq!( + text.lines[3], + Line::from(vec![ + Span::raw(line::VERTICAL), + Span::raw("}"), + Span::raw(" ".repeat(22)), + Span::raw(line::VERTICAL) + ]) + ); + assert_eq!( + text.lines[4], + Line::from(vec![ + Span::raw(line::BOTTOM_LEFT), + Span::raw(line::HORIZONTAL.repeat(23)), + Span::raw(line::BOTTOM_RIGHT) + ]) + ); + } + + #[test] + fn test_emoji_shortcodes() { + for shortcode in ["exploding_head", "polar_bear", "canada"] { + let emoji = emojis::get_by_shortcode(shortcode).unwrap().as_str(); + let emoji_width = UnicodeWidthStr::width(emoji); + let replacement = format!(":{shortcode}:"); + let replacement_width = UnicodeWidthStr::width(replacement.as_str()); + let s = format!("

{emoji}

"); + let tree = parse_matrix_html(s.as_str()); + // Test with emojis_shortcodes set to false + let text = tree.to_text(20, Style::default(), false, false); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::raw(emoji), + space_span(20 - emoji_width, Style::default()), + ]),]); + // Test with emojis_shortcodes set to true + let text = tree.to_text(20, Style::default(), false, true); + assert_eq!(text.lines, vec![Line::from(vec![ + Span::raw(replacement.as_str()), + space_span(20 - replacement_width, Style::default()), + ])]); + } + } +} diff --git a/src/message/mod.rs b/src/message/mod.rs new file mode 100644 index 0000000..ab813bf --- /dev/null +++ b/src/message/mod.rs @@ -0,0 +1,1428 @@ +//! # Room Messages +use std::borrow::Cow; +use std::cmp::{Ord, Ordering, PartialOrd}; +use std::collections::hash_map::DefaultHasher; +use std::collections::hash_set; +use std::collections::BTreeMap; +use std::convert::{TryFrom, TryInto}; +use std::fmt::{self, Display}; +use std::hash::{Hash, Hasher}; +use std::ops::{Deref, DerefMut}; + +use chrono::{DateTime, Local as LocalTz}; +use humansize::{format_size, DECIMAL}; +use serde_json::json; +use unicode_width::UnicodeWidthStr; + +use matrix_sdk::ruma::{ + events::{ + relation::Thread, + room::{ + encrypted::{ + OriginalRoomEncryptedEvent, + RedactedRoomEncryptedEvent, + RoomEncryptedEvent, + }, + message::{ + FormattedBody, + MessageFormat, + MessageType, + OriginalRoomMessageEvent, + RedactedRoomMessageEvent, + Relation, + RoomMessageEvent, + RoomMessageEventContent, + }, + redaction::SyncRoomRedactionEvent, + }, + RedactContent, + RedactedUnsigned, + }, + EventId, + MilliSecondsSinceUnixEpoch, + OwnedEventId, + OwnedUserId, + RoomVersionId, + UInt, +}; + +use ratatui::{ + style::{Modifier as StyleModifier, Style}, + symbols::line::THICK_VERTICAL, + text::{Line, Span, Text}, +}; + +use modalkit::editing::cursor::Cursor; +use modalkit::prelude::*; +use ratatui_image::protocol::Protocol; + +use crate::config::ImagePreviewSize; +use crate::{ + base::RoomInfo, + config::ApplicationSettings, + message::html::{parse_matrix_html, StyleTree}, + util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text}, +}; + +mod compose; +mod html; +mod printer; + +pub use self::compose::text_to_message; + +pub type MessageKey = (MessageTimeStamp, OwnedEventId); + +#[derive(Default)] +pub struct Messages(BTreeMap); + +impl Deref for Messages { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Messages { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Messages { + pub fn insert_message(&mut self, key: MessageKey, msg: impl Into) { + let event_id = key.1.clone(); + let msg = msg.into(); + + self.0.insert(key, msg); + + // Remove any echo. + let key = (MessageTimeStamp::LocalEcho, event_id); + let _ = self.0.remove(&key); + } +} + +const fn span_static(s: &'static str) -> Span<'static> { + Span { + content: Cow::Borrowed(s), + style: Style { + fg: None, + bg: None, + add_modifier: StyleModifier::empty(), + sub_modifier: StyleModifier::empty(), + underline_color: None, + }, + } +} + +const BOLD_STYLE: Style = Style { + fg: None, + bg: None, + add_modifier: StyleModifier::BOLD, + sub_modifier: StyleModifier::empty(), + underline_color: None, +}; + +const TIME_GUTTER: usize = 12; +const READ_GUTTER: usize = 5; +const MIN_MSG_LEN: usize = 30; + +const TIME_GUTTER_EMPTY: &str = " "; +const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY); + +const USIZE_TOO_SMALL: bool = usize::BITS < u64::BITS; + +/// Convert the [u64] hash to [usize] as needed. +fn hash_finish_usize(hasher: DefaultHasher) -> Option { + if USIZE_TOO_SMALL { + (hasher.finish() % usize::MAX as u64).try_into().ok() + } else { + hasher.finish().try_into().ok() + } +} + +/// Hash an [EventId] into a [usize]. +fn hash_event_id(event_id: &EventId) -> Option { + let mut hasher = DefaultHasher::new(); + event_id.hash(&mut hasher); + hash_finish_usize(hasher) +} + +/// Before the image is loaded, already display a placeholder frame of the image size. +fn placeholder_frame( + text: Option<&str>, + outer_width: usize, + image_preview_size: &ImagePreviewSize, +) -> Option { + let ImagePreviewSize { width, height } = image_preview_size; + if outer_width < *width || (*width < 2 || *height < 2) { + return None; + } + let mut placeholder = "\u{230c}".to_string(); + placeholder.push_str(&" ".repeat(width - 2)); + placeholder.push_str("\u{230d}\n"); + if *height > 2 { + if let Some(text) = text { + if text.width() <= width - 2 { + placeholder.push(' '); + placeholder.push_str(text); + } + } + } + + placeholder.push_str(&"\n".repeat(height - 2)); + placeholder.push('\u{230e}'); + placeholder.push_str(&" ".repeat(width - 2)); + placeholder.push_str("\u{230f}\n"); + Some(placeholder) +} + +#[inline] +fn millis_to_datetime(ms: UInt) -> DateTime { + let time = i64::from(ms) / 1000; + let time = DateTime::from_timestamp(time, 0).unwrap_or_default(); + time.into() +} + +#[derive(thiserror::Error, Debug)] +pub enum TimeStampIntError { + #[error("Integer conversion error: {0}")] + IntError(#[from] std::num::TryFromIntError), + + #[error("UInt conversion error: {0}")] + UIntError(>::Error), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MessageTimeStamp { + OriginServer(UInt), + LocalEcho, +} + +impl MessageTimeStamp { + fn as_datetime(&self) -> DateTime { + match self { + MessageTimeStamp::OriginServer(ms) => millis_to_datetime(*ms), + MessageTimeStamp::LocalEcho => LocalTz::now(), + } + } + + fn same_day(&self, other: &Self) -> bool { + let dt1 = self.as_datetime(); + let dt2 = other.as_datetime(); + + dt1.date_naive() == dt2.date_naive() + } + + fn show_date(&self) -> Option { + let time = self.as_datetime().format("%A, %B %d %Y").to_string(); + + Span::styled(time, BOLD_STYLE).into() + } + + fn show_time(&self) -> Option { + match self { + MessageTimeStamp::OriginServer(ms) => { + let time = millis_to_datetime(*ms).format("%T"); + let time = format!(" [{time}]"); + + Span::raw(time).into() + }, + MessageTimeStamp::LocalEcho => None, + } + } + + fn is_local_echo(&self) -> bool { + matches!(self, MessageTimeStamp::LocalEcho) + } + + pub fn as_millis(&self) -> Option { + match self { + MessageTimeStamp::OriginServer(ms) => MilliSecondsSinceUnixEpoch(*ms).into(), + MessageTimeStamp::LocalEcho => None, + } + } +} + +impl Ord for MessageTimeStamp { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (MessageTimeStamp::OriginServer(_), MessageTimeStamp::LocalEcho) => Ordering::Less, + (MessageTimeStamp::OriginServer(a), MessageTimeStamp::OriginServer(b)) => a.cmp(b), + (MessageTimeStamp::LocalEcho, MessageTimeStamp::OriginServer(_)) => Ordering::Greater, + (MessageTimeStamp::LocalEcho, MessageTimeStamp::LocalEcho) => Ordering::Equal, + } + } +} + +impl PartialOrd for MessageTimeStamp { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl From for MessageTimeStamp { + fn from(millis: UInt) -> Self { + MessageTimeStamp::OriginServer(millis) + } +} + +impl From for MessageTimeStamp { + fn from(millis: MilliSecondsSinceUnixEpoch) -> Self { + MessageTimeStamp::OriginServer(millis.0) + } +} + +impl TryFrom<&MessageTimeStamp> for usize { + type Error = TimeStampIntError; + + fn try_from(ts: &MessageTimeStamp) -> Result { + let n = match ts { + MessageTimeStamp::LocalEcho => 0, + MessageTimeStamp::OriginServer(u) => usize::try_from(u64::from(*u))?, + }; + + Ok(n) + } +} + +impl TryFrom for MessageTimeStamp { + type Error = TimeStampIntError; + + fn try_from(u: usize) -> Result { + if u == 0 { + Ok(MessageTimeStamp::LocalEcho) + } else { + let n = u64::try_from(u)?; + let n = UInt::try_from(n).map_err(TimeStampIntError::UIntError)?; + + Ok(MessageTimeStamp::from(n)) + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MessageCursor { + /// When timestamp is None, the corner is determined by moving backwards from + /// the most recently received message. + pub timestamp: Option, + + /// A row within the [Text] representation of a [Message]. + pub text_row: usize, +} + +impl MessageCursor { + pub fn new(timestamp: MessageKey, text_row: usize) -> Self { + MessageCursor { timestamp: Some(timestamp), text_row } + } + + /// Get a cursor that refers to the most recent message. + pub fn latest() -> Self { + MessageCursor::default() + } + + pub fn to_key<'a>(&'a self, thread: &'a Messages) -> Option<&'a MessageKey> { + if let Some(ref key) = self.timestamp { + Some(key) + } else { + Some(thread.last_key_value()?.0) + } + } + + pub fn from_cursor(cursor: &Cursor, thread: &Messages) -> Option { + let ev_hash = cursor.get_x(); + let ev_term = OwnedEventId::try_from("$").ok()?; + + let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?; + let start = (ts_start, ev_term); + + for ((ts, event_id), _) in thread.range(&start..) { + if hash_event_id(event_id)? == ev_hash { + return Self::from((*ts, event_id.clone())).into(); + } + + if ts > &ts_start { + break; + } + } + + // If we can't find the cursor, then go to the nearest timestamp. + thread + .range(start..) + .next() + .map(|((ts, ev), _)| Self::from((*ts, ev.clone()))) + } + + pub fn to_cursor(&self, thread: &Messages) -> Option { + let (ts, event_id) = self.to_key(thread)?; + + let y = usize::try_from(ts).ok()?; + let x = hash_event_id(event_id)?; + + Cursor::new(y, x).into() + } +} + +impl From> for MessageCursor { + fn from(key: Option) -> Self { + MessageCursor { timestamp: key, text_row: 0 } + } +} + +impl From for MessageCursor { + fn from(key: MessageKey) -> Self { + MessageCursor { timestamp: Some(key), text_row: 0 } + } +} + +impl Ord for MessageCursor { + fn cmp(&self, other: &Self) -> Ordering { + match (&self.timestamp, &other.timestamp) { + (None, None) => self.text_row.cmp(&other.text_row), + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(st), Some(ot)) => { + let pcmp = st.cmp(ot); + let tcmp = self.text_row.cmp(&other.text_row); + + pcmp.then(tcmp) + }, + } + } +} + +impl PartialOrd for MessageCursor { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn redaction_reason(ev: &SyncRoomRedactionEvent) -> Option<&str> { + let SyncRoomRedactionEvent::Original(ev) = ev else { + return None; + }; + + return ev.content.reason.as_deref(); +} + +fn redaction_unsigned(ev: SyncRoomRedactionEvent) -> RedactedUnsigned { + let reason = redaction_reason(&ev); + let redacted_because = json!({ + "content": { + "reason": reason + }, + "event_id": ev.event_id(), + "sender": ev.sender(), + "origin_server_ts": ev.origin_server_ts(), + "unsigned": {}, + }); + RedactedUnsigned::new(serde_json::from_value(redacted_because).unwrap()) +} + +#[derive(Clone)] +pub enum MessageEvent { + EncryptedOriginal(Box), + EncryptedRedacted(Box), + Original(Box), + Redacted(Box), + Local(OwnedEventId, Box), +} + +impl MessageEvent { + pub fn event_id(&self) -> &EventId { + match self { + MessageEvent::EncryptedOriginal(ev) => ev.event_id.as_ref(), + MessageEvent::EncryptedRedacted(ev) => ev.event_id.as_ref(), + MessageEvent::Original(ev) => ev.event_id.as_ref(), + MessageEvent::Redacted(ev) => ev.event_id.as_ref(), + MessageEvent::Local(event_id, _) => event_id.as_ref(), + } + } + + pub fn content(&self) -> Option<&RoomMessageEventContent> { + match self { + MessageEvent::EncryptedOriginal(_) => None, + MessageEvent::Original(ev) => Some(&ev.content), + MessageEvent::EncryptedRedacted(_) => None, + MessageEvent::Redacted(_) => None, + MessageEvent::Local(_, content) => Some(content), + } + } + + pub fn is_emote(&self) -> bool { + matches!( + self.content(), + Some(RoomMessageEventContent { msgtype: MessageType::Emote(_), .. }) + ) + } + + pub fn body(&self) -> Cow<'_, str> { + match self { + MessageEvent::EncryptedOriginal(_) => "[Unable to decrypt message]".into(), + MessageEvent::Original(ev) => body_cow_content(&ev.content), + MessageEvent::EncryptedRedacted(ev) => body_cow_reason(&ev.unsigned), + MessageEvent::Redacted(ev) => body_cow_reason(&ev.unsigned), + MessageEvent::Local(_, content) => body_cow_content(content), + } + } + + pub fn html(&self) -> Option { + let content = match self { + MessageEvent::EncryptedOriginal(_) => return None, + MessageEvent::EncryptedRedacted(_) => return None, + MessageEvent::Original(ev) => &ev.content, + MessageEvent::Redacted(_) => return None, + MessageEvent::Local(_, content) => content, + }; + + if let MessageType::Text(content) = &content.msgtype { + if let Some(FormattedBody { format: MessageFormat::Html, body }) = &content.formatted { + Some(parse_matrix_html(body.as_str())) + } else { + None + } + } else { + None + } + } + + fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) { + match self { + MessageEvent::EncryptedOriginal(_) => return, + MessageEvent::EncryptedRedacted(_) => return, + MessageEvent::Redacted(_) => return, + MessageEvent::Local(_, _) => return, + MessageEvent::Original(ev) => { + let redacted = RedactedRoomMessageEvent { + content: ev.content.clone().redact(version), + event_id: ev.event_id.clone(), + sender: ev.sender.clone(), + origin_server_ts: ev.origin_server_ts, + room_id: ev.room_id.clone(), + unsigned: redaction_unsigned(redaction), + }; + *self = MessageEvent::Redacted(Box::new(redacted)); + }, + } + } +} + +/// Macro rule converting a File / Image / Audio / Video to its text content with the shape: +/// `[Attached : [ ()]]` +macro_rules! display_file_to_text { + ( $msgtype:ident, $content:expr ) => { + return Cow::Owned(format!( + "[Attached {}: {}{}]", + stringify!($msgtype), + $content.body, + $content + .info + .as_ref() + .map(|info| { + info.size + .map(|s| format!(" ({})", format_size(u64::from(s), DECIMAL))) + .unwrap_or_else(String::new) + }) + .unwrap_or_else(String::new) + )) + }; +} + +fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { + let s = match &content.msgtype { + MessageType::Text(content) => content.body.as_str(), + MessageType::VerificationRequest(_) => "[Verification Request]", + MessageType::Emote(content) => content.body.as_ref(), + MessageType::Notice(content) => content.body.as_str(), + MessageType::ServerNotice(content) => content.body.as_str(), + + MessageType::Audio(content) => { + display_file_to_text!(Audio, content); + }, + MessageType::File(content) => { + display_file_to_text!(File, content); + }, + MessageType::Image(content) => { + display_file_to_text!(Image, content); + }, + MessageType::Video(content) => { + display_file_to_text!(Video, content); + }, + _ => { + match content.msgtype() { + // Just show the body text for the special Element messages. + "nic.custom.confetti" | + "nic.custom.fireworks" | + "io.element.effect.hearts" | + "io.element.effect.rainfall" | + "io.element.effect.snowfall" | + "io.element.effects.space_invaders" => content.body(), + other => { + return Cow::Owned(format!("[Unknown message type: {other:?}]")); + }, + } + }, + }; + + Cow::Borrowed(s) +} + +fn body_cow_reason(unsigned: &RedactedUnsigned) -> Cow<'_, str> { + let reason = unsigned.redacted_because.content.reason.as_ref(); + + if let Some(r) = reason { + Cow::Owned(format!("[Redacted: {r:?}]")) + } else { + Cow::Borrowed("[Redacted]") + } +} + +enum MessageColumns { + /// Four columns: sender, message, timestamp, read receipts. + Four, + + /// Three columns: sender, message, timestamp. + Three, + + /// Two columns: sender, message. + Two, + + /// One column: message with sender on line before the message. + One, +} + +impl MessageColumns { + fn user_gutter_width(&self, settings: &ApplicationSettings) -> u16 { + if let MessageColumns::One = self { + 0 + } else { + settings.tunables.user_gutter_width as u16 + } + } +} + +struct MessageFormatter<'a> { + settings: &'a ApplicationSettings, + + /// How many columns to print. + cols: MessageColumns, + + /// The full, original width. + orig: usize, + + /// The width that the message contents need to fill. + fill: usize, + + /// The formatted Span for the message sender. + user: Option>, + + /// The time the message was sent. + time: Option>, + + /// The date the message was sent. + date: Option>, + + /// Iterator over the users who have read up to this message. + read: Option>, +} + +impl<'a> MessageFormatter<'a> { + fn width(&self) -> usize { + self.fill + } + + #[inline] + fn push_spans(&mut self, prev_line: Line<'a>, style: Style, text: &mut Text<'a>) { + if let Some(date) = self.date.take() { + let len = date.content.as_ref().len(); + let padding = self.orig.saturating_sub(len); + let leading = space_span(padding / 2, Style::default()); + let trailing = space_span(padding.saturating_sub(padding / 2), Style::default()); + + text.lines.push(Line::from(vec![leading, date, trailing])); + } + + let user_gutter_empty_span = + space_span(self.settings.tunables.user_gutter_width, Style::default()); + + match self.cols { + MessageColumns::Four => { + let settings = self.settings; + let user = self.user.take().unwrap_or(user_gutter_empty_span); + let time = self.time.take().unwrap_or(TIME_GUTTER_EMPTY_SPAN); + + let mut line = vec![user]; + line.extend(prev_line.spans); + line.push(time); + + // Show read receipts. + let user_char = + |user: &'a OwnedUserId| -> Span<'a> { settings.get_user_char_span(user) }; + let mut read = self.read.iter_mut().flatten(); + + let a = read.next().map(user_char).unwrap_or_else(|| Span::raw(" ")); + let b = read.next().map(user_char).unwrap_or_else(|| Span::raw(" ")); + let c = read.next().map(user_char).unwrap_or_else(|| Span::raw(" ")); + + line.push(Span::raw(" ")); + line.push(c); + line.push(b); + line.push(a); + line.push(Span::raw(" ")); + + text.lines.push(Line::from(line)) + }, + MessageColumns::Three => { + let user = self.user.take().unwrap_or(user_gutter_empty_span); + let time = self.time.take().unwrap_or_else(|| Span::from("")); + + let mut line = vec![user]; + line.extend(prev_line.spans); + line.push(time); + + text.lines.push(Line::from(line)) + }, + MessageColumns::Two => { + let user = self.user.take().unwrap_or(user_gutter_empty_span); + let mut line = vec![user]; + line.extend(prev_line.spans); + + text.lines.push(Line::from(line)); + }, + MessageColumns::One => { + if let Some(user) = self.user.take() { + text.lines.push(Line::from(vec![user])); + } + + let leading = space_span(2, style); + let mut line = vec![leading]; + line.extend(prev_line.spans); + + text.lines.push(Line::from(line)); + }, + } + } + + fn push_text(&mut self, append: Text<'a>, style: Style, text: &mut Text<'a>) { + for line in append.lines.into_iter() { + self.push_spans(line, style, text); + } + } + + fn push_in_reply( + &mut self, + msg: &'a Message, + style: Style, + text: &mut Text<'a>, + info: &'a RoomInfo, + ) { + let width = self.width(); + let w = width.saturating_sub(2); + let shortcodes = self.settings.tunables.message_shortcode_display; + let (mut replied, _) = msg.show_msg(w, style, true, shortcodes); + let mut sender = msg.sender_span(info, self.settings); + let sender_width = UnicodeWidthStr::width(sender.content.as_ref()); + let trailing = w.saturating_sub(sender_width + 1); + + sender.style = sender.style.patch(style); + + self.push_spans( + Line::from(vec![ + Span::styled(" ", style), + Span::styled(THICK_VERTICAL, style), + sender, + Span::styled(":", style), + space_span(trailing, style), + ]), + style, + text, + ); + + for line in replied.lines.iter_mut() { + line.spans.insert(0, Span::styled(THICK_VERTICAL, style)); + line.spans.insert(0, Span::styled(" ", style)); + } + + self.push_text(replied, style, text); + } + + fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) { + let mut emojis = printer::TextPrinter::new(self.width(), style, false, false); + let mut reactions = 0; + + for (key, count) in counts { + if reactions != 0 { + emojis.push_str(" ", style); + } + + let name = if self.settings.tunables.reaction_shortcode_display { + if let Some(emoji) = emojis::get(key) { + if let Some(short) = emoji.shortcode() { + short + } else { + // No ASCII shortcode name to show. + continue; + } + } else if key.chars().all(|c| c.is_ascii_alphanumeric()) { + key + } else { + // Not an Emoji or a printable ASCII string. + continue; + } + } else { + key + }; + + emojis.push_str("[", style); + emojis.push_str(name, style); + emojis.push_str(" ", style); + emojis.push_span_nobreak(Span::styled(count.to_string(), style)); + emojis.push_str("]", style); + + reactions += 1; + } + + if reactions > 0 { + self.push_text(emojis.finish(), style, text); + } + } + + fn push_thread_reply_count(&mut self, len: usize, text: &mut Text<'a>) { + if len == 0 { + return; + } + + // If we have threaded replies to this message, show how many. + let plural = len != 1; + let style = Style::default(); + let mut threaded = + printer::TextPrinter::new(self.width(), style, false, false).literal(true); + let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD)); + threaded.push_str(" \u{2937} ", style); + threaded.push_span_nobreak(len); + if plural { + threaded.push_str(" replies in thread", style); + } else { + threaded.push_str(" reply in thread", style); + } + + self.push_text(threaded.finish(), style, text); + } +} + +pub enum ImageStatus { + None, + Downloading(ImagePreviewSize), + Loaded(Box), + Error(String), +} + +pub struct Message { + pub event: MessageEvent, + pub sender: OwnedUserId, + pub timestamp: MessageTimeStamp, + pub downloaded: bool, + pub html: Option, + pub image_preview: ImageStatus, +} + +impl Message { + pub fn new(event: MessageEvent, sender: OwnedUserId, timestamp: MessageTimeStamp) -> Self { + let html = event.html(); + let downloaded = false; + + Message { + event, + sender, + timestamp, + downloaded, + html, + image_preview: ImageStatus::None, + } + } + + pub fn reply_to(&self) -> Option { + let content = match &self.event { + MessageEvent::EncryptedOriginal(_) => return None, + MessageEvent::EncryptedRedacted(_) => return None, + MessageEvent::Local(_, content) => content, + MessageEvent::Original(ev) => &ev.content, + MessageEvent::Redacted(_) => return None, + }; + + match &content.relates_to { + Some(Relation::Reply { in_reply_to }) => Some(in_reply_to.event_id.clone()), + Some(Relation::Thread(Thread { + in_reply_to: Some(in_reply_to), + is_falling_back: false, + .. + })) => Some(in_reply_to.event_id.clone()), + Some(_) | None => None, + } + } + + fn thread_root(&self) -> Option { + let content = match &self.event { + MessageEvent::EncryptedOriginal(_) => return None, + MessageEvent::EncryptedRedacted(_) => return None, + MessageEvent::Local(_, content) => content, + MessageEvent::Original(ev) => &ev.content, + MessageEvent::Redacted(_) => return None, + }; + + match &content.relates_to { + Some(Relation::Thread(Thread { + event_id, + in_reply_to: Some(in_reply_to), + is_falling_back: true, + .. + })) if event_id == &in_reply_to.event_id => Some(event_id.clone()), + Some(_) | None => None, + } + } + + fn get_render_style(&self, selected: bool, settings: &ApplicationSettings) -> Style { + let mut style = Style::default(); + + if selected { + style = style.add_modifier(StyleModifier::REVERSED) + } + + if self.timestamp.is_local_echo() { + style = style.add_modifier(StyleModifier::ITALIC); + } + + if settings.tunables.message_user_color { + let color = settings.get_user_color(&self.sender); + style = style.fg(color); + } + + return style; + } + + fn get_render_format<'a>( + &'a self, + prev: Option<&Message>, + width: usize, + info: &'a RoomInfo, + settings: &'a ApplicationSettings, + ) -> MessageFormatter<'a> { + let orig = width; + let date = match &prev { + Some(prev) if prev.timestamp.same_day(&self.timestamp) => None, + _ => self.timestamp.show_date(), + }; + let user_gutter = settings.tunables.user_gutter_width; + + if user_gutter + TIME_GUTTER + READ_GUTTER + MIN_MSG_LEN <= width && + settings.tunables.read_receipt_display + { + let cols = MessageColumns::Four; + let fill = width - user_gutter - TIME_GUTTER - READ_GUTTER; + let user = self.show_sender(prev, true, info, settings); + let time = self.timestamp.show_time(); + let read = info.event_receipts.get(self.event.event_id()).map(|read| read.iter()); + + MessageFormatter { settings, cols, orig, fill, user, date, time, read } + } else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width { + let cols = MessageColumns::Three; + let fill = width - user_gutter - TIME_GUTTER; + let user = self.show_sender(prev, true, info, settings); + let time = self.timestamp.show_time(); + let read = None; + + MessageFormatter { settings, cols, orig, fill, user, date, time, read } + } else if user_gutter + MIN_MSG_LEN <= width { + let cols = MessageColumns::Two; + let fill = width - user_gutter; + let user = self.show_sender(prev, true, info, settings); + let time = None; + let read = None; + + MessageFormatter { settings, cols, orig, fill, user, date, time, read } + } else { + let cols = MessageColumns::One; + let fill = width.saturating_sub(2); + let user = self.show_sender(prev, false, info, settings); + let time = None; + let read = None; + + MessageFormatter { settings, cols, orig, fill, user, date, time, read } + } + } + + /// Render the message as a [Text] object for the terminal. + /// + /// This will also get the image preview Protocol with an x/y offset. + pub fn show_with_preview<'a>( + &'a self, + prev: Option<&Message>, + selected: bool, + vwctx: &ViewportContext, + info: &'a RoomInfo, + settings: &'a ApplicationSettings, + ) -> (Text<'a>, Option<(&dyn Protocol, u16, u16)>) { + let width = vwctx.get_width(); + + let style = self.get_render_style(selected, settings); + let mut fmt = self.get_render_format(prev, width, info, settings); + let mut text = Text::default(); + let width = fmt.width(); + + // Show the message that this one replied to, if any. + let reply = self + .reply_to() + .or_else(|| self.thread_root()) + .and_then(|e| info.get_event(&e)); + + if let Some(r) = &reply { + fmt.push_in_reply(r, style, &mut text, info); + } + + // Now show the message contents, and the inlined reply if we couldn't find it above. + let (msg, proto) = self.show_msg( + width, + style, + reply.is_some(), + settings.tunables.message_shortcode_display, + ); + + // Given our text so far, determine the image offset. + let proto = proto.map(|p| { + let y_off = text.lines.len() as u16; + let x_off = fmt.cols.user_gutter_width(settings); + // Adjust y_off by 1 if a date was printed before the message to account for the extra line. + let y_off = if fmt.date.is_some() { y_off + 1 } else { y_off }; + (p, x_off, y_off) + }); + + fmt.push_text(msg, style, &mut text); + + if text.lines.is_empty() { + // If there was nothing in the body, just show an empty message. + fmt.push_spans(space_span(width, style).into(), style, &mut text); + } + + if settings.tunables.reaction_display { + let reactions = info.get_reactions(self.event.event_id()); + fmt.push_reactions(reactions, style, &mut text); + } + + if let Some(thread) = info.get_thread(Some(self.event.event_id())) { + fmt.push_thread_reply_count(thread.len(), &mut text); + } + + (text, proto) + } + + pub fn show<'a>( + &'a self, + prev: Option<&Message>, + selected: bool, + vwctx: &ViewportContext, + info: &'a RoomInfo, + settings: &'a ApplicationSettings, + ) -> Text<'a> { + self.show_with_preview(prev, selected, vwctx, info, settings).0 + } + + fn show_msg( + &self, + width: usize, + style: Style, + hide_reply: bool, + emoji_shortcodes: bool, + ) -> (Text, Option<&dyn Protocol>) { + if let Some(html) = &self.html { + (html.to_text(width, style, hide_reply, emoji_shortcodes), None) + } else { + let mut msg = self.event.body(); + if emoji_shortcodes { + msg = Cow::Owned(replace_emojis_in_str(msg.as_ref())); + } + + if self.downloaded { + msg.to_mut().push_str(" \u{2705}"); + } + + let mut proto = None; + let placeholder = match &self.image_preview { + ImageStatus::None => None, + ImageStatus::Downloading(image_preview_size) => { + placeholder_frame(Some("Downloading..."), width, image_preview_size) + }, + ImageStatus::Loaded(backend) => { + proto = Some(backend.as_ref()); + placeholder_frame(None, width, &backend.rect().into()) + }, + ImageStatus::Error(err) => Some(format!("[Image error: {err}]\n")), + }; + + if let Some(placeholder) = placeholder { + msg.to_mut().insert_str(0, &placeholder); + } + + (wrapped_text(msg, width, style), proto) + } + } + + fn sender_span<'a>( + &'a self, + info: &'a RoomInfo, + settings: &'a ApplicationSettings, + ) -> Span<'a> { + settings.get_user_span(self.sender.as_ref(), info) + } + + fn show_sender<'a>( + &'a self, + prev: Option<&Message>, + align_right: bool, + info: &'a RoomInfo, + settings: &'a ApplicationSettings, + ) -> Option> { + if let Some(prev) = prev { + if self.sender == prev.sender && + self.timestamp.same_day(&prev.timestamp) && + !self.event.is_emote() + { + return None; + } + } + + let Span { content, style } = self.sender_span(info, settings); + let user_gutter = settings.tunables.user_gutter_width; + let ((truncated, width), _) = take_width(content, user_gutter - 2); + let padding = user_gutter - 2 - width; + + let sender = if align_right { + space(padding) + &truncated + " " + } else { + truncated.into_owned() + &space(padding) + " " + }; + + Span::styled(sender, style).into() + } + + pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) { + self.event.redact(redaction, version); + self.html = None; + } +} + +impl From for Message { + fn from(event: RoomEncryptedEvent) -> Self { + let timestamp = event.origin_server_ts().into(); + let user_id = event.sender().to_owned(); + let content = match event { + RoomEncryptedEvent::Original(ev) => MessageEvent::EncryptedOriginal(ev.into()), + RoomEncryptedEvent::Redacted(ev) => MessageEvent::EncryptedRedacted(ev.into()), + }; + + Message::new(content, user_id, timestamp) + } +} + +impl From for Message { + fn from(event: OriginalRoomMessageEvent) -> Self { + let timestamp = event.origin_server_ts.into(); + let user_id = event.sender.clone(); + let content = MessageEvent::Original(event.into()); + + Message::new(content, user_id, timestamp) + } +} + +impl From for Message { + fn from(event: RedactedRoomMessageEvent) -> Self { + let timestamp = event.origin_server_ts.into(); + let user_id = event.sender.clone(); + let content = MessageEvent::Redacted(event.into()); + + Message::new(content, user_id, timestamp) + } +} + +impl From for Message { + fn from(event: RoomMessageEvent) -> Self { + match event { + RoomMessageEvent::Original(ev) => ev.into(), + RoomMessageEvent::Redacted(ev) => ev.into(), + } + } +} + +impl Display for Message { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.event.body()) + } +} + +#[cfg(test)] +pub mod tests { + use matrix_sdk::ruma::events::room::{ + message::{ + AudioInfo, + AudioMessageEventContent, + FileInfo, + FileMessageEventContent, + ImageMessageEventContent, + VideoInfo, + VideoMessageEventContent, + }, + ImageInfo, + }; + + use super::*; + use crate::tests::*; + + #[test] + fn test_mc_cmp() { + let mc1 = MessageCursor::from(MSG1_KEY.clone()); + let mc2 = MessageCursor::from(MSG2_KEY.clone()); + let mc3 = MessageCursor::from(MSG3_KEY.clone()); + let mc4 = MessageCursor::from(MSG4_KEY.clone()); + let mc5 = MessageCursor::from(MSG5_KEY.clone()); + + // Everything is equal to itself. + assert_eq!(mc1.cmp(&mc1), Ordering::Equal); + assert_eq!(mc2.cmp(&mc2), Ordering::Equal); + assert_eq!(mc3.cmp(&mc3), Ordering::Equal); + assert_eq!(mc4.cmp(&mc4), Ordering::Equal); + assert_eq!(mc5.cmp(&mc5), Ordering::Equal); + + // Local echo is always greater than an origin server timestamp. + assert_eq!(mc1.cmp(&mc2), Ordering::Greater); + assert_eq!(mc1.cmp(&mc3), Ordering::Greater); + assert_eq!(mc1.cmp(&mc4), Ordering::Greater); + assert_eq!(mc1.cmp(&mc5), Ordering::Greater); + + // mc2 is the smallest timestamp. + assert_eq!(mc2.cmp(&mc1), Ordering::Less); + assert_eq!(mc2.cmp(&mc3), Ordering::Less); + assert_eq!(mc2.cmp(&mc4), Ordering::Less); + assert_eq!(mc2.cmp(&mc5), Ordering::Less); + + // mc3 should be less than mc4 because of its event ID. + assert_eq!(mc3.cmp(&mc1), Ordering::Less); + assert_eq!(mc3.cmp(&mc2), Ordering::Greater); + assert_eq!(mc3.cmp(&mc4), Ordering::Less); + assert_eq!(mc3.cmp(&mc5), Ordering::Less); + + // mc4 should be greater than mc3 because of its event ID. + assert_eq!(mc4.cmp(&mc1), Ordering::Less); + assert_eq!(mc4.cmp(&mc2), Ordering::Greater); + assert_eq!(mc4.cmp(&mc3), Ordering::Greater); + assert_eq!(mc4.cmp(&mc5), Ordering::Less); + + // mc5 is the greatest OriginServer timestamp. + assert_eq!(mc5.cmp(&mc1), Ordering::Less); + assert_eq!(mc5.cmp(&mc2), Ordering::Greater); + assert_eq!(mc5.cmp(&mc3), Ordering::Greater); + assert_eq!(mc5.cmp(&mc4), Ordering::Greater); + } + + #[test] + fn test_mc_to_key() { + let messages = mock_messages(); + let mc1 = MessageCursor::from(MSG1_KEY.clone()); + let mc2 = MessageCursor::from(MSG2_KEY.clone()); + let mc3 = MessageCursor::from(MSG3_KEY.clone()); + let mc4 = MessageCursor::from(MSG4_KEY.clone()); + let mc5 = MessageCursor::from(MSG5_KEY.clone()); + let mc6 = MessageCursor::latest(); + + let k1 = mc1.to_key(&messages).unwrap(); + let k2 = mc2.to_key(&messages).unwrap(); + let k3 = mc3.to_key(&messages).unwrap(); + let k4 = mc4.to_key(&messages).unwrap(); + let k5 = mc5.to_key(&messages).unwrap(); + let k6 = mc6.to_key(&messages).unwrap(); + + // These should all be equal to their MSGN_KEYs. + assert_eq!(k1, &MSG1_KEY.clone()); + assert_eq!(k2, &MSG2_KEY.clone()); + assert_eq!(k3, &MSG3_KEY.clone()); + assert_eq!(k4, &MSG4_KEY.clone()); + assert_eq!(k5, &MSG5_KEY.clone()); + + // MessageCursor::latest() turns into the largest key (our local echo message). + assert_eq!(k6, &MSG1_KEY.clone()); + + // MessageCursor::latest() fails to convert for a room w/o messages. + let messages_empty = Messages::default(); + assert_eq!(mc6.to_key(&messages_empty), None); + } + + #[test] + fn test_mc_to_from_cursor() { + let messages = mock_messages(); + let mc1 = MessageCursor::from(MSG1_KEY.clone()); + let mc2 = MessageCursor::from(MSG2_KEY.clone()); + let mc3 = MessageCursor::from(MSG3_KEY.clone()); + let mc4 = MessageCursor::from(MSG4_KEY.clone()); + let mc5 = MessageCursor::from(MSG5_KEY.clone()); + let mc6 = MessageCursor::latest(); + + let identity = |mc: &MessageCursor| { + let c = mc.to_cursor(&messages).unwrap(); + + MessageCursor::from_cursor(&c, &messages).unwrap() + }; + + // These should all convert to a Cursor and back to the original value. + assert_eq!(identity(&mc1), mc1); + assert_eq!(identity(&mc2), mc2); + assert_eq!(identity(&mc3), mc3); + assert_eq!(identity(&mc4), mc4); + assert_eq!(identity(&mc5), mc5); + + // MessageCursor::latest() should point at the most recent message after conversion. + assert_eq!(identity(&mc6), mc1); + } + + #[test] + fn test_placeholder_frame() { + fn pretty_frame_test(str: &str) -> Option { + Some(str[1..].to_string()) + } + + assert_eq!( + placeholder_frame(None, 4, &ImagePreviewSize { width: 4, height: 4 }), + pretty_frame_test( + r#" +⌌ ⌍ + + +⌎ ⌏ +"# + ) + ); + + assert_eq!(placeholder_frame(None, 2, &ImagePreviewSize { width: 4, height: 4 }), None); + assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 1, height: 4 }), None); + + assert_eq!(placeholder_frame(None, 4, &ImagePreviewSize { width: 4, height: 1 }), None); + + assert_eq!( + placeholder_frame(Some("OK"), 4, &ImagePreviewSize { width: 4, height: 4 }), + pretty_frame_test( + r#" +⌌ ⌍ + OK + +⌎ ⌏ +"# + ) + ); + assert_eq!( + placeholder_frame(Some("idontfit"), 4, &ImagePreviewSize { width: 4, height: 4 }), + pretty_frame_test( + r#" +⌌ ⌍ + + +⌎ ⌏ +"# + ) + ); + assert_eq!( + placeholder_frame(Some("OK"), 4, &ImagePreviewSize { width: 4, height: 2 }), + pretty_frame_test( + r#" +⌌ ⌍ +⌎ ⌏ +"# + ) + ); + assert_eq!( + placeholder_frame(Some("OK"), 4, &ImagePreviewSize { width: 2, height: 3 }), + pretty_frame_test( + r#" +⌌⌍ + +⌎⌏ +"# + ) + ); + } + + #[test] + fn test_display_attachment_size() { + assert_eq!( + body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + ImageMessageEventContent::plain( + "Alt text".to_string(), + "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() + ) + .info(Some(Box::default())) + ))), + "[Attached Image: Alt text]".to_string() + ); + + let mut info = ImageInfo::default(); + info.size = Some(442630_u32.into()); + assert_eq!( + body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + ImageMessageEventContent::plain( + "Alt text".to_string(), + "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() + ) + .info(Some(Box::new(info))) + ))), + "[Attached Image: Alt text (442.63 kB)]".to_string() + ); + + let mut info = ImageInfo::default(); + info.size = Some(12_u32.into()); + assert_eq!( + body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + ImageMessageEventContent::plain( + "Alt text".to_string(), + "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() + ) + .info(Some(Box::new(info))) + ))), + "[Attached Image: Alt text (12 B)]".to_string() + ); + + let mut info = AudioInfo::default(); + info.size = Some(4294967295_u32.into()); + assert_eq!( + body_cow_content(&RoomMessageEventContent::new(MessageType::Audio( + AudioMessageEventContent::plain( + "Alt text".to_string(), + "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() + ) + .info(Some(Box::new(info))) + ))), + "[Attached Audio: Alt text (4.29 GB)]".to_string() + ); + + let mut info = FileInfo::default(); + info.size = Some(4426300_u32.into()); + assert_eq!( + body_cow_content(&RoomMessageEventContent::new(MessageType::File( + FileMessageEventContent::plain( + "Alt text".to_string(), + "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() + ) + .info(Some(Box::new(info))) + ))), + "[Attached File: Alt text (4.43 MB)]".to_string() + ); + + let mut info = VideoInfo::default(); + info.size = Some(44000_u32.into()); + assert_eq!( + body_cow_content(&RoomMessageEventContent::new(MessageType::Video( + VideoMessageEventContent::plain( + "Alt text".to_string(), + "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() + ) + .info(Some(Box::new(info))) + ))), + "[Attached Video: Alt text (44 kB)]".to_string() + ); + } +} diff --git a/src/message/printer.rs b/src/message/printer.rs new file mode 100644 index 0000000..12d03be --- /dev/null +++ b/src/message/printer.rs @@ -0,0 +1,293 @@ +//! # Line Wrapping Logic +//! +//! The [TextPrinter] handles wrapping stylized text and inserting spaces for padding at the end of +//! lines to make concatenation work right (e.g., combining table cells after wrapping their +//! contents). +use std::borrow::Cow; + +use ratatui::layout::Alignment; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::util::{ + replace_emojis_in_line, + replace_emojis_in_span, + replace_emojis_in_str, + space_span, + take_width, +}; + +/// Wrap styled text for the current terminal width. +pub struct TextPrinter<'a> { + text: Text<'a>, + width: usize, + base_style: Style, + hide_reply: bool, + emoji_shortcodes: bool, + + alignment: Alignment, + curr_spans: Vec>, + curr_width: usize, + literal: bool, +} + +impl<'a> TextPrinter<'a> { + /// Create a new printer. + pub fn new(width: usize, base_style: Style, hide_reply: bool, emoji_shortcodes: bool) -> Self { + TextPrinter { + text: Text::default(), + width, + base_style, + hide_reply, + emoji_shortcodes, + + alignment: Alignment::Left, + curr_spans: vec![], + curr_width: 0, + literal: false, + } + } + + /// Configure the alignment for each line. + pub fn align(mut self, alignment: Alignment) -> Self { + self.alignment = alignment; + self + } + + /// Set whether newlines should be treated literally, or turned into spaces. + pub fn literal(mut self, literal: bool) -> Self { + self.literal = literal; + self + } + + /// Indicates whether replies should be pushed to the printer. + pub fn hide_reply(&self) -> bool { + self.hide_reply + } + + /// Indicates whether emojis should be replaced by shortcodes + pub fn emoji_shortcodes(&self) -> bool { + self.emoji_shortcodes + } + + /// Indicates the current printer's width. + pub fn width(&self) -> usize { + self.width + } + + /// Create a new printer with a smaller width. + pub fn sub(&self, indent: usize) -> Self { + TextPrinter { + text: Text::default(), + width: self.width.saturating_sub(indent), + base_style: self.base_style, + hide_reply: self.hide_reply, + emoji_shortcodes: self.emoji_shortcodes, + + alignment: self.alignment, + curr_spans: vec![], + curr_width: 0, + literal: self.literal, + } + } + + fn remaining(&self) -> usize { + self.width.saturating_sub(self.curr_width) + } + + /// If there is any text on the current line, start a new one. + pub fn commit(&mut self) { + if self.curr_width > 0 { + self.push_break(); + } + } + + fn push(&mut self) { + self.curr_width = 0; + self.text.lines.push(Line::from(std::mem::take(&mut self.curr_spans))); + } + + /// Start a new line. + pub fn push_break(&mut self) { + if self.curr_width == 0 && self.text.lines.is_empty() { + // Disallow leading breaks. + return; + } + + let remaining = self.remaining(); + + if remaining > 0 { + match self.alignment { + Alignment::Left => { + let tspan = space_span(remaining, self.base_style); + self.curr_spans.push(tspan); + }, + Alignment::Center => { + let trailing = remaining / 2; + let leading = remaining - trailing; + + let tspan = space_span(trailing, self.base_style); + let lspan = space_span(leading, self.base_style); + + self.curr_spans.push(tspan); + self.curr_spans.insert(0, lspan); + }, + Alignment::Right => { + let lspan = space_span(remaining, self.base_style); + self.curr_spans.insert(0, lspan); + }, + } + } + + self.push(); + } + + fn push_str_wrapped(&mut self, s: T, style: Style) + where + T: Into>, + { + let style = self.base_style.patch(style); + let mut cow = s.into(); + + loop { + let sw = UnicodeWidthStr::width(cow.as_ref()); + + if self.curr_width + sw <= self.width { + // The text fits within the current line. + self.curr_spans.push(Span::styled(cow, style)); + self.curr_width += sw; + break; + } + + // Take a leading portion of the text that fits in the line. + let ((s0, w), s1) = take_width(cow, self.remaining()); + cow = s1; + + self.curr_spans.push(Span::styled(s0, style)); + self.curr_width += w; + + self.commit(); + } + + if self.curr_width == self.width { + // If the last bit fills the full line, start a new one. + self.push(); + } + } + + /// Push a [Span] that isn't allowed to break across lines. + pub fn push_span_nobreak(&mut self, mut span: Span<'a>) { + if self.emoji_shortcodes { + replace_emojis_in_span(&mut span); + } + let sw = UnicodeWidthStr::width(span.content.as_ref()); + + if self.curr_width + sw > self.width { + // Span doesn't fit on this line, so start a new one. + self.commit(); + } + + self.curr_spans.push(span); + self.curr_width += sw; + } + + /// Push text with a [Style]. + pub fn push_str(&mut self, s: &'a str, style: Style) { + let style = self.base_style.patch(style); + + if self.width == 0 { + return; + } + + for mut word in UnicodeSegmentation::split_word_bounds(s) { + if let "\n" | "\r\n" = word { + if self.literal { + self.commit(); + continue; + } + + // Render embedded newlines as spaces. + word = " "; + } + + if !self.literal && self.curr_width == 0 && word.chars().all(char::is_whitespace) { + // Drop leading whitespace. + continue; + } + + let cow = if self.emoji_shortcodes { + Cow::Owned(replace_emojis_in_str(word)) + } else { + Cow::Borrowed(word) + }; + let sw = UnicodeWidthStr::width(cow.as_ref()); + + if sw > self.width { + self.push_str_wrapped(cow, style); + continue; + } + + if self.curr_width + sw > self.width { + // Word doesn't fit on this line, so start a new one. + self.commit(); + + if !self.literal && cow.chars().all(char::is_whitespace) { + // Drop leading whitespace. + continue; + } + } + + let span = Span::styled(cow, style); + self.curr_spans.push(span); + self.curr_width += sw; + } + + if self.curr_width == self.width { + // If the last bit fills the full line, start a new one. + self.push(); + } + } + + /// Push a [Line] into the printer. + pub fn push_line(&mut self, mut line: Line<'a>) { + self.commit(); + if self.emoji_shortcodes { + replace_emojis_in_line(&mut line); + } + self.text.lines.push(line); + } + + /// Push multiline [Text] into the printer. + pub fn push_text(&mut self, mut text: Text<'a>) { + self.commit(); + if self.emoji_shortcodes { + for line in &mut text.lines { + replace_emojis_in_line(line); + } + } + self.text.lines.extend(text.lines); + } + + /// Render the contents of this printer as [Text]. + pub fn finish(mut self) -> Text<'a> { + self.commit(); + self.text + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + #[test] + fn test_push_nobreak() { + let mut printer = TextPrinter::new(5, Style::default(), false, false); + printer.push_span_nobreak("hello world".into()); + let text = printer.finish(); + assert_eq!(text.lines.len(), 1); + assert_eq!(text.lines[0].spans.len(), 1); + assert_eq!(text.lines[0].spans[0].content, "hello world"); + } +} diff --git a/src/notifications.rs b/src/notifications.rs new file mode 100644 index 0000000..b95c1e1 --- /dev/null +++ b/src/notifications.rs @@ -0,0 +1,280 @@ +use std::time::SystemTime; + +use matrix_sdk::{ + notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode}, + room::Room as MatrixRoom, + ruma::{ + api::client::push::get_notifications::v3::Notification, + events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent}, + MilliSecondsSinceUnixEpoch, + RoomId, + }, + Client, +}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::{ + base::{AsyncProgramStore, fixError, fixResult, ProgramStore}, + config::{ApplicationSettings, NotifyVia}, +}; + +const fix_XDG_NAME: &str = match option_env!("fix_XDG_NAME") { + None => "fix", + Some(fix) => fix, +}; + +pub async fn register_notifications( + client: &Client, + settings: &ApplicationSettings, + store: &AsyncProgramStore, +) { + if !settings.tunables.notifications.enabled { + return; + } + let notify_via = settings.tunables.notifications.via; + let show_message = settings.tunables.notifications.show_message; + let server_settings = client.notification_settings().await; + let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else { + return; + }; + + let store = store.clone(); + client + .register_notification_handler(move |notification, room: MatrixRoom, client: Client| { + let store = store.clone(); + let server_settings = server_settings.clone(); + async move { + let mode = global_or_room_mode(&server_settings, &room).await; + if mode == RoomNotificationMode::Mute { + return; + } + + if is_visible_room(&store, room.room_id()).await { + return; + } + + match parse_notification(notification, room, show_message).await { + Ok((summary, body, server_ts)) => { + if server_ts < startup_ts { + return; + } + + if is_missing_mention(&body, mode, &client) { + return; + } + + send_notification(¬ify_via, &store, &summary, body.as_deref()).await; + }, + Err(err) => { + tracing::error!("Failed to extract notification data: {err}") + }, + } + } + }) + .await; +} + +async fn send_notification( + via: &NotifyVia, + store: &AsyncProgramStore, + summary: &str, + body: Option<&str>, +) { + #[cfg(feature = "desktop")] + if via.desktop { + send_notification_desktop(summary, body); + } + #[cfg(not(feature = "desktop"))] + { + let _ = (summary, body, fix_XDG_NAME); + } + + if via.bell { + send_notification_bell(store).await; + } +} + +async fn send_notification_bell(store: &AsyncProgramStore) { + let mut locked = store.lock().await; + locked.application.ring_bell = true; +} + +#[cfg(feature = "desktop")] +fn send_notification_desktop(summary: &str, body: Option<&str>) { + let mut desktop_notification = notify_rust::Notification::new(); + desktop_notification + .summary(summary) + .appname(fix_XDG_NAME) + .icon(fix_XDG_NAME) + .action("default", "default"); + + if let Some(body) = body { + desktop_notification.body(body); + } + + if let Err(err) = desktop_notification.show() { + tracing::error!("Failed to send notification: {err}") + } +} + +async fn global_or_room_mode( + settings: &NotificationSettings, + room: &MatrixRoom, +) -> RoomNotificationMode { + let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await; + if let Some(mode) = room_mode { + return mode; + } + let is_one_to_one = match room.is_direct().await { + Ok(true) => IsOneToOne::Yes, + _ => IsOneToOne::No, + }; + let is_encrypted = match room.is_encrypted().await { + Ok(true) => IsEncrypted::Yes, + _ => IsEncrypted::No, + }; + settings + .get_default_room_notification_mode(is_encrypted, is_one_to_one) + .await +} + +fn is_missing_mention(body: &Option, mode: RoomNotificationMode, client: &Client) -> bool { + if let Some(body) = body { + if mode == RoomNotificationMode::MentionsAndKeywordsOnly { + let mentioned = match client.user_id() { + Some(user_id) => body.contains(user_id.localpart()), + _ => false, + }; + return !mentioned; + } + } + false +} + +fn is_open(locked: &mut ProgramStore, room_id: &RoomId) -> bool { + if let Some(draw_curr) = locked.application.draw_curr { + let info = locked.application.get_room_info(room_id.to_owned()); + if let Some(draw_last) = info.draw_last { + return draw_last == draw_curr; + } + } + false +} + +fn is_focused(locked: &ProgramStore) -> bool { + locked.application.focused +} + +async fn is_visible_room(store: &AsyncProgramStore, room_id: &RoomId) -> bool { + let mut locked = store.lock().await; + + is_focused(&locked) && is_open(&mut locked, room_id) +} + +pub async fn parse_notification( + notification: Notification, + room: MatrixRoom, + show_body: bool, +) -> fixResult<(String, Option, MilliSecondsSinceUnixEpoch)> { + let event = notification.event.deserialize().map_err(fixError::from)?; + + let server_ts = event.origin_server_ts(); + + let sender_id = event.sender(); + let sender = room.get_member_no_sync(sender_id).await.map_err(fixError::from)?; + + let sender_name = sender + .as_ref() + .and_then(|m| m.display_name()) + .unwrap_or_else(|| sender_id.localpart()); + + let summary = if let Ok(room_name) = room.display_name().await { + format!("{sender_name} in {room_name}") + } else { + sender_name.to_string() + }; + + let body = if show_body { + event_notification_body( + &event, + sender_name, + room.is_direct().await.map_err(fixError::from)?, + ) + .map(truncate) + } else { + None + }; + + return Ok((summary, body, server_ts)); +} + +pub fn event_notification_body( + event: &AnySyncTimelineEvent, + sender_name: &str, + is_direct: bool, +) -> Option { + let AnySyncTimelineEvent::MessageLike(event) = event else { + return None; + }; + + match event.original_content()? { + AnyMessageLikeEventContent::RoomMessage(message) => { + let body = match message.msgtype { + MessageType::Audio(_) => { + format!("{sender_name} sent an audio file.") + }, + MessageType::Emote(content) => { + let message = &content.body; + format!("{sender_name}: {message}") + }, + MessageType::File(_) => { + format!("{sender_name} sent a file.") + }, + MessageType::Image(_) => { + format!("{sender_name} sent an image.") + }, + MessageType::Location(_) => { + format!("{sender_name} sent their location.") + }, + MessageType::Notice(content) => { + let message = &content.body; + format!("{sender_name}: {message}") + }, + MessageType::ServerNotice(content) => { + let message = &content.body; + format!("{sender_name}: {message}") + }, + MessageType::Text(content) => { + if is_direct { + content.body + } else { + let message = &content.body; + format!("{sender_name}: {message}") + } + }, + MessageType::Video(_) => { + format!("{sender_name} sent a video.") + }, + MessageType::VerificationRequest(_) => { + format!("{sender_name} sent a verification request.") + }, + _ => { + format!("[Unknown message type: {:?}]", &message.msgtype) + }, + }; + Some(body) + }, + AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")), + _ => None, + } +} + +fn truncate(s: String) -> String { + static MAX_LENGTH: usize = 100; + if s.graphemes(true).count() > MAX_LENGTH { + let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect(); + truncated + "..." + } else { + s + } +} diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..31b1270 --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,172 @@ +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +use matrix_sdk::{ + media::{MediaFormat, MediaRequest}, + ruma::{ + events::{ + room::{ + message::{MessageType, RoomMessageEventContent}, + MediaSource, + }, + MessageLikeEvent, + }, + OwnedEventId, + OwnedRoomId, + }, + Media, +}; +use ratatui::layout::Rect; +use ratatui_image::Resize; + +use crate::{ + base::{AsyncProgramStore, ChatStore, fixError}, + config::ImagePreviewSize, + message::ImageStatus, +}; + +pub fn source_from_event( + ev: &MessageLikeEvent, +) -> Option<(OwnedEventId, MediaSource)> { + if let MessageLikeEvent::Original(ev) = &ev { + if let MessageType::Image(c) = &ev.content.msgtype { + return Some((ev.event_id.clone(), c.source.clone())); + } + } + None +} + +impl From for Rect { + fn from(value: ImagePreviewSize) -> Self { + Rect::new(0, 0, value.width as _, value.height as _) + } +} +impl From for ImagePreviewSize { + fn from(rect: Rect) -> Self { + ImagePreviewSize { width: rect.width as _, height: rect.height as _ } + } +} + +/// Download and prepare the preview, and then lock the store to insert it. +pub fn spawn_insert_preview( + store: AsyncProgramStore, + room_id: OwnedRoomId, + event_id: OwnedEventId, + source: MediaSource, + media: Media, + cache_dir: PathBuf, +) { + tokio::spawn(async move { + let img = download_or_load(event_id.to_owned(), source, media, cache_dir) + .await + .map(std::io::Cursor::new) + .map(image::io::Reader::new) + .map_err(fixError::Matrix) + .and_then(|reader| reader.with_guessed_format().map_err(fixError::IOError)) + .and_then(|reader| reader.decode().map_err(fixError::Image)); + + match img { + Err(err) => { + try_set_msg_preview_error( + &mut store.lock().await.application, + room_id, + event_id, + err, + ); + }, + Ok(img) => { + let mut locked = store.lock().await; + let ChatStore { rooms, picker, settings, .. } = &mut locked.application; + + match picker + .as_mut() + .ok_or_else(|| fixError::Preview("Picker is empty".to_string())) + .and_then(|picker| { + Ok(( + picker, + rooms + .get_or_default(room_id.clone()) + .get_event_mut(&event_id) + .ok_or_else(|| { + fixError::Preview("Message not found".to_string()) + })?, + settings.tunables.image_preview.clone().ok_or_else(|| { + fixError::Preview("image_preview settings not found".to_string()) + })?, + )) + }) + .and_then(|(picker, msg, image_preview)| { + picker + .new_protocol(img, image_preview.size.into(), Resize::Fit(None)) + .map_err(|err| fixError::Preview(format!("{err:?}"))) + .map(|backend| (backend, msg)) + }) { + Err(err) => { + try_set_msg_preview_error(&mut locked.application, room_id, event_id, err); + }, + Ok((backend, msg)) => { + msg.image_preview = ImageStatus::Loaded(backend); + }, + } + }, + } + }); +} + +fn try_set_msg_preview_error( + application: &mut ChatStore, + room_id: OwnedRoomId, + event_id: OwnedEventId, + err: fixError, +) { + let rooms = &mut application.rooms; + + match rooms + .get_or_default(room_id.clone()) + .get_event_mut(&event_id) + .ok_or_else(|| fixError::Preview("Message not found".to_string())) + { + Ok(msg) => msg.image_preview = ImageStatus::Error(format!("{err:?}")), + Err(err) => { + tracing::error!( + "Failed to set error on msg.image_backend for event {}, room {}: {}", + event_id, + room_id, + err + ) + }, + } +} + +async fn download_or_load( + event_id: OwnedEventId, + source: MediaSource, + media: Media, + mut cache_path: PathBuf, +) -> Result, matrix_sdk::Error> { + cache_path.push(Path::new(event_id.localpart())); + + match File::open(&cache_path) { + Ok(mut f) => { + let mut buffer = Vec::new(); + f.read_to_end(&mut buffer)?; + Ok(buffer) + }, + Err(_) => { + media + .get_media_content(&MediaRequest { source, format: MediaFormat::File }, true) + .await + .and_then(|buffer| { + if let Err(err) = + File::create(&cache_path).and_then(|mut f| f.write_all(&buffer)) + { + return Err(err.into()); + } + Ok(buffer) + }) + }, + } +} diff --git a/src/sled_export.rs b/src/sled_export.rs new file mode 100644 index 0000000..fb56ff7 --- /dev/null +++ b/src/sled_export.rs @@ -0,0 +1,58 @@ +//! # sled -> sqlite migration code +//! +//! Before the 0.0.9 release, fix used matrix-sdk@0.6.2, which used [sled] +//! for storing information, including room keys. In matrix-sdk@0.7.0, +//! the SDK switched to using SQLite. This module takes care of opening +//! sled, exporting the inbound group sessions used for decryption, +//! and importing them into SQLite. +//! +//! This code will eventually be removed once people have been given enough +//! time to upgrade off of pre-0.0.9 versions. +//! +//! [sled]: https://docs.rs/sled/0.34.7/sled/index.html +use sled::{Config, IVec}; +use std::path::Path; + +use crate::base::fixError; +use matrix_sdk::crypto::olm::{ExportedRoomKey, InboundGroupSession, PickledInboundGroupSession}; + +#[derive(Debug, thiserror::Error)] +pub enum SledMigrationError { + #[error("sled failure: {0}")] + Sled(#[from] sled::Error), + + #[error("deserialization failure: {0}")] + Deserialize(#[from] serde_json::Error), +} + +fn group_session_from_slice( + (_, bytes): (IVec, IVec), +) -> Result { + serde_json::from_slice(&bytes).map_err(SledMigrationError::from) +} + +async fn export_room_keys_priv( + sled_dir: &Path, +) -> Result, SledMigrationError> { + let path = sled_dir.join("matrix-sdk-state"); + let store = Config::new().temporary(false).path(&path).open()?; + let inbound_groups = store.open_tree("inbound_group_sessions")?; + + let mut exported = vec![]; + let sessions = inbound_groups + .iter() + .map(|p| p.map_err(SledMigrationError::from).and_then(group_session_from_slice)) + .collect::, _>>()? + .into_iter() + .filter_map(|p| InboundGroupSession::from_pickle(p).ok()); + + for session in sessions { + exported.push(session.export().await); + } + + Ok(exported) +} + +pub async fn export_room_keys(sled_dir: &Path) -> Result, fixError> { + export_room_keys_priv(sled_dir).await.map_err(fixError::from) +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..fa379de --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,248 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use matrix_sdk::ruma::{ + event_id, + events::room::message::{OriginalRoomMessageEvent, RoomMessageEventContent}, + server_name, + user_id, + EventId, + OwnedEventId, + OwnedRoomId, + OwnedUserId, + RoomId, + UInt, +}; + +use lazy_static::lazy_static; +use ratatui::style::{Color, Style}; +use tokio::sync::mpsc::unbounded_channel; +use tracing::Level; +use url::Url; + +use crate::{ + base::{ChatStore, EventLocation, ProgramStore, RoomInfo}, + config::{ + user_color, + user_style_from_color, + ApplicationSettings, + DirectoryValues, + Notifications, + NotifyVia, + ProfileConfig, + SortOverrides, + TunableValues, + UserColor, + UserDisplayStyle, + UserDisplayTunables, + }, + message::{ + Message, + MessageEvent, + MessageKey, + MessageTimeStamp::{LocalEcho, OriginServer}, + Messages, + }, + worker::Requester, +}; + +const TEST_ROOM1_ALIAS: &str = "#room1:example.com"; + +lazy_static! { + pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned(); + pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned(); + pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned(); + pub static ref TEST_USER3: OwnedUserId = user_id!("@user3:example.com").to_owned(); + pub static ref TEST_USER4: OwnedUserId = user_id!("@user4:example.com").to_owned(); + pub static ref TEST_USER5: OwnedUserId = user_id!("@user5:example.com").to_owned(); + pub static ref MSG1_EVID: OwnedEventId = EventId::new(server_name!("example.com")); + pub static ref MSG2_EVID: OwnedEventId = EventId::new(server_name!("example.com")); + pub static ref MSG3_EVID: OwnedEventId = + event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned(); + pub static ref MSG4_EVID: OwnedEventId = + event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned(); + pub static ref MSG5_EVID: OwnedEventId = EventId::new(server_name!("example.com")); + pub static ref MSG1_KEY: MessageKey = (LocalEcho, MSG1_EVID.clone()); + pub static ref MSG2_KEY: MessageKey = (OriginServer(UInt::new(1).unwrap()), MSG2_EVID.clone()); + pub static ref MSG3_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG3_EVID.clone()); + pub static ref MSG4_KEY: MessageKey = (OriginServer(UInt::new(2).unwrap()), MSG4_EVID.clone()); + pub static ref MSG5_KEY: MessageKey = (OriginServer(UInt::new(8).unwrap()), MSG5_EVID.clone()); +} + +pub fn user_style(user: &str) -> Style { + user_style_from_color(user_color(user)) +} + +pub fn mock_room1_message( + content: RoomMessageEventContent, + sender: OwnedUserId, + key: MessageKey, +) -> Message { + let origin_server_ts = key.0.as_millis().unwrap(); + let event_id = key.1; + + let event = OriginalRoomMessageEvent { + content, + event_id, + sender, + origin_server_ts, + room_id: TEST_ROOM1_ID.clone(), + unsigned: Default::default(), + }; + + event.into() +} + +pub fn mock_message1() -> Message { + let content = RoomMessageEventContent::text_plain("writhe"); + let content = MessageEvent::Local(MSG1_EVID.clone(), content.into()); + + Message::new(content, TEST_USER1.clone(), MSG1_KEY.0) +} + +pub fn mock_message2() -> Message { + let content = RoomMessageEventContent::text_plain("helium"); + + mock_room1_message(content, TEST_USER2.clone(), MSG2_KEY.clone()) +} + +pub fn mock_message3() -> Message { + let content = RoomMessageEventContent::text_plain("this\nis\na\nmultiline\nmessage"); + + mock_room1_message(content, TEST_USER2.clone(), MSG3_KEY.clone()) +} + +pub fn mock_message4() -> Message { + let content = RoomMessageEventContent::text_plain("help"); + + mock_room1_message(content, TEST_USER1.clone(), MSG4_KEY.clone()) +} + +pub fn mock_message5() -> Message { + let content = RoomMessageEventContent::text_plain("character"); + + mock_room1_message(content, TEST_USER2.clone(), MSG4_KEY.clone()) +} + +pub fn mock_keys() -> HashMap { + let mut keys = HashMap::new(); + + keys.insert(MSG1_EVID.clone(), EventLocation::Message(None, MSG1_KEY.clone())); + keys.insert(MSG2_EVID.clone(), EventLocation::Message(None, MSG2_KEY.clone())); + keys.insert(MSG3_EVID.clone(), EventLocation::Message(None, MSG3_KEY.clone())); + keys.insert(MSG4_EVID.clone(), EventLocation::Message(None, MSG4_KEY.clone())); + keys.insert(MSG5_EVID.clone(), EventLocation::Message(None, MSG5_KEY.clone())); + + keys +} + +pub fn mock_messages() -> Messages { + let mut messages = Messages::default(); + + messages.insert(MSG1_KEY.clone(), mock_message1()); + messages.insert(MSG2_KEY.clone(), mock_message2()); + messages.insert(MSG3_KEY.clone(), mock_message3()); + messages.insert(MSG4_KEY.clone(), mock_message4()); + messages.insert(MSG5_KEY.clone(), mock_message5()); + + messages +} + +pub fn mock_room() -> RoomInfo { + let mut room = RoomInfo::default(); + room.name = Some("Watercooler Discussion".into()); + room.keys = mock_keys(); + *room.get_thread_mut(None) = mock_messages(); + room +} + +pub fn mock_dirs() -> DirectoryValues { + DirectoryValues { + cache: PathBuf::new(), + data: PathBuf::new(), + logs: PathBuf::new(), + downloads: None, + image_previews: PathBuf::new(), + } +} + +pub fn mock_tunables() -> TunableValues { + TunableValues { + default_room: None, + log_level: Level::INFO, + message_shortcode_display: false, + reaction_display: true, + reaction_shortcode_display: false, + read_receipt_send: true, + read_receipt_display: true, + request_timeout: 120, + sort: SortOverrides::default().values(), + typing_notice_send: true, + typing_notice_display: true, + users: vec![(TEST_USER5.clone(), UserDisplayTunables { + color: Some(UserColor(Color::Black)), + name: Some("USER 5".into()), + })] + .into_iter() + .collect::>(), + open_command: None, + external_edit_file_suffix: String::from(".md"), + username_display: UserDisplayStyle::Username, + message_user_color: false, + notifications: Notifications { + enabled: false, + via: NotifyVia::default(), + show_message: true, + }, + image_preview: None, + user_gutter_width: 30, + } +} + +pub fn mock_settings() -> ApplicationSettings { + ApplicationSettings { + layout_json: PathBuf::new(), + session_json: PathBuf::new(), + session_json_old: PathBuf::new(), + sled_dir: PathBuf::new(), + sqlite_dir: PathBuf::new(), + + profile_name: "test".into(), + profile: ProfileConfig { + user_id: user_id!("@user:example.com").to_owned(), + url: None, + settings: None, + dirs: None, + layout: None, + macros: None, + }, + tunables: mock_tunables(), + dirs: mock_dirs(), + layout: Default::default(), + macros: HashMap::default(), + } +} + +pub async fn mock_store() -> ProgramStore { + let (tx, _) = unbounded_channel(); + let homeserver = Url::parse("https://localhost").unwrap(); + let client = matrix_sdk::Client::new(homeserver).await.unwrap(); + let worker = Requester { tx, client }; + + let mut store = ChatStore::new(worker, mock_settings()); + + // Add presence information. + store.presences.get_or_default(TEST_USER1.clone()); + store.presences.get_or_default(TEST_USER2.clone()); + store.presences.get_or_default(TEST_USER3.clone()); + store.presences.get_or_default(TEST_USER4.clone()); + store.presences.get_or_default(TEST_USER5.clone()); + + let room_id = TEST_ROOM1_ID.clone(); + let info = mock_room(); + + store.rooms.insert(room_id.clone(), info); + store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id); + + ProgramStore::new(store) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..bee1810 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,214 @@ +//! # Utility functions +use std::borrow::Cow; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; + +pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) { + match cow { + Cow::Borrowed(s) => { + let s1 = Cow::Borrowed(&s[idx..]); + let s0 = Cow::Borrowed(&s[..idx]); + + (s0, s1) + }, + Cow::Owned(mut s) => { + let s1 = Cow::Owned(s.split_off(idx)); + let s0 = Cow::Owned(s); + + (s0, s1) + }, + } +} + +pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) { + // Find where to split the line. + let mut w = 0; + + let idx = UnicodeSegmentation::grapheme_indices(s.as_ref(), true) + .find_map(|(i, g)| { + let gw = UnicodeWidthStr::width(g); + if w + gw > width { + Some(i) + } else { + w += gw; + None + } + }) + .unwrap_or(s.len()); + + let (s0, s1) = split_cow(s, idx); + + ((s0, w), s1) +} + +pub struct WrappedLinesIterator<'a> { + iter: std::vec::IntoIter>, + curr: Option>, + width: usize, +} + +impl<'a> WrappedLinesIterator<'a> { + fn new(input: T, width: usize) -> Self + where + T: Into>, + { + let width = width.max(2); + + let cows: Vec> = match input.into() { + Cow::Borrowed(s) => s.lines().map(Cow::Borrowed).collect(), + Cow::Owned(s) => s.lines().map(ToOwned::to_owned).map(Cow::Owned).collect(), + }; + + WrappedLinesIterator { iter: cows.into_iter(), curr: None, width } + } +} + +impl<'a> Iterator for WrappedLinesIterator<'a> { + type Item = (Cow<'a, str>, usize); + + fn next(&mut self) -> Option { + if self.curr.is_none() { + self.curr = self.iter.next(); + } + + if let Some(s) = self.curr.take() { + let width = UnicodeWidthStr::width(s.as_ref()); + + if width <= self.width { + return Some((s, width)); + } else { + let (prefix, s1) = take_width(s, self.width); + self.curr = Some(s1); + return Some(prefix); + } + } else { + return None; + } + } +} + +pub fn wrap<'a, T>(input: T, width: usize) -> WrappedLinesIterator<'a> +where + T: Into>, +{ + WrappedLinesIterator::new(input, width) +} + +pub fn wrapped_text<'a, T>(s: T, width: usize, style: Style) -> Text<'a> +where + T: Into>, +{ + let mut text = Text::default(); + + for (line, w) in wrap(s, width) { + let space = space_span(width.saturating_sub(w), style); + let spans = Line::from(vec![Span::styled(line, style), space]); + + text.lines.push(spans); + } + + return text; +} + +pub fn space(width: usize) -> String { + " ".repeat(width) +} + +pub fn space_span(width: usize, style: Style) -> Span<'static> { + Span::styled(space(width), style) +} + +pub fn space_text(width: usize, style: Style) -> Text<'static> { + space_span(width, style).into() +} + +pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>, style: Style) -> Text<'a> { + let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0); + let mut text = Text::from(vec![Line::from(vec![join.clone()]); height]); + + for (mut t, w) in texts.into_iter() { + for i in 0..height { + if let Some(line) = t.lines.get_mut(i) { + text.lines[i].spans.append(&mut line.spans); + } else { + text.lines[i].spans.push(space_span(w, style)); + } + + text.lines[i].spans.push(join.clone()); + } + } + + text +} + +fn replace_emoji_in_grapheme(grapheme: &str) -> String { + emojis::get(grapheme) + .and_then(|emoji| emoji.shortcode()) + .map(|shortcode| format!(":{shortcode}:")) + .unwrap_or_else(|| grapheme.to_owned()) +} + +pub fn replace_emojis_in_str(s: &str) -> String { + let graphemes = s.graphemes(true); + graphemes.map(replace_emoji_in_grapheme).collect() +} + +pub fn replace_emojis_in_span(span: &mut Span) { + span.content = Cow::Owned(replace_emojis_in_str(span.content.as_ref())) +} + +pub fn replace_emojis_in_line(line: &mut Line) { + for span in &mut line.spans { + replace_emojis_in_span(span); + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + #[test] + fn test_wrapped_lines_ascii() { + let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye"; + + let mut iter = wrap(s, 100); + assert_eq!(iter.next(), Some((Cow::Borrowed("hello world!"), 12))); + assert_eq!(iter.next(), Some((Cow::Borrowed("abcdefghijklmnopqrstuvwxyz"), 26))); + assert_eq!(iter.next(), Some((Cow::Borrowed("goodbye"), 7))); + assert_eq!(iter.next(), None); + + let mut iter = wrap(s, 5); + assert_eq!(iter.next(), Some((Cow::Borrowed("hello"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed(" worl"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("d!"), 2))); + assert_eq!(iter.next(), Some((Cow::Borrowed("abcde"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("fghij"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("klmno"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("pqrst"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("uvwxy"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("z"), 1))); + assert_eq!(iter.next(), Some((Cow::Borrowed("goodb"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("ye"), 2))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_wrapped_lines_unicode() { + let s = "CHICKEN"; + + let mut iter = wrap(s, 14); + assert_eq!(iter.next(), Some((Cow::Borrowed(s), 14))); + assert_eq!(iter.next(), None); + + let mut iter = wrap(s, 5); + assert_eq!(iter.next(), Some((Cow::Borrowed("CH"), 4))); + assert_eq!(iter.next(), Some((Cow::Borrowed("IC"), 4))); + assert_eq!(iter.next(), Some((Cow::Borrowed("KE"), 4))); + assert_eq!(iter.next(), Some((Cow::Borrowed("N"), 2))); + assert_eq!(iter.next(), None); + } +} diff --git a/src/windows/mod.rs b/src/windows/mod.rs new file mode 100644 index 0000000..211eccc --- /dev/null +++ b/src/windows/mod.rs @@ -0,0 +1,1694 @@ +//! # Windows for the User Interface +//! +//! This module contains the logic for rendering windows, and handling UI actions that get +//! delegated to individual windows/UI elements (e.g., typing text or selecting a list item). +//! +//! Additionally, some of the fix commands delegate behaviour to the current UI element. For +//! example, [sending messages][crate::base::SendAction] delegate to the [room window][RoomState], +//! where we have the message bar and room ID easily accesible and resetable. +use std::cmp::{Ord, Ordering, PartialOrd}; +use std::fmt::{self, Display}; +use std::ops::Deref; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use matrix_sdk::{ + encryption::verification::{format_emojis, SasVerification}, + room::{Room as MatrixRoom, RoomMember}, + ruma::{ + events::room::member::MembershipState, + events::tag::{TagName, Tags}, + OwnedRoomAliasId, + OwnedRoomId, + RoomAliasId, + RoomId, + }, +}; + +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::{Modifier as StyleModifier, Style}, + text::{Line, Span, Text}, + widgets::StatefulWidget, +}; + +use modalkit::{ + actions::{ + Action, + Editable, + EditorAction, + Jumpable, + PromptAction, + Promptable, + Scrollable, + WindowAction, + }, + editing::completion::CompletionList, + errors::{EditError, EditResult, UIError}, + prelude::*, +}; + +use modalkit_ratatui::{ + list::{List, ListCursor, ListItem, ListState}, + TermOffset, + TerminalCursor, + Window, + WindowOps, +}; + +use crate::base::{ + ChatStore, + fixBufferId, + fixError, + fixId, + fixInfo, + fixResult, + MessageAction, + Need, + ProgramAction, + ProgramContext, + ProgramStore, + RoomAction, + SendAction, + SortColumn, + SortFieldRoom, + SortFieldUser, + SortOrder, + UnreadInfo, +}; + +use self::{room::RoomState, welcome::WelcomeState}; +use crate::message::MessageTimeStamp; + +pub mod room; +pub mod welcome; + +type MatrixRoomInfo = Arc<(MatrixRoom, Option)>; + +const MEMBER_FETCH_DEBOUNCE: Duration = Duration::from_secs(5); + +#[inline] +fn bold_style() -> Style { + Style::default().add_modifier(StyleModifier::BOLD) +} + +#[inline] +fn bold_span(s: &str) -> Span { + Span::styled(s, bold_style()) +} + +#[inline] +fn bold_spans(s: &str) -> Line { + bold_span(s).into() +} + +#[inline] +fn selected_style(selected: bool) -> Style { + if selected { + Style::default().add_modifier(StyleModifier::REVERSED) + } else { + Style::default() + } +} + +#[inline] +fn selected_span(s: &str, selected: bool) -> Span { + Span::styled(s, selected_style(selected)) +} + +#[inline] +fn selected_text(s: &str, selected: bool) -> Text { + Text::from(selected_span(s, selected)) +} + +fn name_and_labels(name: &str, unread: bool, style: Style) -> (Span<'_>, Vec>>) { + let name_style = if unread { + style.add_modifier(StyleModifier::BOLD) + } else { + style + }; + + let name = Span::styled(name, name_style); + let labels = if unread { + vec![vec![Span::styled("Unread", style)]] + } else { + vec![] + }; + + (name, labels) +} + +/// Sort `Some` to be less than `None` so that list items with values come before those without. +#[inline] +fn some_cmp(a: Option, b: Option, f: F) -> Ordering +where + F: Fn(&T, &T) -> Ordering, +{ + match (a, b) { + (Some(a), Some(b)) => f(&a, &b), + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + } +} + +fn user_cmp(a: &MemberItem, b: &MemberItem, field: &SortFieldUser) -> Ordering { + let a_id = a.member.user_id(); + let b_id = b.member.user_id(); + + match field { + SortFieldUser::UserId => a_id.cmp(b_id), + SortFieldUser::LocalPart => a_id.localpart().cmp(b_id.localpart()), + SortFieldUser::Server => a_id.server_name().cmp(b_id.server_name()), + SortFieldUser::PowerLevel => { + // Sort higher power levels towards the top of the list. + b.member.power_level().cmp(&a.member.power_level()) + }, + } +} + +fn room_cmp(a: &T, b: &T, field: &SortFieldRoom) -> Ordering { + match field { + SortFieldRoom::Favorite => { + let fava = a.has_tag(TagName::Favorite); + let favb = b.has_tag(TagName::Favorite); + + // If a has Favorite and b doesn't, it should sort earlier in room list. + favb.cmp(&fava) + }, + SortFieldRoom::LowPriority => { + let lowa = a.has_tag(TagName::LowPriority); + let lowb = b.has_tag(TagName::LowPriority); + + // If a has LowPriority and b doesn't, it should sort later in room list. + lowa.cmp(&lowb) + }, + SortFieldRoom::Name => a.name().cmp(b.name()), + SortFieldRoom::Alias => some_cmp(a.alias(), b.alias(), Ord::cmp), + SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()), + SortFieldRoom::Unread => { + // Sort true (unread) before false (read) + b.is_unread().cmp(&a.is_unread()) + }, + SortFieldRoom::Recent => { + // sort larger timestamps towards the top. + some_cmp(a.recent_ts(), b.recent_ts(), |a, b| b.cmp(a)) + }, + } +} + +/// Compare two rooms according the configured sort criteria. +fn room_fields_cmp( + a: &T, + b: &T, + fields: &[SortColumn], +) -> Ordering { + for SortColumn(field, order) in fields { + match (room_cmp(a, b, field), order) { + (Ordering::Equal, _) => continue, + (o, SortOrder::Ascending) => return o, + (o, SortOrder::Descending) => return o.reverse(), + } + } + + // Break ties on ascending room id. + room_cmp(a, b, &SortFieldRoom::RoomId) +} + +fn user_fields_cmp( + a: &MemberItem, + b: &MemberItem, + fields: &[SortColumn], +) -> Ordering { + for SortColumn(field, order) in fields { + match (user_cmp(a, b, field), order) { + (Ordering::Equal, _) => continue, + (o, SortOrder::Ascending) => return o, + (o, SortOrder::Descending) => return o.reverse(), + } + } + + // Break ties on ascending user id. + user_cmp(a, b, &SortFieldUser::UserId) +} + +fn tag_to_span(tag: &TagName, style: Style) -> Vec> { + match tag { + TagName::Favorite => vec![Span::styled("Favorite", style)], + TagName::LowPriority => vec![Span::styled("Low Priority", style)], + TagName::ServerNotice => vec![Span::styled("Server Notice", style)], + TagName::User(tag) => { + vec![ + Span::styled("User Tag: ", style), + Span::styled(tag.as_ref(), style), + ] + }, + tag => vec![Span::styled(format!("{tag:?}"), style)], + } +} + +fn append_tags<'a>(tags: Vec>>, spans: &mut Vec>, style: Style) { + if tags.is_empty() { + return; + } + + spans.push(Span::styled(" (", style)); + + for (i, tag) in tags.into_iter().enumerate() { + if i > 0 { + spans.push(Span::styled(", ", style)); + } + + spans.extend(tag); + } + + spans.push(Span::styled(")", style)); +} + +trait RoomLikeItem { + fn room_id(&self) -> &RoomId; + fn has_tag(&self, tag: TagName) -> bool; + fn is_unread(&self) -> bool; + fn recent_ts(&self) -> Option<&MessageTimeStamp>; + fn alias(&self) -> Option<&RoomAliasId>; + fn name(&self) -> &str; +} + +#[inline] +fn room_prompt( + room_id: &RoomId, + act: &PromptAction, + ctx: &ProgramContext, +) -> EditResult, fixInfo> { + match act { + PromptAction::Submit => { + let room = fixId::Room(room_id.to_owned(), None); + let open = WindowAction::Switch(OpenTarget::Application(room)); + let acts = vec![(open.into(), ctx.clone())]; + + Ok(acts) + }, + PromptAction::Abort(_) => { + let msg = "Cannot abort entry inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + PromptAction::Recall(..) => { + let msg = "Cannot recall history inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + } +} + +macro_rules! delegate { + ($s: expr, $id: ident => $e: expr) => { + match $s { + fixWindow::Room($id) => $e, + fixWindow::DirectList($id) => $e, + fixWindow::MemberList($id, _, _) => $e, + fixWindow::RoomList($id) => $e, + fixWindow::SpaceList($id) => $e, + fixWindow::VerifyList($id) => $e, + fixWindow::Welcome($id) => $e, + fixWindow::ChatList($id) => $e, + fixWindow::UnreadList($id) => $e, + } + }; +} + +pub enum fixWindow { + DirectList(DirectListState), + MemberList(MemberListState, OwnedRoomId, Option), + Room(RoomState), + VerifyList(VerifyListState), + RoomList(RoomListState), + SpaceList(SpaceListState), + Welcome(WelcomeState), + ChatList(ChatListState), + UnreadList(UnreadListState), +} + +impl fixWindow { + pub fn focus_toggle(&mut self) { + if let fixWindow::Room(w) = self { + w.focus_toggle() + } else { + return; + } + } + + pub async fn message_command( + &mut self, + act: MessageAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + if let fixWindow::Room(w) = self { + w.message_command(act, ctx, store).await + } else { + return Err(fixError::NoSelectedRoom.into()); + } + } + + pub async fn room_command( + &mut self, + act: RoomAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult, ProgramContext)>> { + if let fixWindow::Room(w) = self { + w.room_command(act, ctx, store).await + } else { + return Err(fixError::NoSelectedRoomOrSpace.into()); + } + } + + pub async fn send_command( + &mut self, + act: SendAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + if let fixWindow::Room(w) = self { + w.send_command(act, ctx, store).await + } else { + return Err(fixError::NoSelectedRoom.into()); + } + } +} + +pub type DirectListState = ListState; +pub type MemberListState = ListState; +pub type RoomListState = ListState; +pub type ChatListState = ListState; +pub type UnreadListState = ListState; +pub type SpaceListState = ListState; +pub type VerifyListState = ListState; + +impl From for fixWindow { + fn from(list: ChatListState) -> Self { + fixWindow::ChatList(list) + } +} + +impl From for fixWindow { + fn from(room: RoomState) -> Self { + fixWindow::Room(room) + } +} + +impl From for fixWindow { + fn from(list: VerifyListState) -> Self { + fixWindow::VerifyList(list) + } +} + +impl From for fixWindow { + fn from(list: DirectListState) -> Self { + fixWindow::DirectList(list) + } +} + +impl From for fixWindow { + fn from(list: RoomListState) -> Self { + fixWindow::RoomList(list) + } +} + +impl From for fixWindow { + fn from(list: SpaceListState) -> Self { + fixWindow::SpaceList(list) + } +} + +impl From for fixWindow { + fn from(win: WelcomeState) -> Self { + fixWindow::Welcome(win) + } +} + +impl Editable for fixWindow { + fn editor_command( + &mut self, + act: &EditorAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + delegate!(self, w => w.editor_command(act, ctx, store)) + } +} + +impl Jumpable for fixWindow { + fn jump( + &mut self, + list: PositionList, + dir: MoveDir1D, + count: usize, + ctx: &ProgramContext, + ) -> fixResult { + delegate!(self, w => w.jump(list, dir, count, ctx)) + } +} + +impl Scrollable for fixWindow { + fn scroll( + &mut self, + style: &ScrollStyle, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + delegate!(self, w => w.scroll(style, ctx, store)) + } +} + +impl Promptable for fixWindow { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult, fixInfo> { + delegate!(self, w => w.prompt(act, ctx, store)) + } +} + +impl TerminalCursor for fixWindow { + fn get_term_cursor(&self) -> Option { + delegate!(self, w => w.get_term_cursor()) + } +} + +impl WindowOps for fixWindow { + fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { + match self { + fixWindow::Room(state) => state.draw(area, buf, focused, store), + fixWindow::DirectList(state) => { + let mut items = store + .application + .sync_info + .dms + .clone() + .into_iter() + .map(|room_info| DirectItem::new(room_info, store)) + .collect::>(); + let fields = &store.application.settings.tunables.sort.dms; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); + + state.set(items); + + List::new(store) + .empty_message("No direct messages yet!") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, + fixWindow::MemberList(state, room_id, last_fetch) => { + let need_fetch = match last_fetch { + Some(i) => i.elapsed() >= MEMBER_FETCH_DEBOUNCE, + None => true, + }; + + if need_fetch { + if let Ok(mems) = store.application.worker.members(room_id.clone()) { + let mut items = mems + .into_iter() + .map(|m| MemberItem::new(m, room_id.clone())) + .collect::>(); + let fields = &store.application.settings.tunables.sort.members; + items.sort_by(|a, b| user_fields_cmp(a, b, fields)); + state.set(items); + *last_fetch = Some(Instant::now()); + } + } + + List::new(store) + .empty_message("No users here yet!") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, + fixWindow::RoomList(state) => { + let mut items = store + .application + .sync_info + .rooms + .clone() + .into_iter() + .map(|room_info| RoomItem::new(room_info, store)) + .collect::>(); + let fields = &store.application.settings.tunables.sort.rooms; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); + + state.set(items); + + List::new(store) + .empty_message("You haven't joined any rooms yet") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, + fixWindow::ChatList(state) => { + let mut items = store + .application + .sync_info + .rooms + .clone() + .into_iter() + .map(|room_info| GenericChatItem::new(room_info, store, false)) + .collect::>(); + + let dms = store + .application + .sync_info + .dms + .clone() + .into_iter() + .map(|room_info| GenericChatItem::new(room_info, store, true)); + + items.extend(dms); + + let fields = &store.application.settings.tunables.sort.chats; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); + + state.set(items); + + List::new(store) + .empty_message("You do not have rooms or dms yet") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, + fixWindow::UnreadList(state) => { + let mut items = store + .application + .sync_info + .rooms + .clone() + .into_iter() + .map(|room_info| GenericChatItem::new(room_info, store, false)) + .filter(RoomLikeItem::is_unread) + .collect::>(); + + let dms = store + .application + .sync_info + .dms + .clone() + .into_iter() + .map(|room_info| GenericChatItem::new(room_info, store, true)) + .filter(RoomLikeItem::is_unread); + + items.extend(dms); + + let fields = &store.application.settings.tunables.sort.chats; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); + + state.set(items); + + List::new(store) + .empty_message("You do not have rooms or dms yet") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, + fixWindow::SpaceList(state) => { + let mut items = store + .application + .sync_info + .spaces + .clone() + .into_iter() + .map(|room| SpaceItem::new(room, store)) + .collect::>(); + let fields = &store.application.settings.tunables.sort.spaces; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); + + state.set(items); + + List::new(store) + .empty_message("You haven't joined any spaces yet") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, + fixWindow::VerifyList(state) => { + let verifications = &store.application.verifications; + let mut items = verifications.iter().map(VerifyItem::from).collect::>(); + + // Sort the active verifications towards the top. + items.sort(); + + state.set(items); + + List::new(store) + .empty_message("No in-progress verifications") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, + fixWindow::Welcome(state) => state.draw(area, buf, focused, store), + } + } + + fn dup(&self, store: &mut ProgramStore) -> Self { + match self { + fixWindow::Room(w) => w.dup(store).into(), + fixWindow::DirectList(w) => w.dup(store).into(), + fixWindow::MemberList(w, room_id, last_fetch) => { + fixWindow::MemberList(w.dup(store), room_id.clone(), *last_fetch) + }, + fixWindow::RoomList(w) => w.dup(store).into(), + fixWindow::SpaceList(w) => w.dup(store).into(), + fixWindow::VerifyList(w) => w.dup(store).into(), + fixWindow::Welcome(w) => w.dup(store).into(), + fixWindow::ChatList(w) => w.dup(store).into(), + fixWindow::UnreadList(w) => w.dup(store).into(), + } + } + + fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool { + delegate!(self, w => w.close(flags, store)) + } + + fn write( + &mut self, + path: Option<&str>, + flags: WriteFlags, + store: &mut ProgramStore, + ) -> fixResult { + delegate!(self, w => w.write(path, flags, store)) + } + + fn get_completions(&self) -> Option { + delegate!(self, w => w.get_completions()) + } + + fn get_cursor_word(&self, style: &WordStyle) -> Option { + delegate!(self, w => w.get_cursor_word(style)) + } + + fn get_selected_word(&self) -> Option { + delegate!(self, w => w.get_selected_word()) + } +} + +impl Window for fixWindow { + fn id(&self) -> fixId { + match self { + fixWindow::Room(room) => fixId::Room(room.id().to_owned(), room.thread().cloned()), + fixWindow::DirectList(_) => fixId::DirectList, + fixWindow::MemberList(_, room_id, _) => fixId::MemberList(room_id.clone()), + fixWindow::RoomList(_) => fixId::RoomList, + fixWindow::SpaceList(_) => fixId::SpaceList, + fixWindow::VerifyList(_) => fixId::VerifyList, + fixWindow::Welcome(_) => fixId::Welcome, + fixWindow::ChatList(_) => fixId::ChatList, + fixWindow::UnreadList(_) => fixId::UnreadList, + } + } + + fn get_tab_title(&self, store: &mut ProgramStore) -> Line { + match self { + fixWindow::DirectList(_) => bold_spans("Direct Messages"), + fixWindow::RoomList(_) => bold_spans("Rooms"), + fixWindow::SpaceList(_) => bold_spans("Spaces"), + fixWindow::VerifyList(_) => bold_spans("Verifications"), + fixWindow::Welcome(_) => bold_spans("Welcome to fix"), + fixWindow::ChatList(_) => bold_spans("DMs & Rooms"), + fixWindow::UnreadList(_) => bold_spans("Unread Messages"), + + fixWindow::Room(w) => { + let title = store.application.get_room_title(w.id()); + + Line::from(title) + }, + fixWindow::MemberList(state, room_id, _) => { + let title = store.application.get_room_title(room_id.as_ref()); + let n = state.len(); + let v = vec![ + bold_span("Room Members "), + Span::styled(format!("({n}): "), bold_style()), + title.into(), + ]; + Line::from(v) + }, + } + } + + fn get_win_title(&self, store: &mut ProgramStore) -> Line { + match self { + fixWindow::DirectList(_) => bold_spans("Direct Messages"), + fixWindow::RoomList(_) => bold_spans("Rooms"), + fixWindow::SpaceList(_) => bold_spans("Spaces"), + fixWindow::VerifyList(_) => bold_spans("Verifications"), + fixWindow::Welcome(_) => bold_spans("Welcome to fix"), + fixWindow::ChatList(_) => bold_spans("DMs & Rooms"), + fixWindow::UnreadList(_) => bold_spans("Unread Messages"), + + fixWindow::Room(w) => w.get_title(store), + fixWindow::MemberList(state, room_id, _) => { + let title = store.application.get_room_title(room_id.as_ref()); + let n = state.len(); + let v = vec![ + bold_span("Room Members "), + Span::styled(format!("({n}): "), bold_style()), + title.into(), + ]; + Line::from(v) + }, + } + } + + fn open(id: fixId, store: &mut ProgramStore) -> fixResult { + match id { + fixId::Room(room_id, thread) => { + let (room, name, tags) = store.application.worker.get_room(room_id)?; + let room = RoomState::new(room, thread, name, tags, store); + + store.application.need_load.insert(room.id().to_owned(), Need::MEMBERS); + return Ok(room.into()); + }, + fixId::DirectList => { + let list = DirectListState::new(fixBufferId::DirectList, vec![]); + + return Ok(list.into()); + }, + fixId::MemberList(room_id) => { + let id = fixBufferId::MemberList(room_id.clone()); + let list = MemberListState::new(id, vec![]); + let win = fixWindow::MemberList(list, room_id, None); + + return Ok(win); + }, + fixId::RoomList => { + let list = RoomListState::new(fixBufferId::RoomList, vec![]); + + return Ok(list.into()); + }, + fixId::SpaceList => { + let list = SpaceListState::new(fixBufferId::SpaceList, vec![]); + + return Ok(list.into()); + }, + fixId::VerifyList => { + let list = VerifyListState::new(fixBufferId::VerifyList, vec![]); + + return Ok(list.into()); + }, + fixId::Welcome => { + let win = WelcomeState::new(store); + + return Ok(win.into()); + }, + fixId::ChatList => { + let list = ChatListState::new(fixBufferId::ChatList, vec![]); + + Ok(list.into()) + }, + fixId::UnreadList => { + let list = UnreadListState::new(fixBufferId::UnreadList, vec![]); + + Ok(fixWindow::UnreadList(list)) + }, + } + } + + fn find(name: String, store: &mut ProgramStore) -> fixResult { + let ChatStore { names, worker, .. } = &mut store.application; + + if let Some(room) = names.get_mut(&name) { + let id = fixId::Room(room.clone(), None); + + fixWindow::open(id, store) + } else { + let room_id = worker.join_room(name.clone())?; + names.insert(name, room_id.clone()); + + let (room, name, tags) = store.application.worker.get_room(room_id)?; + let room = RoomState::new(room, None, name, tags, store); + + store.application.need_load.insert(room.id().to_owned(), Need::MEMBERS); + Ok(room.into()) + } + } + + fn posn(index: usize, _: &mut ProgramStore) -> fixResult { + let msg = format!("Cannot find indexed buffer (index = {index})"); + let err = UIError::Unimplemented(msg); + + Err(err) + } + + fn unnamed(store: &mut ProgramStore) -> fixResult { + Self::open(fixId::RoomList, store) + } +} + +#[derive(Clone)] +pub struct GenericChatItem { + room_info: MatrixRoomInfo, + name: String, + alias: Option, + unread: UnreadInfo, + is_dm: bool, +} + +impl GenericChatItem { + fn new(room_info: MatrixRoomInfo, store: &mut ProgramStore, is_dm: bool) -> Self { + let room = &room_info.deref().0; + let room_id = room.room_id(); + + let info = store.application.rooms.get_or_default(room_id.to_owned()); + let name = info.name.clone().unwrap_or_default(); + let alias = room.canonical_alias(); + let unread = info.unreads(&store.application.settings); + info.tags.clone_from(&room_info.deref().1); + + if let Some(alias) = &alias { + store.application.names.insert(alias.to_string(), room_id.to_owned()); + } + + GenericChatItem { room_info, name, alias, is_dm, unread } + } + + #[inline] + fn room(&self) -> &MatrixRoom { + &self.room_info.deref().0 + } + + #[inline] + fn tags(&self) -> &Option { + &self.room_info.deref().1 + } +} + +impl RoomLikeItem for GenericChatItem { + fn name(&self) -> &str { + self.name.as_str() + } + + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn room_id(&self) -> &RoomId { + self.room().room_id() + } + + fn has_tag(&self, tag: TagName) -> bool { + if let Some(tags) = &self.room_info.deref().1 { + tags.contains_key(&tag) + } else { + false + } + } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } +} + +impl Display for GenericChatItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl ListItem for GenericChatItem { + fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + let unread = self.unread.is_unread(); + let style = selected_style(selected); + let (name, mut labels) = name_and_labels(&self.name, unread, style); + let mut spans = vec![name]; + + labels.push(if self.is_dm { + vec![Span::styled("DM", style)] + } else { + vec![Span::styled("Room", style)] + }); + + if let Some(tags) = &self.tags() { + labels.extend(tags.keys().map(|t| tag_to_span(t, style))); + } + + append_tags(labels, &mut spans, style); + Text::from(Line::from(spans)) + } + + fn get_word(&self) -> Option { + self.room_id().to_string().into() + } +} + +impl Promptable for GenericChatItem { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + room_prompt(self.room_id(), act, ctx) + } +} + +#[derive(Clone)] +pub struct RoomItem { + room_info: MatrixRoomInfo, + name: String, + alias: Option, + unread: UnreadInfo, +} + +impl RoomItem { + fn new(room_info: MatrixRoomInfo, store: &mut ProgramStore) -> Self { + let room = &room_info.deref().0; + let room_id = room.room_id(); + + let info = store.application.rooms.get_or_default(room_id.to_owned()); + let name = info.name.clone().unwrap_or_default(); + let alias = room.canonical_alias(); + let unread = info.unreads(&store.application.settings); + info.tags.clone_from(&room_info.deref().1); + + if let Some(alias) = &alias { + store.application.names.insert(alias.to_string(), room_id.to_owned()); + } + + RoomItem { room_info, name, alias, unread } + } + + #[inline] + fn room(&self) -> &MatrixRoom { + &self.room_info.deref().0 + } + + #[inline] + fn tags(&self) -> &Option { + &self.room_info.deref().1 + } +} + +impl RoomLikeItem for RoomItem { + fn name(&self) -> &str { + self.name.as_str() + } + + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn room_id(&self) -> &RoomId { + self.room().room_id() + } + + fn has_tag(&self, tag: TagName) -> bool { + if let Some(tags) = &self.room_info.deref().1 { + tags.contains_key(&tag) + } else { + false + } + } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } +} + +impl Display for RoomItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, ":verify request {}", self.name) + } +} + +impl ListItem for RoomItem { + fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + let unread = self.unread.is_unread(); + let style = selected_style(selected); + let (name, mut labels) = name_and_labels(&self.name, unread, style); + let mut spans = vec![name]; + + if let Some(tags) = &self.tags() { + labels.extend(tags.keys().map(|t| tag_to_span(t, style))); + } + + append_tags(labels, &mut spans, style); + + Text::from(Line::from(spans)) + } + + fn get_word(&self) -> Option { + self.room_id().to_string().into() + } +} + +impl Promptable for RoomItem { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + room_prompt(self.room_id(), act, ctx) + } +} + +#[derive(Clone)] +pub struct DirectItem { + room_info: MatrixRoomInfo, + name: String, + alias: Option, + unread: UnreadInfo, +} + +impl DirectItem { + fn new(room_info: MatrixRoomInfo, store: &mut ProgramStore) -> Self { + let room_id = room_info.0.room_id().to_owned(); + let alias = room_info.0.canonical_alias(); + + let info = store.application.rooms.get_or_default(room_id); + let name = info.name.clone().unwrap_or_default(); + let unread = info.unreads(&store.application.settings); + info.tags.clone_from(&room_info.deref().1); + + DirectItem { room_info, name, alias, unread } + } + + #[inline] + fn room(&self) -> &MatrixRoom { + &self.room_info.deref().0 + } + + #[inline] + fn tags(&self) -> &Option { + &self.room_info.deref().1 + } +} + +impl RoomLikeItem for DirectItem { + fn name(&self) -> &str { + self.name.as_str() + } + + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn has_tag(&self, tag: TagName) -> bool { + if let Some(tags) = &self.room_info.deref().1 { + tags.contains_key(&tag) + } else { + false + } + } + + fn room_id(&self) -> &RoomId { + self.room().room_id() + } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } +} + +impl Display for DirectItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, ":verify request {}", self.name) + } +} + +impl ListItem for DirectItem { + fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + let unread = self.unread.is_unread(); + let style = selected_style(selected); + let (name, mut labels) = name_and_labels(&self.name, unread, style); + let mut spans = vec![name]; + + if let Some(tags) = &self.tags() { + labels.extend(tags.keys().map(|t| tag_to_span(t, style))); + } + + append_tags(labels, &mut spans, style); + + Text::from(Line::from(spans)) + } + + fn get_word(&self) -> Option { + self.room_id().to_string().into() + } +} + +impl Promptable for DirectItem { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + room_prompt(self.room_id(), act, ctx) + } +} + +#[derive(Clone)] +pub struct SpaceItem { + room_info: MatrixRoomInfo, + name: String, + alias: Option, +} + +impl SpaceItem { + fn new(room_info: MatrixRoomInfo, store: &mut ProgramStore) -> Self { + let room_id = room_info.0.room_id(); + let name = store + .application + .get_room_info(room_id.to_owned()) + .name + .clone() + .unwrap_or_default(); + let alias = room_info.0.canonical_alias(); + + if let Some(alias) = &alias { + store.application.names.insert(alias.to_string(), room_id.to_owned()); + } + + SpaceItem { room_info, name, alias } + } + + #[inline] + fn room(&self) -> &MatrixRoom { + &self.room_info.deref().0 + } +} + +impl RoomLikeItem for SpaceItem { + fn name(&self) -> &str { + self.name.as_str() + } + + fn room_id(&self) -> &RoomId { + self.room().room_id() + } + + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn has_tag(&self, _: TagName) -> bool { + // I think that spaces can technically have tags, but afaik no client + // exposes them, so we'll just always return false here for now. + false + } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + // XXX: this needs to determine the room with most recent message and return its timestamp. + None + } + + fn is_unread(&self) -> bool { + // XXX: this needs to check whether the space contains rooms with unread messages + false + } +} + +impl Display for SpaceItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, ":verify request {}", self.room_id()) + } +} + +impl ListItem for SpaceItem { + fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + selected_text(self.name.as_str(), selected) + } + + fn get_word(&self) -> Option { + self.room_id().to_string().into() + } +} + +impl Promptable for SpaceItem { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + room_prompt(self.room_id(), act, ctx) + } +} + +#[derive(Clone)] +pub struct VerifyItem { + user_dev: String, + sasv1: SasVerification, +} + +impl VerifyItem { + fn new(user_dev: String, sasv1: SasVerification) -> Self { + VerifyItem { user_dev, sasv1 } + } + + fn show_item(&self) -> String { + let state = if self.sasv1.is_done() { + "done" + } else if self.sasv1.is_cancelled() { + "cancelled" + } else if self.sasv1.emoji().is_some() { + "accepted" + } else { + "not accepted" + }; + + if self.sasv1.is_self_verification() { + let device = self.sasv1.other_device(); + + if let Some(display_name) = device.display_name() { + format!("Device verification with {display_name} ({state})") + } else { + format!("Device verification with device {} ({})", device.device_id(), state) + } + } else { + format!("User Verification with {} ({})", self.sasv1.other_user_id(), state) + } + } +} + +impl PartialEq for VerifyItem { + fn eq(&self, other: &Self) -> bool { + self.user_dev == other.user_dev + } +} + +impl Eq for VerifyItem {} + +impl Ord for VerifyItem { + fn cmp(&self, other: &Self) -> Ordering { + fn state_val(sas: &SasVerification) -> usize { + if sas.is_done() { + return 3; + } else if sas.is_cancelled() { + return 2; + } else { + return 1; + } + } + + fn device_val(sas: &SasVerification) -> usize { + if sas.is_self_verification() { + return 1; + } else { + return 2; + } + } + + let state1 = state_val(&self.sasv1); + let state2 = state_val(&other.sasv1); + + let dev1 = device_val(&self.sasv1); + let dev2 = device_val(&other.sasv1); + + let scmp = state1.cmp(&state2); + let dcmp = dev1.cmp(&dev2); + + scmp.then(dcmp).then_with(|| { + let did1 = self.sasv1.other_device().device_id(); + let did2 = other.sasv1.other_device().device_id(); + + did1.cmp(did2) + }) + } +} + +impl PartialOrd for VerifyItem { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl From<(&String, &SasVerification)> for VerifyItem { + fn from((user_dev, sasv1): (&String, &SasVerification)) -> Self { + VerifyItem::new(user_dev.clone(), sasv1.clone()) + } +} + +impl Display for VerifyItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.sasv1.is_done() { + return Ok(()); + } + + if self.sasv1.is_cancelled() { + write!(f, ":verify request {}", self.sasv1.other_user_id()) + } else if self.sasv1.emoji().is_some() { + write!(f, ":verify confirm {}", self.user_dev) + } else { + write!(f, ":verify accept {}", self.user_dev) + } + } +} + +impl ListItem for VerifyItem { + fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + let mut lines = vec![]; + + let bold = Style::default().add_modifier(StyleModifier::BOLD); + let item = Span::styled(self.show_item(), selected_style(selected)); + lines.push(Line::from(item)); + + if self.sasv1.is_done() { + // Print nothing. + } else if self.sasv1.is_cancelled() { + if let Some(info) = self.sasv1.cancel_info() { + lines.push(Line::from(format!(" Cancelled: {}", info.reason()))); + lines.push(Line::from("")); + } + + lines.push(Line::from(" You can start a new verification request with:")); + } else if let Some(emoji) = self.sasv1.emoji() { + lines.push(Line::from( + " Both devices should see the following Emoji sequence:".to_string(), + )); + lines.push(Line::from("")); + + for line in format_emojis(emoji).lines() { + lines.push(Line::from(format!(" {line}"))); + } + + lines.push(Line::from("")); + lines.push(Line::from(" If they don't match, run:")); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(":verify mismatch {}", self.user_dev), + bold, + ))); + lines.push(Line::from("")); + lines.push(Line::from(" If everything looks right, you can confirm with:")); + } else { + lines.push(Line::from(" To accept this request, run:")); + } + + let cmd = self.to_string(); + + if !cmd.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::from(" "), Span::styled(cmd, bold)])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::from("You can copy the above command with "), + Span::styled("yy", bold), + Span::from(" and then execute it with "), + Span::styled("@\"", bold), + ])); + } + + Text::from(lines) + } + + fn get_word(&self) -> Option { + None + } +} + +impl Promptable for VerifyItem { + fn prompt( + &mut self, + act: &PromptAction, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + match act { + PromptAction::Submit => Ok(vec![]), + PromptAction::Abort(_) => { + let msg = "Cannot abort entry inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + PromptAction::Recall(..) => { + let msg = "Cannot recall history inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + } + } +} + +#[derive(Clone)] +pub struct MemberItem { + member: RoomMember, + room_id: OwnedRoomId, +} + +impl MemberItem { + fn new(member: RoomMember, room_id: OwnedRoomId) -> Self { + Self { member, room_id } + } +} + +impl Display for MemberItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.member.user_id()) + } +} + +impl ListItem for MemberItem { + fn show( + &self, + selected: bool, + _: &ViewportContext, + store: &mut ProgramStore, + ) -> Text { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + let user_id = self.member.user_id(); + + let (color, name) = store.application.settings.get_user_overrides(self.member.user_id()); + let color = color.unwrap_or_else(|| super::config::user_color(user_id.as_str())); + let mut style = super::config::user_style_from_color(color); + + if selected { + style = style.add_modifier(StyleModifier::REVERSED); + } + + let mut spans = vec![]; + let mut parens = false; + + if let Some(name) = name { + spans.push(Span::styled(name, style)); + parens = true; + } else if let Some(display) = info.display_names.get(user_id) { + spans.push(Span::styled(display.clone(), style)); + parens = true; + } + + spans.extend(parens.then_some(Span::styled(" (", style))); + spans.push(Span::styled(user_id.as_str(), style)); + spans.extend(parens.then_some(Span::styled(")", style))); + + let state = match self.member.membership() { + MembershipState::Ban => Span::raw(" (banned)").into(), + MembershipState::Invite => Span::raw(" (invited)").into(), + MembershipState::Knock => Span::raw(" (wants to join)").into(), + MembershipState::Leave => Span::raw(" (left)").into(), + MembershipState::Join => None, + _ => None, + }; + + spans.extend(state); + + return Line::from(spans).into(); + } + + fn get_word(&self) -> Option { + self.member.user_id().to_string().into() + } +} + +impl Promptable for MemberItem { + fn prompt( + &mut self, + act: &PromptAction, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + match act { + PromptAction::Submit => Ok(vec![]), + PromptAction::Abort(_) => { + let msg = "Cannot abort entry inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + PromptAction::Recall(..) => { + let msg = "Cannot recall history inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use matrix_sdk::ruma::{room_alias_id, server_name}; + + #[derive(Debug, Eq, PartialEq)] + struct TestRoomItem { + room_id: OwnedRoomId, + tags: Vec, + alias: Option, + name: &'static str, + unread: UnreadInfo, + } + + impl RoomLikeItem for &TestRoomItem { + fn room_id(&self) -> &RoomId { + self.room_id.as_ref() + } + + fn has_tag(&self, tag: TagName) -> bool { + self.tags.contains(&tag) + } + + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn name(&self) -> &str { + self.name + } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } + } + + #[test] + fn test_sort_rooms() { + let server = server_name!("example.com"); + + let room1 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![TagName::Favorite], + alias: Some(room_alias_id!("#room1:example.com").to_owned()), + name: "Z", + unread: UnreadInfo::default(), + }; + + let room2 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: Some(room_alias_id!("#a:example.com").to_owned()), + name: "Unnamed Room", + unread: UnreadInfo::default(), + }; + + let room3 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Cool Room", + unread: UnreadInfo::default(), + }; + + // Sort by Name ascending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Ascending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room3, &room2, &room1]); + + // Sort by Name descending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Descending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room1, &room2, &room3]); + + // Sort by Favorite and Alias before Name to show order matters. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[ + SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + SortColumn(SortFieldRoom::Name, SortOrder::Ascending), + ]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room1, &room2, &room3]); + + // Now flip order of Favorite with Descending + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[ + SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + SortColumn(SortFieldRoom::Name, SortOrder::Ascending), + ]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room2, &room3, &room1]); + } + + #[test] + fn test_sort_room_recents() { + let server = server_name!("example.com"); + + let room1 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Room 1", + unread: UnreadInfo { unread: false, latest: None }, + }; + + let room2 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Room 2", + unread: UnreadInfo { + unread: false, + latest: Some(MessageTimeStamp::OriginServer(40u32.into())), + }, + }; + + let room3 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Room 3", + unread: UnreadInfo { + unread: false, + latest: Some(MessageTimeStamp::OriginServer(20u32.into())), + }, + }; + + // Sort by Recent ascending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Ascending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room2, &room3, &room1]); + + // Sort by Recent descending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Descending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room1, &room3, &room2]); + } +} diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs new file mode 100644 index 0000000..9d2ad90 --- /dev/null +++ b/src/windows/room/chat.rs @@ -0,0 +1,992 @@ +//! Window for Matrix rooms +use std::borrow::Cow; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::ops::Deref; +use std::path::{Path, PathBuf}; + +use edit::edit_with_builder as external_edit; +use edit::Builder; +use modalkit::editing::store::RegisterError; +use std::process::Command; +use tokio; +use url::Url; + +use matrix_sdk::{ + attachment::AttachmentConfig, + media::{MediaFormat, MediaRequest}, + room::Room as MatrixRoom, + ruma::{ + events::reaction::ReactionEventContent, + events::relation::{Annotation, Replacement}, + events::room::message::{ + AddMentions, + ForwardThread, + MessageType, + OriginalRoomMessageEvent, + Relation, + ReplyWithinThread, + RoomMessageEventContent, + TextMessageEventContent, + }, + OwnedEventId, + OwnedRoomId, + RoomId, + }, + RoomState, +}; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + text::{Line, Span}, + widgets::{Paragraph, StatefulWidget, Widget}, +}; + +use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo}; + +use modalkit_ratatui::{ + textbox::{TextBox, TextBoxState}, + PromptActions, + TerminalCursor, + WindowOps, +}; + +use modalkit::actions::{ + Action, + Editable, + EditorAction, + Jumpable, + PromptAction, + Promptable, + Scrollable, +}; +use modalkit::editing::{ + completion::CompletionList, + context::Resolve, + history::{self, HistoryList}, + rope::EditRope, +}; +use modalkit::errors::{EditError, EditResult, UIError}; +use modalkit::prelude::*; + +use crate::base::{ + DownloadFlags, + fixAction, + fixBufferId, + fixError, + fixInfo, + fixResult, + MessageAction, + ProgramAction, + ProgramContext, + ProgramStore, + RoomFocus, + RoomInfo, + SendAction, +}; + +use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp}; +use crate::worker::Requester; + +use super::scrollback::{Scrollback, ScrollbackState}; + +/// State needed for rendering [Chat]. +pub struct ChatState { + room_id: OwnedRoomId, + room: MatrixRoom, + + tbox: TextBoxState, + sent: HistoryList, + sent_scrollback: history::ScrollbackState, + + scrollback: ScrollbackState, + focus: RoomFocus, + + reply_to: Option, + editing: Option, +} + +impl ChatState { + pub fn new(room: MatrixRoom, thread: Option, store: &mut ProgramStore) -> Self { + let room_id = room.room_id().to_owned(); + let scrollback = ScrollbackState::new(room_id.clone(), thread.clone()); + let id = fixBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar); + let ebuf = store.load_buffer(id); + let tbox = TextBoxState::new(ebuf); + + ChatState { + room_id, + room, + + tbox, + sent: HistoryList::new(EditRope::from(""), 100), + sent_scrollback: history::ScrollbackState::Pending, + + scrollback, + focus: RoomFocus::MessageBar, + + reply_to: None, + editing: None, + } + } + + pub fn thread(&self) -> Option<&OwnedEventId> { + self.scrollback.thread() + } + + fn get_joined(&self, worker: &Requester) -> Result { + let Some(room) = worker.client.get_room(self.id()) else { + return Err(fixError::NotJoined); + }; + + if room.state() == RoomState::Joined { + Ok(room) + } else { + Err(fixError::NotJoined) + } + } + + fn get_reply_to<'a>(&self, info: &'a RoomInfo) -> Option<&'a OriginalRoomMessageEvent> { + let thread = self.scrollback.get_thread(info)?; + let key = self.reply_to.as_ref()?; + let msg = thread.get(key)?; + + if let MessageEvent::Original(ev) = &msg.event { + Some(ev) + } else { + None + } + } + + fn reset(&mut self) -> EditRope { + self.reply_to = None; + self.editing = None; + self.tbox.reset() + } + + pub fn refresh_room(&mut self, store: &mut ProgramStore) { + if let Some(room) = store.application.worker.client.get_room(self.id()) { + self.room = room; + } + } + + pub async fn message_command( + &mut self, + act: MessageAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + let client = &store.application.worker.client; + + let settings = &store.application.settings; + let info = store.application.rooms.get_or_default(self.room_id.clone()); + + let msg = self.scrollback.get_mut(info).ok_or(fixError::NoSelectedMessage)?; + + match act { + MessageAction::Cancel(skip_confirm) => { + if skip_confirm { + self.reset(); + + return Ok(None); + } + + self.reply_to = None; + self.editing = None; + + let msg = "Would you like to clear the message bar?"; + let act = PromptAction::Abort(false); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + Err(UIError::NeedConfirm(prompt)) + }, + MessageAction::Download(filename, flags) => { + if let MessageEvent::Original(ev) = &msg.event { + let media = client.media(); + + let mut filename = match (filename, &settings.dirs.downloads) { + (Some(f), _) => PathBuf::from(f), + (None, Some(downloads)) => downloads.clone(), + (None, None) => return Err(fixError::NoDownloadDir.into()), + }; + + let (source, msg_filename) = match &ev.content.msgtype { + MessageType::Audio(c) => (c.source.clone(), c.body.as_str()), + MessageType::File(c) => { + (c.source.clone(), c.filename.as_deref().unwrap_or(c.body.as_str())) + }, + MessageType::Image(c) => (c.source.clone(), c.body.as_str()), + MessageType::Video(c) => (c.source.clone(), c.body.as_str()), + _ => { + if !flags.contains(DownloadFlags::OPEN) { + return Err(fixError::NoAttachment.into()); + } + + let links = if let Some(html) = &msg.html { + html.get_links() + } else if let Ok(url) = Url::parse(&msg.event.body()) { + vec![('0', url)] + } else { + vec![] + }; + + if links.is_empty() { + return Err(fixError::NoAttachment.into()); + } + + let choices = links + .into_iter() + .map(|l| { + let url = l.1.to_string(); + let act = fixAction::OpenLink(url.clone()).into(); + MultiChoiceItem::new(l.0, url, vec![act]) + }) + .collect(); + let dialog = MultiChoice::new(choices); + let err = UIError::NeedConfirm(Box::new(dialog)); + + return Err(err); + }, + }; + + if filename.is_dir() { + filename.push(msg_filename); + } + + if filename.exists() && !flags.contains(DownloadFlags::FORCE) { + // Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg + if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) { + let ext = filename.extension(); + let mut filename_incr = filename.clone(); + for n in 1..=1000 { + if let Some(ext) = ext.and_then(OsStr::to_str) { + filename_incr.set_file_name(format!("{}-{}.{}", stem, n, ext)); + } else { + filename_incr.set_file_name(format!("{}-{}", stem, n)); + } + + if !filename_incr.exists() { + filename = filename_incr; + break; + } + } + } + } + + if !filename.exists() || flags.contains(DownloadFlags::FORCE) { + let req = MediaRequest { source, format: MediaFormat::File }; + + let bytes = + media.get_media_content(&req, true).await.map_err(fixError::from)?; + + fs::write(filename.as_path(), bytes.as_slice())?; + + msg.downloaded = true; + } else if !flags.contains(DownloadFlags::OPEN) { + let msg = format!( + "The file {} already exists; add ! to end of command to overwrite it.", + filename.display() + ); + let err = UIError::Failure(msg); + + return Err(err); + } + + let info = if flags.contains(DownloadFlags::OPEN) { + let target = filename.clone().into_os_string(); + match open_command( + store.application.settings.tunables.open_command.as_ref(), + target, + ) { + Ok(_) => { + InfoMessage::from(format!( + "Attachment downloaded to {} and opened", + filename.display() + )) + }, + Err(err) => { + return Err(err); + }, + } + } else { + InfoMessage::from(format!( + "Attachment downloaded to {}", + filename.display() + )) + }; + + return Ok(info.into()); + } + + Err(fixError::NoAttachment.into()) + }, + MessageAction::Edit => { + if msg.sender != settings.profile.user_id { + let msg = "Cannot edit messages sent by someone else"; + let err = UIError::Failure(msg.into()); + + return Err(err); + } + + let ev = match &msg.event { + MessageEvent::Original(ev) => &ev.content, + MessageEvent::Local(_, ev) => ev.deref(), + _ => { + let msg = "Cannot edit a redacted message"; + let err = UIError::Failure(msg.into()); + + return Err(err); + }, + }; + + let text = match &ev.msgtype { + MessageType::Text(msg) => msg.body.as_str(), + _ => { + let msg = "Cannot edit a non-text message"; + let err = UIError::Failure(msg.into()); + + return Err(err); + }, + }; + + self.tbox.set_text(text); + self.reply_to = msg.reply_to().and_then(|id| info.get_message_key(&id)).cloned(); + self.editing = self.scrollback.get_key(info); + self.focus = RoomFocus::MessageBar; + + Ok(None) + }, + MessageAction::React(reaction, literal) => { + let emoji = if literal { + reaction + } else if let Some(emoji) = + emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction)) + { + emoji.to_string() + } else { + let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to react with exactly {reaction:?}?"); + let act = fixAction::Message(MessageAction::React(reaction, true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + }; + + let room = self.get_joined(&store.application.worker)?; + let event_id = match &msg.event { + MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), + MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), + MessageEvent::Original(ev) => ev.event_id.clone(), + MessageEvent::Local(event_id, _) => event_id.clone(), + MessageEvent::Redacted(_) => { + let msg = "Cannot react to a redacted message"; + let err = UIError::Failure(msg.into()); + + return Err(err); + }, + }; + + if info.user_reactions_contains(&settings.profile.user_id, &event_id, &emoji) { + let msg = format!("You’ve already reacted to this message with {}", emoji); + let err = UIError::Failure(msg); + + return Err(err); + } + + let reaction = Annotation::new(event_id, emoji); + let msg = ReactionEventContent::new(reaction); + let _ = room.send(msg).await.map_err(fixError::from)?; + + Ok(None) + }, + MessageAction::Redact(reason, skip_confirm) => { + if !skip_confirm { + let msg = "Are you sure you want to redact this message?"; + let act = fixAction::Message(MessageAction::Redact(reason, true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + } + + let room = self.get_joined(&store.application.worker)?; + let event_id = match &msg.event { + MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), + MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), + MessageEvent::Original(ev) => ev.event_id.clone(), + MessageEvent::Local(event_id, _) => event_id.clone(), + MessageEvent::Redacted(_) => { + let msg = "Cannot redact already redacted message"; + let err = UIError::Failure(msg.into()); + + return Err(err); + }, + }; + + let event_id = event_id.as_ref(); + let reason = reason.as_deref(); + let _ = room.redact(event_id, reason, None).await.map_err(fixError::from)?; + + Ok(None) + }, + MessageAction::Reply => { + self.reply_to = self.scrollback.get_key(info); + self.focus = RoomFocus::MessageBar; + + Ok(None) + }, + MessageAction::Unreact(reaction, literal) => { + let emoji = match reaction { + reaction if literal => reaction, + Some(reaction) => { + if let Some(emoji) = + emojis::get(&reaction).or_else(|| emojis::get_by_shortcode(&reaction)) + { + Some(emoji.to_string()) + } else { + let msg = format!("{reaction:?} is not a known Emoji shortcode; do you want to remove exactly {reaction:?}?"); + let act = + fixAction::Message(MessageAction::Unreact(Some(reaction), true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + } + }, + None => None, + }; + + let room = self.get_joined(&store.application.worker)?; + let event_id = match &msg.event { + MessageEvent::EncryptedOriginal(ev) => ev.event_id.clone(), + MessageEvent::EncryptedRedacted(ev) => ev.event_id.clone(), + MessageEvent::Original(ev) => ev.event_id.clone(), + MessageEvent::Local(event_id, _) => event_id.clone(), + MessageEvent::Redacted(_) => { + let msg = "Cannot unreact to a redacted message"; + let err = UIError::Failure(msg.into()); + + return Err(err); + }, + }; + + let reactions = match info.reactions.get(&event_id) { + Some(r) => r, + None => return Ok(None), + }; + + let reactions = reactions.iter().filter_map(|(event_id, (reaction, user_id))| { + if user_id != &settings.profile.user_id { + return None; + } + + if let Some(emoji) = &emoji { + if emoji == reaction { + return Some(event_id); + } else { + return None; + } + } else { + return Some(event_id); + } + }); + + for reaction in reactions { + let _ = room.redact(reaction, None, None).await.map_err(fixError::from)?; + } + + Ok(None) + }, + } + } + + pub async fn send_command( + &mut self, + act: SendAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + let room = self.get_joined(&store.application.worker)?; + let info = store.application.rooms.get_or_default(self.id().to_owned()); + let mut show_echo = true; + + let (event_id, msg) = match act { + SendAction::Submit | SendAction::SubmitFromEditor => { + let msg = self.tbox.get(); + + let msg = if let SendAction::SubmitFromEditor = act { + let suffix = + store.application.settings.tunables.external_edit_file_suffix.as_str(); + let edited_msg = + external_edit(msg.trim_end().to_string(), Builder::new().suffix(suffix))? + .trim_end() + .to_string(); + if edited_msg.is_empty() { + return Ok(None); + } + edited_msg + } else if msg.is_blank() { + return Ok(None); + } else { + msg.trim_end().to_string() + }; + + let mut msg = text_to_message(msg); + + if let Some((_, event_id)) = &self.editing { + msg.relates_to = Some(Relation::Replacement(Replacement::new( + event_id.clone(), + msg.msgtype.clone().into(), + ))); + + show_echo = false; + } else if let Some(thread_root) = self.scrollback.thread() { + if let Some(m) = self.get_reply_to(info) { + msg = msg.make_for_thread(m, ReplyWithinThread::Yes, AddMentions::No); + } else if let Some(m) = info.get_thread_last(thread_root) { + msg = msg.make_for_thread(m, ReplyWithinThread::No, AddMentions::No); + } else { + // Internal state is wonky? + } + } else if let Some(m) = self.get_reply_to(info) { + msg = msg.make_reply_to(m, ForwardThread::Yes, AddMentions::No); + } + + // XXX: second parameter can be a locally unique transaction id. + // Useful for doing retries. + let resp = room.send(msg.clone()).await.map_err(fixError::from)?; + let event_id = resp.event_id; + + // Reset message bar state now that it's been sent. + self.reset(); + + (event_id, msg) + }, + SendAction::Upload(file) => { + let path = Path::new(file.as_str()); + let mime = mime_guess::from_path(path).first_or(mime::APPLICATION_OCTET_STREAM); + + let bytes = fs::read(path)?; + let name = path + .file_name() + .map(OsStr::to_string_lossy) + .unwrap_or_else(|| Cow::from("Attachment")); + let config = AttachmentConfig::new(); + + let resp = room + .send_attachment(name.as_ref(), &mime, bytes, config) + .await + .map_err(fixError::from)?; + + // Mock up the local echo message for the scrollback. + let msg = TextMessageEventContent::plain(format!("[Attached File: {name}]")); + let msg = MessageType::Text(msg); + let msg = RoomMessageEventContent::new(msg); + + (resp.event_id, msg) + }, + SendAction::UploadImage(width, height, bytes) => { + // Convert to png because arboard does not give us the mime type. + let bytes = + image::ImageBuffer::from_raw(width as _, height as _, bytes.into_owned()) + .ok_or(fixError::Clipboard) + .and_then(|imagebuf| { + let dynimage = image::DynamicImage::ImageRgba8(imagebuf); + let bytes = Vec::::new(); + let mut buff = std::io::Cursor::new(bytes); + dynimage.write_to(&mut buff, image::ImageOutputFormat::Png)?; + Ok(buff.into_inner()) + }) + .map_err(fixError::from)?; + let mime = mime::IMAGE_PNG; + + let name = "Clipboard.png"; + let config = AttachmentConfig::new(); + + let resp = room + .send_attachment(name.as_ref(), &mime, bytes, config) + .await + .map_err(fixError::from)?; + + // Mock up the local echo message for the scrollback. + let msg = TextMessageEventContent::plain(format!("[Attached File: {name}]")); + let msg = MessageType::Text(msg); + let msg = RoomMessageEventContent::new(msg); + + (resp.event_id, msg) + }, + }; + + if show_echo { + let user = store.application.settings.profile.user_id.clone(); + let key = (MessageTimeStamp::LocalEcho, event_id.clone()); + let msg = MessageEvent::Local(event_id, msg.into()); + let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); + let thread = self.scrollback.get_thread_mut(info); + thread.insert(key, msg); + } + + // Jump to the end of the scrollback to show the message. + self.scrollback.goto_latest(); + + Ok(None) + } + + pub fn focus_toggle(&mut self) { + self.focus = match self.focus { + RoomFocus::Scrollback => RoomFocus::MessageBar, + RoomFocus::MessageBar => RoomFocus::Scrollback, + }; + } + + pub fn room(&self) -> &MatrixRoom { + &self.room + } + + pub fn id(&self) -> &RoomId { + &self.room_id + } + + pub fn typing_notice( + &self, + act: &EditorAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) { + if !self.focus.is_msgbar() || act.is_readonly(ctx) { + return; + } + + if !store.application.settings.tunables.typing_notice_send { + return; + } + + store.application.worker.typing_notice(self.room_id.clone()); + } +} + +macro_rules! delegate { + ($s: expr, $id: ident => $e: expr) => { + match $s.focus { + RoomFocus::Scrollback => { + match $s { + ChatState { scrollback: $id, .. } => $e, + } + }, + RoomFocus::MessageBar => { + match $s { + ChatState { tbox: $id, .. } => $e, + } + }, + } + }; +} + +impl WindowOps for ChatState { + fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { + Chat::new(store).focus(focused).render(area, buf, self) + } + + fn dup(&self, store: &mut ProgramStore) -> Self { + // XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to + // find a good way to pass that info here so that it can be part of the content id. + let room_id = self.room_id.clone(); + let thread = self.thread().cloned(); + let id = fixBufferId::Room(room_id.clone(), thread, RoomFocus::MessageBar); + let ebuf = store.load_buffer(id); + let tbox = TextBoxState::new(ebuf); + + ChatState { + room_id, + room: self.room.clone(), + + tbox, + sent: self.sent.clone(), + sent_scrollback: history::ScrollbackState::Pending, + + scrollback: self.scrollback.dup(store), + focus: self.focus, + + reply_to: None, + editing: None, + } + } + + fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool { + // XXX: what's the right closing behaviour for a room? + // Should write send a message? + true + } + + fn write( + &mut self, + _: Option<&str>, + _: WriteFlags, + _: &mut ProgramStore, + ) -> fixResult { + // XXX: what's the right writing behaviour for a room? + // Should write send a message? + Ok(None) + } + + fn get_completions(&self) -> Option { + delegate!(self, w => w.get_completions()) + } + + fn get_cursor_word(&self, style: &WordStyle) -> Option { + delegate!(self, w => w.get_cursor_word(style)) + } + + fn get_selected_word(&self) -> Option { + delegate!(self, w => w.get_selected_word()) + } +} + +impl Editable for ChatState { + fn editor_command( + &mut self, + act: &EditorAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + self.typing_notice(act, ctx, store); + + match delegate!(self, w => w.editor_command(act, ctx, store)) { + res @ Ok(_) => res, + Err(EditError::WrongBuffer(fixBufferId::Room(room_id, thread, focus))) + if room_id == self.room_id && + thread.as_ref() == self.thread() && + act.is_switchable(ctx) => + { + // Switch focus. + self.focus = focus; + + // Run command again. + delegate!(self, w => w.editor_command(act, ctx, store)) + }, + Err(EditError::Register(RegisterError::ClipboardImage(data))) => { + let msg = "Do you really want to upload the image from your system clipboard?"; + let send = + fixAction::Send(SendAction::UploadImage(data.width, data.height, data.bytes)); + let prompt = PromptYesNo::new(msg, vec![Action::from(send)]); + let prompt = Box::new(prompt); + + Err(EditError::NeedConfirm(prompt)) + }, + res @ Err(_) => res, + } + } +} + +impl TerminalCursor for ChatState { + fn get_term_cursor(&self) -> Option<(u16, u16)> { + delegate!(self, w => w.get_term_cursor()) + } +} + +impl Jumpable for ChatState { + fn jump( + &mut self, + list: PositionList, + dir: MoveDir1D, + count: usize, + ctx: &ProgramContext, + ) -> fixResult { + delegate!(self, w => w.jump(list, dir, count, ctx)) + } +} + +impl Scrollable for ChatState { + fn scroll( + &mut self, + style: &ScrollStyle, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + // Send all scroll commands to the scrollback. + // + // If there's enough message text for scrolling to be necessary, + // navigating with movement keys should be enough to do the job. + self.scrollback.scroll(style, ctx, store) + } +} + +impl PromptActions for ChatState { + fn submit( + &mut self, + ctx: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + let act = SendAction::Submit; + + Ok(vec![(fixAction::from(act).into(), ctx.clone())]) + } + + fn abort( + &mut self, + empty: bool, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + let text = self.tbox.get(); + + if empty && text.is_blank() { + return Ok(vec![]); + } + + let text = self.reset().trim(); + + if text.is_empty() { + let _ = self.sent.end(); + } else { + self.sent.select(text); + } + + return Ok(vec![]); + } + + fn recall( + &mut self, + dir: &MoveDir1D, + count: &Count, + prefixed: bool, + ctx: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult, fixInfo> { + let count = ctx.resolve(count); + let rope = self.tbox.get(); + + let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, prefixed, count); + + if let Some(text) = text { + self.tbox.set_text(text); + } + + Ok(vec![]) + } +} + +impl Promptable for ChatState { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult, fixInfo> { + if let RoomFocus::Scrollback = self.focus { + return self.scrollback.prompt(act, ctx, store); + } + + match act { + PromptAction::Submit => self.submit(ctx, store), + PromptAction::Abort(empty) => self.abort(*empty, ctx, store), + PromptAction::Recall(dir, count, prefixed) => { + self.recall(dir, count, *prefixed, ctx, store) + }, + } + } +} + +/// [StatefulWidget] for Matrix rooms. +pub struct Chat<'a> { + store: &'a mut ProgramStore, + focused: bool, +} + +impl<'a> Chat<'a> { + pub fn new(store: &'a mut ProgramStore) -> Chat<'a> { + Chat { store, focused: false } + } + + pub fn focus(mut self, focused: bool) -> Self { + self.focused = focused; + self + } +} + +impl<'a> StatefulWidget for Chat<'a> { + type State = ChatState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + // Determine whether we have a description to show for the message bar. + let desc_spans = match (&state.editing, &state.reply_to, state.thread()) { + (None, None, None) => None, + (None, None, Some(_)) => Some(Line::from("Replying in thread")), + (Some(_), None, None) => Some(Line::from("Editing message")), + (Some(_), None, Some(_)) => Some(Line::from("Editing message in thread")), + (editing, Some(_), thread) => { + self.store.application.rooms.get(state.id()).and_then(|room| { + let msg = state.get_reply_to(room)?; + let user = + self.store.application.settings.get_user_span(msg.sender.as_ref(), room); + let prefix = match (editing.is_some(), thread.is_some()) { + (true, false) => Span::from("Editing reply to "), + (true, true) => Span::from("Editing reply in thread to "), + (false, false) => Span::from("Replying to "), + (false, true) => Span::from("Replying in thread to "), + }; + let spans = Line::from(vec![prefix, user]); + + spans.into() + }) + }, + }; + + // Determine the region to show each UI element. + let lines = state.tbox.has_lines(5).max(1) as u16; + let drawh = area.height; + let texth = lines.min(drawh).clamp(1, 5); + let desch = if desc_spans.is_some() { + drawh.saturating_sub(texth).min(1) + } else { + 0 + }; + let scrollh = drawh.saturating_sub(texth).saturating_sub(desch); + + let scrollarea = Rect::new(area.x, area.y, area.width, scrollh); + let descarea = Rect::new(area.x, scrollarea.y + scrollh, area.width, desch); + let textarea = Rect::new(area.x, descarea.y + desch, area.width, texth); + + // Render the message bar and any description for it. + if let Some(desc_spans) = desc_spans { + Paragraph::new(desc_spans).render(descarea, buf); + } + + let prompt = if self.focused { "> " } else { " " }; + + let tbox = TextBox::new().prompt(prompt); + tbox.render(textarea, buf, &mut state.tbox); + + // Render the message scrollback. + let scrollback_focused = state.focus.is_scrollback() && self.focused; + let scrollback = Scrollback::new(self.store) + .focus(scrollback_focused) + .room_focus(self.focused); + scrollback.render(scrollarea, buf, &mut state.scrollback); + } +} + +fn open_command(open_command: Option<&Vec>, target: OsString) -> fixResult<()> { + if let Some(mut cmd) = open_command.and_then(cmd) { + cmd.arg(target); + cmd.spawn()?; + return Ok(()); + } else { + // open::that may not return until the spawned program closes. + tokio::task::spawn_blocking(move || { + return open::that(target); + }); + return Ok(()); + } +} + +fn cmd(open_command: &Vec) -> Option { + if let [program, args @ ..] = open_command.as_slice() { + let mut cmd = Command::new(program); + cmd.args(args); + return Some(cmd); + } + None +} diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs new file mode 100644 index 0000000..b147c5d --- /dev/null +++ b/src/windows/room/mod.rs @@ -0,0 +1,823 @@ +//! # Windows for Matrix rooms and spaces +use std::collections::HashSet; + +use matrix_sdk::{ + notification_settings::RoomNotificationMode, + room::Room as MatrixRoom, + ruma::{ + api::client::{ + alias::{ + create_alias::v3::Request as CreateAliasRequest, + delete_alias::v3::Request as DeleteAliasRequest, + }, + error::ErrorKind as ClientApiErrorKind, + }, + events::{ + room::{ + canonical_alias::RoomCanonicalAliasEventContent, + history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent}, + name::RoomNameEventContent, + topic::RoomTopicEventContent, + }, + tag::{TagInfo, Tags}, + }, + OwnedEventId, + OwnedRoomAliasId, + OwnedUserId, + RoomId, + }, + DisplayName, + RoomState as MatrixRoomState, +}; + +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::{Modifier as StyleModifier, Style}, + text::{Line, Span, Text}, + widgets::{Paragraph, StatefulWidget, Widget}, +}; + +use modalkit::actions::{ + Action, + Editable, + EditorAction, + Jumpable, + PromptAction, + Promptable, + Scrollable, +}; +use modalkit::errors::{EditResult, UIError}; +use modalkit::prelude::*; +use modalkit::{editing::completion::CompletionList, keybindings::dialog::PromptYesNo}; +use modalkit_ratatui::{TermOffset, TerminalCursor, WindowOps}; + +use crate::base::{ + fixAction, + fixError, + fixId, + fixInfo, + fixResult, + MemberUpdateAction, + MessageAction, + ProgramAction, + ProgramContext, + ProgramStore, + RoomAction, + RoomField, + SendAction, +}; + +use self::chat::ChatState; +use self::space::{Space, SpaceState}; + +use std::convert::TryFrom; + +mod chat; +mod scrollback; +mod space; + +macro_rules! delegate { + ($s: expr, $id: ident => $e: expr) => { + match $s { + RoomState::Chat($id) => $e, + RoomState::Space($id) => $e, + } + }; +} + +fn notification_mode(name: impl Into) -> fixResult { + let name = name.into(); + + let mode = match name.to_lowercase().as_str() { + "mute" => RoomNotificationMode::Mute, + "mentions" | "keywords" => RoomNotificationMode::MentionsAndKeywordsOnly, + "all" => RoomNotificationMode::AllMessages, + _ => return Err(fixError::InvalidNotificationLevel(name).into()), + }; + + Ok(mode) +} + +fn hist_visibility_mode(name: impl Into) -> fixResult { + let name = name.into(); + + let mode = match name.to_lowercase().as_str() { + "invited" => HistoryVisibility::Invited, + "joined" => HistoryVisibility::Joined, + "shared" => HistoryVisibility::Shared, + "world" | "world_readable" => HistoryVisibility::WorldReadable, + _ => return Err(fixError::InvalidHistoryVisibility(name).into()), + }; + + Ok(mode) +} + +/// State for a Matrix room or space. +/// +/// Since spaces function as special rooms within Matrix, we wrap their window state together, so +/// that operations like sending and accepting invites, opening the members window, etc., all work +/// similarly. +pub enum RoomState { + Chat(ChatState), + Space(SpaceState), +} + +impl From for RoomState { + fn from(chat: ChatState) -> Self { + RoomState::Chat(chat) + } +} + +impl From for RoomState { + fn from(space: SpaceState) -> Self { + RoomState::Space(space) + } +} + +impl RoomState { + pub fn new( + room: MatrixRoom, + thread: Option, + name: DisplayName, + tags: Option, + store: &mut ProgramStore, + ) -> Self { + let room_id = room.room_id().to_owned(); + let info = store.application.get_room_info(room_id); + info.name = name.to_string().into(); + info.tags = tags; + + if room.is_space() { + SpaceState::new(room).into() + } else { + ChatState::new(room, thread, store).into() + } + } + + pub fn thread(&self) -> Option<&OwnedEventId> { + match self { + RoomState::Chat(chat) => chat.thread(), + RoomState::Space(_) => None, + } + } + + pub fn refresh_room(&mut self, store: &mut ProgramStore) { + match self { + RoomState::Chat(chat) => chat.refresh_room(store), + RoomState::Space(space) => space.refresh_room(store), + } + } + + fn draw_invite( + &self, + invited: MatrixRoom, + area: Rect, + buf: &mut Buffer, + store: &mut ProgramStore, + ) { + let inviter = store.application.worker.get_inviter(invited.clone()); + + let name = match invited.canonical_alias() { + Some(alias) => alias.to_string(), + None => format!("{:?}", store.application.get_room_title(self.id())), + }; + + let mut invited = vec![Span::from(format!("You have been invited to join {name}"))]; + + if let Ok(Some(inviter)) = &inviter { + let info = store.application.rooms.get_or_default(self.id().to_owned()); + invited.push(Span::from(" by ")); + invited.push(store.application.settings.get_user_span(inviter.user_id(), info)); + } + + let l1 = Line::from(invited); + let l2 = Line::from( + "You can run `:invite accept` or `:invite reject` to accept or reject this invitation.", + ); + let text = Text::from(vec![l1, l2]); + + Paragraph::new(text).alignment(Alignment::Center).render(area, buf); + + return; + } + + pub async fn message_command( + &mut self, + act: MessageAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + match self { + RoomState::Chat(chat) => chat.message_command(act, ctx, store).await, + RoomState::Space(_) => Err(fixError::NoSelectedMessage.into()), + } + } + + pub async fn send_command( + &mut self, + act: SendAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult { + match self { + RoomState::Chat(chat) => chat.send_command(act, ctx, store).await, + RoomState::Space(_) => Err(fixError::NoSelectedRoom.into()), + } + } + + pub async fn room_command( + &mut self, + act: RoomAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> fixResult, ProgramContext)>> { + match act { + RoomAction::InviteAccept => { + if let Some(room) = store.application.worker.client.get_room(self.id()) { + let details = room.invite_details().await.map_err(fixError::from)?; + let details = details.invitee.event().original_content(); + let is_direct = details.and_then(|ev| ev.is_direct).unwrap_or_default(); + + room.join().await.map_err(fixError::from)?; + + if is_direct { + room.set_is_direct(true).await.map_err(fixError::from)?; + } + + Ok(vec![]) + } else { + Err(fixError::NotInvited.into()) + } + }, + RoomAction::InviteReject => { + if let Some(room) = store.application.worker.client.get_room(self.id()) { + room.leave().await.map_err(fixError::from)?; + + Ok(vec![]) + } else { + Err(fixError::NotInvited.into()) + } + }, + RoomAction::InviteSend(user) => { + if let Some(room) = store.application.worker.client.get_room(self.id()) { + room.invite_user_by_id(user.as_ref()).await.map_err(fixError::from)?; + + Ok(vec![]) + } else { + Err(fixError::NotJoined.into()) + } + }, + RoomAction::Leave(skip_confirm) => { + if let Some(room) = store.application.worker.client.get_room(self.id()) { + if skip_confirm { + room.leave().await.map_err(fixError::from)?; + + Ok(vec![]) + } else { + let msg = "Do you really want to leave this room?"; + let leave = fixAction::Room(RoomAction::Leave(true)); + let prompt = PromptYesNo::new(msg, vec![Action::from(leave)]); + let prompt = Box::new(prompt); + + Err(UIError::NeedConfirm(prompt)) + } + } else { + Err(fixError::NotJoined.into()) + } + }, + RoomAction::MemberUpdate(mua, user, reason, skip_confirm) => { + let Some(room) = store.application.worker.client.get_room(self.id()) else { + return Err(fixError::NotJoined.into()); + }; + + let Ok(user_id) = OwnedUserId::try_from(user.as_str()) else { + let err = fixError::InvalidUserId(user); + + return Err(err.into()); + }; + + if !skip_confirm { + let msg = format!("Do you really want to {mua} {user} from this room?"); + let act = RoomAction::MemberUpdate(mua, user, reason, true); + let act = fixAction::from(act); + let prompt = PromptYesNo::new(msg, vec![Action::from(act)]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + } + + match mua { + MemberUpdateAction::Ban => { + room.ban_user(&user_id, reason.as_deref()) + .await + .map_err(fixError::from)?; + }, + MemberUpdateAction::Unban => { + room.unban_user(&user_id, reason.as_deref()) + .await + .map_err(fixError::from)?; + }, + MemberUpdateAction::Kick => { + room.kick_user(&user_id, reason.as_deref()) + .await + .map_err(fixError::from)?; + }, + } + + Ok(vec![]) + }, + RoomAction::Members(mut cmd) => { + let width = Count::Exact(30); + let act = + cmd.default_axis(Axis::Vertical).default_relation(MoveDir1D::Next).window( + OpenTarget::Application(fixId::MemberList(self.id().to_owned())), + width.into(), + ); + + Ok(vec![(act, cmd.context.clone())]) + }, + RoomAction::SetDirect(is_direct) => { + let room = store + .application + .get_joined_room(self.id()) + .ok_or(UIError::Application(fixError::NotJoined))?; + + room.set_is_direct(is_direct).await.map_err(fixError::from)?; + + Ok(vec![]) + }, + RoomAction::Set(field, value) => { + let room = store + .application + .get_joined_room(self.id()) + .ok_or(UIError::Application(fixError::NotJoined))?; + + match field { + RoomField::History => { + let visibility = hist_visibility_mode(value)?; + let ev = RoomHistoryVisibilityEventContent::new(visibility); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::Name => { + let ev = RoomNameEventContent::new(value); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::Tag(tag) => { + let mut info = TagInfo::new(); + info.order = Some(1.0); + + let _ = room.set_tag(tag, info).await.map_err(fixError::from)?; + }, + RoomField::Topic => { + let ev = RoomTopicEventContent::new(value); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::NotificationMode => { + let mode = notification_mode(value)?; + let client = &store.application.worker.client; + let notifications = client.notification_settings().await; + + notifications + .set_room_notification_mode(self.id(), mode) + .await + .map_err(fixError::from)?; + }, + RoomField::CanonicalAlias => { + let client = &mut store.application.worker.client; + + let Ok(orai) = OwnedRoomAliasId::try_from(value.as_str()) else { + let err = fixError::InvalidRoomAlias(value); + + return Err(err.into()); + }; + + let mut alt_aliases = + room.alt_aliases().into_iter().collect::>(); + let canonical_old = room.canonical_alias(); + + // If the room's alias is already that, ignore it + if canonical_old.as_ref() == Some(&orai) { + let msg = format!("The canonical room alias is already {orai}"); + + return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); + } + + // Try creating the room alias on the server. + let alias_create_req = + CreateAliasRequest::new(orai.clone(), room.room_id().into()); + if let Err(e) = client.send(alias_create_req, None).await { + if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() { + // Ignore when it already exists. + } else { + return Err(fixError::from(e).into()); + } + } + + // Demote the previous one to an alt alias. + alt_aliases.extend(canonical_old); + + // At this point the room alias definitely exists, and we can update the + // state event. + let mut ev = RoomCanonicalAliasEventContent::new(); + ev.alias = Some(orai); + ev.alt_aliases = alt_aliases.into_iter().collect(); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::Alias(alias) => { + let client = &mut store.application.worker.client; + + let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else { + let err = fixError::InvalidRoomAlias(alias); + + return Err(err.into()); + }; + + let mut alt_aliases = + room.alt_aliases().into_iter().collect::>(); + let canonical = room.canonical_alias(); + + if alt_aliases.contains(&orai) || canonical.as_ref() == Some(&orai) { + let msg = format!("The alias {orai} already maps to this room"); + + return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); + } else { + alt_aliases.insert(orai.clone()); + } + + // If the room alias does not exist on the server, create it + let alias_create_req = CreateAliasRequest::new(orai, room.room_id().into()); + if let Err(e) = client.send(alias_create_req, None).await { + if let Some(ClientApiErrorKind::Unknown) = e.client_api_error_kind() { + // Ignore when it already exists. + } else { + return Err(fixError::from(e).into()); + } + } + + // And add it to the aliases in the state event. + let mut ev = RoomCanonicalAliasEventContent::new(); + ev.alias = canonical; + ev.alt_aliases = alt_aliases.into_iter().collect(); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::Aliases => { + // This never happens, aliases is only used for showing + }, + } + + Ok(vec![]) + }, + RoomAction::Unset(field) => { + let room = store + .application + .get_joined_room(self.id()) + .ok_or(UIError::Application(fixError::NotJoined))?; + + match field { + RoomField::History => { + let visibility = HistoryVisibility::Joined; + let ev = RoomHistoryVisibilityEventContent::new(visibility); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::Name => { + let ev = RoomNameEventContent::new("".into()); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::Tag(tag) => { + let _ = room.remove_tag(tag).await.map_err(fixError::from)?; + }, + RoomField::Topic => { + let ev = RoomTopicEventContent::new("".into()); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + }, + RoomField::NotificationMode => { + let client = &store.application.worker.client; + let notifications = client.notification_settings().await; + + notifications + .delete_user_defined_room_rules(self.id()) + .await + .map_err(fixError::from)?; + }, + RoomField::CanonicalAlias => { + let Some(alias_to_destroy) = room.canonical_alias() else { + let msg = "This room has no canonical alias to unset"; + + return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); + }; + + // Remove the canonical alias from the state event. + let mut ev = RoomCanonicalAliasEventContent::new(); + ev.alias = None; + ev.alt_aliases = room.alt_aliases(); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + + // And then unmap it on the server. + let del_req = DeleteAliasRequest::new(alias_to_destroy); + let _ = store + .application + .worker + .client + .send(del_req, None) + .await + .map_err(fixError::from)?; + }, + RoomField::Alias(alias) => { + let Ok(orai) = OwnedRoomAliasId::try_from(alias.as_str()) else { + let err = fixError::InvalidRoomAlias(alias); + + return Err(err.into()); + }; + + let alt_aliases = room.alt_aliases(); + let canonical = room.canonical_alias(); + + if !alt_aliases.contains(&orai) && canonical.as_ref() != Some(&orai) { + let msg = format!("The alias {orai:?} isn't mapped to this room"); + + return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); + } + + // Remove the alias from the state event if it's in it. + let mut ev = RoomCanonicalAliasEventContent::new(); + ev.alias = canonical.filter(|canon| canon != &orai); + ev.alt_aliases = alt_aliases; + ev.alt_aliases.retain(|in_orai| in_orai != &orai); + let _ = room.send_state_event(ev).await.map_err(fixError::from)?; + + // And then unmap it on the server. + let del_req = DeleteAliasRequest::new(orai); + let _ = store + .application + .worker + .client + .send(del_req, None) + .await + .map_err(fixError::from)?; + }, + RoomField::Aliases => { + // This will not happen, you cannot unset all aliases + }, + } + + Ok(vec![]) + }, + RoomAction::Show(field) => { + let room = store + .application + .get_joined_room(self.id()) + .ok_or(UIError::Application(fixError::NotJoined))?; + + let msg = match field { + RoomField::History => { + let visibility = room.history_visibility(); + format!("Room history visibility: {visibility}") + }, + RoomField::Name => { + match room.name() { + None => "Room has no name".into(), + Some(name) => format!("Room name: {name:?}"), + } + }, + RoomField::Topic => { + match room.topic() { + None => "Room has no topic".into(), + Some(topic) => format!("Room topic: {topic:?}"), + } + }, + RoomField::NotificationMode => { + let client = &store.application.worker.client; + let notifications = client.notification_settings().await; + let mode = + notifications.get_user_defined_room_notification_mode(self.id()).await; + + let level = match mode { + Some(RoomNotificationMode::Mute) => "mute", + Some(RoomNotificationMode::MentionsAndKeywordsOnly) => "keywords", + Some(RoomNotificationMode::AllMessages) => "all", + None => "default", + }; + + format!("Room notification level: {level:?}") + }, + RoomField::Aliases => { + let aliases = room + .alt_aliases() + .iter() + .map(OwnedRoomAliasId::to_string) + .collect::>(); + + if aliases.is_empty() { + "No alternative aliases in room".into() + } else { + format!("Alternative aliases: {}.", aliases.join(", ")) + } + }, + RoomField::CanonicalAlias => { + match room.canonical_alias() { + None => "No canonical alias for room".into(), + Some(can) => format!("Canonical alias: {can}"), + } + }, + RoomField::Tag(_) => "Cannot currently show value for a tag".into(), + RoomField::Alias(_) => { + "Cannot show a single alias; use `:room aliases show` instead.".into() + }, + }; + + let msg = InfoMessage::Pager(msg); + let act = Action::ShowInfoMessage(msg); + + Ok(vec![(act, ctx)]) + }, + } + } + + pub fn get_title(&self, store: &mut ProgramStore) -> Line { + let title = store.application.get_room_title(self.id()); + let style = Style::default().add_modifier(StyleModifier::BOLD); + let mut spans = vec![]; + + if let RoomState::Chat(chat) = self { + if chat.thread().is_some() { + spans.push("Thread in ".into()); + } + } + + spans.push(Span::styled(title, style)); + + match self.room().topic() { + Some(desc) if !desc.is_empty() => { + spans.push(" (".into()); + spans.push(desc.into()); + spans.push(")".into()); + }, + _ => {}, + } + + Line::from(spans) + } + + pub fn focus_toggle(&mut self) { + match self { + RoomState::Chat(chat) => chat.focus_toggle(), + RoomState::Space(_) => return, + } + } + + pub fn room(&self) -> &MatrixRoom { + match self { + RoomState::Chat(chat) => chat.room(), + RoomState::Space(space) => space.room(), + } + } + + pub fn id(&self) -> &RoomId { + match self { + RoomState::Chat(chat) => chat.id(), + RoomState::Space(space) => space.id(), + } + } +} + +impl Editable for RoomState { + fn editor_command( + &mut self, + act: &EditorAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + delegate!(self, w => w.editor_command(act, ctx, store)) + } +} + +impl Jumpable for RoomState { + fn jump( + &mut self, + list: PositionList, + dir: MoveDir1D, + count: usize, + ctx: &ProgramContext, + ) -> fixResult { + delegate!(self, w => w.jump(list, dir, count, ctx)) + } +} + +impl Scrollable for RoomState { + fn scroll( + &mut self, + style: &ScrollStyle, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + delegate!(self, w => w.scroll(style, ctx, store)) + } +} + +impl Promptable for RoomState { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult, fixInfo> { + delegate!(self, w => w.prompt(act, ctx, store)) + } +} + +impl TerminalCursor for RoomState { + fn get_term_cursor(&self) -> Option { + delegate!(self, w => w.get_term_cursor()) + } +} + +impl WindowOps for RoomState { + fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { + if self.room().state() == MatrixRoomState::Invited { + self.refresh_room(store); + } + + if self.room().state() == MatrixRoomState::Invited { + self.draw_invite(self.room().clone(), area, buf, store); + } + + match self { + RoomState::Chat(chat) => chat.draw(area, buf, focused, store), + RoomState::Space(space) => { + Space::new(store).focus(focused).render(area, buf, space); + }, + } + } + + fn dup(&self, store: &mut ProgramStore) -> Self { + match self { + RoomState::Chat(chat) => RoomState::Chat(chat.dup(store)), + RoomState::Space(space) => RoomState::Space(space.dup(store)), + } + } + + fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool { + match self { + RoomState::Chat(chat) => chat.close(flags, store), + RoomState::Space(space) => space.close(flags, store), + } + } + + fn write( + &mut self, + path: Option<&str>, + flags: WriteFlags, + store: &mut ProgramStore, + ) -> fixResult { + match self { + RoomState::Chat(chat) => chat.write(path, flags, store), + RoomState::Space(space) => space.write(path, flags, store), + } + } + + fn get_completions(&self) -> Option { + match self { + RoomState::Chat(chat) => chat.get_completions(), + RoomState::Space(space) => space.get_completions(), + } + } + + fn get_cursor_word(&self, style: &WordStyle) -> Option { + match self { + RoomState::Chat(chat) => chat.get_cursor_word(style), + RoomState::Space(space) => space.get_cursor_word(style), + } + } + + fn get_selected_word(&self) -> Option { + match self { + RoomState::Chat(chat) => chat.get_selected_word(), + RoomState::Space(space) => space.get_selected_word(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_room_notification_level() { + let tests = vec![ + ("mute", RoomNotificationMode::Mute), + ("mentions", RoomNotificationMode::MentionsAndKeywordsOnly), + ("keywords", RoomNotificationMode::MentionsAndKeywordsOnly), + ("all", RoomNotificationMode::AllMessages), + ]; + + for (input, expect) in tests { + let res = notification_mode(input).unwrap(); + assert_eq!(expect, res); + } + + assert!(notification_mode("invalid").is_err()); + assert!(notification_mode("not a level").is_err()); + assert!(notification_mode("@user:example.com").is_err()); + } +} diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs new file mode 100644 index 0000000..9a1d016 --- /dev/null +++ b/src/windows/room/scrollback.rs @@ -0,0 +1,1730 @@ +//! Message scrollback +use ratatui_image::Image; +use regex::Regex; + +use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId}; + +use modalkit_ratatui::{ScrollActions, TerminalCursor, WindowOps}; +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::{Modifier as StyleModifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, StatefulWidget, Widget}, +}; + +use modalkit::actions::{ + Action, + CursorAction, + EditAction, + Editable, + EditorAction, + EditorActions, + HistoryAction, + InsertTextAction, + Jumpable, + PromptAction, + Promptable, + Scrollable, + Searchable, + SelectionAction, + WindowAction, +}; +use modalkit::editing::{ + completion::CompletionList, + context::Resolve, + cursor::{CursorGroup, CursorState}, + history::HistoryList, + rope::EditRope, + store::{RegisterCell, RegisterPutFlags}, +}; +use modalkit::errors::{EditError, EditResult, UIError, UIResult}; +use modalkit::prelude::*; + +use crate::{ + base::{ + fixBufferId, + fixId, + fixInfo, + fixResult, + Need, + ProgramContext, + ProgramStore, + RoomFetchStatus, + RoomFocus, + RoomInfo, + }, + config::ApplicationSettings, + message::{Message, MessageCursor, MessageKey, Messages}, +}; + +fn no_msgs() -> EditError { + let msg = "No messages to select."; + EditError::Failure(msg.to_string()) +} + +fn nth_key_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey { + let mut end = &pos; + let iter = thread.range(..=&pos).rev().enumerate(); + + for (i, (key, _)) in iter { + end = key; + + if i >= n { + break; + } + } + + end.clone() +} + +fn nth_before(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { + nth_key_before(pos, n, thread).into() +} + +fn nth_key_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageKey { + let mut end = &pos; + let iter = thread.range(&pos..).enumerate(); + + for (i, (key, _)) in iter { + end = key; + + if i >= n { + break; + } + } + + end.clone() +} + +fn nth_after(pos: MessageKey, n: usize, thread: &Messages) -> MessageCursor { + nth_key_after(pos, n, thread).into() +} + +fn prevmsg<'a>(key: &MessageKey, thread: &'a Messages) -> Option<&'a Message> { + thread.range(..key).next_back().map(|(_, v)| v) +} + +pub struct ScrollbackState { + /// The room identifier. + room_id: OwnedRoomId, + + /// The buffer identifier used for saving marks, etc. + id: fixBufferId, + + /// The currently focused thread in this room. + thread: Option, + + /// The currently selected message in the scrollback. + cursor: MessageCursor, + + /// Contextual info about the viewport used during rendering. + viewctx: ViewportContext, + + /// The jumplist of visited messages. + jumped: HistoryList, + + /// Whether the full message should be drawn during the next render() call. + /// + /// This is used to ensure that ^E/^Y work nicely when the cursor is currently + /// on a multiline message. + show_full_on_redraw: bool, +} + +impl ScrollbackState { + pub fn new(room_id: OwnedRoomId, thread: Option) -> ScrollbackState { + let id = fixBufferId::Room(room_id.to_owned(), thread.clone(), RoomFocus::Scrollback); + let cursor = MessageCursor::default(); + let viewctx = ViewportContext::default(); + let jumped = HistoryList::default(); + let show_full_on_redraw = false; + + ScrollbackState { + room_id, + id, + thread, + cursor, + viewctx, + jumped, + show_full_on_redraw, + } + } + + pub fn goto_latest(&mut self) { + self.cursor = MessageCursor::latest(); + } + + /// Set the dimensions and placement within the terminal window for this list. + pub fn set_term_info(&mut self, area: Rect) { + self.viewctx.dimensions = (area.width as usize, area.height as usize); + } + + pub fn get_key(&self, info: &mut RoomInfo) -> Option { + self.cursor + .timestamp + .clone() + .or_else(|| self.get_thread(info)?.last_key_value().map(|kv| kv.0.clone())) + } + + pub fn get_mut<'a>(&mut self, info: &'a mut RoomInfo) -> Option<&'a mut Message> { + let thread = self.get_thread_mut(info); + + if let Some(k) = &self.cursor.timestamp { + thread.get_mut(k) + } else { + thread.last_entry().map(|o| o.into_mut()) + } + } + + pub fn thread(&self) -> Option<&OwnedEventId> { + self.thread.as_ref() + } + + pub fn get_thread<'a>(&self, info: &'a RoomInfo) -> Option<&'a Messages> { + info.get_thread(self.thread.as_deref()) + } + + pub fn get_thread_mut<'a>(&self, info: &'a mut RoomInfo) -> &'a mut Messages { + info.get_thread_mut(self.thread.clone()) + } + + pub fn messages<'a>( + &self, + range: EditRange, + info: &'a RoomInfo, + ) -> impl Iterator { + let Some(thread) = self.get_thread(info) else { + return Default::default(); + }; + + let start = range.start.to_key(thread); + let end = range.end.to_key(thread); + + let (start, end) = if let (Some(start), Some(end)) = (start, end) { + (start, end) + } else if let Some((last, _)) = thread.last_key_value() { + (last, last) + } else { + return thread.range(..); + }; + + if range.inclusive { + thread.range(start..=end) + } else { + thread.range(start..end) + } + } + + fn need_more_messages(&self, info: &RoomInfo) -> bool { + match info.fetch_id { + // Don't fetch if we've already hit the end of history. + RoomFetchStatus::Done => return false, + // Fetch at least once if we're viewing a room. + RoomFetchStatus::NotStarted => return true, + _ => {}, + } + + let first_key = self.get_thread(info).and_then(|t| t.first_key_value()).map(|(k, _)| k); + let at_top = first_key == self.viewctx.corner.timestamp.as_ref(); + + match (at_top, self.thread.as_ref()) { + (false, _) => { + // Not scrolled to top, don't fetch. + false + }, + (true, None) => { + // Scrolled to top in non-thread, fetch. + true + }, + (true, Some(thread_root)) => { + // Scrolled to top in thread, fetch until we have the thread root. + // + // Typically, if the user has entered a thread view, we should already have fetched + // all the way back to the thread root, but it is technically possible via :threads + // or when restoring a thread view in the layout at startup to not have the message + // yet. + !info.keys.contains_key(thread_root) + }, + } + } + + fn scrollview( + &mut self, + idx: MessageKey, + pos: MovePosition, + info: &RoomInfo, + settings: &ApplicationSettings, + ) { + let Some(thread) = self.get_thread(info) else { + return; + }; + + let selidx = if let Some(key) = self.cursor.to_key(thread) { + key + } else { + return; + }; + + match pos { + MovePosition::Beginning => { + self.viewctx.corner = idx.into(); + }, + MovePosition::Middle => { + let mut lines = 0; + let target = self.viewctx.get_height() / 2; + + for (key, item) in thread.range(..=&idx).rev() { + let sel = selidx == key; + let prev = prevmsg(key, thread); + let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len(); + + if key == &idx { + lines += len / 2; + } else { + lines += len; + } + + if lines >= target { + // We've moved back far enough. + self.viewctx.corner.timestamp = key.clone().into(); + self.viewctx.corner.text_row = lines - target; + break; + } + } + }, + MovePosition::End => { + let mut lines = 0; + let target = self.viewctx.get_height(); + + for (key, item) in thread.range(..=&idx).rev() { + let sel = key == selidx; + let prev = prevmsg(key, thread); + let len = item.show(prev, sel, &self.viewctx, info, settings).lines.len(); + + lines += len; + + if lines >= target { + // We've moved back far enough. + self.viewctx.corner.timestamp = key.clone().into(); + self.viewctx.corner.text_row = lines - target; + break; + } + } + }, + } + } + + fn jump_changed(&mut self) -> bool { + self.jumped.current() != &self.cursor + } + + fn push_jump(&mut self) { + self.jumped.push(self.cursor.clone()); + } + + fn shift_cursor(&mut self, info: &RoomInfo, settings: &ApplicationSettings) { + let Some(thread) = self.get_thread(info) else { + return; + }; + + let last_key = if let Some(k) = thread.last_key_value() { + k.0 + } else { + return; + }; + + let corner_key = self.viewctx.corner.timestamp.as_ref().unwrap_or(last_key); + + if self.cursor < self.viewctx.corner { + // Cursor is above the viewport; move it inside. + self.cursor = corner_key.clone().into(); + } + + // Check whether the cursor is below the viewport. + let mut lines = 0; + + let cursor_key = self.cursor.timestamp.as_ref().unwrap_or(last_key); + let mut prev = prevmsg(cursor_key, thread); + + for (idx, item) in thread.range(corner_key.clone()..) { + if idx == cursor_key { + // Cursor is already within the viewport. + break; + } + + lines += item.show(prev, false, &self.viewctx, info, settings).height().max(1); + + if lines >= self.viewctx.get_height() { + // We've reached the end of the viewport; move cursor into it. + self.cursor = idx.clone().into(); + break; + } + + prev = Some(item); + } + } + + fn _range_to(&self, cursor: MessageCursor) -> EditRange { + EditRange::inclusive(self.cursor.clone(), cursor, TargetShape::LineWise) + } + + fn movement( + &self, + pos: MessageKey, + movement: &MoveType, + count: &Count, + ctx: &ProgramContext, + info: &RoomInfo, + ) -> Option { + let count = ctx.resolve(count); + + match movement { + // These movements don't map meaningfully onto the scrollback history. + MoveType::BufferByteOffset => None, + MoveType::Column(_, _) => None, + MoveType::ItemMatch => None, + MoveType::LineColumnOffset => None, + MoveType::LinePercent => None, + MoveType::LinePos(_) => None, + MoveType::SentenceBegin(_) => None, + MoveType::ScreenFirstWord(_) => None, + MoveType::ScreenLinePos(_) => None, + MoveType::WordBegin(_, _) => None, + MoveType::WordEnd(_, _) => None, + + MoveType::BufferLineOffset => None, + MoveType::BufferLinePercent => None, + MoveType::BufferPos(MovePosition::Beginning) => { + let start = self.get_thread(info)?.first_key_value()?.0.clone(); + + Some(start.into()) + }, + MoveType::BufferPos(MovePosition::Middle) => None, + MoveType::BufferPos(MovePosition::End) => Some(MessageCursor::latest()), + MoveType::FinalNonBlank(dir) | + MoveType::FirstWord(dir) | + MoveType::Line(dir) | + MoveType::ScreenLine(dir) | + MoveType::ParagraphBegin(dir) | + MoveType::SectionBegin(dir) | + MoveType::SectionEnd(dir) => { + let thread = self.get_thread(info)?; + + match dir { + MoveDir1D::Previous => nth_before(pos, count, thread).into(), + MoveDir1D::Next => nth_after(pos, count, thread).into(), + } + }, + MoveType::ViewportPos(MovePosition::Beginning) => { + return self.viewctx.corner.timestamp.as_ref().map(|k| k.clone().into()); + }, + MoveType::ViewportPos(MovePosition::Middle) => { + // XXX: Need to calculate an accurate middle position. + return None; + }, + MoveType::ViewportPos(MovePosition::End) => { + // XXX: Need store to calculate an accurate end position. + return None; + }, + + _ => None, + } + } + + fn range_of_movement( + &self, + pos: MessageKey, + movement: &MoveType, + count: &Count, + ctx: &ProgramContext, + info: &RoomInfo, + ) -> Option> { + let other = self.movement(pos.clone(), movement, count, ctx, info)?; + + Some(EditRange::inclusive(pos.into(), other, TargetShape::LineWise)) + } + + fn range( + &self, + pos: MessageKey, + range: &RangeType, + _: bool, + count: &Count, + ctx: &ProgramContext, + info: &RoomInfo, + ) -> Option> { + match range { + RangeType::Bracketed(_, _) => None, + RangeType::Item => None, + RangeType::Quote(_) => None, + RangeType::Word(_) => None, + RangeType::XmlTag => None, + + RangeType::Buffer => { + let thread = self.get_thread(info)?; + let start = thread.first_key_value()?.0.clone(); + let end = thread.last_key_value()?.0.clone(); + + Some(EditRange::inclusive(start.into(), end.into(), TargetShape::LineWise)) + }, + RangeType::Line | RangeType::Paragraph | RangeType::Sentence => { + let thread = self.get_thread(info)?; + let count = ctx.resolve(count); + + if count == 0 { + return None; + } + + let mut end = &pos; + + for (i, (key, _)) in thread.range(&pos..).enumerate() { + if i >= count { + break; + } + + end = key; + } + + let end = end.clone().into(); + let start = pos.into(); + + Some(EditRange::inclusive(start, end, TargetShape::LineWise)) + }, + + _ => None, + } + } + + fn find_message_next( + &self, + start: MessageKey, + needle: &Regex, + mut count: usize, + info: &RoomInfo, + ) -> Option { + let thread = self.get_thread(info)?; + let mut mc = None; + + for (key, msg) in thread.range(&start..) { + if count == 0 { + break; + } + + if key == &start { + continue; + } + + if needle.is_match(msg.event.body().as_ref()) { + mc = MessageCursor::from(key.clone()).into(); + count -= 1; + } + } + + return mc; + } + + fn find_message_prev( + &self, + end: MessageKey, + needle: &Regex, + mut count: usize, + info: &RoomInfo, + ) -> (Option, bool) { + let mut mc = None; + + let Some(thread) = self.get_thread(info) else { + return (None, false); + }; + + for (key, msg) in thread.range(..&end).rev() { + if count == 0 { + break; + } + + if needle.is_match(msg.event.body().as_ref()) { + mc = MessageCursor::from(key.clone()).into(); + count -= 1; + } + } + + return (mc, count > 0); + } + + fn find_message( + &self, + key: MessageKey, + dir: MoveDir1D, + needle: &Regex, + count: usize, + info: &RoomInfo, + ) -> (Option, bool) { + match dir { + MoveDir1D::Next => (self.find_message_next(key, needle, count, info), false), + MoveDir1D::Previous => self.find_message_prev(key, needle, count, info), + } + } +} + +impl WindowOps for ScrollbackState { + fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { + Scrollback::new(store).focus(focused).render(area, buf, self) + } + + fn dup(&self, _: &mut ProgramStore) -> Self { + ScrollbackState { + room_id: self.room_id.clone(), + id: self.id.clone(), + thread: self.thread.clone(), + cursor: self.cursor.clone(), + viewctx: self.viewctx.clone(), + jumped: self.jumped.clone(), + show_full_on_redraw: false, + } + } + + fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool { + // XXX: what's the right closing behaviour for a room? + // Should write send a message? + true + } + + fn write( + &mut self, + _: Option<&str>, + flags: WriteFlags, + _: &mut ProgramStore, + ) -> fixResult { + if flags.contains(WriteFlags::FORCE) { + Ok(None) + } else { + Err(EditError::ReadOnly.into()) + } + } + + fn get_completions(&self) -> Option { + None + } + + fn get_cursor_word(&self, _: &WordStyle) -> Option { + None + } + + fn get_selected_word(&self) -> Option { + None + } +} + +impl EditorActions for ScrollbackState { + fn edit( + &mut self, + operation: &EditAction, + motion: &EditTarget, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + let thread = self.get_thread(info).ok_or_else(no_msgs)?; + let key = self.cursor.to_key(thread).ok_or_else(no_msgs)?.clone(); + + match operation { + EditAction::Motion => { + if motion.is_jumping() { + self.push_jump(); + } + + let pos = match motion { + EditTarget::CurrentPosition | EditTarget::Selection => { + return Ok(None); + }, + EditTarget::Boundary(rt, inc, term, count) => { + self.range(key, rt, *inc, count, ctx, info).map(|r| { + match term { + MoveTerminus::Beginning => r.start, + MoveTerminus::End => r.end, + } + }) + }, + EditTarget::CharJump(mark) | EditTarget::LineJump(mark) => { + let mark = ctx.resolve(mark); + let cursor = store.cursors.get_mark(self.id.clone(), mark)?; + + if let Some(mc) = MessageCursor::from_cursor(&cursor, thread) { + Some(mc) + } else { + let msg = "Failed to restore mark"; + let err = EditError::Failure(msg.into()); + + return Err(err); + } + }, + EditTarget::Motion(mt, count) => self.movement(key, mt, count, ctx, info), + EditTarget::Range(_, _, _) => { + return Err(EditError::Failure("Cannot use ranges in a list".to_string())); + }, + EditTarget::Search(SearchType::Char(_), _, _) => { + let msg = "Cannot perform character search in a list"; + let err = EditError::Failure(msg.into()); + + return Err(err); + }, + EditTarget::Search(SearchType::Regex, flip, count) => { + let count = ctx.resolve(count); + + let dir = ctx.get_search_regex_dir(); + let dir = flip.resolve(&dir); + + let lsearch = store.registers.get_last_search().to_string(); + let needle = Regex::new(lsearch.as_ref())?; + + let (mc, needs_load) = self.find_message(key, dir, &needle, count, info); + if needs_load { + store + .application + .need_load + .insert(self.room_id.clone(), Need::MESSAGES); + } + mc + }, + EditTarget::Search(SearchType::Word(_, _), _, _) => { + let msg = "Cannot perform word search in a list"; + let err = EditError::Failure(msg.into()); + + return Err(err); + }, + + _ => { + let msg = format!("Unknown editing target: {motion:?}"); + let err = EditError::Unimplemented(msg); + + return Err(err); + }, + }; + + if let Some(pos) = pos { + self.cursor = pos; + } + + self.show_full_on_redraw = true; + + return Ok(None); + }, + EditAction::Yank => { + let range = match motion { + EditTarget::CurrentPosition | EditTarget::Selection => { + Some(self._range_to(key.into())) + }, + EditTarget::Boundary(rt, inc, term, count) => { + self.range(key, rt, *inc, count, ctx, info).map(|r| { + self._range_to(match term { + MoveTerminus::Beginning => r.start, + MoveTerminus::End => r.end, + }) + }) + }, + EditTarget::CharJump(mark) | EditTarget::LineJump(mark) => { + let mark = ctx.resolve(mark); + let cursor = store.cursors.get_mark(self.id.clone(), mark)?; + + if let Some(c) = MessageCursor::from_cursor(&cursor, thread) { + self._range_to(c).into() + } else { + let msg = "Failed to restore mark"; + let err = EditError::Failure(msg.into()); + + return Err(err); + } + }, + EditTarget::Motion(mt, count) => { + self.range_of_movement(key, mt, count, ctx, info) + }, + EditTarget::Range(rt, inc, count) => { + self.range(key, rt, *inc, count, ctx, info) + }, + EditTarget::Search(SearchType::Char(_), _, _) => { + let msg = "Cannot perform character search in a list"; + let err = EditError::Failure(msg.into()); + + return Err(err); + }, + EditTarget::Search(SearchType::Regex, flip, count) => { + let count = ctx.resolve(count); + + let dir = ctx.get_search_regex_dir(); + let dir = flip.resolve(&dir); + + let lsearch = store.registers.get_last_search().to_string(); + let needle = Regex::new(lsearch.as_ref())?; + + let (mc, needs_load) = self.find_message(key, dir, &needle, count, info); + if needs_load { + store + .application + .need_load + .insert(self.room_id.to_owned(), Need::MESSAGES); + } + + mc.map(|c| self._range_to(c)) + }, + EditTarget::Search(SearchType::Word(_, _), _, _) => { + let msg = "Cannot perform word search in a list"; + let err = EditError::Failure(msg.into()); + + return Err(err); + }, + + _ => { + let msg = format!("Unknown motion: {motion:?}"); + let err = EditError::Unimplemented(msg); + + return Err(err); + }, + }; + + if let Some(range) = range { + let mut yanked = EditRope::from(""); + + for (_, msg) in self.messages(range, info) { + yanked += EditRope::from(msg.event.body()); + yanked += EditRope::from('\n'); + } + + let cell = RegisterCell::new(TargetShape::LineWise, yanked); + let register = ctx.get_register().unwrap_or(Register::Unnamed); + let mut flags = RegisterPutFlags::NONE; + + if ctx.get_register_append() { + flags |= RegisterPutFlags::APPEND; + } + + store.registers.put(®ister, cell, flags)?; + } + + return Ok(None); + }, + + // Everything else is a modifying action. + EditAction::ChangeCase(_) => Err(EditError::ReadOnly), + EditAction::ChangeNumber(_, _) => Err(EditError::ReadOnly), + EditAction::Delete => Err(EditError::ReadOnly), + EditAction::Format => Err(EditError::ReadOnly), + EditAction::Indent(_) => Err(EditError::ReadOnly), + EditAction::Join(_) => Err(EditError::ReadOnly), + EditAction::Replace(_) => Err(EditError::ReadOnly), + } + } + + fn mark( + &mut self, + name: Mark, + _: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + let info = store.application.get_room_info(self.room_id.clone()); + let thread = self.get_thread(info).ok_or_else(no_msgs)?; + let cursor = self.cursor.to_cursor(thread).ok_or_else(no_msgs)?; + store.cursors.set_mark(self.id.clone(), name, cursor); + + Ok(None) + } + + fn complete( + &mut self, + _: &CompletionType, + _: &CompletionSelection, + _: &CompletionDisplay, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult { + Err(EditError::ReadOnly) + } + + fn insert_text( + &mut self, + _: &InsertTextAction, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult { + Err(EditError::ReadOnly) + } + + fn selection_command( + &mut self, + _: &SelectionAction, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult { + Err(EditError::Failure("Cannot perform selection actions in a list".into())) + } + + fn history_command( + &mut self, + act: &HistoryAction, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult { + match act { + HistoryAction::Checkpoint => Ok(None), + HistoryAction::Undo(_) => Err(EditError::Failure("Nothing to undo".into())), + HistoryAction::Redo(_) => Err(EditError::Failure("Nothing to redo".into())), + } + } + + fn cursor_command( + &mut self, + act: &CursorAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + let info = store.application.get_room_info(self.room_id.clone()); + let thread = self.get_thread(info).ok_or_else(no_msgs)?; + + match act { + CursorAction::Close(_) => Ok(None), + CursorAction::Rotate(_, _) => Ok(None), + CursorAction::Split(_) => Ok(None), + + CursorAction::Restore(_) => { + let reg = ctx.get_register().unwrap_or(Register::UnnamedCursorGroup); + + // Get saved group. + let ngroup = store.cursors.get_group(self.id.clone(), ®)?; + + // Lists don't have groups; override current position. + if self.jump_changed() { + self.push_jump(); + } + + if let Some(mc) = MessageCursor::from_cursor(ngroup.leader.cursor(), thread) { + self.cursor = mc; + + Ok(None) + } else { + let msg = "Cannot restore position in message history"; + let err = EditError::Failure(msg.into()); + + Err(err) + } + }, + CursorAction::Save(_) => { + let reg = ctx.get_register().unwrap_or(Register::UnnamedCursorGroup); + + // Lists don't have groups; override any previously saved group. + let cursor = self.cursor.to_cursor(thread).ok_or_else(|| { + let msg = "Cannot save position in message history"; + EditError::Failure(msg.into()) + })?; + + let group = CursorGroup { + leader: CursorState::Location(cursor), + members: vec![], + }; + + store.cursors.set_group(self.id.clone(), reg, group)?; + + Ok(None) + }, + _ => Err(EditError::Unimplemented(format!("Unknown action: {act:?}"))), + } + } +} + +impl Editable for ScrollbackState { + fn editor_command( + &mut self, + act: &EditorAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + match act { + EditorAction::Cursor(act) => self.cursor_command(act, ctx, store), + EditorAction::Edit(ea, et) => self.edit(&ctx.resolve(ea), et, ctx, store), + EditorAction::History(act) => self.history_command(act, ctx, store), + EditorAction::InsertText(act) => self.insert_text(act, ctx, store), + EditorAction::Mark(name) => self.mark(ctx.resolve(name), ctx, store), + EditorAction::Selection(act) => self.selection_command(act, ctx, store), + + EditorAction::Complete(_, _, _) => { + let msg = "Nothing to complete in message scrollback"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + + _ => Err(EditError::Unimplemented(format!("Unknown action: {act:?}"))), + } + } +} + +impl Jumpable for ScrollbackState { + fn jump( + &mut self, + list: PositionList, + dir: MoveDir1D, + count: usize, + _: &ProgramContext, + ) -> UIResult { + match list { + PositionList::ChangeList => { + let msg = "No changes to jump to within the list"; + let err = UIError::Failure(msg.into()); + + Err(err) + }, + PositionList::JumpList => { + let (len, pos) = match dir { + MoveDir1D::Previous => { + if self.jumped.future_len() == 0 && self.jump_changed() { + // Push current position if this is the first jump backwards. + self.push_jump(); + } + + let plen = self.jumped.past_len(); + let pos = self.jumped.prev(count); + + (plen, pos) + }, + MoveDir1D::Next => { + let flen = self.jumped.future_len(); + let pos = self.jumped.next(count); + + (flen, pos) + }, + }; + + if len > 0 { + self.cursor = pos.clone(); + } + + Ok(count.saturating_sub(len)) + }, + } + } +} + +impl Promptable for ScrollbackState { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult, ProgramContext)>, fixInfo> { + let info = store.application.get_room_info(self.room_id.clone()); + let thread = self.get_thread(info).ok_or_else(no_msgs)?; + + let Some(key) = self.cursor.to_key(thread) else { + let msg = "No message currently selected"; + let err = EditError::Failure(msg.into()); + return Err(err); + }; + + match act { + PromptAction::Submit => { + if self.thread.is_some() { + let msg = + "You are already in a thread. Use :reply to reply to a specific message."; + let err = EditError::Failure(msg.into()); + Err(err) + } else { + let root = key.1.clone(); + let room_id = self.room_id.clone(); + let id = fixId::Room(room_id, Some(root)); + let open = WindowAction::Switch(OpenTarget::Application(id)); + Ok(vec![(open.into(), ctx.clone())]) + } + }, + PromptAction::Abort(..) => { + let msg = "Cannot abort a message."; + let err = EditError::Failure(msg.into()); + Err(err) + }, + PromptAction::Recall(..) => { + let msg = "Cannot recall previous messages."; + let err = EditError::Failure(msg.into()); + Err(err) + }, + } + } +} + +impl ScrollActions for ScrollbackState { + fn dirscroll( + &mut self, + dir: MoveDir2D, + size: ScrollSize, + count: &Count, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + let settings = &store.application.settings; + let mut corner = self.viewctx.corner.clone(); + let thread = self.get_thread(info).ok_or_else(no_msgs)?; + + let last_key = if let Some(k) = thread.last_key_value() { + k.0 + } else { + return Ok(None); + }; + + let corner_key = corner.timestamp.as_ref().unwrap_or(last_key).clone(); + let cursor_key = self.cursor.timestamp.as_ref().unwrap_or(last_key); + + let count = ctx.resolve(count); + let height = self.viewctx.get_height(); + let mut rows = match size { + ScrollSize::Cell => count, + ScrollSize::HalfPage => count.saturating_mul(height) / 2, + ScrollSize::Page => count.saturating_mul(height), + }; + + match dir { + MoveDir2D::Up => { + let first_key = thread.first_key_value().map(|f| f.0.clone()); + + for (key, item) in thread.range(..=&corner_key).rev() { + let sel = key == cursor_key; + let prev = prevmsg(key, thread); + let txt = item.show(prev, sel, &self.viewctx, info, settings); + let len = txt.height().max(1); + let max = len.saturating_sub(1); + + if key != &corner_key { + corner.text_row = max; + } + + corner.timestamp = key.clone().into(); + + if rows == 0 { + break; + } else if corner.text_row >= rows { + corner.text_row -= rows; + break; + } else if corner.timestamp == first_key { + corner.text_row = 0; + break; + } + + rows -= corner.text_row + 1; + } + }, + MoveDir2D::Down => { + let mut prev = prevmsg(&corner_key, thread); + + for (key, item) in thread.range(&corner_key..) { + let sel = key == cursor_key; + let txt = item.show(prev, sel, &self.viewctx, info, settings); + let len = txt.height().max(1); + let max = len.saturating_sub(1); + + prev = Some(item); + + if key != &corner_key { + corner.text_row = 0; + } + + corner.timestamp = key.clone().into(); + + if rows == 0 { + break; + } else if key == last_key { + corner.text_row = corner.text_row.saturating_add(rows).min(max); + break; + } else if corner.text_row >= max { + rows -= 1; + continue; + } else if corner.text_row + rows <= max { + corner.text_row += rows; + break; + } else { + rows -= len - corner.text_row; + continue; + } + } + }, + MoveDir2D::Left | MoveDir2D::Right => { + let msg = "Cannot scroll vertically in message scrollback"; + let err = EditError::Failure(msg.into()); + + return Err(err); + }, + } + + self.viewctx.corner = corner; + self.shift_cursor(info, settings); + + Ok(None) + } + + fn cursorpos( + &mut self, + pos: MovePosition, + axis: Axis, + _: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + match axis { + Axis::Horizontal => { + let msg = "Cannot scroll vertically in message scrollback"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + Axis::Vertical => { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + let settings = &store.application.settings; + let thread = self.get_thread(info).ok_or_else(no_msgs)?; + + if let Some(key) = self.cursor.to_key(thread).cloned() { + self.scrollview(key, pos, info, settings); + } + + Ok(None) + }, + } + } + + fn linepos( + &mut self, + _: MovePosition, + _: &Count, + _: &ProgramContext, + _: &mut ProgramStore, + ) -> EditResult { + let msg = "Cannot scroll in message scrollback using line numbers"; + let err = EditError::Failure(msg.into()); + + Err(err) + } +} + +impl Scrollable for ScrollbackState { + fn scroll( + &mut self, + style: &ScrollStyle, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult { + match style { + ScrollStyle::Direction2D(dir, size, count) => { + return self.dirscroll(*dir, *size, count, ctx, store); + }, + ScrollStyle::CursorPos(pos, axis) => { + return self.cursorpos(*pos, *axis, ctx, store); + }, + ScrollStyle::LinePos(pos, count) => { + return self.linepos(*pos, count, ctx, store); + }, + } + } +} + +impl Searchable for ScrollbackState { + fn search( + &mut self, + dir: MoveDirMod, + count: Count, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> UIResult { + let search = EditTarget::Search(SearchType::Regex, dir, count); + + Ok(self.edit(&EditAction::Motion, &search, ctx, store)?) + } +} + +impl TerminalCursor for ScrollbackState { + fn get_term_cursor(&self) -> Option<(u16, u16)> { + None + } +} + +fn render_jump_to_recent(area: Rect, buf: &mut Buffer, focused: bool) -> Rect { + if area.height <= 5 || area.width <= 20 { + 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); + let msg = vec![ + Span::raw("Use "), + Span::styled("G", Style::default().add_modifier(StyleModifier::BOLD)), + Span::raw(if focused { "" } else { " in scrollback" }), + Span::raw(" to jump to latest message"), + ]; + + Paragraph::new(Line::from(msg)) + .alignment(Alignment::Center) + .render(bar, buf); + + return top; +} + +pub struct Scrollback<'a> { + room_focused: bool, + focused: bool, + store: &'a mut ProgramStore, +} + +impl<'a> Scrollback<'a> { + pub fn new(store: &'a mut ProgramStore) -> Self { + Scrollback { room_focused: false, focused: false, store } + } + + /// Indicate whether the room window is currently focused, regardless of whether the scrollback + /// also is. + pub fn room_focus(mut self, focused: bool) -> Self { + self.room_focused = focused; + self + } + + /// Indicate whether the scrollback is currently focused. + pub fn focus(mut self, focused: bool) -> Self { + self.focused = focused; + self + } +} + +impl<'a> StatefulWidget for Scrollback<'a> { + type State = ScrollbackState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let info = self.store.application.rooms.get_or_default(state.room_id.clone()); + let settings = &self.store.application.settings; + let area = if state.cursor.timestamp.is_some() { + render_jump_to_recent(area, buf, self.focused) + } else { + info.render_typing(area, buf, &self.store.application.settings) + }; + + state.set_term_info(area); + + let height = state.viewctx.get_height(); + + if height == 0 { + return; + } + + let Some(thread) = state.get_thread(info) else { + return; + }; + + if state.cursor.timestamp < state.viewctx.corner.timestamp { + state.viewctx.corner = state.cursor.clone(); + } + + let cursor = &state.cursor; + let cursor_key = if let Some(k) = cursor.to_key(thread) { + k + } else { + if state.need_more_messages(info) { + self.store + .application + .need_load + .insert(state.room_id.to_owned(), Need::MESSAGES); + } + return; + }; + + let corner = &state.viewctx.corner; + let corner_key = if let Some(k) = &corner.timestamp { + k.clone() + } else { + nth_key_before(cursor_key.clone(), height, thread) + }; + + let foc = self.focused || cursor.timestamp.is_some(); + let full = std::mem::take(&mut state.show_full_on_redraw) || cursor.timestamp.is_none(); + let mut lines = vec![]; + let mut sawit = false; + let mut prev = prevmsg(&corner_key, thread); + + for (key, item) in thread.range(&corner_key..) { + let sel = key == cursor_key; + let (txt, mut msg_preview) = + item.show_with_preview(prev, foc && sel, &state.viewctx, info, settings); + + let incomplete_ok = !full || !sel; + + for (row, line) in txt.lines.into_iter().enumerate() { + if sawit && lines.len() >= height && incomplete_ok { + // Check whether we've seen the first line of the + // selected message and can fill the screen. + break; + } + + if key == &corner_key && row < corner.text_row { + // Skip rows above the viewport corner. + continue; + } + + let line_preview = match msg_preview { + // Only take the preview into the matching row number. + Some((_, _, y)) if y as usize == row => msg_preview.take(), + _ => None, + }; + + lines.push((key, row, line, line_preview)); + sawit |= sel; + } + + prev = Some(item); + } + + if lines.len() > height { + let n = lines.len() - height; + let _ = lines.drain(..n); + } + + if let Some(((ts, event_id), row, _, _)) = lines.first() { + state.viewctx.corner.timestamp = Some((*ts, event_id.clone())); + state.viewctx.corner.text_row = *row; + } + + let mut y = area.top(); + let x = area.left(); + + let mut image_previews = vec![]; + for ((_, _), _, txt, line_preview) in lines.into_iter() { + let _ = buf.set_line(x, y, &txt, area.width); + if let Some((backend, msg_x, _)) = line_preview { + image_previews.push((x + msg_x, y, backend)); + } + + y += 1; + } + // Render image previews after all text lines have been drawn, as the render might draw below the current + // line. + for (x, y, backend) in image_previews { + let image_widget = Image::new(backend); + let mut rect = backend.rect(); + rect.x = x; + rect.y = y; + // Don't render outside of scrollback area + if rect.bottom() <= area.bottom() && rect.right() <= area.right() { + image_widget.render(rect, buf); + } + } + + if self.room_focused && + settings.tunables.read_receipt_send && + state.cursor.timestamp.is_none() + { + // If the cursor is at the last message, then update the read marker. + if let Some((k, _)) = thread.last_key_value() { + info.set_receipt(settings.profile.user_id.clone(), k.1.clone()); + } + } + + // Check whether we should load older messages for this room. + if state.need_more_messages(info) { + // If the top of the screen is the older message, load more. + self.store + .application + .need_load + .insert(state.room_id.to_owned(), Need::MESSAGES); + } + + info.draw_last = self.store.application.draw_curr; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::*; + + #[tokio::test] + async fn test_search_messages() { + let room_id = TEST_ROOM1_ID.clone(); + let mut store = mock_store().await; + let mut scrollback = ScrollbackState::new(room_id.clone(), None); + let ctx = ProgramContext::default(); + + let next = MoveDirMod::Exact(MoveDir1D::Next); + let prev = MoveDirMod::Exact(MoveDir1D::Previous); + + // Search through the messages: + // + // MSG2: "helium" + // MSG3: "this\nis\na\nmultiline\nmessage" + // MSG4: "help" + // MSG5: "character" + // MSG1: "writhe" + store.registers.set_last_search("he"); + + assert_eq!(scrollback.cursor, MessageCursor::latest()); + + // Search backwards to MSG4. + scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); + + // Search backwards to MSG2. + scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); + assert_eq!( + std::mem::take(&mut store.application.need_load) + .into_iter() + .collect::>() + .is_empty(), + true, + ); + + // Can't go any further; need_load now contains the room ID. + scrollback.search(prev, 1.into(), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); + assert_eq!( + std::mem::take(&mut store.application.need_load) + .into_iter() + .collect::>(), + vec![(room_id.clone(), Need::MESSAGES)] + ); + + // Search forward twice to MSG1. + scrollback.search(next, 2.into(), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); + + // Can't go any further. + scrollback.search(next, 2.into(), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); + } + + #[tokio::test] + async fn test_movement() { + let mut store = mock_store().await; + let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone(), None); + let ctx = ProgramContext::default(); + + let prev = |n: usize| EditTarget::Motion(MoveType::Line(MoveDir1D::Previous), n.into()); + + let next = |n: usize| EditTarget::Motion(MoveType::Line(MoveDir1D::Next), n.into()); + + assert_eq!(scrollback.cursor, MessageCursor::latest()); + + scrollback.edit(&EditAction::Motion, &prev(1), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG5_KEY.clone().into()); + + scrollback.edit(&EditAction::Motion, &prev(2), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG3_KEY.clone().into()); + + scrollback.edit(&EditAction::Motion, &prev(5), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG2_KEY.clone().into()); + + scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG3_KEY.clone().into()); + + scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); + + scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG5_KEY.clone().into()); + + scrollback.edit(&EditAction::Motion, &next(1), &ctx, &mut store).unwrap(); + assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); + } + + #[tokio::test] + async fn test_dirscroll() { + let mut store = mock_store().await; + let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone(), None); + let ctx = ProgramContext::default(); + + let prev = MoveDir2D::Up; + let next = MoveDir2D::Down; + + // Skip rendering typing notices. + store.application.settings.tunables.typing_notice_display = false; + + assert_eq!(scrollback.cursor, MessageCursor::latest()); + assert_eq!(scrollback.viewctx.dimensions, (0, 0)); + assert_eq!(scrollback.viewctx.corner, MessageCursor::latest()); + + // Set a terminal width of 60, and height of 4, rendering in scrollback as: + // + // |------------------------------------------------------------| + // MSG2: | Wednesday, December 31 1969 | + // | @user2:example.com helium | + // MSG3: | @user2:example.com this | + // | is | + // | a | + // | multiline | + // | message | + // MSG4: | @user1:example.com help | + // MSG5: | @user2:example.com character | + // MSG1: | XXXday, Month NN 20XX | + // | @user1:example.com writhe | + // |------------------------------------------------------------| + let area = Rect::new(0, 0, 60, 4); + let mut buffer = Buffer::empty(area); + scrollback.draw(area, &mut buffer, true, &mut store); + + assert_eq!(scrollback.cursor, MessageCursor::latest()); + assert_eq!(scrollback.viewctx.dimensions, (60, 4)); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG4_KEY.clone(), 0)); + + // Scroll up a line at a time until we hit the first message. + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 4)); + + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 3)); + + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 2)); + + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 1)); + + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 0)); + + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG2_KEY.clone(), 1)); + + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG2_KEY.clone(), 0)); + + // Cannot scroll any further. + scrollback + .dirscroll(prev, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG2_KEY.clone(), 0)); + + // Now scroll back down one line at a time. + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG2_KEY.clone(), 1)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 0)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 1)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 2)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 3)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 4)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG4_KEY.clone(), 0)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG5_KEY.clone(), 0)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG1_KEY.clone(), 0)); + + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG1_KEY.clone(), 1)); + + // Cannot scroll down any further. + scrollback + .dirscroll(next, ScrollSize::Cell, &1.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG1_KEY.clone(), 1)); + + // Scroll up two Pages (eight lines). + scrollback + .dirscroll(prev, ScrollSize::Page, &2.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 0)); + + // Scroll down two HalfPages (four lines). + scrollback + .dirscroll(next, ScrollSize::HalfPage, &2.into(), &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 4)); + } + + #[tokio::test] + async fn test_cursorpos() { + let mut store = mock_store().await; + let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone(), None); + let ctx = ProgramContext::default(); + + // Skip rendering typing notices. + store.application.settings.tunables.typing_notice_display = false; + + // Set a terminal width of 60, and height of 3, rendering in scrollback as: + // + // |------------------------------------------------------------| + // MSG2: | Wednesday, December 31 1969 | + // | @user2:example.com helium | + // MSG3: | @user2:example.com this | + // | is | + // | a | + // | multiline | + // | message | + // MSG4: | @user1:example.com help | + // MSG5: | @user2:example.com character | + // MSG1: | XXXday, Month NN 20XX | + // | @user1:example.com writhe | + // |------------------------------------------------------------| + let area = Rect::new(0, 0, 60, 3); + let mut buffer = Buffer::empty(area); + scrollback.cursor = MSG4_KEY.clone().into(); + scrollback.draw(area, &mut buffer, true, &mut store); + + assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); + assert_eq!(scrollback.viewctx.dimensions, (60, 3)); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 3)); + + // Scroll so that the cursor is at the top of the screen. + scrollback + .cursorpos(MovePosition::Beginning, Axis::Vertical, &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG4_KEY.clone(), 0)); + + // Scroll so that the cursor is at the bottom of the screen. + scrollback + .cursorpos(MovePosition::End, Axis::Vertical, &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 3)); + + // Scroll so that the cursor is in the middle of the screen. + scrollback + .cursorpos(MovePosition::Middle, Axis::Vertical, &ctx, &mut store) + .unwrap(); + assert_eq!(scrollback.cursor, MSG4_KEY.clone().into()); + assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 4)); + } +} diff --git a/src/windows/room/space.rs b/src/windows/room/space.rs new file mode 100644 index 0000000..cf10e7f --- /dev/null +++ b/src/windows/room/space.rs @@ -0,0 +1,164 @@ +//! Window for Matrix spaces +use std::ops::{Deref, DerefMut}; +use std::time::{Duration, Instant}; + +use matrix_sdk::{ + room::Room as MatrixRoom, + ruma::{OwnedRoomId, RoomId}, +}; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + text::{Line, Span, Text}, + widgets::StatefulWidget, +}; + +use modalkit_ratatui::{ + list::{List, ListState}, + TermOffset, + TerminalCursor, + WindowOps, +}; + +use crate::base::{fixBufferId, fixInfo, ProgramStore, RoomFocus}; + +use crate::windows::{room_fields_cmp, RoomItem}; + +const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5); + +/// State needed for rendering [Space]. +pub struct SpaceState { + room_id: OwnedRoomId, + room: MatrixRoom, + list: ListState, + last_fetch: Option, +} + +impl SpaceState { + pub fn new(room: MatrixRoom) -> Self { + let room_id = room.room_id().to_owned(); + let content = fixBufferId::Room(room_id.clone(), None, RoomFocus::Scrollback); + let list = ListState::new(content, vec![]); + let last_fetch = None; + + SpaceState { room_id, room, list, last_fetch } + } + + pub fn refresh_room(&mut self, store: &mut ProgramStore) { + if let Some(room) = store.application.worker.client.get_room(self.id()) { + self.room = room; + } + } + + pub fn room(&self) -> &MatrixRoom { + &self.room + } + + pub fn id(&self) -> &RoomId { + &self.room_id + } + + pub fn dup(&self, store: &mut ProgramStore) -> Self { + SpaceState { + room_id: self.room_id.clone(), + room: self.room.clone(), + list: self.list.dup(store), + last_fetch: self.last_fetch, + } + } +} + +impl TerminalCursor for SpaceState { + fn get_term_cursor(&self) -> Option { + self.list.get_term_cursor() + } +} + +impl Deref for SpaceState { + type Target = ListState; + + fn deref(&self) -> &Self::Target { + &self.list + } +} + +impl DerefMut for SpaceState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.list + } +} + +/// [StatefulWidget] for Matrix spaces. +pub struct Space<'a> { + focused: bool, + store: &'a mut ProgramStore, +} + +impl<'a> Space<'a> { + pub fn new(store: &'a mut ProgramStore) -> Self { + Space { focused: false, store } + } + + pub fn focus(mut self, focused: bool) -> Self { + self.focused = focused; + self + } +} + +impl<'a> StatefulWidget for Space<'a> { + type State = SpaceState; + + fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) { + let mut empty_message = None; + let need_fetch = match state.last_fetch { + Some(i) => i.elapsed() >= SPACE_HIERARCHY_DEBOUNCE, + None => true, + }; + + if need_fetch { + let res = self.store.application.worker.space_members(state.room_id.clone()); + + match res { + Ok(members) => { + let mut items = members + .into_iter() + .filter_map(|id| { + let (room, _, tags) = + self.store.application.worker.get_room(id.clone()).ok()?; + let room_info = std::sync::Arc::new((room, tags)); + + if id != state.room_id { + Some(RoomItem::new(room_info, self.store)) + } else { + None + } + }) + .collect::>(); + let fields = &self.store.application.settings.tunables.sort.rooms; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); + + state.list.set(items); + state.last_fetch = Some(Instant::now()); + }, + Err(e) => { + let lines = vec![ + Line::from("Unable to fetch space room hierarchy:"), + Span::styled(e.to_string(), Style::default().fg(Color::Red)).into(), + ]; + + empty_message = Text::from(lines).into(); + }, + } + } + + let mut list = List::new(self.store).focus(self.focused); + + if let Some(text) = empty_message { + list = list.empty_message(text); + } + + list.render(area, buffer, &mut state.list) + } +} diff --git a/src/windows/welcome.md b/src/windows/welcome.md new file mode 100644 index 0000000..96c3df1 --- /dev/null +++ b/src/windows/welcome.md @@ -0,0 +1,46 @@ +# Welcome to fix! + +## Useful Keybindings + +- `` will send a typed message +- `^V^J` can be used in Insert mode to enter a newline without submitting +- `O`/`o` can be used to insert blank lines before and after the cursor line +- `^Wm` can be used to toggle whether the message bar or scrollback is selected +- `^Wz` can be used to toggle whether the current window takes up the full screen + +## Room Commands + +- `:dms` will open a list of direct messages +- `:rooms` will open a list of joined rooms +- `:chats` will open a list containing both direct messages and rooms +- `:members` will open a list of members for the currently focused room or space +- `:spaces` will open a list of joined spaces +- `:join` can be used to switch to join a new room or start a direct message +- `:split` and `:vsplit` can be used to open rooms in a new window + +## Verification Commands + +The `:verify` command has several different subcommands for working with +verification requests. When used without any arguments, it will take you to a +list of current verifications, where you can see and compare the Emoji. + +The different subcommands are: + +- `:verify request USERNAME` will send a verification request to a user +- `:verify confirm USERNAME/DEVICE` will confirm a verification +- `:verify mismatch USERNAME/DEVICE` will cancel a verification where the Emoji don't match +- `:verify cancel USERNAME/DEVICE` will cancel a verification + +## Other Useful Commands + +- `:welcome` will take you back to this screen + +## Additional Configuration + +You can customize fix in your `$CONFIG_DIR/fix/config.toml` file, where +`$CONFIG_DIR` is your system's per-user configuration directory. For example, +this is typically `~/.config/fix/config.toml` on systems that use the XDG +Base Directory Specification. + +See the manual pages or for more details on how to +further configure or use fix. diff --git a/src/windows/welcome.rs b/src/windows/welcome.rs new file mode 100644 index 0000000..f33abdf --- /dev/null +++ b/src/windows/welcome.rs @@ -0,0 +1,84 @@ +//! Welcome Window +use std::ops::{Deref, DerefMut}; + +use ratatui::{buffer::Buffer, layout::Rect}; + +use modalkit_ratatui::{textbox::TextBoxState, TermOffset, TerminalCursor, WindowOps}; + +use modalkit::editing::completion::CompletionList; +use modalkit::prelude::*; + +use crate::base::{fixBufferId, fixInfo, fixResult, ProgramStore}; + +const WELCOME_TEXT: &str = include_str!("welcome.md"); + +pub struct WelcomeState { + tbox: TextBoxState, +} + +impl WelcomeState { + pub fn new(store: &mut ProgramStore) -> Self { + let buf = store.buffers.load_str(fixBufferId::Welcome, WELCOME_TEXT); + let mut tbox = TextBoxState::new(buf); + tbox.set_readonly(true); + + WelcomeState { tbox } + } +} + +impl Deref for WelcomeState { + type Target = TextBoxState; + + fn deref(&self) -> &Self::Target { + return &self.tbox; + } +} + +impl DerefMut for WelcomeState { + fn deref_mut(&mut self) -> &mut Self::Target { + return &mut self.tbox; + } +} + +impl TerminalCursor for WelcomeState { + fn get_term_cursor(&self) -> Option { + self.tbox.get_term_cursor() + } +} + +impl WindowOps for WelcomeState { + fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) { + self.tbox.draw(area, buf, focused, store) + } + + fn dup(&self, store: &mut ProgramStore) -> Self { + let tbox = self.tbox.dup(store); + + WelcomeState { tbox } + } + + fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool { + self.tbox.close(flags, store) + } + + fn write( + &mut self, + path: Option<&str>, + flags: WriteFlags, + store: &mut ProgramStore, + ) -> fixResult { + self.tbox.write(path, flags, store) + } + + fn get_completions(&self) -> Option { + self.tbox.get_completions() + } + + fn get_cursor_word(&self, style: &WordStyle) -> Option { + self.tbox.get_cursor_word(style) + } + + fn get_selected_word(&self) -> Option { + self.tbox.get_selected_word() + } +} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..54074d5 --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,1471 @@ +//! # Async Matrix Client Worker +//! +//! The worker thread handles asynchronous work, and can receive messages from the main thread that +//! block on a reply from the async worker. +use std::collections::HashMap; +use std::convert::TryFrom; +use std::fmt::{Debug, Formatter}; +use std::ops::{Deref, DerefMut}; +use std::str::FromStr; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use futures::{stream::FuturesUnordered, StreamExt}; +use gethostname::gethostname; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; +use tokio::sync::Semaphore; +use tokio::task::JoinHandle; +use tracing::{error, warn}; +use url::Url; + +use matrix_sdk::{ + config::{RequestConfig, SyncSettings}, + encryption::verification::{SasVerification, Verification}, + encryption::{BackupDownloadStrategy, EncryptionSettings}, + event_handler::Ctx, + matrix_auth::MatrixSession, + reqwest, + room::{Messages, MessagesOptions, Room as MatrixRoom, RoomMember}, + ruma::{ + api::client::{ + filter::{FilterDefinition, LazyLoadOptions, RoomEventFilter, RoomFilter}, + room::create_room::v3::{CreationContent, Request as CreateRoomRequest, RoomPreset}, + room::Visibility, + space::get_hierarchy::v1::Request as SpaceHierarchyRequest, + }, + assign, + events::{ + key::verification::{ + done::{OriginalSyncKeyVerificationDoneEvent, ToDeviceKeyVerificationDoneEvent}, + key::{OriginalSyncKeyVerificationKeyEvent, ToDeviceKeyVerificationKeyEvent}, + request::ToDeviceKeyVerificationRequestEvent, + start::{OriginalSyncKeyVerificationStartEvent, ToDeviceKeyVerificationStartEvent}, + VerificationMethod, + }, + presence::PresenceEvent, + reaction::ReactionEventContent, + receipt::ReceiptType, + receipt::{ReceiptEventContent, ReceiptThread}, + room::{ + encryption::RoomEncryptionEventContent, + member::OriginalSyncRoomMemberEvent, + message::{MessageType, RoomMessageEventContent}, + name::RoomNameEventContent, + redaction::OriginalSyncRoomRedactionEvent, + }, + tag::Tags, + typing::SyncTypingEvent, + AnyInitialStateEvent, + AnyMessageLikeEvent, + AnyTimelineEvent, + EmptyStateKey, + InitialStateEvent, + SyncEphemeralRoomEvent, + SyncMessageLikeEvent, + SyncStateEvent, + }, + room::RoomType, + serde::Raw, + EventEncryptionAlgorithm, + EventId, + OwnedEventId, + OwnedRoomId, + OwnedRoomOrAliasId, + OwnedUserId, + RoomId, + RoomVersionId, + }, + Client, + ClientBuildError, + DisplayName, + Error as MatrixError, + RoomMemberships, +}; + +use modalkit::errors::UIError; +use modalkit::prelude::{EditInfo, InfoMessage}; + +use crate::base::Need; +use crate::notifications::register_notifications; +use crate::{ + base::{ + AsyncProgramStore, + ChatStore, + CreateRoomFlags, + CreateRoomType, + fixError, + fixResult, + ProgramStore, + RoomFetchStatus, + RoomInfo, + VerifyAction, + }, + ApplicationSettings, +}; + +const DEFAULT_ENCRYPTION_SETTINGS: EncryptionSettings = EncryptionSettings { + auto_enable_cross_signing: true, + auto_enable_backups: true, + backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure, +}; + +const fix_DEVICE_NAME: &str = "fix"; +const fix_USER_AGENT: &str = "fix"; +const MIN_MSG_LOAD: u32 = 50; + +type MessageFetchResult = + fixResult<(Option, Vec<(AnyMessageLikeEvent, Vec)>)>; + +fn initial_devname() -> String { + format!("{} on {}", fix_DEVICE_NAME, gethostname().to_string_lossy()) +} + +async fn is_direct(room: &MatrixRoom) -> bool { + room.deref().is_direct().await.unwrap_or_default() +} + +pub async fn create_room( + client: &Client, + room_alias_name: Option, + rt: CreateRoomType, + flags: CreateRoomFlags, +) -> fixResult { + let mut creation_content = None; + let mut initial_state = vec![]; + let mut is_direct = false; + let mut preset = None; + let mut invite = vec![]; + + let visibility = if flags.contains(CreateRoomFlags::PUBLIC) { + Visibility::Public + } else { + Visibility::Private + }; + + match rt { + CreateRoomType::Direct(user) => { + invite.push(user); + is_direct = true; + preset = Some(RoomPreset::TrustedPrivateChat); + }, + CreateRoomType::Space => { + let mut cc = CreationContent::new(); + cc.room_type = Some(RoomType::Space); + + let raw_cc = Raw::new(&cc).map_err(fixError::from)?; + creation_content = Some(raw_cc); + }, + CreateRoomType::Room => {}, + } + + // Set up encryption. + if flags.contains(CreateRoomFlags::ENCRYPTED) { + // XXX: Once matrix-sdk uses ruma 0.8, then this can skip the cast. + let algo = EventEncryptionAlgorithm::MegolmV1AesSha2; + let content = RoomEncryptionEventContent::new(algo); + let encr = InitialStateEvent { content, state_key: EmptyStateKey }; + let encr_raw = Raw::new(&encr).map_err(fixError::from)?; + let encr_raw = encr_raw.cast::(); + initial_state.push(encr_raw); + } + + let request = assign!(CreateRoomRequest::new(), { + room_alias_name, + creation_content, + initial_state, + invite, + is_direct, + visibility, + preset, + }); + + let resp = client.create_room(request).await.map_err(fixError::from)?; + + if is_direct { + if let Some(room) = client.get_room(resp.room_id()) { + room.set_is_direct(true).await.map_err(fixError::from)?; + } else { + error!( + room_id = resp.room_id().as_str(), + "Couldn't set is_direct for new direct message room" + ); + } + } + + return Ok(resp.room_id().to_owned()); +} + +async fn update_event_receipts(info: &mut RoomInfo, room: &MatrixRoom, event_id: &EventId) { + let receipts = match room + .load_event_receipts(ReceiptType::Read, ReceiptThread::Main, event_id) + .await + { + Ok(receipts) => receipts, + Err(e) => { + tracing::warn!(?event_id, "failed to get event receipts: {e}"); + return; + }, + }; + + for (user_id, _) in receipts { + info.set_receipt(user_id, event_id.to_owned()); + } +} + +#[derive(Debug)] +enum Plan { + Messages(OwnedRoomId, Option), + Members(OwnedRoomId), +} + +async fn load_plans(store: &AsyncProgramStore) -> Vec { + let mut locked = store.lock().await; + let ChatStore { need_load, rooms, .. } = &mut locked.application; + let mut plan = Vec::with_capacity(need_load.rooms() * 2); + + for (room_id, mut need) in std::mem::take(need_load).into_iter() { + if need.contains(Need::MESSAGES) { + let info = rooms.get_or_default(room_id.clone()); + + if !info.recently_fetched() && !info.fetching { + info.fetch_last = Instant::now().into(); + info.fetching = true; + + let fetch_id = match &info.fetch_id { + RoomFetchStatus::Done => continue, + RoomFetchStatus::HaveMore(fetch_id) => Some(fetch_id.clone()), + RoomFetchStatus::NotStarted => None, + }; + + plan.push(Plan::Messages(room_id.to_owned(), fetch_id)); + need.remove(Need::MESSAGES); + } + } + if need.contains(Need::MEMBERS) { + plan.push(Plan::Members(room_id.to_owned())); + need.remove(Need::MEMBERS); + } + if !need.is_empty() { + need_load.insert(room_id, need); + } + } + + return plan; +} + +async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permits: &Semaphore) { + let permit = permits.acquire().await; + match plan { + Plan::Messages(room_id, fetch_id) => { + let limit = MIN_MSG_LOAD; + let client = client.clone(); + let store_clone = store.clone(); + + let res = load_older_one(&client, &room_id, fetch_id, limit).await; + let mut locked = store.lock().await; + load_insert(room_id, res, locked.deref_mut(), store_clone); + }, + Plan::Members(room_id) => { + let res = members_load(client, &room_id).await; + let mut locked = store.lock().await; + members_insert(room_id, res, locked.deref_mut()); + }, + } + drop(permit); +} + +async fn load_older_one( + client: &Client, + room_id: &RoomId, + fetch_id: Option, + limit: u32, +) -> MessageFetchResult { + if let Some(room) = client.get_room(room_id) { + let mut opts = match &fetch_id { + Some(id) => MessagesOptions::backward().from(id.as_str()), + None => MessagesOptions::backward(), + }; + opts.limit = limit.into(); + + let Messages { end, chunk, .. } = room.messages(opts).await.map_err(fixError::from)?; + + let mut msgs = vec![]; + + for ev in chunk.into_iter() { + let msg = match ev.event.deserialize() { + Ok(AnyTimelineEvent::MessageLike(msg)) => msg, + Ok(AnyTimelineEvent::State(_)) => continue, + Err(_) => continue, + }; + + let event_id = msg.event_id(); + let receipts = match room + .load_event_receipts(ReceiptType::Read, ReceiptThread::Main, event_id) + .await + { + Ok(receipts) => receipts.into_iter().map(|(u, _)| u).collect(), + Err(e) => { + tracing::warn!(?event_id, "failed to get event receipts: {e}"); + vec![] + }, + }; + + msgs.push((msg, receipts)); + } + + Ok((end, msgs)) + } else { + Err(fixError::UnknownRoom(room_id.to_owned()).into()) + } +} + +fn load_insert( + room_id: OwnedRoomId, + res: MessageFetchResult, + locked: &mut ProgramStore, + store: AsyncProgramStore, +) { + let ChatStore { presences, rooms, worker, picker, settings, .. } = &mut locked.application; + let info = rooms.get_or_default(room_id.clone()); + info.fetching = false; + let client = &worker.client; + + match res { + Ok((fetch_id, msgs)) => { + for (msg, receipts) in msgs.into_iter() { + let sender = msg.sender().to_owned(); + let _ = presences.get_or_default(sender); + + for user_id in receipts { + info.set_receipt(user_id, msg.event_id().to_owned()); + } + + match msg { + AnyMessageLikeEvent::RoomEncrypted(msg) => { + info.insert_encrypted(msg); + }, + AnyMessageLikeEvent::RoomMessage(msg) => { + info.insert_with_preview( + room_id.clone(), + store.clone(), + *picker, + msg, + settings, + client.media(), + ); + }, + AnyMessageLikeEvent::Reaction(ev) => { + info.insert_reaction(ev); + }, + _ => continue, + } + } + + info.fetch_id = fetch_id.map_or(RoomFetchStatus::Done, RoomFetchStatus::HaveMore); + }, + Err(e) => { + warn!(room_id = room_id.as_str(), err = e.to_string(), "Failed to load older messages"); + + // Wait and try again. + locked.application.need_load.insert(room_id, Need::MESSAGES); + }, + } +} + +async fn load_older(client: &Client, store: &AsyncProgramStore) -> usize { + // This is an arbitrary limit on how much work we do in parallel to avoid + // spawning too many tasks at startup and overwhelming the client. We + // should normally only surpass this limit at startup when doing an initial. + // fetch for each room. + const LIMIT: usize = 15; + + // Plans are run in parallel. Any room *may* have several plans. + let plans = load_plans(store).await; + let permits = Semaphore::new(LIMIT); + + plans + .into_iter() + .map(|plan| run_plan(client, store, plan, &permits)) + .collect::>() + .count() + .await +} + +async fn members_load(client: &Client, room_id: &RoomId) -> fixResult> { + if let Some(room) = client.get_room(room_id) { + Ok(room + .members_no_sync(RoomMemberships::all()) + .await + .map_err(fixError::from)?) + } else { + Err(fixError::UnknownRoom(room_id.to_owned()).into()) + } +} + +fn members_insert( + room_id: OwnedRoomId, + res: fixResult>, + store: &mut ProgramStore, +) { + if let Ok(members) = res { + let ChatStore { rooms, .. } = &mut store.application; + let info = rooms.get_or_default(room_id); + + for member in members { + let user_id = member.user_id(); + let display_name = + member.display_name().map_or(user_id.to_string(), |str| str.to_string()); + info.display_names.insert(user_id.to_owned(), display_name); + } + } + // else ??? +} + +async fn load_older_forever(client: &Client, store: &AsyncProgramStore) { + // Load any pending older messages or members every 2 seconds. + let mut interval = tokio::time::interval(Duration::from_secs(2)); + + loop { + interval.tick().await; + load_older(client, store).await; + } +} + +async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) { + let mut names = vec![]; + + let mut spaces = vec![]; + let mut rooms = vec![]; + let mut dms = vec![]; + + for room in client.invited_rooms().into_iter() { + let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string(); + let tags = room.tags().await.unwrap_or_default(); + + names.push((room.room_id().to_owned(), name)); + + if is_direct(&room).await { + dms.push(Arc::new((room, tags))); + } else if room.is_space() { + spaces.push(Arc::new((room, tags))); + } else { + rooms.push(Arc::new((room, tags))); + } + } + + for room in client.joined_rooms().into_iter() { + let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string(); + let tags = room.tags().await.unwrap_or_default(); + + names.push((room.room_id().to_owned(), name)); + + if is_direct(&room).await { + dms.push(Arc::new((room, tags))); + } else if room.is_space() { + spaces.push(Arc::new((room, tags))); + } else { + rooms.push(Arc::new((room, tags))); + } + } + + let mut locked = store.lock().await; + locked.application.sync_info.spaces = spaces; + locked.application.sync_info.rooms = rooms; + locked.application.sync_info.dms = dms; + + for (room_id, name) in names { + locked.application.set_room_name(&room_id, &name); + } +} + +async fn refresh_rooms_forever(client: &Client, store: &AsyncProgramStore) { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + + loop { + refresh_rooms(client, store).await; + interval.tick().await; + } +} + +async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) { + let mut interval = tokio::time::interval(Duration::from_secs(2)); + let mut sent = HashMap::::default(); + + loop { + interval.tick().await; + + let locked = store.lock().await; + let user_id = &locked.application.settings.profile.user_id; + let updates = client + .joined_rooms() + .into_iter() + .filter_map(|room| { + let room_id = room.room_id().to_owned(); + let info = locked.application.rooms.get(&room_id)?; + let new_receipt = info.get_receipt(user_id)?; + let old_receipt = sent.get(&room_id); + if Some(new_receipt) != old_receipt { + Some((room_id, new_receipt.clone())) + } else { + None + } + }) + .collect::>(); + drop(locked); + + for (room_id, new_receipt) in updates { + use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType; + + let Some(room) = client.get_room(&room_id) else { + continue; + }; + + match room + .send_single_receipt( + ReceiptType::Read, + ReceiptThread::Unthreaded, + new_receipt.clone(), + ) + .await + { + Ok(()) => { + sent.insert(room_id, new_receipt); + }, + Err(e) => tracing::warn!(?room_id, "Failed to set read receipt: {e}"), + } + } + } +} + +pub async fn do_first_sync(client: &Client, store: &AsyncProgramStore) -> Result<(), MatrixError> { + // Perform an initial, lazily-loaded sync. + let mut room = RoomEventFilter::default(); + room.lazy_load_options = LazyLoadOptions::Enabled { include_redundant_members: false }; + + let mut room_ev = RoomFilter::default(); + room_ev.state = room; + + let mut filter = FilterDefinition::default(); + filter.room = room_ev; + + let settings = SyncSettings::new().filter(filter.into()); + + client.sync_once(settings).await?; + + // Populate sync_info with our initial set of rooms/dms/spaces. + refresh_rooms(client, store).await; + + // Insert Need::Messages to fetch accurate recent timestamps in the background. + let mut locked = store.lock().await; + let ChatStore { sync_info, need_load, .. } = &mut locked.application; + + for room in sync_info.rooms.iter() { + let room_id = room.as_ref().0.room_id().to_owned(); + need_load.insert(room_id, Need::MESSAGES); + } + + for room in sync_info.dms.iter() { + let room_id = room.as_ref().0.room_id().to_owned(); + need_load.insert(room_id, Need::MESSAGES); + } + + Ok(()) +} + +#[derive(Debug)] +pub enum LoginStyle { + SessionRestore(MatrixSession), + Password(String), + SingleSignOn, +} + +pub struct ClientResponse(Receiver); +pub struct ClientReply(SyncSender); + +impl ClientResponse { + fn recv(self) -> T { + self.0.recv().expect("failed to receive response from client thread") + } +} + +impl ClientReply { + fn send(self, t: T) { + self.0.send(t).unwrap(); + } +} + +fn oneshot() -> (ClientReply, ClientResponse) { + let (tx, rx) = sync_channel(1); + let reply = ClientReply(tx); + let response = ClientResponse(rx); + + return (reply, response); +} + +pub type FetchedRoom = (MatrixRoom, DisplayName, Option); + +pub enum WorkerTask { + Init(AsyncProgramStore, ClientReply<()>), + Login(LoginStyle, ClientReply>), + Logout(String, ClientReply>), + GetInviter(MatrixRoom, ClientReply>>), + GetRoom(OwnedRoomId, ClientReply>), + JoinRoom(String, ClientReply>), + Members(OwnedRoomId, ClientReply>>), + SpaceMembers(OwnedRoomId, ClientReply>>), + TypingNotice(OwnedRoomId), + Verify(VerifyAction, SasVerification, ClientReply>), + VerifyRequest(OwnedUserId, ClientReply>), +} + +impl Debug for WorkerTask { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + WorkerTask::Init(_, _) => { + f.debug_tuple("WorkerTask::Init") + .field(&format_args!("_")) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::Login(style, _) => { + f.debug_tuple("WorkerTask::Login") + .field(style) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::Logout(user_id, _) => { + f.debug_tuple("WorkerTask::Logout").field(user_id).finish() + }, + WorkerTask::GetInviter(invite, _) => { + f.debug_tuple("WorkerTask::GetInviter").field(invite).finish() + }, + WorkerTask::GetRoom(room_id, _) => { + f.debug_tuple("WorkerTask::GetRoom") + .field(room_id) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::JoinRoom(s, _) => { + f.debug_tuple("WorkerTask::JoinRoom") + .field(s) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::Members(room_id, _) => { + f.debug_tuple("WorkerTask::Members") + .field(room_id) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::SpaceMembers(room_id, _) => { + f.debug_tuple("WorkerTask::SpaceMembers") + .field(room_id) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::TypingNotice(room_id) => { + f.debug_tuple("WorkerTask::TypingNotice").field(room_id).finish() + }, + WorkerTask::Verify(act, sasv1, _) => { + f.debug_tuple("WorkerTask::Verify") + .field(act) + .field(sasv1) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::VerifyRequest(user_id, _) => { + f.debug_tuple("WorkerTask::VerifyRequest") + .field(user_id) + .field(&format_args!("_")) + .finish() + }, + } + } +} + +async fn create_client_inner( + homeserver: &Option, + settings: &ApplicationSettings, +) -> Result { + let req_timeout = Duration::from_secs(settings.tunables.request_timeout); + + // Set up the HTTP client. + let http = reqwest::Client::builder() + .user_agent(fix_USER_AGENT) + .timeout(req_timeout) + .pool_idle_timeout(Duration::from_secs(60)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(10)) + .build() + .unwrap(); + + let req_config = RequestConfig::new().timeout(req_timeout).retry_timeout(req_timeout); + + // Set up the Matrix client for the selected profile. + let builder = Client::builder() + .http_client(http) + .sqlite_store(settings.sqlite_dir.as_path(), None) + .request_config(req_config) + .with_encryption_settings(DEFAULT_ENCRYPTION_SETTINGS); + + let builder = if let Some(url) = homeserver { + // Use the explicitly specified homeserver. + builder.homeserver_url(url.as_str()) + } else { + // Try to discover the homeserver from the user ID. + let account = &settings.profile; + builder.server_name(account.user_id.server_name()) + }; + + builder.build().await +} + +pub async fn create_client(settings: &ApplicationSettings) -> Client { + let account = &settings.profile; + let res = match create_client_inner(&account.url, settings).await { + Err(ClientBuildError::AutoDiscovery(_)) => { + let url = format!("https://{}/", account.user_id.server_name().as_str()); + let url = Url::parse(&url).unwrap(); + create_client_inner(&Some(url), settings).await + }, + res => res, + }; + + res.expect("Failed to instantiate client") +} + +#[derive(Clone)] +pub struct Requester { + pub client: Client, + pub tx: UnboundedSender, +} + +impl Requester { + pub fn init(&self, store: AsyncProgramStore) { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::Init(store, reply)).unwrap(); + + return response.recv(); + } + + pub fn login(&self, style: LoginStyle) -> fixResult { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::Login(style, reply)).unwrap(); + + return response.recv(); + } + + pub fn logout(&self, user_id: String) -> fixResult { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::Logout(user_id, reply)).unwrap(); + + return response.recv(); + } + + pub fn get_inviter(&self, invite: MatrixRoom) -> fixResult> { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::GetInviter(invite, reply)).unwrap(); + + return response.recv(); + } + + pub fn get_room(&self, room_id: OwnedRoomId) -> fixResult { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::GetRoom(room_id, reply)).unwrap(); + + return response.recv(); + } + + pub fn join_room(&self, name: String) -> fixResult { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::JoinRoom(name, reply)).unwrap(); + + return response.recv(); + } + + pub fn members(&self, room_id: OwnedRoomId) -> fixResult> { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::Members(room_id, reply)).unwrap(); + + return response.recv(); + } + + pub fn space_members(&self, space: OwnedRoomId) -> fixResult> { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::SpaceMembers(space, reply)).unwrap(); + + return response.recv(); + } + + pub fn typing_notice(&self, room_id: OwnedRoomId) { + self.tx.send(WorkerTask::TypingNotice(room_id)).unwrap(); + } + + pub fn verify(&self, act: VerifyAction, sas: SasVerification) -> fixResult { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::Verify(act, sas, reply)).unwrap(); + + return response.recv(); + } + + pub fn verify_request(&self, user_id: OwnedUserId) -> fixResult { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::VerifyRequest(user_id, reply)).unwrap(); + + return response.recv(); + } +} + +pub struct ClientWorker { + initialized: bool, + settings: ApplicationSettings, + client: Client, + load_handle: Option>, + sync_handle: Option>, +} + +impl ClientWorker { + pub async fn spawn(client: Client, settings: ApplicationSettings) -> Requester { + let (tx, rx) = unbounded_channel(); + + let mut worker = ClientWorker { + initialized: false, + settings, + client: client.clone(), + load_handle: None, + sync_handle: None, + }; + + tokio::spawn(async move { + worker.work(rx).await; + }); + + return Requester { client, tx }; + } + + async fn work(&mut self, mut rx: UnboundedReceiver) { + loop { + let t = rx.recv().await; + + match t { + Some(task) => self.run(task).await, + None => { + break; + }, + } + } + + if let Some(handle) = self.sync_handle.take() { + handle.abort(); + } + } + + async fn run(&mut self, task: WorkerTask) { + match task { + WorkerTask::Init(store, reply) => { + assert_eq!(self.initialized, false); + self.init(store).await; + reply.send(()); + }, + WorkerTask::JoinRoom(room_id, reply) => { + assert!(self.initialized); + reply.send(self.join_room(room_id).await); + }, + WorkerTask::GetInviter(invited, reply) => { + assert!(self.initialized); + reply.send(self.get_inviter(invited).await); + }, + WorkerTask::GetRoom(room_id, reply) => { + assert!(self.initialized); + reply.send(self.get_room(room_id).await); + }, + WorkerTask::Login(style, reply) => { + assert!(self.initialized); + reply.send(self.login_and_sync(style).await); + }, + WorkerTask::Logout(user_id, reply) => { + assert!(self.initialized); + reply.send(self.logout(user_id).await); + }, + WorkerTask::Members(room_id, reply) => { + assert!(self.initialized); + reply.send(self.members(room_id).await); + }, + WorkerTask::SpaceMembers(space, reply) => { + assert!(self.initialized); + reply.send(self.space_members(space).await); + }, + WorkerTask::TypingNotice(room_id) => { + assert!(self.initialized); + self.typing_notice(room_id).await; + }, + WorkerTask::Verify(act, sas, reply) => { + assert!(self.initialized); + reply.send(self.verify(act, sas).await); + }, + WorkerTask::VerifyRequest(user_id, reply) => { + assert!(self.initialized); + reply.send(self.verify_request(user_id).await); + }, + } + } + + async fn init(&mut self, store: AsyncProgramStore) { + self.client.add_event_handler_context(store.clone()); + + let _ = self.client.add_event_handler( + |ev: SyncTypingEvent, room: MatrixRoom, store: Ctx| { + async move { + let room_id = room.room_id().to_owned(); + let mut locked = store.lock().await; + + let users = ev + .content + .user_ids + .into_iter() + .filter(|u| u != &locked.application.settings.profile.user_id) + .collect(); + + locked.application.get_room_info(room_id).set_typing(users); + } + }, + ); + + let _ = + self.client + .add_event_handler(|ev: PresenceEvent, store: Ctx| { + async move { + let mut locked = store.lock().await; + locked.application.presences.insert(ev.sender, ev.content.presence); + } + }); + + let _ = self.client.add_event_handler( + |ev: SyncStateEvent, + room: MatrixRoom, + store: Ctx| { + async move { + if let SyncStateEvent::Original(ev) = ev { + let room_id = room.room_id().to_owned(); + let room_name = Some(ev.content.name); + let mut locked = store.lock().await; + let info = locked.application.rooms.get_or_default(room_id.clone()); + info.name = room_name; + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: SyncMessageLikeEvent, + room: MatrixRoom, + client: Client, + store: Ctx| { + async move { + let room_id = room.room_id(); + + if let Some(msg) = ev.as_original() { + if let MessageType::VerificationRequest(_) = msg.content.msgtype { + if let Some(request) = client + .encryption() + .get_verification_request(ev.sender(), ev.event_id()) + .await + { + request.accept().await.expect("Failed to accept request"); + } + } + } + + let mut locked = store.lock().await; + + let sender = ev.sender().to_owned(); + let _ = locked.application.presences.get_or_default(sender); + + let ChatStore { rooms, picker, settings, .. } = &mut locked.application; + let info = rooms.get_or_default(room_id.to_owned()); + + update_event_receipts(info, &room, ev.event_id()).await; + + let full_ev = ev.into_full_event(room_id.to_owned()); + info.insert_with_preview( + room_id.to_owned(), + store.clone(), + *picker, + full_ev, + settings, + client.media(), + ); + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: SyncMessageLikeEvent, + room: MatrixRoom, + store: Ctx| { + async move { + let room_id = room.room_id(); + + let mut locked = store.lock().await; + + let sender = ev.sender().to_owned(); + let _ = locked.application.presences.get_or_default(sender); + + let info = locked.application.get_room_info(room_id.to_owned()); + update_event_receipts(info, &room, ev.event_id()).await; + info.insert_reaction(ev.into_full_event(room_id.to_owned())); + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: SyncEphemeralRoomEvent, + room: MatrixRoom, + store: Ctx| { + async move { + let room_id = room.room_id(); + + let mut locked = store.lock().await; + + let info = locked.application.get_room_info(room_id.to_owned()); + for (event_id, receipts) in ev.content.0.into_iter() { + let Some(receipts) = receipts.get(&ReceiptType::Read) else { + continue; + }; + for user_id in receipts.keys() { + info.set_receipt(user_id.to_owned(), event_id.clone()); + } + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: OriginalSyncRoomRedactionEvent, + room: MatrixRoom, + store: Ctx| { + async move { + let room_id = room.room_id(); + let room_info = room.clone_info(); + let room_version = room_info.room_version().unwrap_or(&RoomVersionId::V1); + + let mut locked = store.lock().await; + let info = locked.application.get_room_info(room_id.to_owned()); + info.redact(ev, room_version); + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: OriginalSyncRoomMemberEvent, + room: MatrixRoom, + client: Client, + store: Ctx| { + async move { + let room_id = room.room_id(); + let user_id = ev.state_key; + + let ambiguous_name = + ev.content.displayname.as_deref().unwrap_or_else(|| user_id.localpart()); + let ambiguous = client + .store() + .get_users_with_display_name(room_id, ambiguous_name) + .await + .map(|users| users.len() > 1) + .unwrap_or_default(); + + let mut locked = store.lock().await; + let info = locked.application.get_room_info(room_id.to_owned()); + + if ambiguous { + info.display_names.remove(&user_id); + } else if let Some(display) = ev.content.displayname { + info.display_names.insert(user_id, display); + } else { + info.display_names.remove(&user_id); + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: OriginalSyncKeyVerificationStartEvent, + client: Client, + store: Ctx| { + async move { + let tx_id = ev.content.relates_to.event_id.as_ref(); + + if let Some(Verification::SasV1(sas)) = + client.encryption().get_verification(&ev.sender, tx_id).await + { + sas.accept().await.unwrap(); + + store.lock().await.application.insert_sas(sas) + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: OriginalSyncKeyVerificationKeyEvent, + client: Client, + store: Ctx| { + async move { + let tx_id = ev.content.relates_to.event_id.as_ref(); + + if let Some(Verification::SasV1(sas)) = + client.encryption().get_verification(&ev.sender, tx_id).await + { + store.lock().await.application.insert_sas(sas); + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: OriginalSyncKeyVerificationDoneEvent, + client: Client, + store: Ctx| { + async move { + let tx_id = ev.content.relates_to.event_id.as_ref(); + + if let Some(Verification::SasV1(sas)) = + client.encryption().get_verification(&ev.sender, tx_id).await + { + store.lock().await.application.insert_sas(sas); + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: ToDeviceKeyVerificationRequestEvent, client: Client| { + async move { + let request = client + .encryption() + .get_verification_request(&ev.sender, &ev.content.transaction_id) + .await; + + if let Some(request) = request { + request.accept().await.unwrap(); + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: ToDeviceKeyVerificationStartEvent, + client: Client, + store: Ctx| { + async move { + let tx_id = ev.content.transaction_id; + + if let Some(Verification::SasV1(sas)) = + client.encryption().get_verification(&ev.sender, tx_id.as_ref()).await + { + sas.accept().await.unwrap(); + + store.lock().await.application.insert_sas(sas); + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: ToDeviceKeyVerificationKeyEvent, client: Client, store: Ctx| { + async move { + let tx_id = ev.content.transaction_id; + + if let Some(Verification::SasV1(sas)) = + client.encryption().get_verification(&ev.sender, tx_id.as_ref()).await + { + store.lock().await.application.insert_sas(sas); + } + } + }, + ); + + let _ = self.client.add_event_handler( + |ev: ToDeviceKeyVerificationDoneEvent, + client: Client, + store: Ctx| { + async move { + let tx_id = ev.content.transaction_id; + + if let Some(Verification::SasV1(sas)) = + client.encryption().get_verification(&ev.sender, tx_id.as_ref()).await + { + store.lock().await.application.insert_sas(sas); + } + } + }, + ); + + self.load_handle = tokio::spawn({ + let client = self.client.clone(); + let settings = self.settings.clone(); + + async move { + while !client.logged_in() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let load = load_older_forever(&client, &store); + let rcpt = send_receipts_forever(&client, &store); + let room = refresh_rooms_forever(&client, &store); + let notifications = register_notifications(&client, &settings, &store); + let ((), (), (), ()) = tokio::join!(load, rcpt, room, notifications); + } + }) + .into(); + + self.initialized = true; + } + + async fn login_and_sync(&mut self, style: LoginStyle) -> fixResult { + let client = self.client.clone(); + + match style { + LoginStyle::SessionRestore(session) => { + client.restore_session(session).await.map_err(fixError::from)?; + }, + LoginStyle::Password(password) => { + let resp = client + .matrix_auth() + .login_username(&self.settings.profile.user_id, &password) + .initial_device_display_name(initial_devname().as_str()) + .send() + .await + .map_err(fixError::from)?; + let session = MatrixSession::from(&resp); + self.settings.write_session(session)?; + }, + LoginStyle::SingleSignOn => { + let resp = client + .matrix_auth() + .login_sso(|url| { + let opened = format!( + "The following URL should have been opened in your browser:\n {url}" + ); + + async move { + tokio::task::spawn_blocking(move || open::that(url)); + println!("\n{opened}\n"); + Ok(()) + } + }) + .initial_device_display_name(initial_devname().as_str()) + .send() + .await + .map_err(fixError::from)?; + + let session = MatrixSession::from(&resp); + self.settings.write_session(session)?; + }, + } + + self.sync_handle = tokio::spawn(async move { + loop { + let settings = SyncSettings::default(); + + let _ = client.sync(settings).await; + } + }) + .into(); + + Ok(Some(InfoMessage::from("* Successfully logged in!"))) + } + + async fn logout(&mut self, user_id: String) -> fixResult { + // Verify that the user is logging out of the correct profile. + let curr = self.settings.profile.user_id.as_str(); + + if user_id != curr { + let msg = format!("Incorrect user ID (currently logged in as {curr})"); + let err = UIError::Failure(msg); + + return Err(err); + } + + // Send the logout request. + if let Err(e) = self.client.matrix_auth().logout().await { + let msg = format!("Failed to logout: {e}"); + let err = UIError::Failure(msg); + + return Err(err); + } + + // Remove the session.json file. + std::fs::remove_file(&self.settings.session_json)?; + + Ok(Some(InfoMessage::from("Sucessfully logged out"))) + } + + async fn direct_message(&mut self, user: OwnedUserId) -> fixResult { + for room in self.client.rooms() { + if !is_direct(&room).await { + continue; + } + + if room.get_member(user.as_ref()).await.map_err(fixError::from)?.is_some() { + return Ok(room.room_id().to_owned()); + } + } + + let rt = CreateRoomType::Direct(user.clone()); + let flags = CreateRoomFlags::ENCRYPTED; + + create_room(&self.client, None, rt, flags).await.map_err(|e| { + error!( + user_id = user.as_str(), + err = e.to_string(), + "Failed to create direct message room" + ); + + let msg = format!("Could not open a room with {user}"); + UIError::Failure(msg) + }) + } + + async fn get_inviter(&mut self, invited: MatrixRoom) -> fixResult> { + let details = invited.invite_details().await.map_err(fixError::from)?; + + Ok(details.inviter) + } + + async fn get_room(&mut self, room_id: OwnedRoomId) -> fixResult { + if let Some(room) = self.client.get_room(&room_id) { + let name = room.display_name().await.map_err(fixError::from)?; + let tags = room.tags().await.map_err(fixError::from)?; + + Ok((room, name, tags)) + } else { + Err(fixError::UnknownRoom(room_id).into()) + } + } + + async fn join_room(&mut self, name: String) -> fixResult { + if let Ok(alias_id) = OwnedRoomOrAliasId::from_str(name.as_str()) { + match self.client.join_room_by_id_or_alias(&alias_id, &[]).await { + Ok(resp) => Ok(resp.room_id().to_owned()), + Err(e) => { + let msg = e.to_string(); + let err = UIError::Failure(msg); + + return Err(err); + }, + } + } else if let Ok(user) = OwnedUserId::try_from(name.as_str()) { + self.direct_message(user).await + } else { + let msg = format!("{:?} is not a valid room or user name", name.as_str()); + let err = UIError::Failure(msg); + + return Err(err); + } + } + + async fn members(&mut self, room_id: OwnedRoomId) -> fixResult> { + if let Some(room) = self.client.get_room(room_id.as_ref()) { + Ok(room.members(RoomMemberships::ACTIVE).await.map_err(fixError::from)?) + } else { + Err(fixError::UnknownRoom(room_id).into()) + } + } + + async fn space_members(&mut self, space: OwnedRoomId) -> fixResult> { + let mut req = SpaceHierarchyRequest::new(space); + req.limit = Some(1000u32.into()); + req.max_depth = Some(1u32.into()); + + let resp = self.client.send(req, None).await.map_err(fixError::from)?; + + let rooms = resp.rooms.into_iter().map(|chunk| chunk.room_id).collect(); + + Ok(rooms) + } + + async fn typing_notice(&mut self, room_id: OwnedRoomId) { + if let Some(room) = self.client.get_room(room_id.as_ref()) { + let _ = room.typing_notice(true).await; + } + } + + async fn verify(&self, action: VerifyAction, sas: SasVerification) -> fixResult { + match action { + VerifyAction::Accept => { + sas.accept().await.map_err(fixError::from)?; + + Ok(Some(InfoMessage::from("Accepted verification request"))) + }, + VerifyAction::Confirm => { + if sas.is_done() || sas.is_cancelled() { + let msg = "Can only confirm in-progress verifications!"; + let err = UIError::Failure(msg.into()); + + return Err(err); + } + + sas.confirm().await.map_err(fixError::from)?; + + Ok(Some(InfoMessage::from("Confirmed verification"))) + }, + VerifyAction::Cancel => { + if sas.is_done() || sas.is_cancelled() { + let msg = "Can only cancel in-progress verifications!"; + let err = UIError::Failure(msg.into()); + + return Err(err); + } + + sas.cancel().await.map_err(fixError::from)?; + + Ok(Some(InfoMessage::from("Cancelled verification"))) + }, + VerifyAction::Mismatch => { + if sas.is_done() || sas.is_cancelled() { + let msg = "Can only cancel in-progress verifications!"; + let err = UIError::Failure(msg.into()); + + return Err(err); + } + + sas.mismatch().await.map_err(fixError::from)?; + + Ok(Some(InfoMessage::from("Cancelled verification"))) + }, + } + } + + async fn verify_request(&self, user_id: OwnedUserId) -> fixResult { + let enc = self.client.encryption(); + + match enc.get_user_identity(user_id.as_ref()).await.map_err(fixError::from)? { + Some(identity) => { + let methods = vec![VerificationMethod::SasV1]; + let request = identity.request_verification_with_methods(methods); + let _req = request.await.map_err(fixError::from)?; + let info = format!("Sent verification request to {user_id}"); + + Ok(Some(InfoMessage::from(info))) + }, + None => { + let msg = format!("Could not find identity information for {user_id}"); + let err = UIError::Failure(msg); + + Err(err) + }, + } + } +}