Compare commits

..

No commits in common. "master" and "v0.7.1" have entirely different histories.

68 changed files with 2012 additions and 3327 deletions

View file

@ -1,75 +0,0 @@
# What software is installed by default:
# https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
name: build
on:
push:
pull_request:
defaults:
run:
shell: bash
jobs:
build:
strategy:
matrix:
os:
- ubuntu-22.04
- windows-latest
- macos-latest
- macos-13
runs-on: ${{ matrix.os }}
steps:
- name: Check out repo
uses: actions/checkout@v4
- name: Set up rust
run: rustup update
- name: Build
run: cargo build --release
- name: Test
run: cargo test --release
- name: Record target triple
run: rustc -vV | awk '/^host/ { print $2 }' > target/release/host
- name: Upload
uses: actions/upload-artifact@v4
with:
name: cove-${{ matrix.os }}
path: |
target/release/cove
target/release/cove.exe
target/release/host
release:
runs-on: ubuntu-latest
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
needs:
- build
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
- name: Zip artifacts
run: |
chmod +x cove-ubuntu-22.04/cove
chmod +x cove-windows-latest/cove.exe
chmod +x cove-macos-latest/cove
chmod +x cove-macos-13/cove
zip -jr "cove-$(cat cove-ubuntu-22.04/host).zip" cove-ubuntu-22.04/cove
zip -jr "cove-$(cat cove-windows-latest/host).zip" cove-windows-latest/cove.exe
zip -jr "cove-$(cat cove-macos-latest/host).zip" cove-macos-latest/cove
zip -jr "cove-$(cat cove-macos-13/host).zip" cove-macos-13/cove
- name: Create new release
uses: softprops/action-gh-release@v2
with:
body: Automated release, see [CHANGELOG.md](CHANGELOG.md) for more details.
files: "*.zip"

View file

@ -2,7 +2,7 @@
"files.insertFinalNewline": true,
"rust-analyzer.cargo.features": "all",
"rust-analyzer.imports.granularity.enforce": true,
"rust-analyzer.imports.granularity.group": "crate",
"rust-analyzer.imports.granularity.group": "module",
"rust-analyzer.imports.group.enable": true,
"evenBetterToml.formatter.columnWidth": 100,
}

View file

@ -4,135 +4,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Procedure when bumping the version number:
1. Update dependencies in a separate commit
2. Set version number in `Cargo.toml`
3. Add new section in this changelog
4. Run `cargo run help-config > CONFIG.md`
5. Commit with message `Bump version to X.Y.Z`
6. Create tag named `vX.Y.Z`
7. Push `master` and the new tag
7. Fast-forward branch `latest`
8. Push `master`, `latest` and the new tag
## Unreleased
### Changed
- Display emoji user id hashes in the nick list
- Compile linux binary with older glibc version
## v0.9.3 - 2025-05-31
### Added
- Key bindings for emoji-based user id hashing
### Fixed
- `keys.rooms.action.connect_autojoin` connecting to non-autojoin rooms
## v0.9.2 - 2025-03-14
### Added
- `bell_on_mention` config option
## v0.9.1 - 2025-03-01
### Fixed
- Rendering glitches with unicode-based width estimation
## v0.9.0 - 2025-02-23
### Added
- Unicode-based grapheme width estimation method
- `width_estimation_method` config option
- `--width-estimation-method` option
- Room links are now included in the `I` message links list
### Changed
- Updated documentation for `time_zone` config option
- When connecting to a room using `n` in the room list, the cursor now moves to that room
- Updated list of emoji names
### Removed
- Special handling of &rl2dev
### Fixed
- Nick color in rare edge cases
- Message link list rendering bug
## v0.8.3 - 2024-05-20
### Changed
- Updated list of emoji names
## v0.8.2 - 2024-04-25
### Changed
- Renamed `json-stream` export format to `json-lines` (see <https://jsonlines.org/>)
- Changed `json-lines` file extension from `.json` to `.jsonl`
### Fixed
- Crash when window is too small while empty message editor is visible
- Mistakes in output and docs
- Cove not cleaning up terminal state properly
## v0.8.1 - 2024-01-11
### Added
- Support for setting window title
- More information to room list heading
- Key bindings for live caesar cipher de- and encoding
### Removed
- Key binding to open present page
## v0.8.0 - 2024-01-04
### Added
- Support for multiple euph server domains
- Support for `TZ` environment variable
- `time_zone` config option
- `--domain` option to `cove export` command
- `--domain` option to `cove clear-cookies` command
- Domain field to "connect to new room" popup
- Welcome info box next to room list
### Changed
- The default euph domain is now https://euphoria.leet.nu/ everywhere
- The config file format was changed to support multiple euph servers with different domains.
Options previously located at `euph.rooms.*` should be reviewed and moved to `euph.servers."euphoria.leet.nu".rooms.*`.
- Tweaked F1 popup
- Tweaked chat message editor when nick list is foused
- Reduced connection timeout from 30 seconds to 10 seconds
### Fixed
- Room deletion popup accepting any room name
- Duplicated key presses on Windows
## v0.7.1 - 2023-08-31
### Changed
- Updated dependencies
## v0.7.0 - 2023-05-14
### Added
- Auto-generated config documentation
- in [CONFIG.md](CONFIG.md)
- via `help-config` CLI command
@ -140,7 +30,6 @@ Procedure when bumping the version number:
- `measure_widths` config option
### Changed
- Overhauled widget system and extracted generic widgets to [toss](https://github.com/Garmelon/toss)
- Overhauled config system to support auto-generating documentation
- Overhauled key binding system to make key bindings configurable
@ -154,18 +43,15 @@ Procedure when bumping the version number:
## v0.6.1 - 2023-04-10
### Changed
- Improved JSON export performance
- Always show rooms from config file in room list
### Fixed
- Rooms reconnecting instead of showing error popups
## v0.6.0 - 2023-04-04
### Added
- Emoji support
- `flake.nix`, making cove available as a nix flake
- `json-stream` room export format
@ -173,37 +59,31 @@ Procedure when bumping the version number:
- `--verbose` flag
### Changed
- Non-export info is now printed to stderr instead of stdout
- Recognizes links without scheme (e.g. `euphoria.io` instead of `https://euphoria.io`)
- Rooms waiting for reconnect are no longer sorted to bottom in default sort order
### Fixed
- Mentions not being stopped by `>`
## v0.5.2 - 2023-01-14
### Added
- Key binding to open present page
### Changed
- Always connect to &rl2dev in ephemeral mode
- Reduce amount of messages per &rl2dev log request
## v0.5.1 - 2022-11-27
### Changed
- Increase reconnect delay to one minute
- Print errors that occurred while cove was running more compactly
## v0.5.0 - 2022-09-26
### Added
- Key bindings to navigate nick list
- Room deletion confirmation popup
- Message inspection popup
@ -212,12 +92,10 @@ Procedure when bumping the version number:
- `rooms_sort_order` config option
### Changed
- Use nick changes to detect sessions for nick list
- Support Unicode 15
### Fixed
- Cursor being visible through popups
- Cursor in lists when highlighted item moves off-screen
- User disappearing from nick list when only one of their sessions disconnects
@ -225,7 +103,6 @@ Procedure when bumping the version number:
## v0.4.0 - 2022-09-01
### Added
- Config file and `--config` cli option
- `data_dir` config option
- `ephemeral` config option
@ -241,17 +118,14 @@ Procedure when bumping the version number:
- Key bindings to view and open links in a message
### Changed
- Some key bindings in the rooms list
### Fixed
- Rooms being stuck in "Connecting" state
## v0.3.0 - 2022-08-22
### Added
- Account login and logout
- Authentication dialog for password-protected rooms
- Error popups in rooms when something goes wrong
@ -259,12 +133,10 @@ Procedure when bumping the version number:
- Key binding to download more logs
### Changed
- Reduced amount of unnecessary redraws
- Description of `export` CLI command
### Fixed
- Crash when connecting to nonexistent rooms
- Crash when connecting to rooms that require authentication
- Pasting multi-line strings into the editor
@ -272,18 +144,15 @@ Procedure when bumping the version number:
## v0.2.1 - 2022-08-11
### Added
- Support for modifiers on special keys via the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
### Fixed
- Joining new rooms no longer crashes cove
- Scrolling when exiting message editor
## v0.2.0 - 2022-08-10
### Added
- New messages are now marked as unseen
- Sub-trees can now be folded
- Support for pasting text into editors
@ -296,12 +165,10 @@ Procedure when bumping the version number:
- Support for exporting multiple/all rooms at once
### Changed
- Reorganized export command
- Slowed down room history download speed
### Fixed
- Chat rendering when deleting and re-joining a room
- Spacing in some popups

143
CONFIG.md
View file

@ -8,11 +8,15 @@ Here is an example config that changes a few different options:
measure_widths = true
rooms_sort_order = "importance"
[euph.servers."euphoria.leet.nu".rooms]
welcome.autojoin = true
test.username = "badingle"
test.force_username = true
private.password = "foobar"
[euph.rooms.welcome]
autojoin = true
[euph.rooms.test]
username = "badingle"
force_username = true
[euph.rooms.private]
password = "foobar"
[keys]
general.abort = ["esc", "ctrl+c"]
@ -20,6 +24,17 @@ general.exit = "ctrl+q"
tree.action.fold_tree = "f"
```
If you want to configure lots of rooms, TOML lets you write this in a more
compact way:
```toml
[euph.rooms]
foo = { autojoin = true }
bar = { autojoin = true }
baz = { autojoin = true }
private = { autojoin = true, password = "foobar" }
```
## Key bindings
Key bindings are specified as strings or lists of strings. Each string specifies
@ -53,14 +68,6 @@ Available modifiers:
## Available options
### `bell_on_mention`
**Required:** yes
**Type:** boolean
**Default:** `false`
Ring the bell (character 0x07) when you are mentioned in a room.
### `data_dir`
**Required:** no
@ -87,7 +94,7 @@ any options related to the data dir.
See also the `--ephemeral` command line option.
### `euph.servers.<domain>.rooms.<room>.autojoin`
### `euph.rooms.<room>.autojoin`
**Required:** yes
**Type:** boolean
@ -95,17 +102,17 @@ See also the `--ephemeral` command line option.
Whether to automatically join this room on startup.
### `euph.servers.<domain>.rooms.<room>.force_username`
### `euph.rooms.<room>.force_username`
**Required:** yes
**Type:** boolean
**Default:** `false`
If `euph.servers.<domain>.rooms.<room>.username` is set, this will force
cove to set the username even if there is already a different username
associated with the current session.
If `euph.rooms.<room>.username` is set, this will force cove to set the
username even if there is already a different username associated with
the current session.
### `euph.servers.<domain>.rooms.<room>.password`
### `euph.rooms.<room>.password`
**Required:** no
**Type:** string
@ -113,7 +120,7 @@ associated with the current session.
If set, cove will try once to use this password to authenticate, should
the room be password-protected.
### `euph.servers.<domain>.rooms.<room>.username`
### `euph.rooms.<room>.username`
**Required:** no
**Type:** string
@ -329,6 +336,14 @@ Download more messages.
Change nick.
### `keys.room.action.present`
**Required:** yes
**Type:** key binding
**Default:** `"ctrl+p"`
Open room's plugh.de/present page.
### `keys.rooms.action.change_sort_order`
**Required:** yes
@ -457,14 +472,6 @@ Scroll up half a screen.
Scroll up one line.
### `keys.tree.action.decrease_caesar`
**Required:** yes
**Type:** key binding
**Default:** `"C"`
Decrease caesar cipher rotation.
### `keys.tree.action.fold_tree`
**Required:** yes
@ -473,14 +480,6 @@ Decrease caesar cipher rotation.
Fold current message's subtree.
### `keys.tree.action.increase_caesar`
**Required:** yes
**Type:** key binding
**Default:** `"c"`
Increase caesar cipher rotation.
### `keys.tree.action.inspect`
**Required:** yes
@ -537,14 +536,6 @@ Reply to message, inline if possible.
Reply opposite to normal reply.
### `keys.tree.action.toggle_nick_emoji`
**Required:** yes
**Type:** key binding
**Default:** `"e"`
Toggle agent id based nick emoji.
### `keys.tree.action.toggle_seen`
**Required:** yes
@ -623,13 +614,14 @@ Move to root.
**Type:** boolean
**Default:** `false`
Whether to measure the width of graphemes (i.e. characters) as displayed
by the terminal emulator instead of estimating the width.
Whether to measure the width of characters as displayed by the terminal
emulator instead of guessing the width.
Enabling this makes rendering a bit slower but more accurate. The screen
might also flash when encountering new graphemes.
might also flash when encountering new characters (or, more accurately,
graphemes).
See also the `--measure-widths` command line option.
See also the `--measure-graphemes` command line option.
### `offline`
@ -650,62 +642,15 @@ See also the `--offline` command line option.
**Required:** yes
**Type:** string
**Values:** `"alphabet"`, `"importance"`
**Default:** `"alphabet"`
**Default:** `alphabet`
Initial sort order of rooms list.
`"alphabet"` sorts rooms in alphabetic order.
`alphabet` sorts rooms in alphabetic order.
`"importance"` sorts rooms by the following criteria (in descending
order of priority):
`importance` sorts rooms by the following criteria (in descending order
of priority):
1. connected rooms before unconnected rooms
2. rooms with unread messages before rooms without
3. alphabetic order
### `time_zone`
**Required:** no
**Type:** string
**Default:** `$TZ` or local system time zone
Time zone that chat timestamps should be displayed in.
This option can either be the string `"localtime"`, a [POSIX TZ string],
or a [tz identifier] from the [tz database].
When not set or when set to `"localtime"`, cove attempts to use your
system's configured time zone, falling back to UTC.
When the string begins with a colon or doesn't match the a POSIX TZ
string format, it is interpreted as a tz identifier and looked up in
your system's tz database (or a bundled tz database on Windows).
If the `TZ` environment variable exists, it overrides this option.
[POSIX TZ string]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03
[tz identifier]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
[tz database]: https://en.wikipedia.org/wiki/Tz_database
### `width_estimation_method`
**Required:** yes
**Type:** string
**Values:** `"legacy"`, `"unicode"`
**Default:** `"legacy"`
How to estimate the width of graphemes (i.e. characters) as displayed by
the terminal emulator.
`"legacy"`: Use a legacy method that should mostly work on most terminal
emulators. This method will never be correct in all cases since every
terminal emulator handles grapheme widths slightly differently. However,
those cases are usually rare (unless you view a lot of emoji).
`"unicode"`: Use the unicode standard in a best-effort manner to
determine grapheme widths. Some terminals (e.g. ghostty) can make use of
this.
This method is used when `measure_widths` is set to `false`.
See also the `--width-estimation-method` command line option.

1339
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,72 +1,21 @@
[workspace]
resolver = "3"
resolver = "2"
members = ["cove", "cove-*"]
[workspace.package]
version = "0.9.3"
edition = "2024"
version = "0.7.1"
edition = "2021"
[workspace.dependencies]
anyhow = "1.0.97"
async-trait = "0.1.87"
clap = { version = "4.5.32", features = ["derive", "deprecated"] }
cookie = "0.18.1"
crossterm = "0.28.1"
directories = "6.0.0"
edit = "0.1.5"
jiff = "0.2.4"
linkify = "0.10.0"
log = { version = "0.4.26", features = ["std"] }
open = "5.3.2"
parking_lot = "0.12.3"
proc-macro2 = "1.0.94"
quote = "1.0.40"
rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
rustls = "0.23.23"
serde = { version = "1.0.219", features = ["derive"] }
crossterm = "0.27.0"
parking_lot = "0.12.1"
serde = { version = "1.0.188", features = ["derive"] }
serde_either = "0.2.1"
serde_json = "1.0.140"
syn = "2.0.100"
thiserror = "2.0.12"
tokio = { version = "1.44.1", features = ["full"] }
toml = "0.8.20"
unicode-width = "0.2.0"
[workspace.dependencies.euphoxide]
git = "https://github.com/Garmelon/euphoxide.git"
tag = "v0.6.1"
features = ["bot"]
thiserror = "1.0.47"
[workspace.dependencies.toss]
git = "https://github.com/Garmelon/toss.git"
tag = "v0.3.4"
[workspace.dependencies.vault]
git = "https://github.com/Garmelon/vault.git"
tag = "v0.4.0"
features = ["tokio"]
[workspace.lints]
rust.unsafe_code = { level = "forbid", priority = 1 }
# Lint groups
rust.deprecated_safe = "warn"
rust.future_incompatible = "warn"
rust.keyword_idents = "warn"
rust.rust_2018_idioms = "warn"
rust.unused = "warn"
# Individual lints
rust.non_local_definitions = "warn"
rust.redundant_imports = "warn"
rust.redundant_lifetimes = "warn"
rust.single_use_lifetimes = "warn"
rust.unit_bindings = "warn"
rust.unnameable_types = "warn"
rust.unused_crate_dependencies = "warn"
rust.unused_import_braces = "warn"
rust.unused_lifetimes = "warn"
rust.unused_qualifications = "warn"
# Clippy
clippy.use_self = "warn"
tag = "v0.2.0"
[profile.dev.package."*"]
opt-level = 3

View file

@ -1,17 +1,12 @@
# cove
Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded
Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded
real-time chat platform.
![A very meta screenshot](screenshot.png)
It runs on Linux, Windows, and macOS.
## Installing cove
Download a binary of your choice from the
[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest).
## Using cove
To start cove, simply run `cove` in your terminal. For more info about the
@ -31,3 +26,61 @@ file or via `cove help-config`.
When launched, cove prints the location it is loading its config file from. To
configure cove, create a config file at that location. This location can be
changed via the `--config` command line option.
## Installation
At this point, cove is not available via any package manager.
Cove is available as a Nix Flake. To try it out, you can use
```bash
$ nix run --override-input nixpkgs nixpkgs github:Garmelon/cove/latest
```
## Manual installation
This section contains instructions on how to install cove by compiling it yourself.
It doesn't assume you know how to program, but it does assume basic familiarity with the command line on your platform of choice.
Cove runs in the terminal, after all.
### Installing rustup
Cove is written in Rust, so the first step is to install rustup. Either install
it from your package manager of choice (if you have one) or use the
[installer](https://rustup.rs/).
Test your installation by running `rustup --version` and `cargo --version`. If
rustup is installed correctly, both of these should show a version number.
Cove is designed on the current version of the stable toolchain. If cove doesn't
compile, you can try switching to the stable toolchain and updating it using the
following commands:
```bash
$ rustup default stable
$ rustup update
```
### Installing cove
To install or update to the latest release of cove, run the following command:
```bash
$ cargo install --force --git https://github.com/Garmelon/cove --branch latest
```
If you like to live dangerously and want to install or update to the latest,
bleeding-edge, possibly-broken commit from the repo's main branch, run the
following command.
**Warning:** This could corrupt your vault. Make sure to make a backup before
running the command.
```bash
$ cargo install --force --git https://github.com/Garmelon/cove
```
To install a specific version of cove, run the following command and substitute
in the full version you want to install:
```bash
$ cargo install --force --git https://github.com/Garmelon/cove --tag v0.1.0
```

View file

View file

@ -1,15 +1,13 @@
[package]
name = "cove-config"
version.workspace = true
edition.workspace = true
version = { workspace = true }
edition = { workspace = true }
[dependencies]
cove-input = { path = "../cove-input" }
cove-macro = { path = "../cove-macro" }
serde.workspace = true
thiserror.workspace = true
toml.workspace = true
serde = { workspace = true }
thiserror = { workspace = true }
[lints]
workspace = true
toml = "0.7.6"

View file

@ -1,6 +1,7 @@
//! Auto-generate markdown documentation.
use std::{collections::HashMap, path::PathBuf};
use std::collections::HashMap;
use std::path::PathBuf;
use cove_input::KeyBinding;
pub use cove_macro::Document;
@ -16,11 +17,15 @@ Here is an example config that changes a few different options:
measure_widths = true
rooms_sort_order = "importance"
[euph.servers."euphoria.leet.nu".rooms]
welcome.autojoin = true
test.username = "badingle"
test.force_username = true
private.password = "foobar"
[euph.rooms.welcome]
autojoin = true
[euph.rooms.test]
username = "badingle"
force_username = true
[euph.rooms.private]
password = "foobar"
[keys]
general.abort = ["esc", "ctrl+c"]
@ -28,6 +33,17 @@ general.exit = "ctrl+q"
tree.action.fold_tree = "f"
```
If you want to configure lots of rooms, TOML lets you write this in a more
compact way:
```toml
[euph.rooms]
foo = { autojoin = true }
bar = { autojoin = true }
baz = { autojoin = true }
private = { autojoin = true, password = "foobar" }
```
## Key bindings
Key bindings are specified as strings or lists of strings. Each string specifies

View file

@ -23,9 +23,9 @@ pub struct EuphRoom {
/// associated with the current session.
pub username: Option<String>,
/// If `euph.servers.<domain>.rooms.<room>.username` is set, this will force
/// cove to set the username even if there is already a different username
/// associated with the current session.
/// If `euph.rooms.<room>.username` is set, this will force cove to set the
/// username even if there is already a different username associated with
/// the current session.
#[serde(default)]
pub force_username: bool,
@ -35,13 +35,7 @@ pub struct EuphRoom {
}
#[derive(Debug, Default, Deserialize, Document)]
pub struct EuphServer {
pub struct Euph {
#[document(metavar = "room")]
pub rooms: HashMap<String, EuphRoom>,
}
#[derive(Debug, Default, Deserialize, Document)]
pub struct Euph {
#[document(metavar = "domain")]
pub servers: HashMap<String, EuphServer>,
}

View file

@ -81,6 +81,7 @@ default_bindings! {
pub fn nick => ["n"];
pub fn more_messages => ["m"];
pub fn account => ["A"];
pub fn present => ["ctrl+p"];
}
pub mod tree_cursor {
@ -104,9 +105,6 @@ default_bindings! {
pub fn mark_older_seen => ["ctrl+s"];
pub fn info => ["i"];
pub fn links => ["I"];
pub fn toggle_nick_emoji => ["e"];
pub fn increase_caesar => ["c"];
pub fn decrease_caesar => ["C"];
}
}
@ -124,6 +122,7 @@ pub struct General {
#[serde(default = "default::general::confirm")]
pub confirm: KeyBinding,
/// Advance focus.
// TODO Mention examples where this is used
#[serde(default = "default::general::focus")]
pub focus: KeyBinding,
/// Show this help.
@ -288,6 +287,9 @@ pub struct RoomAction {
/// Manage account.
#[serde(default = "default::room_action::account")]
pub account: KeyBinding,
/// Open room's plugh.de/present page.
#[serde(default = "default::room_action::present")]
pub present: KeyBinding,
}
#[derive(Debug, Default, Deserialize, Document)]
@ -357,15 +359,6 @@ pub struct TreeAction {
/// List links found in message.
#[serde(default = "default::tree_action::links")]
pub links: KeyBinding,
/// Toggle agent id based nick emoji.
#[serde(default = "default::tree_action::toggle_nick_emoji")]
pub toggle_nick_emoji: KeyBinding,
/// Increase caesar cipher rotation.
#[serde(default = "default::tree_action::increase_caesar")]
pub increase_caesar: KeyBinding,
/// Decrease caesar cipher rotation.
#[serde(default = "default::tree_action::decrease_caesar")]
pub decrease_caesar: KeyBinding,
}
#[derive(Debug, Default, Deserialize, Document)]

View file

@ -1,18 +1,28 @@
use std::{
fs,
io::{self, ErrorKind},
path::{Path, PathBuf},
};
use doc::Document;
use serde::{Deserialize, Serialize};
pub use crate::{euph::*, keys::*};
#![forbid(unsafe_code)]
// Rustc lint groups
#![warn(future_incompatible)]
#![warn(rust_2018_idioms)]
#![warn(unused)]
// Rustc lints
#![warn(noop_method_call)]
#![warn(single_use_lifetimes)]
// Clippy lints
#![warn(clippy::use_self)]
pub mod doc;
mod euph;
mod keys;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::{fs, io};
use doc::Document;
use serde::Deserialize;
pub use crate::euph::*;
pub use crate::keys::*;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("failed to read config file")]
@ -21,14 +31,6 @@ pub enum Error {
Toml(#[from] toml::de::Error),
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Document)]
#[serde(rename_all = "snake_case")]
pub enum WidthEstimationMethod {
#[default]
Legacy,
Unicode,
}
#[derive(Debug, Default, Deserialize, Document)]
pub struct Config {
/// The directory that cove stores its data in when not running in ephemeral
@ -47,34 +49,19 @@ pub struct Config {
///
/// See also the `--ephemeral` command line option.
#[serde(default)]
#[document(default = "`false`")]
pub ephemeral: bool,
/// How to estimate the width of graphemes (i.e. characters) as displayed by
/// the terminal emulator.
///
/// `"legacy"`: Use a legacy method that should mostly work on most terminal
/// emulators. This method will never be correct in all cases since every
/// terminal emulator handles grapheme widths slightly differently. However,
/// those cases are usually rare (unless you view a lot of emoji).
///
/// `"unicode"`: Use the unicode standard in a best-effort manner to
/// determine grapheme widths. Some terminals (e.g. ghostty) can make use of
/// this.
///
/// This method is used when `measure_widths` is set to `false`.
///
/// See also the `--width-estimation-method` command line option.
#[serde(default)]
pub width_estimation_method: WidthEstimationMethod,
/// Whether to measure the width of graphemes (i.e. characters) as displayed
/// by the terminal emulator instead of estimating the width.
/// Whether to measure the width of characters as displayed by the terminal
/// emulator instead of guessing the width.
///
/// Enabling this makes rendering a bit slower but more accurate. The screen
/// might also flash when encountering new graphemes.
/// might also flash when encountering new characters (or, more accurately,
/// graphemes).
///
/// See also the `--measure-widths` command line option.
/// See also the `--measure-graphemes` command line option.
#[serde(default)]
#[document(default = "`false`")]
pub measure_widths: bool,
/// Whether to start in offline mode.
@ -85,46 +72,23 @@ pub struct Config {
///
/// See also the `--offline` command line option.
#[serde(default)]
#[document(default = "`false`")]
pub offline: bool,
/// Initial sort order of rooms list.
///
/// `"alphabet"` sorts rooms in alphabetic order.
/// `alphabet` sorts rooms in alphabetic order.
///
/// `"importance"` sorts rooms by the following criteria (in descending
/// order of priority):
/// `importance` sorts rooms by the following criteria (in descending order
/// of priority):
///
/// 1. connected rooms before unconnected rooms
/// 2. rooms with unread messages before rooms without
/// 3. alphabetic order
#[serde(default)]
#[document(default = "`alphabet`")]
pub rooms_sort_order: RoomsSortOrder,
/// Ring the bell (character 0x07) when you are mentioned in a room.
#[serde(default)]
pub bell_on_mention: bool,
/// Time zone that chat timestamps should be displayed in.
///
/// This option can either be the string `"localtime"`, a [POSIX TZ string],
/// or a [tz identifier] from the [tz database].
///
/// When not set or when set to `"localtime"`, cove attempts to use your
/// system's configured time zone, falling back to UTC.
///
/// When the string begins with a colon or doesn't match the a POSIX TZ
/// string format, it is interpreted as a tz identifier and looked up in
/// your system's tz database (or a bundled tz database on Windows).
///
/// If the `TZ` environment variable exists, it overrides this option.
///
/// [POSIX TZ string]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03
/// [tz identifier]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
/// [tz database]: https://en.wikipedia.org/wiki/Tz_database
#[serde(default)]
#[document(default = "`$TZ` or local system time zone")]
pub time_zone: Option<String>,
#[serde(default)]
#[document(no_default)]
pub euph: Euph,
@ -143,16 +107,7 @@ impl Config {
})
}
pub fn euph_room(&self, domain: &str, name: &str) -> EuphRoom {
if let Some(server) = self.euph.servers.get(domain) {
if let Some(room) = server.rooms.get(name) {
return room.clone();
}
}
EuphRoom::default()
}
pub fn time_zone_ref(&self) -> Option<&str> {
self.time_zone.as_ref().map(|s| s as &str)
pub fn euph_room(&self, name: &str) -> EuphRoom {
self.euph.rooms.get(name).cloned().unwrap_or_default()
}
}

View file

@ -1,18 +1,16 @@
[package]
name = "cove-input"
version.workspace = true
edition.workspace = true
version = { workspace = true }
edition = { workspace = true }
[dependencies]
cove-macro = { path = "../cove-macro" }
crossterm.workspace = true
edit.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_either.workspace = true
thiserror.workspace = true
toss.workspace = true
crossterm = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true }
serde_either = { workspace = true }
thiserror = { workspace = true }
toss = { workspace = true }
[lints]
workspace = true
edit = "0.1.4"

View file

@ -1,7 +1,10 @@
use std::{fmt, num::ParseIntError, str::FromStr};
use std::fmt;
use std::num::ParseIntError;
use std::str::FromStr;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
use serde::{de::Error, Deserialize, Deserializer};
use serde::{Serialize, Serializer};
use serde_either::SingleOrVec;
#[derive(Debug, thiserror::Error)]
@ -114,7 +117,7 @@ impl KeyPress {
"alt" if !self.alt => self.alt = true,
"any" if !self.shift && !self.ctrl && !self.alt => self.any = true,
m @ ("shift" | "ctrl" | "alt" | "any") => {
return Err(ParseKeysError::ConflictingModifier(m.to_string()));
return Err(ParseKeysError::ConflictingModifier(m.to_string()))
}
m => return Err(ParseKeysError::UnknownModifier(m.to_string())),
}
@ -148,7 +151,7 @@ impl FromStr for KeyPress {
let mut parts = s.split('+');
let code = parts.next_back().ok_or(ParseKeysError::NoKeyCode)?;
let mut keys = Self::parse_key_code(code)?;
let mut keys = KeyPress::parse_key_code(code)?;
let shift_allowed = !conflicts_with_shift(keys.code);
for modifier in parts {
keys.parse_modifier(modifier, shift_allowed)?;

View file

@ -1,14 +1,15 @@
use std::{io, sync::Arc};
mod keys;
use std::io;
use std::sync::Arc;
pub use cove_macro::KeyGroup;
use crossterm::event::{Event, KeyEvent, KeyEventKind};
use crossterm::event::{Event, KeyEvent};
use parking_lot::FairMutex;
use toss::{Frame, Terminal, WidthDb};
pub use crate::keys::*;
mod keys;
pub struct KeyBindingInfo<'a> {
pub name: &'static str,
pub binding: &'a KeyBinding,
@ -39,7 +40,7 @@ impl<'a> KeyGroupInfo<'a> {
}
pub struct InputEvent<'a> {
event: Event,
event: crossterm::event::Event,
terminal: &'a mut Terminal,
crossterm_lock: Arc<FairMutex<()>>,
}
@ -57,15 +58,11 @@ impl<'a> InputEvent<'a> {
}
}
/// If the current event represents a key press, returns the [`KeyEvent`]
/// associated with that key press.
pub fn key_event(&self) -> Option<KeyEvent> {
if let Event::Key(event) = &self.event {
if matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return Some(*event);
}
match &self.event {
Event::Key(event) => Some(*event),
_ => None,
}
None
}
pub fn paste_event(&self) -> Option<&str> {

View file

@ -1,15 +1,13 @@
[package]
name = "cove-macro"
version.workspace = true
edition.workspace = true
version = { workspace = true }
edition = { workspace = true }
[dependencies]
proc-macro2.workspace = true
quote.workspace = true
syn.workspace = true
case = "1.0.0"
proc-macro2 = "1.0.66"
quote = "1.0.33"
syn = "2.0.29"
[lib]
proc-macro = true
[lints]
workspace = true

View file

@ -1,6 +1,7 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr, spanned::Spanned};
use syn::spanned::Spanned;
use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr};
use crate::util::{self, SerdeDefault};

View file

@ -1,8 +1,9 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, spanned::Spanned};
use syn::spanned::Spanned;
use syn::{Data, DeriveInput};
use crate::util;
use crate::util::{self, bail};
fn decapitalize(s: &str) -> String {
let mut chars = s.chars();
@ -33,7 +34,7 @@ pub fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
let default = util::serde_default(field)?;
let Some(default) = default else {
return util::bail(field_ident.span(), "must have serde default");
return bail(field_ident.span(), "must have serde default");
};
let default_value = default.value();

View file

@ -1,4 +1,15 @@
use syn::{DeriveInput, parse_macro_input};
#![forbid(unsafe_code)]
// Rustc lint groups
#![warn(future_incompatible)]
#![warn(rust_2018_idioms)]
#![warn(unused)]
// Rustc lints
#![warn(noop_method_call)]
#![warn(single_use_lifetimes)]
// Clippy lints
#![warn(clippy::use_self)]
use syn::{parse_macro_input, DeriveInput};
mod document;
mod key_group;

View file

@ -1,9 +1,8 @@
use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::{
Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type, parse::Parse,
punctuated::Punctuated,
};
use syn::parse::Parse;
use syn::punctuated::Punctuated;
use syn::{Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type};
pub fn bail<T>(span: Span, message: &str) -> syn::Result<T> {
Err(syn::Error::new(span, message))

View file

@ -1,32 +1,46 @@
[package]
name = "cove"
version.workspace = true
edition.workspace = true
version = { workspace = true }
edition = { workspace = true }
[dependencies]
cove-config = { path = "../cove-config" }
cove-input = { path = "../cove-input" }
anyhow.workspace = true
async-trait.workspace = true
clap.workspace = true
cookie.workspace = true
crossterm.workspace = true
directories.workspace = true
euphoxide.workspace = true
jiff.workspace = true
linkify.workspace = true
log.workspace = true
open.workspace = true
parking_lot.workspace = true
rusqlite.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
toss.workspace = true
unicode-width.workspace = true
vault.workspace = true
rustls.workspace = true
crossterm = { workspace = true }
parking_lot = { workspace = true }
thiserror = { workspace = true }
toss = { workspace = true }
[lints]
workspace = true
anyhow = "1.0.75"
async-trait = "0.1.73"
clap = { version = "4.4.1", features = ["derive", "deprecated"] }
cookie = "0.17.0"
directories = "5.0.1"
linkify = "0.10.0"
log = { version = "0.4.20", features = ["std"] }
once_cell = "1.18.0"
open = "5.0.0"
rusqlite = { version = "0.29.0", features = ["bundled", "time"] }
serde_json = "1.0.105"
tokio = { version = "1.32.0", features = ["full"] }
unicode-segmentation = "1.10.1"
unicode-width = "0.1.10"
[dependencies.time]
version = "0.3.28"
features = ["macros", "formatting", "parsing", "serde"]
[dependencies.tokio-tungstenite]
version = "0.20.0"
features = ["rustls-tls-native-roots"]
[dependencies.euphoxide]
git = "https://github.com/Garmelon/euphoxide.git"
tag = "v0.4.0"
features = ["bot"]
[dependencies.vault]
git = "https://github.com/Garmelon/vault.git"
tag = "v0.2.0"
features = ["tokio"]

View file

@ -1,9 +1,7 @@
pub use highlight::*;
pub use room::*;
pub use small_message::*;
pub use util::*;
mod highlight;
mod room;
mod small_message;
mod util;
pub use room::*;
pub use small_message::*;
pub use util::*;

View file

@ -1,211 +0,0 @@
use std::ops::Range;
use crossterm::style::Stylize;
use toss::{Style, Styled};
use crate::euph::util;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpanType {
Mention,
Room,
Emoji,
}
fn nick_char(ch: char) -> bool {
// Closely following the heim mention regex:
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
// `>` has been experimentally confirmed to delimit mentions as well.
match ch {
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
_ => !ch.is_whitespace(),
}
}
fn room_char(ch: char) -> bool {
// Basically just \w, see also
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
ch.is_ascii_alphanumeric() || ch == '_'
}
struct SpanFinder<'a> {
content: &'a str,
span: Option<(SpanType, usize)>,
room_or_mention_possible: bool,
result: Vec<(SpanType, Range<usize>)>,
}
impl<'a> SpanFinder<'a> {
fn is_valid_span(&self, span: SpanType, range: Range<usize>) -> bool {
let text = &self.content[range.start..range.end];
match span {
SpanType::Mention => range.len() > 1 && text.starts_with('@'),
SpanType::Room => range.len() > 1 && text.starts_with('&'),
SpanType::Emoji => {
if range.len() <= 2 {
return false;
}
let Some(name) = Some(text)
.and_then(|it| it.strip_prefix(':'))
.and_then(|it| it.strip_suffix(':'))
else {
return false;
};
util::EMOJI.get(name).is_some()
}
}
}
fn close_span(&mut self, end: usize) {
let Some((span, start)) = self.span else {
return;
};
if self.is_valid_span(span, start..end) {
self.result.push((span, start..end));
}
self.span = None;
}
fn open_span(&mut self, span: SpanType, start: usize) {
self.close_span(start);
self.span = Some((span, start))
}
fn step(&mut self, idx: usize, char: char) {
match (char, self.span) {
('@', Some((SpanType::Mention, _))) => {} // Continue the mention
('@', _) if self.room_or_mention_possible => self.open_span(SpanType::Mention, idx),
('&', _) if self.room_or_mention_possible => self.open_span(SpanType::Room, idx),
(':', None) => self.open_span(SpanType::Emoji, idx),
(':', Some((SpanType::Emoji, _))) => self.close_span(idx + 1),
(c, Some((SpanType::Mention, _))) if !nick_char(c) => self.close_span(idx),
(c, Some((SpanType::Room, _))) if !room_char(c) => self.close_span(idx),
_ => {}
}
// More permissive than the heim web client
self.room_or_mention_possible = !char.is_alphanumeric();
}
fn find(content: &'a str) -> Vec<(SpanType, Range<usize>)> {
let mut this = Self {
content,
span: None,
room_or_mention_possible: true,
result: vec![],
};
for (idx, char) in content.char_indices() {
this.step(idx, char);
}
this.close_span(content.len());
this.result
}
}
pub fn find_spans(content: &str) -> Vec<(SpanType, Range<usize>)> {
SpanFinder::find(content)
}
/// Highlight spans in a string.
///
/// The list of spans must be non-overlapping and in ascending order.
///
/// If `exact` is specified, colon-delimited emoji are not replaced with their
/// unicode counterparts.
pub fn apply_spans(
content: &str,
spans: &[(SpanType, Range<usize>)],
base: Style,
exact: bool,
) -> Styled {
let mut result = Styled::default();
let mut i = 0;
for (span, range) in spans {
assert!(i <= range.start);
assert!(range.end <= content.len());
if i < range.start {
result = result.then(&content[i..range.start], base);
}
let text = &content[range.start..range.end];
result = match span {
SpanType::Mention if exact => result.and_then(util::style_mention_exact(text, base)),
SpanType::Mention => result.and_then(util::style_mention(text, base)),
SpanType::Room => result.then(text, base.blue().bold()),
SpanType::Emoji if exact => result.then(text, base.magenta()),
SpanType::Emoji => {
let name = text.strip_prefix(':').unwrap_or(text);
let name = name.strip_suffix(':').unwrap_or(name);
if let Some(Some(replacement)) = util::EMOJI.get(name) {
result.then(replacement, base)
} else {
result.then(text, base.magenta())
}
}
};
i = range.end;
}
if i < content.len() {
result = result.then(&content[i..], base);
}
result
}
/// Highlight an euphoria message's content.
///
/// If `exact` is specified, colon-delimited emoji are not replaced with their
/// unicode counterparts.
pub fn highlight(content: &str, base: Style, exact: bool) -> Styled {
apply_spans(content, &find_spans(content), base, exact)
}
#[cfg(test)]
mod tests {
use crate::euph::SpanType;
use super::find_spans;
#[test]
fn mentions() {
assert_eq!(find_spans("@foo"), vec![(SpanType::Mention, 0..4)]);
assert_eq!(find_spans("&@foo"), vec![(SpanType::Mention, 1..5)]);
assert_eq!(find_spans("a @foo b"), vec![(SpanType::Mention, 2..6)]);
assert_eq!(find_spans("@@foo@@"), vec![(SpanType::Mention, 0..7)]);
assert_eq!(find_spans("a @b@c d"), vec![(SpanType::Mention, 2..6)]);
assert_eq!(
find_spans("a @b @c d"),
vec![(SpanType::Mention, 2..4), (SpanType::Mention, 5..7)]
);
}
#[test]
fn rooms() {
assert_eq!(find_spans("&foo"), vec![(SpanType::Room, 0..4)]);
assert_eq!(find_spans("@&foo"), vec![(SpanType::Room, 1..5)]);
assert_eq!(find_spans("a &foo b"), vec![(SpanType::Room, 2..6)]);
assert_eq!(find_spans("&&foo&&"), vec![(SpanType::Room, 1..5)]);
assert_eq!(find_spans("a &b&c d"), vec![(SpanType::Room, 2..4)]);
assert_eq!(
find_spans("a &b &c d"),
vec![(SpanType::Room, 2..4), (SpanType::Room, 5..7)]
);
}
#[test]
fn emoji_in_mentions() {
assert_eq!(find_spans(" @a:b:c "), vec![(SpanType::Mention, 1..7)]);
}
}

View file

@ -1,21 +1,24 @@
use std::{convert::Infallible, time::Duration};
// TODO Stop if room does not exist (e.g. 404)
use euphoxide::{
api::{
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply,
Time, UserId, packet::ParsedPacket,
},
bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig},
conn::{self, ConnTx, Joined},
use std::convert::Infallible;
use std::time::Duration;
use euphoxide::api::packet::ParsedPacket;
use euphoxide::api::{
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply, Time,
UserId,
};
use log::{debug, info, warn};
use tokio::{select, sync::oneshot};
use euphoxide::bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig};
use euphoxide::conn::{self, ConnTx};
use log::{debug, error, info, warn};
use tokio::select;
use tokio::sync::oneshot;
use crate::{macros::logging_unwrap, vault::EuphRoomVault};
use crate::macros::logging_unwrap;
use crate::vault::EuphRoomVault;
const LOG_INTERVAL: Duration = Duration::from_secs(10);
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum State {
Disconnected,
@ -32,13 +35,6 @@ impl State {
None
}
}
pub fn joined(&self) -> Option<&Joined> {
match self {
Self::Connected(_, conn::State::Joined(joined)) => Some(joined),
_ => None,
}
}
}
#[derive(Debug, thiserror::Error)]
@ -69,13 +65,19 @@ impl Room {
where
F: Fn(Event) + std::marker::Send + Sync + 'static,
{
// &rl2dev's message history is broken and requesting old messages past
// a certain point results in errors. Cove should not keep retrying log
// requests when hitting that limit, so &rl2dev is always opened in
// ephemeral mode.
let ephemeral = vault.vault().vault().ephemeral() || vault.room() == "rl2dev";
Self {
ephemeral: vault.vault().vault().ephemeral(),
vault,
ephemeral,
instance: instance_config.build(on_event),
state: State::Disconnected,
last_msg_id: None,
log_request_canary: None,
vault,
}
}
@ -123,8 +125,7 @@ impl Room {
let cookies = &*self.instance.config().server.cookies;
let cookies = cookies.lock().unwrap().clone();
let domain = self.vault.room().domain.clone();
logging_unwrap!(self.vault.vault().set_cookies(domain, cookies).await);
logging_unwrap!(self.vault.vault().set_cookies(cookies).await);
}
Event::Packet(_, packet, ConnSnapshot { conn_tx, state }) => {
self.state = State::Connected(conn_tx, state);
@ -136,6 +137,7 @@ impl Room {
self.log_request_canary = None;
}
Event::Stopped(_) => {
// TODO Remove room somewhere if this happens? If it doesn't already happen during stabilization
self.state = State::Stopped;
}
}
@ -181,9 +183,15 @@ impl Room {
None => None,
};
debug!("{:?}: requesting logs", vault.room());
debug!("{}: requesting logs", vault.room());
let _ = conn_tx.send(Log { n: 1000, before }).await;
// &rl2dev's message history is broken and requesting old messages past
// a certain point results in errors. By reducing the amount of messages
// in each log request, we can get closer to this point. Since &rl2dev
// is fairly low in activity, this should be fine.
let n = if vault.room() == "rl2dev" { 50 } else { 1000 };
let _ = conn_tx.send(Log { n, before }).await;
// The code handling incoming events and replies also handles
// `LogReply`s, so we don't need to do anything special here.
}

View file

@ -1,18 +1,212 @@
use std::mem;
use crossterm::style::Stylize;
use euphoxide::api::{MessageId, Snowflake, Time, UserId};
use jiff::Timestamp;
use euphoxide::api::{MessageId, Snowflake, Time};
use time::OffsetDateTime;
use toss::{Style, Styled};
use crate::{store::Msg, ui::ChatMsg};
use crate::store::Msg;
use crate::ui::ChatMsg;
use super::util;
fn nick_char(ch: char) -> bool {
// Closely following the heim mention regex:
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
// `>` has been experimentally confirmed to delimit mentions as well.
match ch {
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
_ => !ch.is_whitespace(),
}
}
fn room_char(ch: char) -> bool {
// Basically just \w, see also
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
ch.is_ascii_alphanumeric() || ch == '_'
}
enum Span {
Nothing,
Mention,
Room,
Emoji,
}
struct Highlighter<'a> {
content: &'a str,
base_style: Style,
exact: bool,
span: Span,
span_start: usize,
room_or_mention_possible: bool,
result: Styled,
}
impl<'a> Highlighter<'a> {
/// Does *not* guarantee `self.span_start == idx` after running!
fn close_mention(&mut self, idx: usize) {
let span_length = idx.saturating_sub(self.span_start);
if span_length <= 1 {
// We can repurpose the current span
self.span = Span::Nothing;
return;
}
let text = &self.content[self.span_start..idx]; // Includes @
self.result = mem::take(&mut self.result).and_then(if self.exact {
util::style_nick_exact(text, self.base_style)
} else {
util::style_nick(text, self.base_style)
});
self.span = Span::Nothing;
self.span_start = idx;
}
/// Does *not* guarantee `self.span_start == idx` after running!
fn close_room(&mut self, idx: usize) {
let span_length = idx.saturating_sub(self.span_start);
if span_length <= 1 {
// We can repurpose the current span
self.span = Span::Nothing;
return;
}
self.result = mem::take(&mut self.result).then(
&self.content[self.span_start..idx],
self.base_style.blue().bold(),
);
self.span = Span::Nothing;
self.span_start = idx;
}
// Warning: `idx` is the index of the closing colon.
fn close_emoji(&mut self, idx: usize) {
let name = &self.content[self.span_start + 1..idx];
if let Some(replace) = util::EMOJI.get(name) {
match replace {
Some(replace) if !self.exact => {
self.result = mem::take(&mut self.result).then(replace, self.base_style);
}
_ => {
let text = &self.content[self.span_start..=idx];
let style = self.base_style.magenta();
self.result = mem::take(&mut self.result).then(text, style);
}
}
self.span = Span::Nothing;
self.span_start = idx + 1;
} else {
self.close_plain(idx);
self.span = Span::Emoji;
}
}
/// Guarantees `self.span_start == idx` after running.
fn close_plain(&mut self, idx: usize) {
if self.span_start == idx {
// Span has length 0
return;
}
self.result =
mem::take(&mut self.result).then(&self.content[self.span_start..idx], self.base_style);
self.span = Span::Nothing;
self.span_start = idx;
}
fn close_span_before_current_char(&mut self, idx: usize, char: char) {
match self.span {
Span::Mention if !nick_char(char) => self.close_mention(idx),
Span::Room if !room_char(char) => self.close_room(idx),
Span::Emoji if char == '&' || char == '@' => {
self.span = Span::Nothing;
}
_ => {}
}
}
fn update_span_with_current_char(&mut self, idx: usize, char: char) {
match self.span {
Span::Nothing if char == '@' && self.room_or_mention_possible => {
self.close_plain(idx);
self.span = Span::Mention;
}
Span::Nothing if char == '&' && self.room_or_mention_possible => {
self.close_plain(idx);
self.span = Span::Room;
}
Span::Nothing if char == ':' => {
self.close_plain(idx);
self.span = Span::Emoji;
}
Span::Emoji if char == ':' => self.close_emoji(idx),
_ => {}
}
}
fn close_final_span(&mut self) {
let idx = self.content.len();
if self.span_start >= idx {
return; // Span has no contents
}
match self.span {
Span::Mention => self.close_mention(idx),
Span::Room => self.close_room(idx),
_ => {}
}
self.close_plain(idx);
}
fn step(&mut self, idx: usize, char: char) {
if self.span_start < idx {
self.close_span_before_current_char(idx, char);
}
self.update_span_with_current_char(idx, char);
// More permissive than the heim web client
self.room_or_mention_possible = !char.is_alphanumeric();
}
fn highlight(content: &'a str, base_style: Style, exact: bool) -> Styled {
let mut this = Self {
content: if exact { content } else { content.trim() },
base_style,
exact,
span: Span::Nothing,
span_start: 0,
room_or_mention_possible: true,
result: Styled::default(),
};
for (idx, char) in (if exact { content } else { content.trim() }).char_indices() {
this.step(idx, char);
}
this.close_final_span();
this.result
}
}
fn highlight_content(content: &str, base_style: Style, exact: bool) -> Styled {
Highlighter::highlight(content, base_style, exact)
}
#[derive(Debug, Clone)]
pub struct SmallMessage {
pub id: MessageId,
pub parent: Option<MessageId>,
pub time: Time,
pub user_id: UserId,
pub nick: String,
pub content: String,
pub seen: bool,
@ -28,22 +222,22 @@ fn style_me() -> Style {
fn styled_nick(nick: &str) -> Styled {
Styled::new_plain("[")
.and_then(super::style_nick(nick, Style::new()))
.and_then(util::style_nick(nick, Style::new()))
.then_plain("]")
}
fn styled_nick_me(nick: &str) -> Styled {
let style = style_me();
Styled::new("*", style).and_then(super::style_nick(nick, style))
Styled::new("*", style).and_then(util::style_nick(nick, style))
}
fn styled_content(content: &str) -> Styled {
super::highlight(content.trim(), Style::new(), false)
highlight_content(content.trim(), Style::new(), false)
}
fn styled_content_me(content: &str) -> Styled {
let style = style_me();
super::highlight(content.trim(), style, false).then("*", style)
highlight_content(content.trim(), style, false).then("*", style)
}
fn styled_editor_content(content: &str) -> Styled {
@ -52,7 +246,7 @@ fn styled_editor_content(content: &str) -> Styled {
} else {
Style::new()
};
super::highlight(content, style, true)
highlight_content(content, style, true)
}
impl Msg for SmallMessage {
@ -73,15 +267,11 @@ impl Msg for SmallMessage {
fn last_possible_id() -> Self::Id {
MessageId(Snowflake::MAX)
}
fn nick_emoji(&self) -> Option<String> {
Some(util::user_id_emoji(&self.user_id))
}
}
impl ChatMsg for SmallMessage {
fn time(&self) -> Option<Timestamp> {
Some(self.time.as_timestamp())
fn time(&self) -> OffsetDateTime {
self.time.0
}
fn styled(&self) -> (Styled, Styled) {

View file

@ -1,27 +1,9 @@
use std::{
collections::HashSet,
hash::{DefaultHasher, Hash, Hasher},
sync::LazyLock,
};
use crossterm::style::{Color, Stylize};
use euphoxide::{Emoji, api::UserId};
use euphoxide::Emoji;
use once_cell::sync::Lazy;
use toss::{Style, Styled};
pub static EMOJI: LazyLock<Emoji> = LazyLock::new(Emoji::load);
pub static EMOJI_LIST: LazyLock<Vec<String>> = LazyLock::new(|| {
let mut list = EMOJI
.0
.values()
.flatten()
.cloned()
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
list.sort_unstable();
list
});
pub static EMOJI: Lazy<Emoji> = Lazy::new(Emoji::load);
/// Convert HSL to RGB following [this approach from wikipedia][1].
///
@ -72,25 +54,3 @@ pub fn style_nick(nick: &str, base: Style) -> Styled {
pub fn style_nick_exact(nick: &str, base: Style) -> Styled {
Styled::new(nick, nick_style(nick, base))
}
pub fn style_mention(mention: &str, base: Style) -> Styled {
let nick = mention
.strip_prefix('@')
.expect("mention must start with @");
Styled::new(EMOJI.replace(mention), nick_style(nick, base))
}
pub fn style_mention_exact(mention: &str, base: Style) -> Styled {
let nick = mention
.strip_prefix('@')
.expect("mention must start with @");
Styled::new(mention, nick_style(nick, base))
}
pub fn user_id_emoji(user_id: &UserId) -> String {
let mut hasher = DefaultHasher::new();
user_id.0.hash(&mut hasher);
let hash = hasher.finish();
let emoji = &EMOJI_LIST[hash as usize % EMOJI_LIST.len()];
emoji.clone()
}

View file

@ -1,24 +1,21 @@
//! Export logs from the vault to plain text files.
use std::{
fs::File,
io::{self, BufWriter, Write},
};
use crate::vault::{EuphRoomVault, EuphVault, RoomIdentifier};
mod json;
mod text;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use crate::vault::{EuphRoomVault, EuphVault};
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum Format {
/// Human-readable tree-structured messages.
Text,
/// Array of message objects in the same format as the euphoria API uses.
Json,
/// Message objects in the same format as the euphoria API uses, one per
/// line (https://jsonlines.org/).
JsonLines,
/// Message objects in the same format as the euphoria API uses, one per line.
JsonStream,
}
impl Format {
@ -26,15 +23,14 @@ impl Format {
match self {
Self::Text => "text",
Self::Json => "json",
Self::JsonLines => "json lines",
Self::JsonStream => "json stream",
}
}
fn extension(&self) -> &'static str {
match self {
Self::Text => "txt",
Self::Json => "json",
Self::JsonLines => "jsonl",
Self::Json | Self::JsonStream => "json",
}
}
}
@ -47,10 +43,6 @@ pub struct Args {
#[arg(long, short)]
all: bool,
/// Domain to resolve the room names with.
#[arg(long, short, default_value = "euphoria.leet.nu")]
domain: String,
/// Format of the output file.
#[arg(long, short, value_enum, default_value_t = Format::Text)]
format: Format,
@ -82,7 +74,7 @@ async fn export_room<W: Write>(
match format {
Format::Text => text::export(vault, out).await?,
Format::Json => json::export(vault, out).await?,
Format::JsonLines => json::export_lines(vault, out).await?,
Format::JsonStream => json::export_stream(vault, out).await?,
}
Ok(())
}
@ -93,12 +85,7 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
}
let rooms = if args.all {
let mut rooms = vault
.rooms()
.await?
.into_iter()
.map(|id| id.name)
.collect::<Vec<_>>();
let mut rooms = vault.rooms().await?;
rooms.sort_unstable();
rooms
} else {
@ -114,14 +101,14 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
for room in rooms {
if args.out == "-" {
eprintln!("Exporting &{room} as {} to stdout", args.format.name());
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
let vault = vault.room(room);
let mut stdout = BufWriter::new(io::stdout());
export_room(&vault, &mut stdout, args.format).await?;
stdout.flush()?;
} else {
let out = format_out(&args.out, &room, args.format);
eprintln!("Exporting &{room} as {} to {out}", args.format.name());
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
let vault = vault.room(room);
let mut file = BufWriter::new(File::create(out)?);
export_room(&vault, &mut file, args.format).await?;
file.flush()?;

View file

@ -37,7 +37,7 @@ pub async fn export<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Re
Ok(())
}
pub async fn export_lines<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
pub async fn export_stream<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
let mut total = 0;
let mut last_msg_id = None;
loop {

View file

@ -1,11 +1,16 @@
use std::io::Write;
use euphoxide::api::MessageId;
use time::format_description::FormatItem;
use time::macros::format_description;
use unicode_width::UnicodeWidthStr;
use crate::{euph::SmallMessage, store::Tree, vault::EuphRoomVault};
use crate::euph::SmallMessage;
use crate::store::Tree;
use crate::vault::EuphRoomVault;
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
const TIME_FORMAT: &[FormatItem<'_>] =
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
const TIME_EMPTY: &str = " ";
pub async fn export<W: Write>(vault: &EuphRoomVault, out: &mut W) -> anyhow::Result<()> {
@ -62,7 +67,11 @@ fn write_msg<W: Write>(
for (i, line) in msg.content.lines().enumerate() {
if i == 0 {
let time = msg.time.as_timestamp().strftime(TIME_FORMAT);
let time = msg
.time
.0
.format(TIME_FORMAT)
.expect("time can be formatted");
writeln!(file, "{time} {indent_string}[{nick}] {line}")?;
} else {
writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?;

View file

@ -1,22 +1,22 @@
use std::{convert::Infallible, sync::Arc, vec};
use std::convert::Infallible;
use std::sync::Arc;
use std::vec;
use async_trait::async_trait;
use crossterm::style::Stylize;
use jiff::Timestamp;
use log::{Level, LevelFilter, Log};
use parking_lot::Mutex;
use time::OffsetDateTime;
use tokio::sync::mpsc;
use toss::{Style, Styled};
use crate::{
store::{Msg, MsgStore, Path, Tree},
ui::ChatMsg,
};
use crate::store::{Msg, MsgStore, Path, Tree};
use crate::ui::ChatMsg;
#[derive(Debug, Clone)]
pub struct LogMsg {
id: usize,
time: Timestamp,
time: OffsetDateTime,
level: Level,
content: String,
}
@ -42,8 +42,8 @@ impl Msg for LogMsg {
}
impl ChatMsg for LogMsg {
fn time(&self) -> Option<Timestamp> {
Some(self.time)
fn time(&self) -> OffsetDateTime {
self.time
}
fn styled(&self) -> (Styled, Styled) {
@ -209,7 +209,7 @@ impl Log for Logger {
let mut guard = self.messages.lock();
let msg = LogMsg {
id: guard.len(),
time: Timestamp::now(),
time: OffsetDateTime::now_utc(),
level: record.level(),
content: format!("<{}> {}", record.target(), record.args()),
};

View file

@ -1,3 +1,4 @@
// TODO Get rid of this macro as much as possible
macro_rules! logging_unwrap {
($e:expr) => {
match $e {

View file

@ -1,23 +1,19 @@
#![forbid(unsafe_code)]
// Rustc lint groups
#![warn(future_incompatible)]
#![warn(rust_2018_idioms)]
#![warn(unused)]
// Rustc lints
#![warn(noop_method_call)]
#![warn(single_use_lifetimes)]
// Clippy lints
#![warn(clippy::use_self)]
// TODO Enable warn(unreachable_pub)?
// TODO Remove unnecessary Debug impls and compare compile times
// TODO Time zones other than UTC
// TODO Invoke external notification command?
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use cove_config::{Config, doc::Document};
use directories::{BaseDirs, ProjectDirs};
use log::info;
use tokio::sync::mpsc;
use toss::Terminal;
use crate::{
logger::Logger,
ui::Ui,
vault::Vault,
version::{NAME, VERSION},
};
mod euph;
mod export;
mod logger;
@ -26,7 +22,21 @@ mod store;
mod ui;
mod util;
mod vault;
mod version;
use std::path::PathBuf;
use clap::Parser;
use cookie::CookieJar;
use cove_config::doc::Document;
use cove_config::Config;
use directories::{BaseDirs, ProjectDirs};
use log::info;
use tokio::sync::mpsc;
use toss::Terminal;
use crate::logger::Logger;
use crate::ui::Ui;
use crate::vault::Vault;
#[derive(Debug, clap::Parser)]
enum Command {
@ -37,21 +47,11 @@ enum Command {
/// Compact and clean up vault.
Gc,
/// Clear euphoria session cookies.
ClearCookies {
/// Clear cookies for a specific domain only.
#[arg(long, short)]
domain: Option<String>,
},
ClearCookies,
/// Print config documentation as markdown.
HelpConfig,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum WidthEstimationMethod {
Legacy,
Unicode,
}
impl Default for Command {
fn default() -> Self {
Self::Run
@ -85,11 +85,6 @@ struct Args {
#[arg(long, short)]
offline: bool,
/// Method for estimating the width of characters as displayed by the
/// terminal emulator.
#[arg(long, short)]
width_estimation_method: Option<WidthEstimationMethod>,
/// Measure the width of characters as displayed by the terminal emulator
/// instead of guessing the width.
#[arg(long, short)]
@ -125,26 +120,18 @@ fn update_config_with_args(config: &mut Config, args: &Args) {
}
config.ephemeral |= args.ephemeral;
if let Some(method) = args.width_estimation_method {
config.width_estimation_method = match method {
WidthEstimationMethod::Legacy => cove_config::WidthEstimationMethod::Legacy,
WidthEstimationMethod::Unicode => cove_config::WidthEstimationMethod::Unicode,
}
}
config.measure_widths |= args.measure_widths;
config.offline |= args.offline;
}
fn open_vault(config: &Config, dirs: &ProjectDirs) -> anyhow::Result<Vault> {
let vault = if config.ephemeral {
vault::launch_in_memory()?
fn open_vault(config: &Config, dirs: &ProjectDirs) -> rusqlite::Result<Vault> {
if config.ephemeral {
vault::launch_in_memory()
} else {
let data_dir = data_dir(config, dirs);
eprintln!("Data dir: {}", data_dir.to_string_lossy());
vault::launch(&data_dir.join("vault.db"))?
};
Ok(vault)
vault::launch(&data_dir.join("vault.db"))
}
}
#[tokio::main]
@ -154,11 +141,6 @@ async fn main() -> anyhow::Result<()> {
let (logger, logger_guard, logger_rx) = Logger::init(args.verbose);
let dirs = ProjectDirs::from("de", "plugh", "cove").expect("failed to find config directory");
// https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.unwrap();
// Locate config
let config_path = config_path(&args, &dirs);
eprintln!("Config file: {}", config_path.to_string_lossy());
@ -172,7 +154,7 @@ async fn main() -> anyhow::Result<()> {
Command::Run => run(logger, logger_rx, config, &dirs).await?,
Command::Export(args) => export(config, &dirs, args).await?,
Command::Gc => gc(config, &dirs).await?,
Command::ClearCookies { domain } => clear_cookies(config, &dirs, domain).await?,
Command::ClearCookies => clear_cookies(config, &dirs).await?,
Command::HelpConfig => help_config(),
}
@ -191,19 +173,17 @@ async fn run(
config: &'static Config,
dirs: &ProjectDirs,
) -> anyhow::Result<()> {
info!("Welcome to {NAME} {VERSION}",);
let tz = util::load_time_zone(config.time_zone_ref()).context("failed to load time zone")?;
info!(
"Welcome to {} {}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
);
let vault = open_vault(config, dirs)?;
let mut terminal = Terminal::new()?;
terminal.set_measuring(config.measure_widths);
terminal.set_width_estimation_method(match config.width_estimation_method {
cove_config::WidthEstimationMethod::Legacy => toss::WidthEstimationMethod::Legacy,
cove_config::WidthEstimationMethod::Unicode => toss::WidthEstimationMethod::Unicode,
});
Ui::run(config, tz, &mut terminal, vault.clone(), logger, logger_rx).await?;
Ui::run(config, &mut terminal, vault.clone(), logger, logger_rx).await?;
drop(terminal);
vault.close().await;
@ -234,15 +214,11 @@ async fn gc(config: &'static Config, dirs: &ProjectDirs) -> anyhow::Result<()> {
Ok(())
}
async fn clear_cookies(
config: &'static Config,
dirs: &ProjectDirs,
domain: Option<String>,
) -> anyhow::Result<()> {
async fn clear_cookies(config: &'static Config, dirs: &ProjectDirs) -> anyhow::Result<()> {
let vault = open_vault(config, dirs)?;
eprintln!("Clearing cookies");
vault.euph().clear_cookies(domain).await?;
vault.euph().set_cookies(CookieJar::new()).await?;
vault.close().await;
Ok(())

View file

@ -1,4 +1,7 @@
use std::{collections::HashMap, fmt::Debug, hash::Hash, vec};
use std::collections::HashMap;
use std::fmt::Debug;
use std::hash::Hash;
use std::vec;
use async_trait::async_trait;
@ -8,10 +11,6 @@ pub trait Msg {
fn parent(&self) -> Option<Self::Id>;
fn seen(&self) -> bool;
fn nick_emoji(&self) -> Option<String> {
None
}
fn last_possible_id() -> Self::Id;
}
@ -28,6 +27,10 @@ impl<I> Path<I> {
self.0.iter().take(self.0.len() - 1)
}
pub fn push(&mut self, segment: I) {
self.0.push(segment)
}
pub fn first(&self) -> &I {
self.0.first().expect("path is empty")
}
@ -131,7 +134,6 @@ impl<M: Msg> Tree<M> {
}
}
#[allow(dead_code)]
#[async_trait]
pub trait MsgStore<M: Msg> {
type Error;

View file

@ -1,30 +1,3 @@
use std::{
convert::Infallible,
io,
sync::{Arc, Weak},
time::{Duration, Instant},
};
use cove_config::Config;
use cove_input::InputEvent;
use jiff::tz::TimeZone;
use parking_lot::FairMutex;
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender, error::TryRecvError},
task,
};
use toss::{Terminal, WidgetExt, widgets::BoxedAsync};
use crate::{
logger::{LogMsg, Logger},
macros::logging_unwrap,
util::InfallibleExt,
vault::Vault,
};
pub use self::chat::ChatMsg;
use self::{chat::ChatState, rooms::Rooms, widgets::ListState};
mod chat;
mod euph;
mod key_bindings;
@ -32,6 +5,30 @@ mod rooms;
mod util;
mod widgets;
use std::convert::Infallible;
use std::io;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use cove_config::Config;
use cove_input::InputEvent;
use parking_lot::FairMutex;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task;
use toss::widgets::BoxedAsync;
use toss::{Terminal, WidgetExt};
use crate::logger::{LogMsg, Logger};
use crate::macros::logging_unwrap;
use crate::util::InfallibleExt;
use crate::vault::Vault;
pub use self::chat::ChatMsg;
use self::chat::ChatState;
use self::rooms::Rooms;
use self::widgets::ListState;
/// Time to spend batch processing events before redrawing the screen.
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
@ -50,7 +47,6 @@ impl From<Infallible> for UiError {
}
}
#[expect(clippy::large_enum_variant)]
pub enum UiEvent {
GraphemeWidthsChanged,
LogChanged,
@ -88,7 +84,6 @@ impl Ui {
pub async fn run(
config: &'static Config,
tz: TimeZone,
terminal: &mut Terminal,
vault: Vault,
logger: Logger,
@ -117,8 +112,8 @@ impl Ui {
config,
event_tx: event_tx.clone(),
mode: Mode::Main,
rooms: Rooms::new(config, tz.clone(), vault, event_tx.clone()).await,
log_chat: ChatState::new(logger, tz),
rooms: Rooms::new(config, vault, event_tx.clone()).await,
log_chat: ChatState::new(logger),
key_bindings_visible: false,
key_bindings_list: ListState::new(),
};
@ -186,8 +181,9 @@ impl Ui {
}
// Handle events (in batches)
let Some(mut event) = event_rx.recv().await else {
return Ok(());
let mut event = match event_rx.recv().await {
Some(event) => event,
None => return Ok(()),
};
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
loop {

View file

@ -1,28 +1,24 @@
use cove_config::Keys;
use cove_input::InputEvent;
use jiff::{Timestamp, tz::TimeZone};
use toss::{
Styled, WidgetExt,
widgets::{BoxedAsync, EditorState},
};
use crate::{
store::{Msg, MsgStore},
util,
};
use super::UiError;
use self::{cursor::Cursor, tree::TreeViewState};
mod blocks;
mod cursor;
mod renderer;
mod tree;
mod widgets;
use cove_config::Keys;
use cove_input::InputEvent;
use time::OffsetDateTime;
use toss::widgets::{BoxedAsync, EditorState};
use toss::{Styled, WidgetExt};
use crate::store::{Msg, MsgStore};
use self::cursor::Cursor;
use self::tree::TreeViewState;
use super::UiError;
pub trait ChatMsg {
fn time(&self) -> Option<Timestamp>;
fn time(&self) -> OffsetDateTime;
fn styled(&self) -> (Styled, Styled);
fn edit(nick: &str, content: &str) -> (Styled, Styled);
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
@ -37,31 +33,23 @@ pub struct ChatState<M: Msg, S: MsgStore<M>> {
cursor: Cursor<M::Id>,
editor: EditorState,
nick_emoji: bool,
caesar: i8,
mode: Mode,
tree: TreeViewState<M, S>,
}
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
pub fn new(store: S, tz: TimeZone) -> Self {
pub fn new(store: S) -> Self {
Self {
cursor: Cursor::Bottom,
editor: EditorState::new(),
nick_emoji: false,
caesar: 0,
mode: Mode::Tree,
tree: TreeViewState::new(store.clone(), tz),
tree: TreeViewState::new(store.clone()),
store,
}
}
pub fn nick_emoji(&self) -> bool {
self.nick_emoji
}
}
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
@ -80,14 +68,7 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
match self.mode {
Mode::Tree => self
.tree
.widget(
&mut self.cursor,
&mut self.editor,
nick,
focused,
self.nick_emoji,
self.caesar,
)
.widget(&mut self.cursor, &mut self.editor, nick, focused)
.boxed_async(),
}
}
@ -104,7 +85,7 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
S: Send + Sync,
S::Error: Send,
{
let reaction = match self.mode {
match self.mode {
Mode::Tree => {
self.tree
.handle_input_event(
@ -114,33 +95,9 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
&mut self.editor,
can_compose,
)
.await?
.await
}
};
Ok(match reaction {
Reaction::Composed { parent, content } if self.caesar != 0 => {
let content = util::caesar(&content, self.caesar);
Reaction::Composed { parent, content }
}
Reaction::NotHandled if event.matches(&keys.tree.action.toggle_nick_emoji) => {
self.nick_emoji = !self.nick_emoji;
Reaction::Handled
}
Reaction::NotHandled if event.matches(&keys.tree.action.increase_caesar) => {
self.caesar = (self.caesar + 1).rem_euclid(26);
Reaction::Handled
}
Reaction::NotHandled if event.matches(&keys.tree.action.decrease_caesar) => {
self.caesar = (self.caesar - 1).rem_euclid(26);
Reaction::Handled
}
reaction => reaction,
})
}
}
pub fn cursor(&self) -> Option<&M::Id> {

View file

@ -1,6 +1,6 @@
//! Common rendering logic.
use std::collections::{VecDeque, vec_deque};
use std::collections::{vec_deque, VecDeque};
use toss::widgets::Predrawn;
@ -161,6 +161,14 @@ impl<Id> Blocks<Id> {
pub fn shift(&mut self, delta: i32) {
self.range = self.range.shifted(delta);
}
pub fn set_top(&mut self, top: i32) {
self.shift(top - self.range.top);
}
pub fn set_bottom(&mut self, bottom: i32) {
self.shift(bottom - self.range.bottom);
}
}
pub struct Iter<'a, Id> {

View file

@ -1,6 +1,7 @@
//! Common cursor movement logic.
use std::{collections::HashSet, hash::Hash};
use std::collections::HashSet;
use std::hash::Hash;
use crate::store::{Msg, MsgStore, Tree};

View file

@ -14,6 +14,7 @@ pub trait Renderer<Id> {
fn blocks(&self) -> &Blocks<Id>;
fn blocks_mut(&mut self) -> &mut Blocks<Id>;
fn into_blocks(self) -> Blocks<Id>;
async fn expand_top(&mut self) -> Result<(), Self::Error>;
async fn expand_bottom(&mut self) -> Result<(), Self::Error>;
@ -274,6 +275,27 @@ where
}
}
pub fn clamp_scroll_biased_upwards<Id, R>(r: &mut R)
where
R: Renderer<Id>,
{
let area = visible_area(r);
let blocks = r.blocks().range();
// Delta that moves blocks.top to the top of the screen. If this is
// negative, we need to move the blocks because they're too low.
let move_to_top = blocks.top - area.top;
// Delta that moves blocks.bottom to the bottom of the screen. If this is
// positive, we need to move the blocks because they're too high.
let move_to_bottom = blocks.bottom - area.bottom;
// If the screen is higher, the blocks should rather be moved to the top
// than the bottom because of the upwards bias.
let delta = 0.max(move_to_bottom).min(move_to_top);
r.blocks_mut().shift(delta);
}
pub fn clamp_scroll_biased_downwards<Id, R>(r: &mut R)
where
R: Renderer<Id>,

View file

@ -2,31 +2,29 @@
// TODO Focusing on sub-trees
mod renderer;
mod scroll;
mod widgets;
use std::collections::HashSet;
use async_trait::async_trait;
use cove_config::Keys;
use cove_input::InputEvent;
use jiff::tz::TimeZone;
use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb, widgets::EditorState};
use toss::widgets::EditorState;
use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb};
use crate::{
store::{Msg, MsgStore},
ui::{UiError, util},
util::InfallibleExt,
};
use super::{ChatMsg, Reaction, cursor::Cursor};
use crate::store::{Msg, MsgStore};
use crate::ui::{util, ChatMsg, UiError};
use crate::util::InfallibleExt;
use self::renderer::{TreeContext, TreeRenderer};
mod renderer;
mod scroll;
mod widgets;
use super::cursor::Cursor;
use super::Reaction;
pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
store: S,
tz: TimeZone,
last_size: Size,
last_nick: String,
@ -38,10 +36,9 @@ pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
}
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
pub fn new(store: S, tz: TimeZone) -> Self {
pub fn new(store: S) -> Self {
Self {
store,
tz,
last_size: Size::ZERO,
last_nick: String::new(),
last_cursor: Cursor::Bottom,
@ -389,8 +386,6 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
editor: &'a mut EditorState,
nick: String,
focused: bool,
nick_emoji: bool,
caesar: i8,
) -> TreeView<'a, M, S> {
TreeView {
state: self,
@ -398,8 +393,6 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
editor,
nick,
focused,
nick_emoji,
caesar,
}
}
}
@ -412,9 +405,6 @@ pub struct TreeView<'a, M: Msg, S: MsgStore<M>> {
nick: String,
focused: bool,
nick_emoji: bool,
caesar: i8,
}
#[async_trait]
@ -442,8 +432,6 @@ where
size,
nick: self.nick.clone(),
focused: self.focused,
nick_emoji: self.nick_emoji,
caesar: self.caesar,
last_cursor: self.state.last_cursor.clone(),
last_cursor_top: self.state.last_cursor_top,
};
@ -451,7 +439,6 @@ where
let mut renderer = TreeRenderer::new(
context,
&self.state.store,
&self.state.tz,
&mut self.state.folded,
self.cursor,
self.editor,

View file

@ -1,26 +1,18 @@
//! A [`Renderer`] for message trees.
use std::{collections::HashSet, convert::Infallible};
use std::collections::HashSet;
use std::convert::Infallible;
use async_trait::async_trait;
use jiff::tz::TimeZone;
use toss::{
Size, Widget, WidthDb,
widgets::{EditorState, Empty, Predrawn, Resize},
};
use toss::widgets::{EditorState, Empty, Predrawn, Resize};
use toss::{Size, Widget, WidthDb};
use crate::{
store::{Msg, MsgStore, Tree},
ui::{
ChatMsg,
chat::{
blocks::{Block, Blocks, Range},
cursor::Cursor,
renderer::{self, Renderer, overlaps},
},
},
util::InfallibleExt,
};
use crate::store::{Msg, MsgStore, Tree};
use crate::ui::chat::blocks::{Block, Blocks, Range};
use crate::ui::chat::cursor::Cursor;
use crate::ui::chat::renderer::{self, overlaps, Renderer};
use crate::ui::ChatMsg;
use crate::util::InfallibleExt;
use super::widgets;
@ -80,8 +72,6 @@ pub struct TreeContext<Id> {
pub size: Size,
pub nick: String,
pub focused: bool,
pub nick_emoji: bool,
pub caesar: i8,
pub last_cursor: Cursor<Id>,
pub last_cursor_top: i32,
}
@ -90,7 +80,6 @@ pub struct TreeRenderer<'a, M: Msg, S: MsgStore<M>> {
context: TreeContext<M::Id>,
store: &'a S,
tz: &'a TimeZone,
folded: &'a mut HashSet<M::Id>,
cursor: &'a mut Cursor<M::Id>,
editor: &'a mut EditorState,
@ -118,7 +107,6 @@ where
pub fn new(
context: TreeContext<M::Id>,
store: &'a S,
tz: &'a TimeZone,
folded: &'a mut HashSet<M::Id>,
cursor: &'a mut Cursor<M::Id>,
editor: &'a mut EditorState,
@ -127,7 +115,6 @@ where
Self {
context,
store,
tz,
folded,
cursor,
editor,
@ -161,12 +148,8 @@ where
None => TreeBlockId::Bottom,
};
let widget = widgets::editor::<M>(
indent,
&self.context.nick,
self.context.focused,
self.editor,
);
// TODO Unhighlighted version when focusing on nick list
let widget = widgets::editor::<M>(indent, &self.context.nick, self.editor);
let widget = Self::predraw(widget, self.context.size, self.widthdb);
let mut block = Block::new(id, widget, false);
@ -184,6 +167,7 @@ where
None => TreeBlockId::Bottom,
};
// TODO Unhighlighted version when focusing on nick list
let widget = widgets::pseudo::<M>(indent, &self.context.nick, self.editor);
let widget = Self::predraw(widget, self.context.size, self.widthdb);
Block::new(id, widget, false)
@ -203,15 +187,7 @@ where
};
let highlighted = highlighted && self.context.focused;
let widget = widgets::msg(
highlighted,
self.tz.clone(),
indent,
msg,
self.context.nick_emoji,
self.context.caesar,
folded_info,
);
let widget = widgets::msg(highlighted, indent, msg, folded_info);
let widget = Self::predraw(widget, self.context.size, self.widthdb);
Block::new(TreeBlockId::Msg(msg_id), widget, true)
}
@ -446,7 +422,7 @@ where
pub fn into_visible_blocks(
self,
) -> impl Iterator<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> + use<M, S> {
) -> impl Iterator<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> {
let area = renderer::visible_area(&self);
self.blocks
.into_iter()
@ -480,6 +456,10 @@ where
&mut self.blocks
}
fn into_blocks(self) -> TreeBlocks<M::Id> {
self.blocks
}
async fn expand_top(&mut self) -> Result<(), Self::Error> {
let prev_root_id = if let Some(top_root_id) = &self.top_root_id {
self.store.prev_root_id(top_root_id).await?

View file

@ -1,14 +1,12 @@
use toss::{WidthDb, widgets::EditorState};
use toss::widgets::EditorState;
use toss::WidthDb;
use crate::{
store::{Msg, MsgStore},
ui::{ChatMsg, chat::cursor::Cursor},
};
use crate::store::{Msg, MsgStore};
use crate::ui::chat::cursor::Cursor;
use crate::ui::ChatMsg;
use super::{
TreeViewState,
renderer::{TreeContext, TreeRenderer},
};
use super::renderer::{TreeContext, TreeRenderer};
use super::TreeViewState;
impl<M, S> TreeViewState<M, S>
where
@ -22,8 +20,6 @@ where
size: self.last_size,
nick: self.last_nick.clone(),
focused: true,
nick_emoji: false,
caesar: 0,
last_cursor: self.last_cursor.clone(),
last_cursor_top: self.last_cursor_top,
}
@ -40,7 +36,6 @@ where
let mut renderer = TreeRenderer::new(
context,
&self.store,
&self.tz,
&mut self.folded,
cursor,
editor,
@ -68,7 +63,6 @@ where
let mut renderer = TreeRenderer::new(
context,
&self.store,
&self.tz,
&mut self.folded,
cursor,
editor,

View file

@ -1,20 +1,12 @@
use std::convert::Infallible;
use crossterm::style::Stylize;
use jiff::tz::TimeZone;
use toss::{
Style, Styled, WidgetExt,
widgets::{Boxed, EditorState, Join2, Join4, Join5, Text},
};
use toss::widgets::{Boxed, EditorState, Join2, Join4, Join5, Text};
use toss::{Style, Styled, WidgetExt};
use crate::{
store::Msg,
ui::{
ChatMsg,
chat::widgets::{Indent, Seen, Time},
},
util,
};
use crate::store::Msg;
use crate::ui::chat::widgets::{Indent, Seen, Time};
use crate::ui::ChatMsg;
pub const PLACEHOLDER: &str = "[...]";
@ -38,10 +30,6 @@ fn style_indent(highlighted: bool) -> Style {
}
}
fn style_caesar() -> Style {
Style::new().green()
}
fn style_info() -> Style {
Style::new().italic().dark_grey()
}
@ -56,28 +44,11 @@ fn style_pseudo_highlight() -> Style {
pub fn msg<M: Msg + ChatMsg>(
highlighted: bool,
tz: TimeZone,
indent: usize,
msg: &M,
nick_emoji: bool,
caesar: i8,
folded_info: Option<usize>,
) -> Boxed<'static, Infallible> {
let (mut nick, mut content) = msg.styled();
if nick_emoji {
if let Some(emoji) = msg.nick_emoji() {
nick = nick.then_plain("(").then_plain(emoji).then_plain(")");
}
}
if caesar != 0 {
// Apply caesar in inverse because we're decoding
let rotated = util::caesar(content.text(), -caesar);
content = content
.then_plain("\n")
.then(format!("{rotated} [rot{caesar}]"), style_caesar());
}
let (nick, mut content) = msg.styled();
if let Some(amount) = folded_info {
content = content
@ -87,7 +58,7 @@ pub fn msg<M: Msg + ChatMsg>(
Join5::horizontal(
Seen::new(msg.seen()).segment().with_fixed(true),
Time::new(msg.time().map(|t| t.to_zoned(tz)), style_time(highlighted))
Time::new(Some(msg.time()), style_time(highlighted))
.padding()
.with_right(1)
.with_stretch(true)
@ -145,14 +116,10 @@ pub fn msg_placeholder(
pub fn editor<'a, M: ChatMsg>(
indent: usize,
nick: &str,
focus: bool,
editor: &'a mut EditorState,
) -> Boxed<'a, Infallible> {
let (nick, content) = M::edit(nick, editor.text());
let editor = editor
.widget()
.with_highlight(|_| content)
.with_focus(focus);
let editor = editor.widget().with_highlight(|_| content);
Join5::horizontal(
Seen::new(true).segment().with_fixed(true),

View file

@ -1,11 +1,11 @@
use std::convert::Infallible;
use crossterm::style::Stylize;
use jiff::Zoned;
use toss::{
Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb,
widgets::{Boxed, Empty, Text},
};
use time::format_description::FormatItem;
use time::macros::format_description;
use time::OffsetDateTime;
use toss::widgets::{Boxed, Empty, Text};
use toss::{Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb};
use crate::util::InfallibleExt;
@ -46,15 +46,15 @@ impl<E> Widget<E> for Indent {
}
}
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M";
const TIME_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day] [hour]:[minute]");
const TIME_WIDTH: u16 = 16;
pub struct Time(Boxed<'static, Infallible>);
impl Time {
pub fn new(time: Option<Zoned>, style: Style) -> Self {
pub fn new(time: Option<OffsetDateTime>, style: Style) -> Self {
let widget = if let Some(time) = time {
let text = time.strftime(TIME_FORMAT).to_string();
let text = time.format(TIME_FORMAT).expect("could not format time");
Text::new((text, style))
.background()
.with_style(style)

View file

@ -1,16 +1,14 @@
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize;
use euphoxide::{api::PersonalAccountView, conn};
use toss::{
Style, Widget, WidgetExt,
widgets::{EditorState, Empty, Join3, Join4, Join5, Text},
};
use euphoxide::api::PersonalAccountView;
use euphoxide::conn;
use toss::widgets::{EditorState, Empty, Join3, Join4, Join5, Text};
use toss::{Style, Widget, WidgetExt};
use crate::{
euph::{self, Room},
ui::{UiError, util, widgets::Popup},
};
use crate::euph::{self, Room};
use crate::ui::widgets::Popup;
use crate::ui::{util, UiError};
use super::popup::PopupResult;
@ -35,7 +33,7 @@ impl LoggedOut {
}
}
fn widget(&mut self) -> impl Widget<UiError> {
fn widget(&mut self) -> impl Widget<UiError> + '_ {
let bold = Style::new().bold();
Join4::vertical(
Text::new(("Not logged in", bold.yellow())).segment(),
@ -68,7 +66,7 @@ impl LoggedOut {
pub struct LoggedIn(PersonalAccountView);
impl LoggedIn {
fn widget(&self) -> impl Widget<UiError> + use<> {
fn widget(&self) -> impl Widget<UiError> {
let bold = Style::new().bold();
Join5::vertical(
Text::new(("Logged in", bold.green())).segment(),
@ -111,7 +109,7 @@ impl AccountUiState {
}
}
pub fn widget(&mut self) -> impl Widget<UiError> {
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
let inner = match self {
Self::LoggedOut(logged_out) => logged_out.widget().first2(),
Self::LoggedIn(logged_in) => logged_in.widget().second2(),

View file

@ -1,11 +1,11 @@
use cove_config::Keys;
use cove_input::InputEvent;
use toss::{Widget, widgets::EditorState};
use toss::widgets::EditorState;
use toss::Widget;
use crate::{
euph::Room,
ui::{UiError, util, widgets::Popup},
};
use crate::euph::Room;
use crate::ui::widgets::Popup;
use crate::ui::{util, UiError};
use super::popup::PopupResult;
@ -13,7 +13,7 @@ pub fn new() -> EditorState {
EditorState::new()
}
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> {
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
Popup::new(
editor.widget().with_hidden_default_placeholder(),
"Enter password",

View file

@ -1,13 +1,13 @@
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize;
use euphoxide::{
api::{Message, NickEvent, SessionView},
conn::SessionInfo,
};
use toss::{Style, Styled, Widget, widgets::Text};
use euphoxide::api::{Message, NickEvent, SessionView};
use euphoxide::conn::SessionInfo;
use toss::widgets::Text;
use toss::{Style, Styled, Widget};
use crate::ui::{UiError, widgets::Popup};
use crate::ui::widgets::Popup;
use crate::ui::UiError;
use super::popup::PopupResult;
@ -91,7 +91,7 @@ fn message_lines(mut text: Styled, msg: &Message) -> Styled {
text
}
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> + use<> {
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> {
let heading_style = Style::new().bold();
let text = match session {
@ -108,7 +108,7 @@ pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> + use<> {
Popup::new(Text::new(text), "Inspect session")
}
pub fn message_widget(msg: &Message) -> impl Widget<UiError> + use<> {
pub fn message_widget(msg: &Message) -> impl Widget<UiError> {
let heading_style = Style::new().bold();
let mut text = Styled::new("Message", heading_style).then_plain("\n");

View file

@ -1,31 +1,19 @@
use cove_config::{Config, Keys};
use cove_input::InputEvent;
use crossterm::{event::KeyCode, style::Stylize};
use crossterm::event::KeyCode;
use crossterm::style::Stylize;
use linkify::{LinkFinder, LinkKind};
use toss::{
Style, Styled, Widget, WidgetExt,
widgets::{Join2, Text},
};
use toss::widgets::{Join2, Text};
use toss::{Style, Styled, Widget, WidgetExt};
use crate::{
euph::{self, SpanType},
ui::{
UiError, key_bindings, util,
widgets::{ListBuilder, ListState, Popup},
},
};
use crate::ui::widgets::{ListBuilder, ListState, Popup};
use crate::ui::{key_bindings, util, UiError};
use super::popup::PopupResult;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Link {
Url(String),
Room(String),
}
pub struct LinksState {
config: &'static Config,
links: Vec<Link>,
links: Vec<String>,
list: ListState<usize>,
}
@ -33,34 +21,12 @@ const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0
impl LinksState {
pub fn new(config: &'static Config, content: &str) -> Self {
let mut links = vec![];
// Collect URL-like links
for link in LinkFinder::new()
let links = LinkFinder::new()
.url_must_have_scheme(false)
.kinds(&[LinkKind::Url])
.links(content)
{
links.push((
link.start(),
link.end(),
Link::Url(link.as_str().to_string()),
));
}
// Collect room links
for (span, range) in euph::find_spans(content) {
if span == SpanType::Room {
let name = &content[range.start + 1..range.end];
links.push((range.start, range.end, Link::Room(name.to_string())));
}
}
links.sort();
let links = links
.into_iter()
.map(|(_, _, link)| link)
.collect::<Vec<_>>();
.map(|l| l.as_str().to_string())
.collect();
Self {
config,
@ -69,7 +35,7 @@ impl LinksState {
}
}
pub fn widget(&mut self) -> impl Widget<UiError> {
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
let style_selected = Style::new().black().on_white();
let mut list_builder = ListBuilder::new();
@ -80,29 +46,29 @@ impl LinksState {
for (id, link) in self.links.iter().enumerate() {
let link = link.clone();
list_builder.add_sel(id, move |selected| {
let mut text = Styled::default();
// Number key indicator
text = match NUMBER_KEYS.get(id) {
None if selected => text.then(" ", style_selected),
None => text.then_plain(" "),
Some(key) if selected => text.then(format!("[{key}] "), style_selected.bold()),
Some(key) => text.then(format!("[{key}] "), Style::new().dark_grey().bold()),
};
// The link itself
text = match link {
Link::Url(url) if selected => text.then(url, style_selected),
Link::Url(url) => text.then_plain(url),
Link::Room(name) if selected => {
text.then(format!("&{name}"), style_selected.bold())
}
Link::Room(name) => text.then(format!("&{name}"), Style::new().blue().bold()),
};
Text::new(text).with_wrap(false)
});
if let Some(&number_key) = NUMBER_KEYS.get(id) {
list_builder.add_sel(id, move |selected| {
let text = if selected {
Styled::new(format!("[{number_key}]"), style_selected.bold())
.then(" ", style_selected)
.then(link, style_selected)
} else {
Styled::new(format!("[{number_key}]"), Style::new().dark_grey().bold())
.then_plain(" ")
.then_plain(link)
};
Text::new(text)
});
} else {
list_builder.add_sel(id, move |selected| {
let text = if selected {
Styled::new(format!(" {link}"), style_selected)
} else {
Styled::new_plain(format!(" {link}"))
};
Text::new(text)
});
}
}
let hint_style = Style::new().grey().italic();
@ -126,24 +92,18 @@ impl LinksState {
}
fn open_link_by_id(&self, id: usize) -> PopupResult {
match self.links.get(id) {
Some(Link::Url(url)) => {
// The `http://` or `https://` schema is necessary for
// open::that to successfully open the link in the browser.
let link = if url.starts_with("http://") || url.starts_with("https://") {
url.clone()
} else {
format!("https://{url}")
};
if let Some(link) = self.links.get(id) {
// The `http://` or `https://` schema is necessary for open::that to
// successfully open the link in the browser.
let link = if link.starts_with("http://") || link.starts_with("https://") {
link.clone()
} else {
format!("https://{link}")
};
if let Err(error) = open::that(&link) {
return PopupResult::ErrorOpeningLink { link, error };
}
if let Err(error) = open::that(&link) {
return PopupResult::ErrorOpeningLink { link, error };
}
Some(Link::Room(name)) => return PopupResult::SwitchToRoom { name: name.clone() },
_ => {}
}
PopupResult::Handled
}

View file

@ -1,12 +1,12 @@
use cove_config::Keys;
use cove_input::InputEvent;
use euphoxide::conn::Joined;
use toss::{Style, Widget, widgets::EditorState};
use toss::widgets::EditorState;
use toss::{Style, Widget};
use crate::{
euph::{self, Room},
ui::{UiError, util, widgets::Popup},
};
use crate::euph::{self, Room};
use crate::ui::widgets::Popup;
use crate::ui::{util, UiError};
use super::popup::PopupResult;
@ -14,7 +14,7 @@ pub fn new(joined: Joined) -> EditorState {
EditorState::with_initial_text(joined.session.name)
}
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> {
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
let inner = editor
.widget()
.with_highlight(|s| euph::style_nick_exact(s, Style::new()));

View file

@ -1,31 +1,22 @@
use std::iter;
use crossterm::style::{Color, Stylize};
use euphoxide::{
api::{NickEvent, SessionId, SessionType, SessionView, UserId},
conn::{Joined, SessionInfo},
};
use toss::{
Style, Styled, Widget, WidgetExt,
widgets::{Background, Text},
};
use euphoxide::api::{NickEvent, SessionId, SessionType, SessionView, UserId};
use euphoxide::conn::{Joined, SessionInfo};
use toss::widgets::{Background, Text};
use toss::{Style, Styled, Widget, WidgetExt};
use crate::{
euph,
ui::{
UiError,
widgets::{ListBuilder, ListState},
},
};
use crate::euph;
use crate::ui::widgets::{ListBuilder, ListState};
use crate::ui::UiError;
pub fn widget<'a>(
list: &'a mut ListState<SessionId>,
joined: &Joined,
focused: bool,
nick_emoji: bool,
) -> impl Widget<UiError> + use<'a> {
) -> impl Widget<UiError> + 'a {
let mut list_builder = ListBuilder::new();
render_rows(&mut list_builder, joined, focused, nick_emoji);
render_rows(&mut list_builder, joined, focused);
list_builder.build(list)
}
@ -71,7 +62,6 @@ fn render_rows(
list_builder: &mut ListBuilder<'_, SessionId, Background<Text>>,
joined: &Joined,
focused: bool,
nick_emoji: bool,
) {
let mut people = vec![];
let mut bots = vec![];
@ -97,38 +87,10 @@ fn render_rows(
lurkers.sort_unstable();
nurkers.sort_unstable();
render_section(
list_builder,
"People",
&people,
&joined.session,
focused,
nick_emoji,
);
render_section(
list_builder,
"Bots",
&bots,
&joined.session,
focused,
nick_emoji,
);
render_section(
list_builder,
"Lurkers",
&lurkers,
&joined.session,
focused,
nick_emoji,
);
render_section(
list_builder,
"Nurkers",
&nurkers,
&joined.session,
focused,
nick_emoji,
);
render_section(list_builder, "People", &people, &joined.session, focused);
render_section(list_builder, "Bots", &bots, &joined.session, focused);
render_section(list_builder, "Lurkers", &lurkers, &joined.session, focused);
render_section(list_builder, "Nurkers", &nurkers, &joined.session, focused);
}
fn render_section(
@ -137,7 +99,6 @@ fn render_section(
sessions: &[HalfSession],
own_session: &SessionView,
focused: bool,
nick_emoji: bool,
) {
if sessions.is_empty() {
return;
@ -155,7 +116,7 @@ fn render_section(
list_builder.add_unsel(Text::new(row).background());
for session in sessions {
render_row(list_builder, session, own_session, focused, nick_emoji);
render_row(list_builder, session, own_session, focused);
}
}
@ -164,7 +125,6 @@ fn render_row(
session: &HalfSession,
own_session: &SessionView,
focused: bool,
nick_emoji: bool,
) {
let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() {
let name = "lurk".to_string();
@ -198,24 +158,16 @@ fn render_row(
" "
};
let emoji = if nick_emoji {
format!(" ({})", euph::user_id_emoji(&session.id))
} else {
"".to_string()
};
list_builder.add_sel(session.session_id.clone(), move |selected| {
if focused && selected {
let text = Styled::new_plain(owner)
.then(name, style_inv)
.then(perms, perms_style_inv)
.then(emoji, perms_style_inv);
.then(perms, perms_style_inv);
Text::new(text).background().with_style(style_inv)
} else {
let text = Styled::new_plain(owner)
.then(&name, style)
.then_plain(perms)
.then_plain(emoji);
.then_plain(perms);
Text::new(text).background()
}
});

View file

@ -1,16 +1,18 @@
use std::io;
use crossterm::style::Stylize;
use toss::{Style, Styled, Widget, widgets::Text};
use toss::widgets::Text;
use toss::{Style, Styled, Widget};
use crate::ui::{UiError, widgets::Popup};
use crate::ui::widgets::Popup;
use crate::ui::UiError;
pub enum RoomPopup {
Error { description: String, reason: String },
}
impl RoomPopup {
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> + use<> {
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> {
let border_style = Style::new().red().bold();
let text = Styled::new_plain(description)
.then_plain("\n\n")
@ -21,7 +23,7 @@ impl RoomPopup {
Popup::new(Text::new(text), ("Error", border_style)).with_border_style(border_style)
}
pub fn widget(&self) -> impl Widget<UiError> + use<> {
pub fn widget(&self) -> impl Widget<UiError> {
match self {
Self::Error {
description,
@ -35,6 +37,5 @@ pub enum PopupResult {
NotHandled,
Handled,
Close,
SwitchToRoom { name: String },
ErrorOpeningLink { link: String, error: io::Error },
}

View file

@ -3,40 +3,25 @@ use std::collections::VecDeque;
use cove_config::{Config, Keys};
use cove_input::InputEvent;
use crossterm::style::Stylize;
use euphoxide::{
api::{Data, Message, MessageId, PacketType, SessionId, packet::ParsedPacket},
bot::instance::{ConnSnapshot, Event, ServerConfig},
conn::{self, Joined, Joining, SessionInfo},
};
use jiff::tz::TimeZone;
use tokio::sync::{
mpsc,
oneshot::{self, error::TryRecvError},
};
use toss::{
Style, Styled, Widget, WidgetExt,
widgets::{BoxedAsync, EditorState, Join2, Layer, Text},
};
use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId};
use euphoxide::bot::instance::{Event, ServerConfig};
use euphoxide::conn::{self, Joined, Joining, SessionInfo};
use tokio::sync::oneshot::error::TryRecvError;
use tokio::sync::{mpsc, oneshot};
use toss::widgets::{BoxedAsync, EditorState, Join2, Layer, Text};
use toss::{Style, Styled, Widget, WidgetExt};
use crate::{
euph::{self, SpanType},
macros::logging_unwrap,
ui::{
UiError, UiEvent,
chat::{ChatState, Reaction},
util,
widgets::ListState,
},
vault::{EuphRoomVault, RoomIdentifier},
};
use crate::euph;
use crate::macros::logging_unwrap;
use crate::ui::chat::{ChatState, Reaction};
use crate::ui::widgets::ListState;
use crate::ui::{util, UiError, UiEvent};
use crate::vault::EuphRoomVault;
use super::{
account::AccountUiState,
auth, inspect,
links::LinksState,
nick, nick_list,
popup::{PopupResult, RoomPopup},
};
use super::account::AccountUiState;
use super::links::LinksState;
use super::popup::{PopupResult, RoomPopup};
use super::{auth, inspect, nick, nick_list};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
@ -73,8 +58,6 @@ pub struct EuphRoom {
last_msg_sent: Option<oneshot::Receiver<MessageId>>,
nick_list: ListState<SessionId>,
mentioned: bool,
}
impl EuphRoom {
@ -83,7 +66,6 @@ impl EuphRoom {
server_config: ServerConfig,
room_config: cove_config::EuphRoom,
vault: EuphRoomVault,
tz: TimeZone,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
) -> Self {
Self {
@ -95,10 +77,9 @@ impl EuphRoom {
focus: Focus::Chat,
state: State::Normal,
popups: VecDeque::new(),
chat: ChatState::new(vault, tz),
chat: ChatState::new(vault),
last_msg_sent: None,
nick_list: ListState::new(),
mentioned: false,
}
}
@ -106,12 +87,8 @@ impl EuphRoom {
self.chat.store()
}
fn domain(&self) -> &str {
&self.vault().room().domain
}
fn name(&self) -> &str {
&self.vault().room().name
self.vault().room()
}
pub fn connect(&mut self, next_instance_id: &mut usize) {
@ -120,8 +97,8 @@ impl EuphRoom {
let instance_config = self
.server_config
.clone()
.room(self.vault().room().name.clone())
.name(format!("{room:?}-{next_instance_id}"))
.room(self.vault().room().to_string())
.name(format!("{room}-{}", next_instance_id))
.human(true)
.username(self.room_config.username.clone())
.force_username(self.room_config.force_username)
@ -151,9 +128,7 @@ impl EuphRoom {
}
}
pub fn room_state_joined(&self) -> Option<&Joined> {
self.room_state().and_then(|s| s.joined())
}
// TODO fn room_state_joined(&self) -> Option<&Joined> {}
pub fn stopped(&self) -> bool {
self.room.as_ref().map(|r| r.stopped()).unwrap_or(true)
@ -167,12 +142,6 @@ impl EuphRoom {
}
}
pub fn retrieve_mentioned(&mut self) -> bool {
let mentioned = self.mentioned;
self.mentioned = false;
mentioned
}
pub async fn unseen_msgs_count(&self) -> usize {
logging_unwrap!(self.vault().unseen_msgs_count().await)
}
@ -194,8 +163,9 @@ impl EuphRoom {
}
fn stabilize_focus(&mut self) {
if self.room_state_joined().is_none() {
self.focus = Focus::Chat; // There is no nick list to focus on
match self.room_state() {
Some(euph::State::Connected(_, conn::State::Joined(_))) => {}
_ => self.focus = Focus::Chat, // There is no nick list to focus on
}
}
@ -237,15 +207,17 @@ impl EuphRoom {
let room_state = self.room.as_ref().map(|room| room.state());
let status_widget = self.status_widget(room_state).await;
let chat = match room_state.and_then(|s| s.joined()) {
Some(joined) => Self::widget_with_nick_list(
let chat = if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = room_state
{
Self::widget_with_nick_list(
&mut self.chat,
status_widget,
&mut self.nick_list,
joined,
self.focus,
),
None => Self::widget_without_nick_list(&mut self.chat, status_widget),
)
} else {
Self::widget_without_nick_list(&mut self.chat, status_widget)
};
let mut layers = vec![chat];
@ -291,16 +263,11 @@ impl EuphRoom {
joined: &Joined,
focus: Focus,
) -> BoxedAsync<'a, UiError> {
let nick_list_widget = nick_list::widget(
nick_list,
joined,
focus == Focus::NickList,
chat.nick_emoji(),
)
.padding()
.with_right(1)
.border()
.desync();
let nick_list_widget = nick_list::widget(nick_list, joined, focus == Focus::NickList)
.padding()
.with_right(1)
.border()
.desync();
let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat);
@ -315,10 +282,9 @@ impl EuphRoom {
.boxed_async()
}
async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget<UiError> + use<> {
async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget<UiError> {
let room_style = Style::new().bold().blue();
let mut info = Styled::new(format!("{} ", self.domain()), Style::new().grey())
.then(format!("&{}", self.name()), room_style);
let mut info = Styled::new(format!("&{}", self.name()), room_style);
info = match state {
None | Some(euph::State::Stopped) => info.then_plain(", archive"),
@ -349,21 +315,14 @@ impl EuphRoom {
.then_plain(")");
}
let title = if unseen > 0 {
format!("&{} ({unseen})", self.name())
} else {
format!("&{}", self.name())
};
Text::new(info)
.padding()
.with_horizontal(1)
.border()
.title(title)
Text::new(info).padding().with_horizontal(1).border()
}
async fn handle_chat_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
let can_compose = self.room_state_joined().is_some();
let can_compose = matches!(
self.room_state(),
Some(euph::State::Connected(_, conn::State::Joined(_)))
);
let reaction = self.chat.handle_input_event(event, keys, can_compose).await;
let reaction = logging_unwrap!(reaction);
@ -422,6 +381,18 @@ impl EuphRoom {
_ => {}
}
// Always applicable
if event.matches(&keys.room.action.present) {
let link = format!("https://plugh.de/present/{}/", self.name());
if let Err(error) = open::that(&link) {
self.popups.push_front(RoomPopup::Error {
description: format!("Failed to open link: {link}"),
reason: format!("{error}"),
});
}
return true;
}
false
}
@ -471,7 +442,8 @@ impl EuphRoom {
}
if event.matches(&keys.tree.action.inspect) {
if let Some(joined) = self.room_state_joined() {
if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state()
{
if let Some(id) = self.nick_list.selected() {
if *id == joined.session.session_id {
self.state =
@ -494,9 +466,11 @@ impl EuphRoom {
return true;
}
if self.room_state_joined().is_some() && event.matches(&keys.general.focus) {
self.focus = Focus::NickList;
return true;
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() {
if event.matches(&keys.general.focus) {
self.focus = Focus::NickList;
return true;
}
}
}
Focus::NickList => {
@ -514,22 +488,18 @@ impl EuphRoom {
false
}
pub async fn handle_input_event(
&mut self,
event: &mut InputEvent<'_>,
keys: &Keys,
) -> RoomResult {
pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
if !self.popups.is_empty() {
if event.matches(&keys.general.abort) {
self.popups.pop_back();
return RoomResult::Handled;
return true;
}
// Prevent event from reaching anything below the popup
return RoomResult::NotHandled;
return false;
}
let result = match &mut self.state {
State::Normal => return self.handle_normal_input_event(event, keys).await.into(),
State::Normal => return self.handle_normal_input_event(event, keys).await,
State::Auth(editor) => auth::handle_input_event(event, keys, &self.room, editor),
State::Nick(editor) => nick::handle_input_event(event, keys, &self.room, editor),
State::Account(account) => account.handle_input_event(event, keys, &self.room),
@ -540,30 +510,27 @@ impl EuphRoom {
};
match result {
PopupResult::NotHandled => RoomResult::NotHandled,
PopupResult::Handled => RoomResult::Handled,
PopupResult::NotHandled => false,
PopupResult::Handled => true,
PopupResult::Close => {
self.state = State::Normal;
RoomResult::Handled
true
}
PopupResult::SwitchToRoom { name } => RoomResult::SwitchToRoom {
room: RoomIdentifier {
domain: self.vault().room().domain.clone(),
name,
},
},
PopupResult::ErrorOpeningLink { link, error } => {
self.popups.push_front(RoomPopup::Error {
description: format!("Failed to open link: {link}"),
reason: format!("{error}"),
});
RoomResult::Handled
true
}
}
}
pub async fn handle_event(&mut self, event: Event) -> bool {
let Some(room) = &self.room else { return false };
let room = match &self.room {
None => return false,
Some(room) => room,
};
if event.config().name != room.instance().config().name {
// If we allowed names other than the current one, old instances
@ -571,35 +538,6 @@ impl EuphRoom {
return false;
}
if let Event::Packet(
_,
ParsedPacket {
content: Ok(Data::SendEvent(send)),
..
},
ConnSnapshot {
state: conn::State::Joined(joined),
..
},
) = &event
{
let normalized_name = euphoxide::nick::normalize(&joined.session.name);
let content = &*send.0.content;
for (rtype, rspan) in euph::find_spans(content) {
if rtype != SpanType::Mention {
continue;
}
let Some(mention) = content[rspan].strip_prefix('@') else {
continue;
};
let normalized_mention = euphoxide::nick::normalize(mention);
if normalized_name == normalized_mention {
self.mentioned = true;
break;
}
}
}
// We handle the packet internally first because the room event handling
// will consume it while we only need a reference.
let handled = if let Event::Packet(_, packet, _) = &event {
@ -691,18 +629,3 @@ impl EuphRoom {
true
}
}
pub enum RoomResult {
NotHandled,
Handled,
SwitchToRoom { room: RoomIdentifier },
}
impl From<bool> for RoomResult {
fn from(value: bool) -> Self {
match value {
true => Self::Handled,
false => Self::NotHandled,
}
}
}

View file

@ -5,15 +5,11 @@ use std::convert::Infallible;
use cove_config::{Config, Keys};
use cove_input::{InputEvent, KeyBinding, KeyBindingInfo, KeyGroupInfo};
use crossterm::style::Stylize;
use toss::{
Style, Styled, Widget, WidgetExt,
widgets::{Either2, Join2, Padding, Text},
};
use toss::widgets::{Either2, Join2, Padding, Text};
use toss::{Style, Styled, Widget, WidgetExt};
use super::{
UiError, util,
widgets::{ListBuilder, ListState, Popup},
};
use super::widgets::{ListBuilder, ListState, Popup};
use super::{util, UiError};
type Line = Either2<Text, Join2<Padding<Text>, Text>>;
type Builder = ListBuilder<'static, Infallible, Line>;
@ -73,7 +69,7 @@ fn render_group_info(builder: &mut Builder, group_info: KeyGroupInfo<'_>) {
pub fn widget<'a>(
list: &'a mut ListState<Infallible>,
config: &Config,
) -> impl Widget<UiError> + use<'a> {
) -> impl Widget<UiError> + 'a {
let mut list_builder = ListBuilder::new();
for group_info in config.keys.groups() {
@ -83,23 +79,7 @@ pub fn widget<'a>(
render_group_info(&mut list_builder, group_info);
}
let scroll_info_style = Style::new().grey().italic();
let scroll_info = Styled::new("(Scroll with ", scroll_info_style)
.and_then(format_binding(&config.keys.cursor.down))
.then(" and ", scroll_info_style)
.and_then(format_binding(&config.keys.cursor.up))
.then(")", scroll_info_style);
let inner = Join2::vertical(
list_builder.build(list).segment(),
Text::new(scroll_info)
.float()
.with_center_h()
.segment()
.with_growing(false),
);
Popup::new(inner, "Key bindings")
Popup::new(list_builder.build(list), "Key bindings")
}
pub fn handle_input_event(

View file

@ -1,52 +1,30 @@
use std::{
collections::{HashMap, HashSet, hash_map::Entry},
iter,
sync::{Arc, Mutex},
time::Duration,
};
use std::collections::{HashMap, HashSet};
use std::iter;
use std::sync::{Arc, Mutex};
use cove_config::{Config, Keys, RoomsSortOrder};
use cove_input::InputEvent;
use crossterm::style::Stylize;
use euphoxide::{
api::SessionType,
bot::instance::{Event, ServerConfig},
conn::{self, Joined},
};
use jiff::tz::TimeZone;
use euphoxide::api::SessionType;
use euphoxide::bot::instance::{Event, ServerConfig};
use euphoxide::conn::{self, Joined};
use tokio::sync::mpsc;
use toss::{
Style, Styled, Widget, WidgetExt,
widgets::{BellState, BoxedAsync, Empty, Join2, Text},
};
use toss::widgets::{BoxedAsync, EditorState, Empty, Join2, Text};
use toss::{Style, Styled, Widget, WidgetExt};
use crate::{
euph,
macros::logging_unwrap,
vault::{EuphVault, RoomIdentifier, Vault},
version::{NAME, VERSION},
};
use crate::euph;
use crate::macros::logging_unwrap;
use crate::vault::Vault;
use super::{
UiError, UiEvent,
euph::room::{EuphRoom, RoomResult},
key_bindings, util,
widgets::{ListBuilder, ListState},
};
use self::{
connect::{ConnectResult, ConnectState},
delete::{DeleteResult, DeleteState},
};
mod connect;
mod delete;
use super::euph::room::EuphRoom;
use super::widgets::{ListBuilder, ListState, Popup};
use super::{key_bindings, util, UiError, UiEvent};
enum State {
ShowList,
ShowRoom(RoomIdentifier),
Connect(ConnectState),
Delete(DeleteState),
ShowRoom(String),
Connect(EditorState),
Delete(String, EditorState),
}
#[derive(Clone, Copy)]
@ -64,70 +42,47 @@ impl Order {
}
}
struct EuphServer {
config: ServerConfig,
next_instance_id: usize,
}
impl EuphServer {
async fn new(vault: &EuphVault, domain: String) -> Self {
let cookies = logging_unwrap!(vault.cookies(domain.clone()).await);
let config = ServerConfig::default()
.domain(domain)
.cookies(Arc::new(Mutex::new(cookies)))
.timeout(Duration::from_secs(10));
Self {
config,
next_instance_id: 0,
}
}
}
pub struct Rooms {
config: &'static Config,
tz: TimeZone,
vault: Vault,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
state: State,
list: ListState<RoomIdentifier>,
list: ListState<String>,
order: Order,
bell: BellState,
euph_servers: HashMap<String, EuphServer>,
euph_rooms: HashMap<RoomIdentifier, EuphRoom>,
euph_server_config: ServerConfig,
euph_next_instance_id: usize,
euph_rooms: HashMap<String, EuphRoom>,
}
impl Rooms {
pub async fn new(
config: &'static Config,
tz: TimeZone,
vault: Vault,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
) -> Self {
let cookies = logging_unwrap!(vault.euph().cookies().await);
let euph_server_config = ServerConfig::default().cookies(Arc::new(Mutex::new(cookies)));
let mut result = Self {
config,
tz,
vault,
ui_event_tx,
state: State::ShowList,
list: ListState::new(),
order: Order::from_rooms_sort_order(config.rooms_sort_order),
bell: BellState::new(),
euph_servers: HashMap::new(),
euph_server_config,
euph_next_instance_id: 0,
euph_rooms: HashMap::new(),
};
if !config.offline {
for (domain, server) in &config.euph.servers {
for (name, room) in &server.rooms {
if room.autojoin {
let id = RoomIdentifier::new(domain.clone(), name.clone());
result.connect_to_room(id).await;
}
for (name, config) in &config.euph.rooms {
if config.autojoin {
result.connect_to_room(name.clone());
}
}
}
@ -135,68 +90,39 @@ impl Rooms {
result
}
async fn get_or_insert_server<'a>(
vault: &Vault,
euph_servers: &'a mut HashMap<String, EuphServer>,
domain: String,
) -> &'a mut EuphServer {
match euph_servers.entry(domain.clone()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let server = EuphServer::new(&vault.euph(), domain).await;
entry.insert(server)
}
}
}
async fn get_or_insert_room(&mut self, room: RoomIdentifier) -> &mut EuphRoom {
let server =
Self::get_or_insert_server(&self.vault, &mut self.euph_servers, room.domain.clone())
.await;
self.euph_rooms.entry(room.clone()).or_insert_with(|| {
fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom {
self.euph_rooms.entry(name.clone()).or_insert_with(|| {
EuphRoom::new(
self.config,
server.config.clone(),
self.config.euph_room(&room.domain, &room.name),
self.vault.euph().room(room),
self.tz.clone(),
self.euph_server_config.clone(),
self.config.euph_room(&name),
self.vault.euph().room(name),
self.ui_event_tx.clone(),
)
})
}
async fn connect_to_room(&mut self, room: RoomIdentifier) {
let server =
Self::get_or_insert_server(&self.vault, &mut self.euph_servers, room.domain.clone())
.await;
let room = self.euph_rooms.entry(room.clone()).or_insert_with(|| {
fn connect_to_room(&mut self, name: String) {
let room = self.euph_rooms.entry(name.clone()).or_insert_with(|| {
EuphRoom::new(
self.config,
server.config.clone(),
self.config.euph_room(&room.domain, &room.name),
self.vault.euph().room(room),
self.tz.clone(),
self.euph_server_config.clone(),
self.config.euph_room(&name),
self.vault.euph().room(name),
self.ui_event_tx.clone(),
)
});
room.connect(&mut server.next_instance_id);
room.connect(&mut self.euph_next_instance_id);
}
async fn connect_to_all_rooms(&mut self) {
for (id, room) in &mut self.euph_rooms {
let server =
Self::get_or_insert_server(&self.vault, &mut self.euph_servers, id.domain.clone())
.await;
room.connect(&mut server.next_instance_id);
fn connect_to_all_rooms(&mut self) {
for room in self.euph_rooms.values_mut() {
room.connect(&mut self.euph_next_instance_id);
}
}
fn disconnect_from_room(&mut self, room: &RoomIdentifier) {
if let Some(room) = self.euph_rooms.get_mut(room) {
fn disconnect_from_room(&mut self, name: &str) {
if let Some(room) = self.euph_rooms.get_mut(name) {
room.disconnect();
}
}
@ -216,21 +142,10 @@ impl Rooms {
/// - rooms that were deleted from the db.
async fn stabilize_rooms(&mut self) {
// Collect all rooms from the db and config file
let rooms_from_db = logging_unwrap!(self.vault.euph().rooms().await);
let rooms_from_config = self
.config
.euph
.servers
.iter()
.flat_map(|(domain, server)| {
server
.rooms
.keys()
.map(|name| RoomIdentifier::new(domain.clone(), name.clone()))
});
let mut rooms_set = rooms_from_db
let rooms = logging_unwrap!(self.vault.euph().rooms().await);
let mut rooms_set = rooms
.into_iter()
.chain(rooms_from_config)
.chain(self.config.euph.rooms.keys().cloned())
.collect::<HashSet<_>>();
// Prevent room that is currently being shown from being removed. This
@ -246,9 +161,7 @@ impl Rooms {
.retain(|n, r| !r.stopped() || rooms_set.contains(n));
for room in rooms_set {
let room = self.get_or_insert_room(room).await;
room.retain();
self.bell.ring |= room.retrieve_mentioned();
self.get_or_insert_room(room).retain();
}
}
@ -258,58 +171,96 @@ impl Rooms {
_ => self.stabilize_rooms().await,
}
let widget = match &mut self.state {
State::ShowList => Self::rooms_widget(
&self.vault,
self.config,
&mut self.list,
self.order,
&self.euph_rooms,
)
.await
.desync()
.boxed_async(),
match &mut self.state {
State::ShowList => {
Self::rooms_widget(self.config, &mut self.list, self.order, &self.euph_rooms)
.await
.desync()
.boxed_async()
}
State::ShowRoom(id) => {
State::ShowRoom(name) => {
self.euph_rooms
.get_mut(id)
.get_mut(name)
.expect("room exists after stabilization")
.widget()
.await
}
State::Connect(connect) => Self::rooms_widget(
&self.vault,
self.config,
&mut self.list,
self.order,
&self.euph_rooms,
)
.await
.below(connect.widget())
.desync()
.boxed_async(),
State::Connect(editor) => {
Self::rooms_widget(self.config, &mut self.list, self.order, &self.euph_rooms)
.await
.below(Self::new_room_widget(editor))
.desync()
.boxed_async()
}
State::Delete(delete) => Self::rooms_widget(
&self.vault,
self.config,
&mut self.list,
self.order,
&self.euph_rooms,
)
.await
.below(delete.widget())
.desync()
.boxed_async(),
};
if self.config.bell_on_mention {
widget.above(self.bell.widget().desync()).boxed_async()
} else {
widget
State::Delete(name, editor) => {
Self::rooms_widget(self.config, &mut self.list, self.order, &self.euph_rooms)
.await
.below(Self::delete_room_widget(name, editor))
.desync()
.boxed_async()
}
}
}
fn new_room_widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
let room_style = Style::new().bold().blue();
let inner = Join2::horizontal(
Text::new(("&", room_style)).segment().with_fixed(true),
editor
.widget()
.with_highlight(|s| Styled::new(s, room_style))
.segment(),
);
Popup::new(inner, "Connect to")
}
fn delete_room_widget<'a>(
name: &str,
editor: &'a mut EditorState,
) -> impl Widget<UiError> + 'a {
let warn_style = Style::new().bold().red();
let room_style = Style::new().bold().blue();
let text = Styled::new_plain("Are you sure you want to delete ")
.then("&", room_style)
.then(name, room_style)
.then_plain("?\n\n")
.then_plain("This will delete the entire room history from your vault. ")
.then_plain("To shrink your vault afterwards, run ")
.then("cove gc", Style::new().italic().grey())
.then_plain(".\n\n")
.then_plain("To confirm the deletion, ")
.then_plain("enter the full name of the room and press enter:");
let inner = Join2::vertical(
// The Join prevents the text from filling up the entire available
// space if the editor is wider than the text.
Join2::horizontal(
Text::new(text)
.resize()
.with_max_width(54)
.segment()
.with_growing(false),
Empty::new().segment(),
)
.segment(),
Join2::horizontal(
Text::new(("&", room_style)).segment().with_fixed(true),
editor
.widget()
.with_highlight(|s| Styled::new(s, room_style))
.segment(),
)
.segment(),
);
Popup::new(inner, "Delete room").with_border_style(warn_style)
}
fn format_pbln(joined: &Joined) -> String {
let mut p = 0_usize;
let mut b = 0_usize;
@ -394,45 +345,48 @@ impl Rooms {
}
}
fn sort_rooms(rooms: &mut [(&RoomIdentifier, Option<&euph::State>, usize)], order: Order) {
fn sort_rooms(rooms: &mut [(&String, Option<&euph::State>, usize)], order: Order) {
match order {
Order::Alphabet => rooms.sort_unstable_by_key(|(id, _, _)| *id),
Order::Importance => rooms
.sort_unstable_by_key(|(id, state, unseen)| (state.is_none(), *unseen == 0, *id)),
Order::Alphabet => rooms.sort_unstable_by_key(|(name, _, _)| *name),
Order::Importance => rooms.sort_unstable_by_key(|(name, state, unseen)| {
(state.is_none(), *unseen == 0, *name)
}),
}
}
async fn render_rows(
list_builder: &mut ListBuilder<'_, RoomIdentifier, Text>,
config: &Config,
list_builder: &mut ListBuilder<'_, String, Text>,
order: Order,
euph_rooms: &HashMap<RoomIdentifier, EuphRoom>,
euph_rooms: &HashMap<String, EuphRoom>,
) {
if euph_rooms.is_empty() {
let style = Style::new().grey().italic();
list_builder.add_unsel(Text::new(
Styled::new("Press ", style)
.and_then(key_bindings::format_binding(&config.keys.general.help))
.then(" for key bindings", style),
));
}
let mut rooms = vec![];
for (id, room) in euph_rooms {
for (name, room) in euph_rooms {
let state = room.room_state();
let unseen = room.unseen_msgs_count().await;
rooms.push((id, state, unseen));
rooms.push((name, state, unseen));
}
Self::sort_rooms(&mut rooms, order);
for (id, state, unseen) in rooms {
let id = id.clone();
for (name, state, unseen) in rooms {
let name = name.clone();
let info = Self::format_room_info(state, unseen);
list_builder.add_sel(id.clone(), move |selected| {
let domain_style = if selected {
Style::new().black().on_white()
} else {
Style::new().grey()
};
let room_style = if selected {
list_builder.add_sel(name.clone(), move |selected| {
let style = if selected {
Style::new().bold().black().on_white()
} else {
Style::new().bold().blue()
};
let text = Styled::new(format!("{} ", id.domain), domain_style)
.then(format!("&{}", id.name), room_style)
.and_then(info);
let text = Styled::new(format!("&{name}"), style).and_then(info);
Text::new(text)
});
@ -440,66 +394,29 @@ impl Rooms {
}
async fn rooms_widget<'a>(
vault: &Vault,
config: &Config,
list: &'a mut ListState<RoomIdentifier>,
list: &'a mut ListState<String>,
order: Order,
euph_rooms: &HashMap<RoomIdentifier, EuphRoom>,
) -> impl Widget<UiError> + use<'a> {
let version_info = Styled::new_plain("Welcome to ")
.then(format!("{NAME} {VERSION}"), Style::new().yellow().bold())
.then_plain("!");
let help_info = Styled::new("Press ", Style::new().grey())
.and_then(key_bindings::format_binding(&config.keys.general.help))
.then(" for key bindings.", Style::new().grey());
let info = Join2::vertical(
Text::new(version_info).float().with_center_h().segment(),
Text::new(help_info).segment(),
)
.padding()
.with_horizontal(1)
.border();
let mut heading = Styled::new("Rooms", Style::new().bold());
let mut title = "Rooms".to_string();
let total_rooms = euph_rooms.len();
let connected_rooms = euph_rooms
.iter()
.filter(|r| r.1.room_state().is_some())
.count();
let total_unseen = logging_unwrap!(vault.euph().total_unseen_msgs_count().await);
if total_unseen > 0 {
heading = heading
.then_plain(format!(" ({connected_rooms}/{total_rooms}, "))
.then(format!("{total_unseen}"), Style::new().bold().green())
.then_plain(")");
title.push_str(&format!(" ({total_unseen})"));
} else {
heading = heading.then_plain(format!(" ({connected_rooms}/{total_rooms})"))
}
euph_rooms: &HashMap<String, EuphRoom>,
) -> impl Widget<UiError> + 'a {
let heading_style = Style::new().bold();
let heading_text =
Styled::new("Rooms", heading_style).then_plain(format!(" ({})", euph_rooms.len()));
let mut list_builder = ListBuilder::new();
Self::render_rows(&mut list_builder, order, euph_rooms).await;
Self::render_rows(config, &mut list_builder, order, euph_rooms).await;
Join2::horizontal(
Join2::vertical(
Text::new(heading).segment().with_fixed(true),
list_builder.build(list).segment(),
)
.segment(),
Join2::vertical(info.segment().with_growing(false), Empty::new().segment())
.segment()
.with_growing(false),
Join2::vertical(
Text::new(heading_text).segment().with_fixed(true),
list_builder.build(list).segment(),
)
.title(title)
}
async fn handle_showlist_input_event(
&mut self,
event: &mut InputEvent<'_>,
keys: &Keys,
) -> bool {
fn room_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn handle_showlist_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
// Open room
if event.matches(&keys.general.confirm) {
if let Some(name) = self.list.selected() {
@ -516,17 +433,17 @@ impl Rooms {
// Room actions
if event.matches(&keys.rooms.action.connect) {
if let Some(name) = self.list.selected() {
self.connect_to_room(name.clone()).await;
self.connect_to_room(name.clone());
}
return true;
}
if event.matches(&keys.rooms.action.connect_all) {
self.connect_to_all_rooms().await;
self.connect_to_all_rooms();
return true;
}
if event.matches(&keys.rooms.action.disconnect) {
if let Some(room) = self.list.selected() {
self.disconnect_from_room(&room.clone());
if let Some(name) = self.list.selected() {
self.disconnect_from_room(&name.clone());
}
return true;
}
@ -535,20 +452,22 @@ impl Rooms {
return true;
}
if event.matches(&keys.rooms.action.connect_autojoin) {
for (domain, server) in &self.config.euph.servers {
for (name, room) in &server.rooms {
if !room.autojoin {
continue;
}
let id = RoomIdentifier::new(domain.clone(), name.clone());
self.connect_to_room(id).await;
for (name, options) in &self.config.euph.rooms {
if options.autojoin {
self.connect_to_room(name.clone());
}
}
return true;
}
if event.matches(&keys.rooms.action.disconnect_non_autojoin) {
for (id, room) in &mut self.euph_rooms {
let autojoin = self.config.euph_room(&id.domain, &id.name).autojoin;
for (name, room) in &mut self.euph_rooms {
let autojoin = self
.config
.euph
.rooms
.get(name)
.map(|r| r.autojoin)
.unwrap_or(false);
if !autojoin {
room.disconnect();
}
@ -556,12 +475,12 @@ impl Rooms {
return true;
}
if event.matches(&keys.rooms.action.new) {
self.state = State::Connect(ConnectState::new());
self.state = State::Connect(EditorState::new());
return true;
}
if event.matches(&keys.rooms.action.delete) {
if let Some(room) = self.list.selected() {
self.state = State::Delete(DeleteState::new(room.clone()));
if let Some(name) = self.list.selected() {
self.state = State::Delete(name.clone(), EditorState::new());
}
return true;
}
@ -581,21 +500,14 @@ impl Rooms {
match &mut self.state {
State::ShowList => {
if self.handle_showlist_input_event(event, keys).await {
if self.handle_showlist_input_event(event, keys) {
return true;
}
}
State::ShowRoom(name) => {
if let Some(room) = self.euph_rooms.get_mut(name) {
match room.handle_input_event(event, keys).await {
RoomResult::NotHandled => {}
RoomResult::Handled => return true,
RoomResult::SwitchToRoom { room } => {
self.list.move_cursor_to_id(&room);
self.connect_to_room(room.clone()).await;
self.state = State::ShowRoom(room);
return true;
}
if room.handle_input_event(event, keys).await {
return true;
}
if event.matches(&keys.general.abort) {
self.state = State::ShowList;
@ -603,54 +515,53 @@ impl Rooms {
}
}
}
State::Connect(connect) => match connect.handle_input_event(event, keys) {
ConnectResult::Close => {
State::Connect(editor) => {
if event.matches(&keys.general.abort) {
self.state = State::ShowList;
return true;
}
ConnectResult::Connect(room) => {
self.list.move_cursor_to_id(&room);
self.connect_to_room(room.clone()).await;
self.state = State::ShowRoom(room);
if event.matches(&keys.general.confirm) {
let name = editor.text().to_string();
if !name.is_empty() {
self.connect_to_room(name.clone());
self.state = State::ShowRoom(name);
}
return true;
}
ConnectResult::Handled => {
if util::handle_editor_input_event(editor, event, keys, Self::room_char) {
return true;
}
ConnectResult::Unhandled => {}
},
State::Delete(delete) => match delete.handle_input_event(event, keys) {
DeleteResult::Close => {
}
State::Delete(name, editor) => {
if event.matches(&keys.general.abort) {
self.state = State::ShowList;
return true;
}
DeleteResult::Delete(room) => {
self.euph_rooms.remove(&room);
logging_unwrap!(self.vault.euph().room(room).delete().await);
if event.matches(&keys.general.confirm) {
self.euph_rooms.remove(name);
logging_unwrap!(self.vault.euph().room(name.clone()).delete().await);
self.state = State::ShowList;
return true;
}
DeleteResult::Handled => {
if util::handle_editor_input_event(editor, event, keys, Self::room_char) {
return true;
}
DeleteResult::Unhandled => {}
},
}
}
false
}
pub async fn handle_euph_event(&mut self, event: Event) -> bool {
let config = event.config();
let room_id = RoomIdentifier::new(config.server.domain.clone(), config.room.clone());
let Some(room) = self.euph_rooms.get_mut(&room_id) else {
let room_name = event.config().room.clone();
let Some(room) = self.euph_rooms.get_mut(&room_name) else {
return false;
};
let handled = room.handle_event(event).await;
let room_visible = match &self.state {
State::ShowRoom(id) => *id == room_id,
State::ShowRoom(name) => *name == room_name,
_ => true,
};
handled && room_visible

View file

@ -1,123 +0,0 @@
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize;
use toss::{
Style, Styled, Widget, WidgetExt,
widgets::{EditorState, Empty, Join2, Join3, Text},
};
use crate::{
ui::{UiError, util, widgets::Popup},
vault::RoomIdentifier,
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Name,
Domain,
}
impl Focus {
fn advance(self) -> Self {
match self {
Self::Name => Self::Domain,
Self::Domain => Self::Name,
}
}
}
pub struct ConnectState {
focus: Focus,
name: EditorState,
domain: EditorState,
}
pub enum ConnectResult {
Close,
Connect(RoomIdentifier),
Handled,
Unhandled,
}
impl ConnectState {
pub fn new() -> Self {
Self {
focus: Focus::Name,
name: EditorState::new(),
domain: EditorState::with_initial_text("euphoria.leet.nu".to_string()),
}
}
pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> ConnectResult {
if event.matches(&keys.general.abort) {
return ConnectResult::Close;
}
if event.matches(&keys.general.focus) {
self.focus = self.focus.advance();
return ConnectResult::Handled;
}
if event.matches(&keys.general.confirm) {
let id = RoomIdentifier {
domain: self.domain.text().to_string(),
name: self.name.text().to_string(),
};
if !id.domain.is_empty() && !id.name.is_empty() {
return ConnectResult::Connect(id);
}
}
let handled = match self.focus {
Focus::Name => {
util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char)
}
Focus::Domain => {
util::handle_editor_input_event(&mut self.domain, event, keys, |c| c != '\n')
}
};
if handled {
return ConnectResult::Handled;
}
ConnectResult::Unhandled
}
pub fn widget(&mut self) -> impl Widget<UiError> {
let room_style = Style::new().bold().blue();
let domain_style = Style::new().grey();
let name = Join2::horizontal(
Text::new(Styled::new_plain("Room: ").then("&", room_style))
.with_wrap(false)
.segment()
.with_fixed(true),
self.name
.widget()
.with_highlight(|s| Styled::new(s, room_style))
.with_focus(self.focus == Focus::Name)
.segment(),
);
let domain = Join3::horizontal(
Text::new("Domain:")
.with_wrap(false)
.segment()
.with_fixed(true),
Empty::new().with_width(1).segment().with_fixed(true),
self.domain
.widget()
.with_highlight(|s| Styled::new(s, domain_style))
.with_focus(self.focus == Focus::Domain)
.segment(),
);
let inner = Join2::vertical(
name.segment().with_fixed(true),
domain.segment().with_fixed(true),
);
Popup::new(inner, "Connect to")
}
}

View file

@ -1,90 +0,0 @@
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize;
use toss::{
Style, Styled, Widget, WidgetExt,
widgets::{EditorState, Empty, Join2, Text},
};
use crate::{
ui::{UiError, util, widgets::Popup},
vault::RoomIdentifier,
};
pub struct DeleteState {
id: RoomIdentifier,
name: EditorState,
}
pub enum DeleteResult {
Close,
Delete(RoomIdentifier),
Handled,
Unhandled,
}
impl DeleteState {
pub fn new(id: RoomIdentifier) -> Self {
Self {
id,
name: EditorState::new(),
}
}
pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> DeleteResult {
if event.matches(&keys.general.abort) {
return DeleteResult::Close;
}
if event.matches(&keys.general.confirm) && self.name.text() == self.id.name {
return DeleteResult::Delete(self.id.clone());
}
if util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char) {
return DeleteResult::Handled;
}
DeleteResult::Unhandled
}
pub fn widget(&mut self) -> impl Widget<UiError> {
let warn_style = Style::new().bold().red();
let room_style = Style::new().bold().blue();
let text = Styled::new_plain("Are you sure you want to delete ")
.then("&", room_style)
.then(&self.id.name, room_style)
.then_plain(" on the ")
.then(&self.id.domain, Style::new().grey())
.then_plain(" server?\n\n")
.then_plain("This will delete the entire room history from your vault. ")
.then_plain("To shrink your vault afterwards, run ")
.then("cove gc", Style::new().italic().grey())
.then_plain(".\n\n")
.then_plain("To confirm the deletion, ")
.then_plain("enter the full name of the room and press enter:");
let inner = Join2::vertical(
// The Join prevents the text from filling up the entire available
// space if the editor is wider than the text.
Join2::horizontal(
Text::new(text)
.resize()
.with_max_width(54)
.segment()
.with_growing(false),
Empty::new().segment(),
)
.segment(),
Join2::horizontal(
Text::new(("&", room_style)).segment().with_fixed(true),
self.name
.widget()
.with_highlight(|s| Styled::new(s, room_style))
.segment(),
)
.segment(),
);
Popup::new(inner, "Delete room").with_border_style(warn_style)
}
}

View file

@ -5,11 +5,6 @@ use toss::widgets::EditorState;
use super::widgets::ListState;
/// Test if a character is allowed to be typed in a room name.
pub fn is_room_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
//////////
// List //
//////////

View file

@ -1,5 +1,5 @@
pub use self::list::*;
pub use self::popup::*;
mod list;
mod popup;
pub use self::list::*;
pub use self::popup::*;

View file

@ -239,12 +239,6 @@ impl<Id: Clone + Eq> ListState<Id> {
})
}
pub fn move_cursor_to_id(&mut self, id: &Id) {
if let Some(new_cursor) = self.selectable_of_id(id) {
self.move_cursor_to(new_cursor);
}
}
fn fix_cursor(&mut self) {
let new_cursor = if let Some(cursor) = &self.cursor {
self.selectable_of_id(&cursor.id)

View file

@ -1,7 +1,5 @@
use toss::{
Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb,
widgets::{Background, Border, Desync, Float, Layer2, Padding, Text},
};
use toss::widgets::{Background, Border, Desync, Float, Layer2, Padding, Text};
use toss::{Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb};
type Body<I> = Background<Border<Padding<I>>>;
type Title = Float<Padding<Background<Padding<Text>>>>;

View file

@ -1,6 +1,4 @@
use std::{convert::Infallible, env};
use jiff::tz::TimeZone;
use std::convert::Infallible;
pub trait InfallibleExt {
type Inner;
@ -15,56 +13,3 @@ impl<T> InfallibleExt for Result<T, Infallible> {
self.expect("infallible")
}
}
/// Load a [`TimeZone`] specified by the `TZ` environment varible, or by the
/// provided string if the environment variable does not exist.
///
/// If a string is provided, it is interpreted in the same format that the `TZ`
/// environment variable uses.
///
/// If no `TZ` environment variable could be found and no string is provided,
/// the system local time (or UTC on Windows) is used.
pub fn load_time_zone(tz_string: Option<&str>) -> Result<TimeZone, jiff::Error> {
let env_string = env::var("TZ").ok();
let tz_string = env_string.as_ref().map(|s| s as &str).or(tz_string);
let Some(tz_string) = tz_string else {
return Ok(TimeZone::system());
};
if tz_string == "localtime" {
return Ok(TimeZone::system());
}
if let Some(tz_string) = tz_string.strip_prefix(':') {
return TimeZone::get(tz_string);
}
// The time zone is either a manually specified string or a file in the tz
// database. We'll try to parse it as a manually specified string first
// because that doesn't require a fs lookup.
if let Ok(tz) = TimeZone::posix(tz_string) {
return Ok(tz);
}
TimeZone::get(tz_string)
}
pub fn caesar(text: &str, by: i8) -> String {
let by = by.rem_euclid(26) as u8;
text.chars()
.map(|c| {
if c.is_ascii_lowercase() {
let c = c as u8 - b'a';
let c = (c + by) % 26;
(c + b'a') as char
} else if c.is_ascii_uppercase() {
let c = c as u8 - b'A';
let c = (c + by) % 26;
(c + b'A') as char
} else {
c
}
})
.collect()
}

View file

@ -1,14 +1,16 @@
use std::{fs, path::Path};
use rusqlite::Connection;
use vault::{Action, tokio::TokioVault};
pub use self::euph::{EuphRoomVault, EuphVault, RoomIdentifier};
mod euph;
mod migrate;
mod prepare;
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use vault::tokio::TokioVault;
use vault::Action;
pub use self::euph::{EuphRoomVault, EuphVault};
#[derive(Debug, Clone)]
pub struct Vault {
tokio_vault: TokioVault,
@ -48,6 +50,8 @@ fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result
conn.pragma_update(None, "foreign_keys", true)?;
conn.pragma_update(None, "trusted_schema", false)?;
eprintln!("Opening vault");
let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?;
Ok(Vault {
tokio_vault,

View file

@ -1,25 +1,27 @@
use std::{fmt, mem, str::FromStr};
use std::mem;
use std::str::FromStr;
use async_trait::async_trait;
use cookie::{Cookie, CookieJar};
use euphoxide::api::{Message, MessageId, SessionId, SessionView, Snowflake, Time, UserId};
use rusqlite::{
Connection, OptionalExtension, Row, ToSql, Transaction, named_params, params,
types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef},
};
use rusqlite::types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef};
use rusqlite::{named_params, params, Connection, OptionalExtension, Row, ToSql, Transaction};
use time::OffsetDateTime;
use vault::Action;
use crate::{
euph::SmallMessage,
store::{MsgStore, Path, Tree},
};
use crate::euph::SmallMessage;
use crate::store::{MsgStore, Path, Tree};
///////////////////
// Wrapper types //
///////////////////
/// Wrapper for [`Snowflake`] that implements useful rusqlite traits.
struct WSnowflake(Snowflake);
impl ToSql for WSnowflake {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
self.0.0.to_sql()
self.0 .0.to_sql()
}
}
@ -34,7 +36,7 @@ struct WTime(Time);
impl ToSql for WTime {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
let timestamp = self.0.0;
let timestamp = self.0 .0.unix_timestamp();
Ok(ToSqlOutput::Owned(Value::Integer(timestamp)))
}
}
@ -42,25 +44,9 @@ impl ToSql for WTime {
impl FromSql for WTime {
fn column_result(value: ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
let timestamp = i64::column_result(value)?;
Ok(Self(Time(timestamp)))
}
}
#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoomIdentifier {
pub domain: String,
pub name: String,
}
impl fmt::Debug for RoomIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "&{}@{}", self.name, self.domain)
}
}
impl RoomIdentifier {
pub fn new(domain: String, name: String) -> Self {
Self { domain, name }
Ok(Self(Time(
OffsetDateTime::from_unix_timestamp(timestamp).expect("timestamp in range"),
)))
}
}
@ -82,10 +68,10 @@ impl EuphVault {
&self.vault
}
pub fn room(&self, room: RoomIdentifier) -> EuphRoomVault {
pub fn room(&self, name: String) -> EuphRoomVault {
EuphRoomVault {
vault: self.clone(),
room,
room: name,
}
}
}
@ -111,11 +97,9 @@ macro_rules! euph_vault_actions {
}
euph_vault_actions! {
GetCookies : cookies(domain: String) -> CookieJar;
SetCookies : set_cookies(domain: String, cookies: CookieJar) -> ();
ClearCookies : clear_cookies(domain: Option<String>) -> ();
GetRooms : rooms() -> Vec<RoomIdentifier>;
GetTotalUnseenMsgsCount : total_unseen_msgs_count() -> usize;
GetCookies : cookies() -> CookieJar;
SetCookies : set_cookies(cookies: CookieJar) -> ();
GetRooms : rooms() -> Vec<String>;
}
impl Action for GetCookies {
@ -128,10 +112,9 @@ impl Action for GetCookies {
"
SELECT cookie
FROM euph_cookies
WHERE domain = ?
",
)?
.query_map([self.domain], |row| {
.query_map([], |row| {
let cookie_str: String = row.get(0)?;
Ok(Cookie::from_str(&cookie_str).expect("cookie in db is valid"))
})?
@ -154,21 +137,16 @@ impl Action for SetCookies {
// Since euphoria sets all cookies on every response, we can just delete
// all previous cookies.
tx.execute(
"
DELETE FROM euph_cookies
WHERE domain = ?",
[&self.domain],
)?;
tx.execute_batch("DELETE FROM euph_cookies")?;
let mut insert_cookie = tx.prepare(
"
INSERT INTO euph_cookies (domain, cookie)
VALUES (?, ?)
INSERT INTO euph_cookies (cookie)
VALUES (?)
",
)?;
for cookie in self.cookies.iter() {
insert_cookie.execute(params![self.domain, format!("{cookie}")])?;
insert_cookie.execute([format!("{cookie}")])?;
}
drop(insert_cookie);
@ -177,57 +155,22 @@ impl Action for SetCookies {
}
}
impl Action for ClearCookies {
type Output = ();
type Error = rusqlite::Error;
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
if let Some(domain) = self.domain {
conn.execute("DELETE FROM euph_cookies WHERE domain = ?", [domain])?;
} else {
conn.execute_batch("DELETE FROM euph_cookies")?;
}
Ok(())
}
}
impl Action for GetRooms {
type Output = Vec<RoomIdentifier>;
type Output = Vec<String>;
type Error = rusqlite::Error;
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
conn.prepare(
"
SELECT domain, room
SELECT room
FROM euph_rooms
",
)?
.query_map([], |row| {
Ok(RoomIdentifier {
domain: row.get(0)?,
name: row.get(1)?,
})
})?
.query_map([], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()
}
}
impl Action for GetTotalUnseenMsgsCount {
type Output = usize;
type Error = rusqlite::Error;
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
conn.prepare(
"
SELECT COALESCE(SUM(amount), 0)
FROM euph_unseen_counts
",
)?
.query_row([], |row| row.get(0))
}
}
///////////////////
// EuphRoomVault //
///////////////////
@ -235,7 +178,7 @@ impl Action for GetTotalUnseenMsgsCount {
#[derive(Debug, Clone)]
pub struct EuphRoomVault {
vault: EuphVault,
room: RoomIdentifier,
room: String,
}
impl EuphRoomVault {
@ -243,7 +186,7 @@ impl EuphRoomVault {
&self.vault
}
pub fn room(&self) -> &RoomIdentifier {
pub fn room(&self) -> &str {
&self.room
}
}
@ -254,7 +197,7 @@ macro_rules! euph_room_vault_actions {
)* ) => {
$(
struct $struct {
room: RoomIdentifier,
room: String,
$( $arg: $arg_ty, )*
}
)*
@ -310,16 +253,12 @@ impl Action for Join {
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
conn.execute(
"
INSERT INTO euph_rooms (domain, room, first_joined, last_joined)
VALUES (:domain, :room, :time, :time)
ON CONFLICT (domain, room) DO UPDATE
INSERT INTO euph_rooms (room, first_joined, last_joined)
VALUES (:room, :time, :time)
ON CONFLICT (room) DO UPDATE
SET last_joined = :time
",
named_params! {
":domain": self.room.domain,
":room": self.room.name,
":time": WTime(self.time),
},
named_params! {":room": self.room, ":time": WTime(self.time)},
)?;
Ok(())
}
@ -333,10 +272,9 @@ impl Action for Delete {
conn.execute(
"
DELETE FROM euph_rooms
WHERE domain = ?
AND room = ?
WHERE room = ?
",
[&self.room.domain, &self.room.name],
[&self.room],
)?;
Ok(())
}
@ -344,33 +282,29 @@ impl Action for Delete {
fn insert_msgs(
tx: &Transaction<'_>,
room: &RoomIdentifier,
room: &str,
own_user_id: &Option<UserId>,
msgs: Vec<Message>,
) -> rusqlite::Result<()> {
let mut insert_msg = tx.prepare(
"
INSERT INTO euph_msgs (
domain, room,
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
room, id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address,
seen
)
VALUES (
:domain, :room,
:id, :parent, :previous_edit_id, :time, :content, :encryption_key_id, :edited, :deleted, :truncated,
:room, :id, :parent, :previous_edit_id, :time, :content, :encryption_key_id, :edited, :deleted, :truncated,
:user_id, :name, :server_id, :server_era, :session_id, :is_staff, :is_manager, :client_address, :real_client_address,
(:user_id == :own_user_id OR EXISTS(
SELECT 1
FROM euph_rooms
WHERE domain = :domain
AND room = :room
WHERE room = :room
AND :time < first_joined
))
)
ON CONFLICT (domain, room, id) DO UPDATE
ON CONFLICT (room, id) DO UPDATE
SET
domain = :domain,
room = :room,
id = :id,
parent = :parent,
@ -397,8 +331,7 @@ fn insert_msgs(
let own_user_id = own_user_id.as_ref().map(|u| &u.0);
for msg in msgs {
insert_msg.execute(named_params! {
":domain": room.domain,
":room": room.name,
":room": room,
":id": WSnowflake(msg.id.0),
":parent": msg.parent.map(|id| WSnowflake(id.0)),
":previous_edit_id": msg.previous_edit_id.map(WSnowflake),
@ -426,7 +359,7 @@ fn insert_msgs(
fn add_span(
tx: &Transaction<'_>,
room: &RoomIdentifier,
room: &str,
start: Option<MessageId>,
end: Option<MessageId>,
) -> rusqlite::Result<()> {
@ -436,11 +369,10 @@ fn add_span(
"
SELECT start, end
FROM euph_spans
WHERE domain = ?
AND room = ?
WHERE room = ?
",
)?
.query_map([&room.domain, &room.name], |row| {
.query_map([room], |row| {
let start = row.get::<_, Option<WSnowflake>>(0)?.map(|s| MessageId(s.0));
let end = row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0));
Ok((start, end))
@ -480,23 +412,21 @@ fn add_span(
tx.execute(
"
DELETE FROM euph_spans
WHERE domain = ?
AND room = ?
WHERE room = ?
",
[&room.domain, &room.name],
[room],
)?;
// Re-insert combined spans for the room
let mut stmt = tx.prepare(
"
INSERT INTO euph_spans (domain, room, start, end)
VALUES (?, ?, ?, ?)
INSERT INTO euph_spans (room, start, end)
VALUES (?, ?, ?)
",
)?;
for (start, end) in result {
stmt.execute(params![
room.domain,
room.name,
room,
start.map(|id| WSnowflake(id.0)),
end.map(|id| WSnowflake(id.0))
])?;
@ -555,13 +485,12 @@ impl Action for GetLastSpan {
"
SELECT start, end
FROM euph_spans
WHERE domain = ?
AND room = ?
WHERE room = ?
ORDER BY start DESC
LIMIT 1
",
)?
.query_row([&self.room.domain, &self.room.name], |row| {
.query_row([self.room], |row| {
Ok((
row.get::<_, Option<WSnowflake>>(0)?.map(|s| MessageId(s.0)),
row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
@ -581,12 +510,12 @@ impl Action for GetPath {
.prepare(
"
WITH RECURSIVE
path (domain, room, id) AS (
VALUES (?, ?, ?)
path (room, id) AS (
VALUES (?, ?)
UNION
SELECT domain, room, parent
SELECT room, parent
FROM euph_msgs
JOIN path USING (domain, room, id)
JOIN path USING (room, id)
)
SELECT id
FROM path
@ -594,10 +523,9 @@ impl Action for GetPath {
ORDER BY id ASC
",
)?
.query_map(
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
)?
.query_map(params![self.room, WSnowflake(self.id.0)], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(Path::new(path))
}
@ -611,22 +539,20 @@ impl Action for GetMsg {
let msg = conn
.query_row(
"
SELECT id, parent, time, user_id, name, content, seen
SELECT id, parent, time, name, content, seen
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND id = ?
",
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
params![self.room, WSnowflake(self.id.0)],
|row| {
Ok(SmallMessage {
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
time: row.get::<_, WTime>(2)?.0,
user_id: UserId(row.get(3)?),
nick: row.get(4)?,
content: row.get(5)?,
seen: row.get(6)?,
nick: row.get(3)?,
content: row.get(4)?,
seen: row.get(5)?,
})
},
)
@ -646,40 +572,36 @@ impl Action for GetFullMsg {
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND id = ?
"
)?;
let msg = query
.query_row(
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|row| {
Ok(Message {
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0),
time: row.get::<_, WTime>(3)?.0,
content: row.get(4)?,
encryption_key_id: row.get(5)?,
edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0),
deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0),
truncated: row.get(8)?,
sender: SessionView {
id: UserId(row.get(9)?),
name: row.get(10)?,
server_id: row.get(11)?,
server_era: row.get(12)?,
session_id: SessionId(row.get(13)?),
is_staff: row.get(14)?,
is_manager: row.get(15)?,
client_address: row.get(16)?,
real_client_address: row.get(17)?,
},
})
},
)
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
Ok(Message {
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0),
time: row.get::<_, WTime>(3)?.0,
content: row.get(4)?,
encryption_key_id: row.get(5)?,
edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0),
deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0),
truncated: row.get(8)?,
sender: SessionView {
id: UserId(row.get(9)?),
name: row.get(10)?,
server_id: row.get(11)?,
server_era: row.get(12)?,
session_id: SessionId(row.get(13)?),
is_staff: row.get(14)?,
is_manager: row.get(15)?,
client_address: row.get(16)?,
real_client_address: row.get(17)?,
},
})
})
.optional()?;
Ok(msg)
}
@ -694,36 +616,31 @@ impl Action for GetTree {
.prepare(
"
WITH RECURSIVE
tree (domain, room, id) AS (
VALUES (?, ?, ?)
tree (room, id) AS (
VALUES (?, ?)
UNION
SELECT euph_msgs.domain, euph_msgs.room, euph_msgs.id
SELECT euph_msgs.room, euph_msgs.id
FROM euph_msgs
JOIN tree
ON tree.domain = euph_msgs.domain
AND tree.room = euph_msgs.room
ON tree.room = euph_msgs.room
AND tree.id = euph_msgs.parent
)
SELECT id, parent, time, user_id, name, content, seen
SELECT id, parent, time, name, content, seen
FROM euph_msgs
JOIN tree USING (domain, room, id)
JOIN tree USING (room, id)
ORDER BY id ASC
",
)?
.query_map(
params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)],
|row| {
Ok(SmallMessage {
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
time: row.get::<_, WTime>(2)?.0,
user_id: UserId(row.get(3)?),
nick: row.get(4)?,
content: row.get(5)?,
seen: row.get(6)?,
})
},
)?
.query_map(params![self.room, WSnowflake(self.root_id.0)], |row| {
Ok(SmallMessage {
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
time: row.get::<_, WTime>(2)?.0,
nick: row.get(3)?,
content: row.get(4)?,
seen: row.get(5)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(Tree::new(self.root_id, msgs))
}
@ -739,13 +656,12 @@ impl Action for GetFirstRootId {
"
SELECT id
FROM euph_trees
WHERE domain = ?
AND room = ?
WHERE room = ?
ORDER BY id ASC
LIMIT 1
",
)?
.query_row([&self.room.domain, &self.room.name], |row| {
.query_row([self.room], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
@ -763,13 +679,12 @@ impl Action for GetLastRootId {
"
SELECT id
FROM euph_trees
WHERE domain = ?
AND room = ?
WHERE room = ?
ORDER BY id DESC
LIMIT 1
",
)?
.query_row([&self.room.domain, &self.room.name], |row| {
.query_row([self.room], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
@ -787,17 +702,15 @@ impl Action for GetPrevRootId {
"
SELECT id
FROM euph_trees
WHERE domain = ?
AND room = ?
WHERE room = ?
AND id < ?
ORDER BY id DESC
LIMIT 1
",
)?
.query_row(
params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)],
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
)
.query_row(params![self.room, WSnowflake(self.root_id.0)], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
Ok(root_id)
}
@ -813,17 +726,15 @@ impl Action for GetNextRootId {
"
SELECT id
FROM euph_trees
WHERE domain = ?
AND room = ?
WHERE room = ?
AND id > ?
ORDER BY id ASC
LIMIT 1
",
)?
.query_row(
params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)],
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
)
.query_row(params![self.room, WSnowflake(self.root_id.0)], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
Ok(root_id)
}
@ -839,13 +750,12 @@ impl Action for GetOldestMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
ORDER BY id ASC
LIMIT 1
",
)?
.query_row([&self.room.domain, &self.room.name], |row| {
.query_row([self.room], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
@ -863,13 +773,12 @@ impl Action for GetNewestMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
ORDER BY id DESC
LIMIT 1
",
)?
.query_row([&self.room.domain, &self.room.name], |row| {
.query_row([self.room], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
@ -887,17 +796,15 @@ impl Action for GetOlderMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND id < ?
ORDER BY id DESC
LIMIT 1
",
)?
.query_row(
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
)
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
Ok(msg_id)
}
@ -912,17 +819,15 @@ impl Action for GetNewerMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND id > ?
ORDER BY id ASC
LIMIT 1
",
)?
.query_row(
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
)
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
Ok(msg_id)
}
@ -938,14 +843,13 @@ impl Action for GetOldestUnseenMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND NOT seen
ORDER BY id ASC
LIMIT 1
",
)?
.query_row([&self.room.domain, &self.room.name], |row| {
.query_row([self.room], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
@ -963,14 +867,13 @@ impl Action for GetNewestUnseenMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND NOT seen
ORDER BY id DESC
LIMIT 1
",
)?
.query_row([&self.room.domain, &self.room.name], |row| {
.query_row([self.room], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
@ -988,18 +891,16 @@ impl Action for GetOlderUnseenMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND NOT seen
AND id < ?
ORDER BY id DESC
LIMIT 1
",
)?
.query_row(
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
)
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
Ok(msg_id)
}
@ -1015,18 +916,16 @@ impl Action for GetNewerUnseenMsgId {
"
SELECT id
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND NOT seen
AND id > ?
ORDER BY id ASC
LIMIT 1
",
)?
.query_row(
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
)
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
})
.optional()?;
Ok(msg_id)
}
@ -1042,11 +941,10 @@ impl Action for GetUnseenMsgsCount {
"
SELECT amount
FROM euph_unseen_counts
WHERE domain = ?
AND room = ?
WHERE room = ?
",
)?
.query_row(params![self.room.domain, self.room.name], |row| row.get(0))
.query_row(params![self.room], |row| row.get(0))
.optional()?
.unwrap_or(0);
Ok(amount)
@ -1062,16 +960,10 @@ impl Action for SetSeen {
"
UPDATE euph_msgs
SET seen = :seen
WHERE domain = :domain
AND room = :room
WHERE room = :room
AND id = :id
",
named_params! {
":domain": self.room.domain,
":room": self.room.name,
":id": WSnowflake(self.id.0),
":seen": self.seen,
},
named_params! { ":room": self.room, ":id": WSnowflake(self.id.0), ":seen": self.seen },
)?;
Ok(())
}
@ -1086,17 +978,11 @@ impl Action for SetOlderSeen {
"
UPDATE euph_msgs
SET seen = :seen
WHERE domain = :domain
AND room = :room
WHERE room = :room
AND id <= :id
AND seen != :seen
",
named_params! {
":domain": self.room.domain,
":room": self.room.name,
":id": WSnowflake(self.id.0),
":seen": self.seen,
},
named_params! { ":room": self.room, ":id": WSnowflake(self.id.0), ":seen": self.seen },
)?;
Ok(())
}
@ -1138,13 +1024,12 @@ impl Action for GetChunkAfter {
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
AND id > ?
ORDER BY id ASC
LIMIT ?
")?
.query_map(params![self.room.domain, self.room.name, WSnowflake(id.0), self.amount], row2msg)?
.query_map(params![self.room, WSnowflake(id.0), self.amount], row2msg)?
.collect::<rusqlite::Result<_>>()?
} else {
conn.prepare("
@ -1152,12 +1037,11 @@ impl Action for GetChunkAfter {
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
FROM euph_msgs
WHERE domain = ?
AND room = ?
WHERE room = ?
ORDER BY id ASC
LIMIT ?
")?
.query_map(params![self.room.domain, self.room.name, self.amount], row2msg)?
.query_map(params![self.room, self.amount], row2msg)?
.collect::<rusqlite::Result<_>>()?
};

View file

@ -1,14 +1,10 @@
use rusqlite::Transaction;
use vault::Migration;
pub const MIGRATIONS: [Migration; 3] = [m1, m2, m3];
fn eprint_status(nr: usize, total: usize) {
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
}
pub const MIGRATIONS: [Migration; 2] = [m1, m2];
fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
eprint_status(nr, total);
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
tx.execute_batch(
"
CREATE TABLE euph_rooms (
@ -71,7 +67,7 @@ fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()>
}
fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
eprint_status(nr, total);
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
tx.execute_batch(
"
ALTER TABLE euph_msgs
@ -82,143 +78,3 @@ fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()>
",
)
}
fn m3(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
eprint_status(nr, total);
println!(" This migration might take quite a while.");
println!(" Aborting it will not corrupt your vault.");
// Rooms should be identified not just via their name but also their domain.
// The domain should be required but there should be no default value.
//
// To accomplish this, we need to recreate and repopulate all euph related
// tables because SQLite's ALTER TABLE is not powerful enough.
eprintln!(" Preparing tables...");
tx.execute_batch(
"
DROP INDEX euph_idx_msgs_room_id_parent;
DROP INDEX euph_idx_msgs_room_parent_id;
DROP INDEX euph_idx_msgs_room_id_seen;
ALTER TABLE euph_rooms RENAME TO old_euph_rooms;
ALTER TABLE euph_msgs RENAME TO old_euph_msgs;
ALTER TABLE euph_spans RENAME TO old_euph_spans;
ALTER TABLE euph_cookies RENAME TO old_euph_cookies;
CREATE TABLE euph_rooms (
domain TEXT NOT NULL,
room TEXT NOT NULL,
first_joined INT NOT NULL,
last_joined INT NOT NULL,
PRIMARY KEY (domain, room)
) STRICT;
CREATE TABLE euph_msgs (
domain TEXT NOT NULL,
room TEXT NOT NULL,
seen INT NOT NULL,
-- Message
id INT NOT NULL,
parent INT,
previous_edit_id INT,
time INT NOT NULL,
content TEXT NOT NULL,
encryption_key_id TEXT,
edited INT,
deleted INT,
truncated INT NOT NULL,
-- SessionView
user_id TEXT NOT NULL,
name TEXT,
server_id TEXT NOT NULL,
server_era TEXT NOT NULL,
session_id TEXT NOT NULL,
is_staff INT NOT NULL,
is_manager INT NOT NULL,
client_address TEXT,
real_client_address TEXT,
PRIMARY KEY (domain, room, id),
FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room)
ON DELETE CASCADE
) STRICT;
CREATE TABLE euph_spans (
domain TEXT NOT NULL,
room TEXT NOT NULL,
start INT,
end INT,
UNIQUE (domain, room, start, end),
FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room)
ON DELETE CASCADE,
CHECK (start IS NULL OR end IS NOT NULL)
) STRICT;
CREATE TABLE euph_cookies (
domain TEXT NOT NULL,
cookie TEXT NOT NULL
) STRICT;
",
)?;
eprintln!(" Migrating data...");
tx.execute_batch(
"
INSERT INTO euph_rooms (domain, room, first_joined, last_joined)
SELECT 'euphoria.io', room, first_joined, last_joined
FROM old_euph_rooms;
INSERT INTO euph_msgs (
domain, room, seen,
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
)
SELECT
'euphoria.io', room, seen,
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
FROM old_euph_msgs;
INSERT INTO euph_spans (domain, room, start, end)
SELECT 'euphoria.io', room, start, end
FROM old_euph_spans;
INSERT INTO euph_cookies (domain, cookie)
SELECT 'euphoria.io', cookie
FROM old_euph_cookies;
",
)?;
eprintln!(" Recreating indexes...");
tx.execute_batch(
"
CREATE INDEX euph_idx_msgs_domain_room_id_parent
ON euph_msgs (domain, room, id, parent);
CREATE INDEX euph_idx_msgs_domain_room_parent_id
ON euph_msgs (domain, room, parent, id);
CREATE INDEX euph_idx_msgs_domain_room_id_seen
ON euph_msgs (domain, room, id, seen);
",
)?;
eprintln!(" Cleaning up loose ends...");
tx.execute_batch(
"
DROP TABLE old_euph_rooms;
DROP TABLE old_euph_msgs;
DROP TABLE old_euph_spans;
DROP TABLE old_euph_cookies;
ANALYZE;
",
)?;
Ok(())
}

View file

@ -1,32 +1,28 @@
use rusqlite::Connection;
pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
eprintln!("Preparing vault");
// Cache ids of tree roots.
conn.execute_batch(
"
CREATE TEMPORARY TABLE euph_trees (
domain TEXT NOT NULL,
room TEXT NOT NULL,
id INT NOT NULL,
PRIMARY KEY (domain, room, id)
PRIMARY KEY (room, id)
) STRICT;
INSERT INTO euph_trees (domain, room, id)
SELECT domain, room, id
INSERT INTO euph_trees (room, id)
SELECT room, id
FROM euph_msgs
WHERE parent IS NULL
UNION
SELECT domain, room, parent
SELECT room, parent
FROM euph_msgs
WHERE parent IS NOT NULL
AND NOT EXISTS(
SELECT *
FROM euph_msgs AS parents
WHERE parents.domain = euph_msgs.domain
AND parents.room = euph_msgs.room
WHERE parents.room = euph_msgs.room
AND parents.id = euph_msgs.parent
);
@ -34,16 +30,15 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
AFTER DELETE ON main.euph_rooms
BEGIN
DELETE FROM euph_trees
WHERE domain = old.domain
AND room = old.room;
WHERE room = old.room;
END;
CREATE TEMPORARY TRIGGER et_insert_msg_without_parent
AFTER INSERT ON main.euph_msgs
WHEN new.parent IS NULL
BEGIN
INSERT OR IGNORE INTO euph_trees (domain, room, id)
VALUES (new.domain, new.room, new.id);
INSERT OR IGNORE INTO euph_trees (room, id)
VALUES (new.room, new.id);
END;
CREATE TEMPORARY TRIGGER et_insert_msg_with_parent
@ -51,18 +46,16 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
WHEN new.parent IS NOT NULL
BEGIN
DELETE FROM euph_trees
WHERE domain = new.domain
AND room = new.room
WHERE room = new.room
AND id = new.id;
INSERT OR IGNORE INTO euph_trees (domain, room, id)
INSERT OR IGNORE INTO euph_trees (room, id)
SELECT *
FROM (VALUES (new.domain, new.room, new.parent))
FROM (VALUES (new.room, new.parent))
WHERE NOT EXISTS(
SELECT *
FROM euph_msgs
WHERE domain = new.domain
AND room = new.room
WHERE room = new.room
AND id = new.parent
AND parent IS NOT NULL
);
@ -74,37 +67,35 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"
CREATE TEMPORARY TABLE euph_unseen_counts (
domain TEXT NOT NULL,
room TEXT NOT NULL,
amount INTEGER NOT NULL,
PRIMARY KEY (domain, room)
PRIMARY KEY (room)
) STRICT;
-- There must be an entry for every existing room.
INSERT INTO euph_unseen_counts (domain, room, amount)
SELECT domain, room, 0
INSERT INTO euph_unseen_counts (room, amount)
SELECT room, 0
FROM euph_rooms;
INSERT OR REPLACE INTO euph_unseen_counts (domain, room, amount)
SELECT domain, room, COUNT(*)
INSERT OR REPLACE INTO euph_unseen_counts (room, amount)
SELECT room, COUNT(*)
FROM euph_msgs
WHERE NOT seen
GROUP BY domain, room;
GROUP BY room;
CREATE TEMPORARY TRIGGER euc_insert_room
AFTER INSERT ON main.euph_rooms
BEGIN
INSERT INTO euph_unseen_counts (domain, room, amount)
VALUES (new.domain, new.room, 0);
INSERT INTO euph_unseen_counts (room, amount)
VALUES (new.room, 0);
END;
CREATE TEMPORARY TRIGGER euc_delete_room
AFTER DELETE ON main.euph_rooms
BEGIN
DELETE FROM euph_unseen_counts
WHERE domain = old.domain
AND room = old.room;
WHERE room = old.room;
END;
CREATE TEMPORARY TRIGGER euc_insert_msg
@ -113,8 +104,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
BEGIN
UPDATE euph_unseen_counts
SET amount = amount + 1
WHERE domain = new.domain
AND room = new.room;
WHERE room = new.room;
END;
CREATE TEMPORARY TRIGGER euc_update_msg
@ -123,8 +113,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
BEGIN
UPDATE euph_unseen_counts
SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END
WHERE domain = new.domain
AND room = new.room;
WHERE room = new.room;
END;
",
)?;

View file

@ -1,2 +0,0 @@
pub const NAME: &str = env!("CARGO_PKG_NAME");
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

47
flake.lock generated Normal file
View file

@ -0,0 +1,47 @@
{
"nodes": {
"naersk": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1679567394,
"narHash": "sha256-ZvLuzPeARDLiQUt6zSZFGOs+HZmE+3g4QURc8mkBsfM=",
"owner": "nix-community",
"repo": "naersk",
"rev": "88cd22380154a2c36799fe8098888f0f59861a15",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "naersk",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1680643271,
"narHash": "sha256-m76rYcvqs+NzTyETfxh1o/9gKdBuJ/Hl+PI/kp73mDw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "246567a3ad88e3119c2001e2fe78be233474cde0",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"naersk": "naersk",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

29
flake.nix Normal file
View file

@ -0,0 +1,29 @@
{
description = "TUI client for euphoria.io, a threaded real-time chat platform";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
naersk.url = "github:nix-community/naersk";
naersk.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, naersk }:
let forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
in {
packages = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
naersk' = pkgs.callPackage naersk { };
cargoToml = pkgs.lib.importTOML ./Cargo.toml;
in
{
default = naersk'.buildPackage {
name = "cove";
version = cargoToml.workspace.package.version;
root = ./.;
};
}
);
};
}