Compare commits
63 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10214f3369 | |||
| 2ca6190d97 | |||
| 67e77c8880 | |||
| b70d7548da | |||
| 732d462775 | |||
| 40de073799 | |||
| 8b928184e8 | |||
| ca0f0b6c31 | |||
| 74fbf386b2 | |||
| a17630aeaa | |||
| 496cdde18d | |||
| 6157ca5088 | |||
| 30c344031a | |||
| 4cf6a15577 | |||
| b207e91c25 | |||
| 676c92752d | |||
| cc436bbb3a | |||
| 56896a861e | |||
| 03b91ec1cd | |||
| cab37cb633 | |||
| 967293db37 | |||
| 972e4938aa | |||
| b64f56fce5 | |||
| b4c4a89625 | |||
| 9435fbece6 | |||
| 315db43010 | |||
| 24c8c92070 | |||
| bf9a9d640b | |||
| 8040b82ff1 | |||
| 17185ea536 | |||
| 900a686d0d | |||
| 2fa1bec421 | |||
| e750f81b11 | |||
| 866176dab6 | |||
| bf11e055b6 | |||
| 6c884f3077 | |||
| d29e3e6651 | |||
| fbc64de607 | |||
| 816d8f86a3 | |||
| 25d2cc7c98 | |||
| f45e66f572 | |||
| bd43fe060b | |||
| e1ba15cb9e | |||
| edc4219258 | |||
| 55d4321770 | |||
| e80d41cc47 | |||
| f471b9ce00 | |||
| 2ecc482533 | |||
| cff933b0bf | |||
| e43b27acfd | |||
| 461cc37d88 | |||
| 106a047b78 | |||
| 7aba041c9f | |||
| 19242a658e | |||
| db734c5740 | |||
| d3666674b2 | |||
| c2cfa6e527 | |||
| 3a3d42bcf3 | |||
| db529688e9 | |||
| 131b581880 | |||
| 50be653244 | |||
| 998a2f2ffd | |||
| a5b33440c5 |
63 changed files with 2098 additions and 1490 deletions
75
.github/workflows/build.yml
vendored
Normal file
75
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 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"
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -2,7 +2,7 @@
|
|||
"files.insertFinalNewline": true,
|
||||
"rust-analyzer.cargo.features": "all",
|
||||
"rust-analyzer.imports.granularity.enforce": true,
|
||||
"rust-analyzer.imports.granularity.group": "module",
|
||||
"rust-analyzer.imports.granularity.group": "crate",
|
||||
"rust-analyzer.imports.group.enable": true,
|
||||
"evenBetterToml.formatter.columnWidth": 100,
|
||||
}
|
||||
|
|
|
|||
106
CHANGELOG.md
106
CHANGELOG.md
|
|
@ -4,30 +4,103 @@ 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 and flake in a separate commit
|
||||
|
||||
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. Fast-forward branch `latest`
|
||||
8. Push `master`, `latest` and the new tag
|
||||
7. Push `master` 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
|
||||
|
|
@ -37,6 +110,7 @@ Procedure when bumping the version number:
|
|||
- 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.*`.
|
||||
|
|
@ -45,17 +119,20 @@ Procedure when bumping the version number:
|
|||
- 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
|
||||
|
|
@ -63,6 +140,7 @@ 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
|
||||
|
|
@ -76,15 +154,18 @@ 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
|
||||
|
|
@ -92,31 +173,37 @@ 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
|
||||
|
|
@ -125,10 +212,12 @@ 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
|
||||
|
|
@ -136,6 +225,7 @@ 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
|
||||
|
|
@ -151,14 +241,17 @@ 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
|
||||
|
|
@ -166,10 +259,12 @@ 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
|
||||
|
|
@ -177,15 +272,18 @@ 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
|
||||
|
|
@ -198,10 +296,12 @@ 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
|
||||
|
||||
|
|
|
|||
78
CONFIG.md
78
CONFIG.md
|
|
@ -53,6 +53,14 @@ 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
|
||||
|
|
@ -93,9 +101,9 @@ Whether to automatically join this room on startup.
|
|||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### `euph.servers.<domain>.rooms.<room>.password`
|
||||
|
||||
|
|
@ -529,6 +537,14 @@ 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
|
||||
|
|
@ -607,14 +623,13 @@ Move to root.
|
|||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
Whether to measure the width of characters as displayed by the terminal
|
||||
emulator instead of guessing the width.
|
||||
Whether to measure the width of graphemes (i.e. characters) as displayed
|
||||
by the terminal emulator instead of estimating the width.
|
||||
|
||||
Enabling this makes rendering a bit slower but more accurate. The screen
|
||||
might also flash when encountering new characters (or, more accurately,
|
||||
graphemes).
|
||||
might also flash when encountering new graphemes.
|
||||
|
||||
See also the `--measure-graphemes` command line option.
|
||||
See also the `--measure-widths` command line option.
|
||||
|
||||
### `offline`
|
||||
|
||||
|
|
@ -656,18 +671,41 @@ order of priority):
|
|||
|
||||
Time zone that chat timestamps should be displayed in.
|
||||
|
||||
This option is interpreted as a POSIX TZ string. It is described here in
|
||||
further detail:
|
||||
<https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html>
|
||||
This option can either be the string `"localtime"`, a [POSIX TZ string],
|
||||
or a [tz identifier] from the [tz database].
|
||||
|
||||
On a normal system, the string `"localtime"` as well as any value from
|
||||
the "TZ identifier" column of the following wikipedia article should be
|
||||
valid TZ strings:
|
||||
<https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
|
||||
When not set or when set to `"localtime"`, cove attempts to use your
|
||||
system's configured time zone, falling back to UTC.
|
||||
|
||||
If the `TZ` environment variable exists, it overrides this option. If
|
||||
neither exist, cove uses the system's local time zone.
|
||||
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).
|
||||
|
||||
**Warning:** On Windows, cove can't get the local time zone and uses UTC
|
||||
instead. However, you can still specify a path to a tz data file or a
|
||||
custom time zone string.
|
||||
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.
|
||||
|
|
|
|||
1200
Cargo.lock
generated
1200
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
69
Cargo.toml
69
Cargo.toml
|
|
@ -1,23 +1,72 @@
|
|||
# TODO Configure lints in here
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
resolver = "3"
|
||||
members = ["cove", "cove-*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
version = "0.9.3"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
crossterm = "0.27.0"
|
||||
parking_lot = "0.12.1"
|
||||
serde = { version = "1.0.195", features = ["derive"] }
|
||||
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"] }
|
||||
serde_either = "0.2.1"
|
||||
thiserror = "1.0.56"
|
||||
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"]
|
||||
|
||||
[workspace.dependencies.toss]
|
||||
git = "https://github.com/Garmelon/toss.git"
|
||||
tag = "v0.2.1"
|
||||
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"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -7,6 +7,11 @@ real-time chat platform.
|
|||
|
||||
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
|
||||
|
|
@ -26,61 +31,3 @@ 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
[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 }
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
toml = "0.8.8"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! Auto-generate markdown documentation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use cove_input::KeyBinding;
|
||||
pub use cove_macro::Document;
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ pub struct EuphRoom {
|
|||
/// associated with the current session.
|
||||
pub username: Option<String>,
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub force_username: bool,
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ 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"];
|
||||
}
|
||||
|
|
@ -356,6 +357,9 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,18 @@
|
|||
#![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 std::{
|
||||
fs,
|
||||
io::{self, ErrorKind},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use doc::Document;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::{euph::*, keys::*};
|
||||
|
||||
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")]
|
||||
|
|
@ -31,6 +21,14 @@ 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
|
||||
|
|
@ -51,14 +49,31 @@ pub struct Config {
|
|||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
|
||||
/// Whether to measure the width of characters as displayed by the terminal
|
||||
/// emulator instead of guessing the width.
|
||||
/// 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.
|
||||
///
|
||||
/// Enabling this makes rendering a bit slower but more accurate. The screen
|
||||
/// might also flash when encountering new characters (or, more accurately,
|
||||
/// graphemes).
|
||||
/// might also flash when encountering new graphemes.
|
||||
///
|
||||
/// See also the `--measure-graphemes` command line option.
|
||||
/// See also the `--measure-widths` command line option.
|
||||
#[serde(default)]
|
||||
pub measure_widths: bool,
|
||||
|
||||
|
|
@ -85,23 +100,27 @@ pub struct Config {
|
|||
#[serde(default)]
|
||||
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 is interpreted as a POSIX TZ string. It is described here in
|
||||
/// further detail:
|
||||
/// <https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html>
|
||||
/// This option can either be the string `"localtime"`, a [POSIX TZ string],
|
||||
/// or a [tz identifier] from the [tz database].
|
||||
///
|
||||
/// On a normal system, the string `"localtime"` as well as any value from
|
||||
/// the "TZ identifier" column of the following wikipedia article should be
|
||||
/// valid TZ strings:
|
||||
/// <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
|
||||
/// When not set or when set to `"localtime"`, cove attempts to use your
|
||||
/// system's configured time zone, falling back to UTC.
|
||||
///
|
||||
/// If the `TZ` environment variable exists, it overrides this option. If
|
||||
/// neither exist, cove uses the system's local time zone.
|
||||
/// 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).
|
||||
///
|
||||
/// **Warning:** On Windows, cove can't get the local time zone and uses UTC
|
||||
/// instead. However, you can still specify a path to a tz data file or a
|
||||
/// custom time zone string.
|
||||
/// 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>,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
[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 }
|
||||
parking_lot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_either = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toss = { workspace = true }
|
||||
crossterm.workspace = true
|
||||
edit.workspace = true
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
serde_either.workspace = true
|
||||
thiserror.workspace = true
|
||||
toss.workspace = true
|
||||
|
||||
edit = "0.1.5"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
use std::fmt;
|
||||
use std::num::ParseIntError;
|
||||
use std::str::FromStr;
|
||||
use std::{fmt, num::ParseIntError, str::FromStr};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{de::Error, Deserialize, Deserializer};
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
|
||||
use serde_either::SingleOrVec;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -117,7 +114,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())),
|
||||
}
|
||||
|
|
@ -151,7 +148,7 @@ impl FromStr for KeyPress {
|
|||
let mut parts = s.split('+');
|
||||
let code = parts.next_back().ok_or(ParseKeysError::NoKeyCode)?;
|
||||
|
||||
let mut keys = KeyPress::parse_key_code(code)?;
|
||||
let mut keys = Self::parse_key_code(code)?;
|
||||
let shift_allowed = !conflicts_with_shift(keys.code);
|
||||
for modifier in parts {
|
||||
keys.parse_modifier(modifier, shift_allowed)?;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
mod keys;
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::{io, sync::Arc};
|
||||
|
||||
pub use cove_macro::KeyGroup;
|
||||
use crossterm::event::{Event, KeyEvent, KeyEventKind};
|
||||
|
|
@ -10,6 +7,8 @@ use toss::{Frame, Terminal, WidthDb};
|
|||
|
||||
pub use crate::keys::*;
|
||||
|
||||
mod keys;
|
||||
|
||||
pub struct KeyBindingInfo<'a> {
|
||||
pub name: &'static str,
|
||||
pub binding: &'a KeyBinding,
|
||||
|
|
@ -40,7 +39,7 @@ impl<'a> KeyGroupInfo<'a> {
|
|||
}
|
||||
|
||||
pub struct InputEvent<'a> {
|
||||
event: crossterm::event::Event,
|
||||
event: Event,
|
||||
terminal: &'a mut Terminal,
|
||||
crossterm_lock: Arc<FairMutex<()>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
[package]
|
||||
name = "cove-macro"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
case = "1.0.0"
|
||||
proc-macro2 = "1.0.76"
|
||||
quote = "1.0.35"
|
||||
syn = "2.0.48"
|
||||
proc-macro2.workspace = true
|
||||
quote.workspace = true
|
||||
syn.workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr};
|
||||
use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr, spanned::Spanned};
|
||||
|
||||
use crate::util::{self, SerdeDefault};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{Data, DeriveInput};
|
||||
use syn::{Data, DeriveInput, spanned::Spanned};
|
||||
|
||||
use crate::util::{self, bail};
|
||||
use crate::util;
|
||||
|
||||
fn decapitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
|
|
@ -34,7 +33,7 @@ pub fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
|
|||
|
||||
let default = util::serde_default(field)?;
|
||||
let Some(default) = default else {
|
||||
return bail(field_ident.span(), "must have serde default");
|
||||
return util::bail(field_ident.span(), "must have serde default");
|
||||
};
|
||||
let default_value = default.value();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,4 @@
|
|||
#![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};
|
||||
use syn::{DeriveInput, parse_macro_input};
|
||||
|
||||
mod document;
|
||||
mod key_group;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
use proc_macro2::{Span, TokenStream};
|
||||
use quote::quote;
|
||||
use syn::parse::Parse;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::{Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type};
|
||||
use syn::{
|
||||
Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type, parse::Parse,
|
||||
punctuated::Punctuated,
|
||||
};
|
||||
|
||||
pub fn bail<T>(span: Span, message: &str) -> syn::Result<T> {
|
||||
Err(syn::Error::new(span, message))
|
||||
|
|
|
|||
|
|
@ -1,47 +1,32 @@
|
|||
[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" }
|
||||
|
||||
crossterm = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toss = { workspace = true }
|
||||
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
|
||||
|
||||
anyhow = "1.0.79"
|
||||
async-trait = "0.1.77"
|
||||
clap = { version = "4.4.14", features = ["derive", "deprecated"] }
|
||||
cookie = "0.18.0"
|
||||
directories = "5.0.1"
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.20", features = ["std"] }
|
||||
once_cell = "1.19.0"
|
||||
open = "5.0.1"
|
||||
rusqlite = { version = "0.30.0", features = ["bundled", "time"] }
|
||||
serde_json = "1.0.111"
|
||||
tokio = { version = "1.35.1", features = ["full"] }
|
||||
tz-rs = "0.6.14"
|
||||
unicode-segmentation = "1.10.1"
|
||||
unicode-width = "0.1.11"
|
||||
|
||||
[dependencies.time]
|
||||
version = "0.3.31"
|
||||
features = ["macros", "formatting", "parsing", "serde"]
|
||||
|
||||
[dependencies.tokio-tungstenite]
|
||||
version = "0.21.0"
|
||||
features = ["rustls-tls-native-roots"]
|
||||
|
||||
[dependencies.euphoxide]
|
||||
git = "https://github.com/Garmelon/euphoxide.git"
|
||||
tag = "v0.5.0"
|
||||
features = ["bot"]
|
||||
|
||||
[dependencies.vault]
|
||||
git = "https://github.com/Garmelon/vault.git"
|
||||
tag = "v0.3.0"
|
||||
features = ["tokio"]
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
mod room;
|
||||
mod small_message;
|
||||
mod util;
|
||||
|
||||
pub use highlight::*;
|
||||
pub use room::*;
|
||||
pub use small_message::*;
|
||||
pub use util::*;
|
||||
|
||||
mod highlight;
|
||||
mod room;
|
||||
mod small_message;
|
||||
mod util;
|
||||
|
|
|
|||
211
cove/src/euph/highlight.rs
Normal file
211
cove/src/euph/highlight.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,17 @@
|
|||
// TODO Remove rl2dev-specific code
|
||||
use std::{convert::Infallible, time::Duration};
|
||||
|
||||
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 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 euphoxide::bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig};
|
||||
use euphoxide::conn::{self, ConnTx, Joined};
|
||||
use log::{debug, error, info, warn};
|
||||
use tokio::select;
|
||||
use tokio::sync::oneshot;
|
||||
use log::{debug, info, warn};
|
||||
use tokio::{select, sync::oneshot};
|
||||
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::vault::EuphRoomVault;
|
||||
use crate::{macros::logging_unwrap, vault::EuphRoomVault};
|
||||
|
||||
const LOG_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
|
|
@ -73,20 +69,13 @@ 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 is_rl2dev = vault.room().domain == "euphoria.io" && vault.room().name == "rl2dev";
|
||||
let ephemeral = vault.vault().vault().ephemeral() || is_rl2dev;
|
||||
|
||||
Self {
|
||||
vault,
|
||||
ephemeral,
|
||||
ephemeral: vault.vault().vault().ephemeral(),
|
||||
instance: instance_config.build(on_event),
|
||||
state: State::Disconnected,
|
||||
last_msg_id: None,
|
||||
log_request_canary: None,
|
||||
vault,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,14 +183,7 @@ impl Room {
|
|||
|
||||
debug!("{:?}: requesting logs", vault.room());
|
||||
|
||||
// &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 is_rl2dev = vault.room().domain == "euphoria.io" && vault.room().name == "rl2dev";
|
||||
let n = if is_rl2dev { 50 } else { 1000 };
|
||||
|
||||
let _ = conn_tx.send(Log { n, before }).await;
|
||||
let _ = conn_tx.send(Log { n: 1000, before }).await;
|
||||
// The code handling incoming events and replies also handles
|
||||
// `LogReply`s, so we don't need to do anything special here.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,214 +1,18 @@
|
|||
use std::mem;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::{MessageId, Snowflake, Time};
|
||||
use time::OffsetDateTime;
|
||||
use euphoxide::api::{MessageId, Snowflake, Time, UserId};
|
||||
use jiff::Timestamp;
|
||||
use toss::{Style, Styled};
|
||||
use tz::TimeZone;
|
||||
|
||||
use crate::store::Msg;
|
||||
use crate::ui::ChatMsg;
|
||||
use crate::{store::Msg, 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 time_zone: &'static TimeZone,
|
||||
pub user_id: UserId,
|
||||
pub nick: String,
|
||||
pub content: String,
|
||||
pub seen: bool,
|
||||
|
|
@ -224,22 +28,22 @@ fn style_me() -> Style {
|
|||
|
||||
fn styled_nick(nick: &str) -> Styled {
|
||||
Styled::new_plain("[")
|
||||
.and_then(util::style_nick(nick, Style::new()))
|
||||
.and_then(super::style_nick(nick, Style::new()))
|
||||
.then_plain("]")
|
||||
}
|
||||
|
||||
fn styled_nick_me(nick: &str) -> Styled {
|
||||
let style = style_me();
|
||||
Styled::new("*", style).and_then(util::style_nick(nick, style))
|
||||
Styled::new("*", style).and_then(super::style_nick(nick, style))
|
||||
}
|
||||
|
||||
fn styled_content(content: &str) -> Styled {
|
||||
highlight_content(content.trim(), Style::new(), false)
|
||||
super::highlight(content.trim(), Style::new(), false)
|
||||
}
|
||||
|
||||
fn styled_content_me(content: &str) -> Styled {
|
||||
let style = style_me();
|
||||
highlight_content(content.trim(), style, false).then("*", style)
|
||||
super::highlight(content.trim(), style, false).then("*", style)
|
||||
}
|
||||
|
||||
fn styled_editor_content(content: &str) -> Styled {
|
||||
|
|
@ -248,7 +52,7 @@ fn styled_editor_content(content: &str) -> Styled {
|
|||
} else {
|
||||
Style::new()
|
||||
};
|
||||
highlight_content(content, style, true)
|
||||
super::highlight(content, style, true)
|
||||
}
|
||||
|
||||
impl Msg for SmallMessage {
|
||||
|
|
@ -269,11 +73,15 @@ 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<OffsetDateTime> {
|
||||
crate::util::convert_to_time_zone(self.time_zone, self.time.0)
|
||||
fn time(&self) -> Option<Timestamp> {
|
||||
Some(self.time.as_timestamp())
|
||||
}
|
||||
|
||||
fn styled(&self) -> (Styled, Styled) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use euphoxide::Emoji;
|
||||
use once_cell::sync::Lazy;
|
||||
use euphoxide::{Emoji, api::UserId};
|
||||
use toss::{Style, Styled};
|
||||
|
||||
pub static EMOJI: Lazy<Emoji> = Lazy::new(Emoji::load);
|
||||
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
|
||||
});
|
||||
|
||||
/// Convert HSL to RGB following [this approach from wikipedia][1].
|
||||
///
|
||||
|
|
@ -54,3 +72,25 @@ 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
//! 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, RoomIdentifier};
|
||||
|
||||
#[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.
|
||||
JsonStream,
|
||||
/// Message objects in the same format as the euphoria API uses, one per
|
||||
/// line (https://jsonlines.org/).
|
||||
JsonLines,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
|
|
@ -23,14 +26,15 @@ impl Format {
|
|||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Json => "json",
|
||||
Self::JsonStream => "json stream",
|
||||
Self::JsonLines => "json lines",
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "txt",
|
||||
Self::Json | Self::JsonStream => "json",
|
||||
Self::Json => "json",
|
||||
Self::JsonLines => "jsonl",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +82,7 @@ async fn export_room<W: Write>(
|
|||
match format {
|
||||
Format::Text => text::export(vault, out).await?,
|
||||
Format::Json => json::export(vault, out).await?,
|
||||
Format::JsonStream => json::export_stream(vault, out).await?,
|
||||
Format::JsonLines => json::export_lines(vault, out).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ pub async fn export<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Re
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn export_stream<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
pub async fn export_lines<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
let mut total = 0;
|
||||
let mut last_msg_id = None;
|
||||
loop {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
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;
|
||||
use crate::store::Tree;
|
||||
use crate::vault::EuphRoomVault;
|
||||
use crate::{euph::SmallMessage, store::Tree, vault::EuphRoomVault};
|
||||
|
||||
const TIME_FORMAT: &[FormatItem<'_>] =
|
||||
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
|
||||
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
||||
const TIME_EMPTY: &str = " ";
|
||||
|
||||
pub async fn export<W: Write>(vault: &EuphRoomVault, out: &mut W) -> anyhow::Result<()> {
|
||||
|
|
@ -67,11 +62,7 @@ fn write_msg<W: Write>(
|
|||
|
||||
for (i, line) in msg.content.lines().enumerate() {
|
||||
if i == 0 {
|
||||
let time = msg
|
||||
.time
|
||||
.0
|
||||
.format(TIME_FORMAT)
|
||||
.expect("time can be formatted");
|
||||
let time = msg.time.as_timestamp().strftime(TIME_FORMAT);
|
||||
writeln!(file, "{time} {indent_string}[{nick}] {line}")?;
|
||||
} else {
|
||||
writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::vec;
|
||||
use std::{convert::Infallible, sync::Arc, 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};
|
||||
use crate::ui::ChatMsg;
|
||||
use crate::{
|
||||
store::{Msg, MsgStore, Path, Tree},
|
||||
ui::ChatMsg,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogMsg {
|
||||
id: usize,
|
||||
time: OffsetDateTime,
|
||||
time: Timestamp,
|
||||
level: Level,
|
||||
content: String,
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ impl Msg for LogMsg {
|
|||
}
|
||||
|
||||
impl ChatMsg for LogMsg {
|
||||
fn time(&self) -> Option<OffsetDateTime> {
|
||||
fn time(&self) -> Option<Timestamp> {
|
||||
Some(self.time)
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ impl Log for Logger {
|
|||
let mut guard = self.messages.lock();
|
||||
let msg = LogMsg {
|
||||
id: guard.len(),
|
||||
time: OffsetDateTime::now_utc(),
|
||||
time: Timestamp::now(),
|
||||
level: record.level(),
|
||||
content: format!("<{}> {}", record.target(), record.args()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
#![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;
|
||||
|
|
@ -24,22 +28,6 @@ mod util;
|
|||
mod vault;
|
||||
mod version;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
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;
|
||||
use crate::version::{NAME, VERSION};
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
enum Command {
|
||||
/// Run the client interactively (default).
|
||||
|
|
@ -58,6 +46,12 @@ enum Command {
|
|||
HelpConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
enum WidthEstimationMethod {
|
||||
Legacy,
|
||||
Unicode,
|
||||
}
|
||||
|
||||
impl Default for Command {
|
||||
fn default() -> Self {
|
||||
Self::Run
|
||||
|
|
@ -91,6 +85,11 @@ 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)]
|
||||
|
|
@ -126,21 +125,23 @@ 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 time_zone =
|
||||
util::load_time_zone(config.time_zone_ref()).context("failed to load time zone")?;
|
||||
let time_zone = Box::leak(Box::new(time_zone));
|
||||
|
||||
let vault = if config.ephemeral {
|
||||
vault::launch_in_memory(time_zone)?
|
||||
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"), time_zone)?
|
||||
vault::launch(&data_dir.join("vault.db"))?
|
||||
};
|
||||
|
||||
Ok(vault)
|
||||
|
|
@ -153,6 +154,11 @@ 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());
|
||||
|
|
@ -187,11 +193,17 @@ async fn run(
|
|||
) -> anyhow::Result<()> {
|
||||
info!("Welcome to {NAME} {VERSION}",);
|
||||
|
||||
let tz = util::load_time_zone(config.time_zone_ref()).context("failed to load time zone")?;
|
||||
|
||||
let vault = open_vault(config, dirs)?;
|
||||
|
||||
let mut terminal = Terminal::new()?;
|
||||
terminal.set_measuring(config.measure_widths);
|
||||
Ui::run(config, &mut terminal, vault.clone(), logger, logger_rx).await?;
|
||||
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?;
|
||||
drop(terminal);
|
||||
|
||||
vault.close().await;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
use std::vec;
|
||||
use std::{collections::HashMap, fmt::Debug, hash::Hash, vec};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
|
|
@ -11,6 +8,10 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +131,7 @@ impl<M: Msg> Tree<M> {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[async_trait]
|
||||
pub trait MsgStore<M: Msg> {
|
||||
type Error;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,30 @@
|
|||
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;
|
||||
|
|
@ -5,30 +32,6 @@ 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
|
||||
|
||||
|
|
@ -47,6 +50,7 @@ impl From<Infallible> for UiError {
|
|||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::large_enum_variant)]
|
||||
pub enum UiEvent {
|
||||
GraphemeWidthsChanged,
|
||||
LogChanged,
|
||||
|
|
@ -84,6 +88,7 @@ impl Ui {
|
|||
|
||||
pub async fn run(
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
terminal: &mut Terminal,
|
||||
vault: Vault,
|
||||
logger: Logger,
|
||||
|
|
@ -112,8 +117,8 @@ impl Ui {
|
|||
config,
|
||||
event_tx: event_tx.clone(),
|
||||
mode: Mode::Main,
|
||||
rooms: Rooms::new(config, vault, event_tx.clone()).await,
|
||||
log_chat: ChatState::new(logger),
|
||||
rooms: Rooms::new(config, tz.clone(), vault, event_tx.clone()).await,
|
||||
log_chat: ChatState::new(logger, tz),
|
||||
key_bindings_visible: false,
|
||||
key_bindings_list: ListState::new(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
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 crate::util;
|
||||
|
||||
use self::cursor::Cursor;
|
||||
use self::tree::TreeViewState;
|
||||
|
||||
use super::UiError;
|
||||
|
||||
pub trait ChatMsg {
|
||||
fn time(&self) -> Option<OffsetDateTime>;
|
||||
fn time(&self) -> Option<Timestamp>;
|
||||
fn styled(&self) -> (Styled, Styled);
|
||||
fn edit(nick: &str, content: &str) -> (Styled, Styled);
|
||||
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
|
||||
|
|
@ -34,6 +37,7 @@ pub struct ChatState<M: Msg, S: MsgStore<M>> {
|
|||
|
||||
cursor: Cursor<M::Id>,
|
||||
editor: EditorState,
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
|
||||
mode: Mode,
|
||||
|
|
@ -41,18 +45,23 @@ pub struct ChatState<M: Msg, S: MsgStore<M>> {
|
|||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
|
||||
pub fn new(store: S) -> Self {
|
||||
pub fn new(store: S, tz: TimeZone) -> Self {
|
||||
Self {
|
||||
cursor: Cursor::Bottom,
|
||||
editor: EditorState::new(),
|
||||
nick_emoji: false,
|
||||
caesar: 0,
|
||||
|
||||
mode: Mode::Tree,
|
||||
tree: TreeViewState::new(store.clone()),
|
||||
tree: TreeViewState::new(store.clone(), tz),
|
||||
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nick_emoji(&self) -> bool {
|
||||
self.nick_emoji
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
||||
|
|
@ -76,6 +85,7 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
|||
&mut self.editor,
|
||||
nick,
|
||||
focused,
|
||||
self.nick_emoji,
|
||||
self.caesar,
|
||||
)
|
||||
.boxed_async(),
|
||||
|
|
@ -114,6 +124,11 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Common rendering logic.
|
||||
|
||||
use std::collections::{vec_deque, VecDeque};
|
||||
use std::collections::{VecDeque, vec_deque};
|
||||
|
||||
use toss::widgets::Predrawn;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! Common cursor movement logic.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::hash::Hash;
|
||||
use std::{collections::HashSet, hash::Hash};
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ 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>;
|
||||
|
|
|
|||
|
|
@ -2,29 +2,31 @@
|
|||
|
||||
// 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 toss::widgets::EditorState;
|
||||
use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb};
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb, widgets::EditorState};
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
use crate::ui::{util, ChatMsg, UiError};
|
||||
use crate::util::InfallibleExt;
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
ui::{UiError, util},
|
||||
util::InfallibleExt,
|
||||
};
|
||||
|
||||
use super::{ChatMsg, Reaction, cursor::Cursor};
|
||||
|
||||
use self::renderer::{TreeContext, TreeRenderer};
|
||||
|
||||
use super::cursor::Cursor;
|
||||
use super::Reaction;
|
||||
mod renderer;
|
||||
mod scroll;
|
||||
mod widgets;
|
||||
|
||||
pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
tz: TimeZone,
|
||||
|
||||
last_size: Size,
|
||||
last_nick: String,
|
||||
|
|
@ -36,9 +38,10 @@ pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
|
|||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
||||
pub fn new(store: S) -> Self {
|
||||
pub fn new(store: S, tz: TimeZone) -> Self {
|
||||
Self {
|
||||
store,
|
||||
tz,
|
||||
last_size: Size::ZERO,
|
||||
last_nick: String::new(),
|
||||
last_cursor: Cursor::Bottom,
|
||||
|
|
@ -386,6 +389,7 @@ 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 {
|
||||
|
|
@ -394,6 +398,7 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
|||
editor,
|
||||
nick,
|
||||
focused,
|
||||
nick_emoji,
|
||||
caesar,
|
||||
}
|
||||
}
|
||||
|
|
@ -407,6 +412,8 @@ pub struct TreeView<'a, M: Msg, S: MsgStore<M>> {
|
|||
|
||||
nick: String,
|
||||
focused: bool,
|
||||
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
}
|
||||
|
||||
|
|
@ -435,6 +442,7 @@ 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,
|
||||
|
|
@ -443,6 +451,7 @@ where
|
|||
let mut renderer = TreeRenderer::new(
|
||||
context,
|
||||
&self.state.store,
|
||||
&self.state.tz,
|
||||
&mut self.state.folded,
|
||||
self.cursor,
|
||||
self.editor,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,26 @@
|
|||
//! A [`Renderer`] for message trees.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::convert::Infallible;
|
||||
use std::{collections::HashSet, convert::Infallible};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use toss::widgets::{EditorState, Empty, Predrawn, Resize};
|
||||
use toss::{Size, Widget, WidthDb};
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{
|
||||
Size, Widget, WidthDb,
|
||||
widgets::{EditorState, Empty, Predrawn, Resize},
|
||||
};
|
||||
|
||||
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 crate::{
|
||||
store::{Msg, MsgStore, Tree},
|
||||
ui::{
|
||||
ChatMsg,
|
||||
chat::{
|
||||
blocks::{Block, Blocks, Range},
|
||||
cursor::Cursor,
|
||||
renderer::{self, Renderer, overlaps},
|
||||
},
|
||||
},
|
||||
util::InfallibleExt,
|
||||
};
|
||||
|
||||
use super::widgets;
|
||||
|
||||
|
|
@ -72,6 +80,7 @@ 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,
|
||||
|
|
@ -81,6 +90,7 @@ 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,
|
||||
|
|
@ -108,6 +118,7 @@ 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,
|
||||
|
|
@ -116,6 +127,7 @@ where
|
|||
Self {
|
||||
context,
|
||||
store,
|
||||
tz,
|
||||
folded,
|
||||
cursor,
|
||||
editor,
|
||||
|
|
@ -191,7 +203,15 @@ where
|
|||
};
|
||||
let highlighted = highlighted && self.context.focused;
|
||||
|
||||
let widget = widgets::msg(highlighted, indent, msg, self.context.caesar, folded_info);
|
||||
let widget = widgets::msg(
|
||||
highlighted,
|
||||
self.tz.clone(),
|
||||
indent,
|
||||
msg,
|
||||
self.context.nick_emoji,
|
||||
self.context.caesar,
|
||||
folded_info,
|
||||
);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
Block::new(TreeBlockId::Msg(msg_id), widget, true)
|
||||
}
|
||||
|
|
@ -426,7 +446,7 @@ where
|
|||
|
||||
pub fn into_visible_blocks(
|
||||
self,
|
||||
) -> impl Iterator<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> {
|
||||
) -> impl Iterator<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> + use<M, S> {
|
||||
let area = renderer::visible_area(&self);
|
||||
self.blocks
|
||||
.into_iter()
|
||||
|
|
@ -460,10 +480,6 @@ 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?
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
use toss::widgets::EditorState;
|
||||
use toss::WidthDb;
|
||||
use toss::{WidthDb, widgets::EditorState};
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
use crate::ui::chat::cursor::Cursor;
|
||||
use crate::ui::ChatMsg;
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
ui::{ChatMsg, chat::cursor::Cursor},
|
||||
};
|
||||
|
||||
use super::renderer::{TreeContext, TreeRenderer};
|
||||
use super::TreeViewState;
|
||||
use super::{
|
||||
TreeViewState,
|
||||
renderer::{TreeContext, TreeRenderer},
|
||||
};
|
||||
|
||||
impl<M, S> TreeViewState<M, S>
|
||||
where
|
||||
|
|
@ -20,6 +22,7 @@ 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,
|
||||
|
|
@ -37,6 +40,7 @@ where
|
|||
let mut renderer = TreeRenderer::new(
|
||||
context,
|
||||
&self.store,
|
||||
&self.tz,
|
||||
&mut self.folded,
|
||||
cursor,
|
||||
editor,
|
||||
|
|
@ -64,6 +68,7 @@ where
|
|||
let mut renderer = TreeRenderer::new(
|
||||
context,
|
||||
&self.store,
|
||||
&self.tz,
|
||||
&mut self.folded,
|
||||
cursor,
|
||||
editor,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use toss::widgets::{Boxed, EditorState, Join2, Join4, Join5, Text};
|
||||
use toss::{Style, Styled, WidgetExt};
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{
|
||||
Style, Styled, WidgetExt,
|
||||
widgets::{Boxed, EditorState, Join2, Join4, Join5, Text},
|
||||
};
|
||||
|
||||
use crate::store::Msg;
|
||||
use crate::ui::chat::widgets::{Indent, Seen, Time};
|
||||
use crate::ui::ChatMsg;
|
||||
use crate::util;
|
||||
use crate::{
|
||||
store::Msg,
|
||||
ui::{
|
||||
ChatMsg,
|
||||
chat::widgets::{Indent, Seen, Time},
|
||||
},
|
||||
util,
|
||||
};
|
||||
|
||||
pub const PLACEHOLDER: &str = "[...]";
|
||||
|
||||
|
|
@ -49,12 +56,20 @@ 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 (nick, mut content) = msg.styled();
|
||||
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
|
||||
|
|
@ -72,7 +87,7 @@ pub fn msg<M: Msg + ChatMsg>(
|
|||
|
||||
Join5::horizontal(
|
||||
Seen::new(msg.seen()).segment().with_fixed(true),
|
||||
Time::new(msg.time(), style_time(highlighted))
|
||||
Time::new(msg.time().map(|t| t.to_zoned(tz)), style_time(highlighted))
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.with_stretch(true)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
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 jiff::Zoned;
|
||||
use toss::{
|
||||
Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb,
|
||||
widgets::{Boxed, Empty, Text},
|
||||
};
|
||||
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
|
|
@ -46,15 +46,15 @@ impl<E> Widget<E> for Indent {
|
|||
}
|
||||
}
|
||||
|
||||
const TIME_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day] [hour]:[minute]");
|
||||
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M";
|
||||
const TIME_WIDTH: u16 = 16;
|
||||
|
||||
pub struct Time(Boxed<'static, Infallible>);
|
||||
|
||||
impl Time {
|
||||
pub fn new(time: Option<OffsetDateTime>, style: Style) -> Self {
|
||||
pub fn new(time: Option<Zoned>, style: Style) -> Self {
|
||||
let widget = if let Some(time) = time {
|
||||
let text = time.format(TIME_FORMAT).expect("could not format time");
|
||||
let text = time.strftime(TIME_FORMAT).to_string();
|
||||
Text::new((text, style))
|
||||
.background()
|
||||
.with_style(style)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::PersonalAccountView;
|
||||
use euphoxide::conn;
|
||||
use toss::widgets::{EditorState, Empty, Join3, Join4, Join5, Text};
|
||||
use toss::{Style, Widget, WidgetExt};
|
||||
use euphoxide::{api::PersonalAccountView, conn};
|
||||
use toss::{
|
||||
Style, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join3, Join4, Join5, Text},
|
||||
};
|
||||
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
use crate::{
|
||||
euph::{self, Room},
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
|
|
@ -33,7 +35,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(),
|
||||
|
|
@ -66,7 +68,7 @@ impl LoggedOut {
|
|||
pub struct LoggedIn(PersonalAccountView);
|
||||
|
||||
impl LoggedIn {
|
||||
fn widget(&self) -> impl Widget<UiError> {
|
||||
fn widget(&self) -> impl Widget<UiError> + use<> {
|
||||
let bold = Style::new().bold();
|
||||
Join5::vertical(
|
||||
Text::new(("Logged in", bold.green())).segment(),
|
||||
|
|
@ -109,7 +111,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(),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use toss::widgets::EditorState;
|
||||
use toss::Widget;
|
||||
use toss::{Widget, widgets::EditorState};
|
||||
|
||||
use crate::euph::Room;
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
use crate::{
|
||||
euph::Room,
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::{Message, NickEvent, SessionView};
|
||||
use euphoxide::conn::SessionInfo;
|
||||
use toss::widgets::Text;
|
||||
use toss::{Style, Styled, Widget};
|
||||
use euphoxide::{
|
||||
api::{Message, NickEvent, SessionView},
|
||||
conn::SessionInfo,
|
||||
};
|
||||
use toss::{Style, Styled, Widget, widgets::Text};
|
||||
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::UiError;
|
||||
use crate::ui::{UiError, widgets::Popup};
|
||||
|
||||
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> {
|
||||
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> + use<> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let text = match session {
|
||||
|
|
@ -108,7 +108,7 @@ pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> {
|
|||
Popup::new(Text::new(text), "Inspect session")
|
||||
}
|
||||
|
||||
pub fn message_widget(msg: &Message) -> impl Widget<UiError> {
|
||||
pub fn message_widget(msg: &Message) -> impl Widget<UiError> + use<> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let mut text = Styled::new("Message", heading_style).then_plain("\n");
|
||||
|
|
|
|||
|
|
@ -1,19 +1,31 @@
|
|||
use cove_config::{Config, Keys};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::style::Stylize;
|
||||
use crossterm::{event::KeyCode, style::Stylize};
|
||||
use linkify::{LinkFinder, LinkKind};
|
||||
use toss::widgets::{Join2, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Join2, Text},
|
||||
};
|
||||
|
||||
use crate::ui::widgets::{ListBuilder, ListState, Popup};
|
||||
use crate::ui::{key_bindings, util, UiError};
|
||||
use crate::{
|
||||
euph::{self, SpanType},
|
||||
ui::{
|
||||
UiError, key_bindings, util,
|
||||
widgets::{ListBuilder, ListState, Popup},
|
||||
},
|
||||
};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Link {
|
||||
Url(String),
|
||||
Room(String),
|
||||
}
|
||||
|
||||
pub struct LinksState {
|
||||
config: &'static Config,
|
||||
links: Vec<String>,
|
||||
links: Vec<Link>,
|
||||
list: ListState<usize>,
|
||||
}
|
||||
|
||||
|
|
@ -21,12 +33,34 @@ 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 links = LinkFinder::new()
|
||||
let mut links = vec![];
|
||||
|
||||
// Collect URL-like links
|
||||
for link in LinkFinder::new()
|
||||
.url_must_have_scheme(false)
|
||||
.kinds(&[LinkKind::Url])
|
||||
.links(content)
|
||||
.map(|l| l.as_str().to_string())
|
||||
.collect();
|
||||
{
|
||||
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<_>>();
|
||||
|
||||
Self {
|
||||
config,
|
||||
|
|
@ -35,7 +69,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();
|
||||
|
|
@ -46,29 +80,29 @@ impl LinksState {
|
|||
|
||||
for (id, link) in self.links.iter().enumerate() {
|
||||
let link = link.clone();
|
||||
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)
|
||||
});
|
||||
}
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
let hint_style = Style::new().grey().italic();
|
||||
|
|
@ -92,18 +126,24 @@ impl LinksState {
|
|||
}
|
||||
|
||||
fn open_link_by_id(&self, id: usize) -> PopupResult {
|
||||
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}")
|
||||
};
|
||||
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 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use euphoxide::conn::Joined;
|
||||
use toss::widgets::EditorState;
|
||||
use toss::{Style, Widget};
|
||||
use toss::{Style, Widget, widgets::EditorState};
|
||||
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
use crate::{
|
||||
euph::{self, Room},
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
|
||||
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()));
|
||||
|
|
|
|||
|
|
@ -1,22 +1,31 @@
|
|||
use std::iter;
|
||||
|
||||
use crossterm::style::{Color, Stylize};
|
||||
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 euphoxide::{
|
||||
api::{NickEvent, SessionId, SessionType, SessionView, UserId},
|
||||
conn::{Joined, SessionInfo},
|
||||
};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Background, Text},
|
||||
};
|
||||
|
||||
use crate::euph;
|
||||
use crate::ui::widgets::{ListBuilder, ListState};
|
||||
use crate::ui::UiError;
|
||||
use crate::{
|
||||
euph,
|
||||
ui::{
|
||||
UiError,
|
||||
widgets::{ListBuilder, ListState},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn widget<'a>(
|
||||
list: &'a mut ListState<SessionId>,
|
||||
joined: &Joined,
|
||||
focused: bool,
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
nick_emoji: bool,
|
||||
) -> impl Widget<UiError> + use<'a> {
|
||||
let mut list_builder = ListBuilder::new();
|
||||
render_rows(&mut list_builder, joined, focused);
|
||||
render_rows(&mut list_builder, joined, focused, nick_emoji);
|
||||
list_builder.build(list)
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +71,7 @@ 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![];
|
||||
|
|
@ -87,10 +97,38 @@ fn render_rows(
|
|||
lurkers.sort_unstable();
|
||||
nurkers.sort_unstable();
|
||||
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_section(
|
||||
|
|
@ -99,6 +137,7 @@ fn render_section(
|
|||
sessions: &[HalfSession],
|
||||
own_session: &SessionView,
|
||||
focused: bool,
|
||||
nick_emoji: bool,
|
||||
) {
|
||||
if sessions.is_empty() {
|
||||
return;
|
||||
|
|
@ -116,7 +155,7 @@ fn render_section(
|
|||
list_builder.add_unsel(Text::new(row).background());
|
||||
|
||||
for session in sessions {
|
||||
render_row(list_builder, session, own_session, focused);
|
||||
render_row(list_builder, session, own_session, focused, nick_emoji);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +164,7 @@ 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();
|
||||
|
|
@ -158,16 +198,24 @@ 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(perms, perms_style_inv)
|
||||
.then(emoji, 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(perms)
|
||||
.then_plain(emoji);
|
||||
Text::new(text).background()
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
use std::io;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use toss::widgets::Text;
|
||||
use toss::{Style, Styled, Widget};
|
||||
use toss::{Style, Styled, Widget, widgets::Text};
|
||||
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::UiError;
|
||||
use crate::ui::{UiError, widgets::Popup};
|
||||
|
||||
pub enum RoomPopup {
|
||||
Error { description: String, reason: String },
|
||||
}
|
||||
|
||||
impl RoomPopup {
|
||||
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> {
|
||||
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> + use<> {
|
||||
let border_style = Style::new().red().bold();
|
||||
let text = Styled::new_plain(description)
|
||||
.then_plain("\n\n")
|
||||
|
|
@ -23,7 +21,7 @@ impl RoomPopup {
|
|||
Popup::new(Text::new(text), ("Error", border_style)).with_border_style(border_style)
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> impl Widget<UiError> {
|
||||
pub fn widget(&self) -> impl Widget<UiError> + use<> {
|
||||
match self {
|
||||
Self::Error {
|
||||
description,
|
||||
|
|
@ -37,5 +35,6 @@ pub enum PopupResult {
|
|||
NotHandled,
|
||||
Handled,
|
||||
Close,
|
||||
SwitchToRoom { name: String },
|
||||
ErrorOpeningLink { link: String, error: io::Error },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,25 +3,40 @@ 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};
|
||||
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 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 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 crate::{
|
||||
euph::{self, SpanType},
|
||||
macros::logging_unwrap,
|
||||
ui::{
|
||||
UiError, UiEvent,
|
||||
chat::{ChatState, Reaction},
|
||||
util,
|
||||
widgets::ListState,
|
||||
},
|
||||
vault::{EuphRoomVault, RoomIdentifier},
|
||||
};
|
||||
|
||||
use super::account::AccountUiState;
|
||||
use super::links::LinksState;
|
||||
use super::popup::{PopupResult, RoomPopup};
|
||||
use super::{auth, inspect, nick, nick_list};
|
||||
use super::{
|
||||
account::AccountUiState,
|
||||
auth, inspect,
|
||||
links::LinksState,
|
||||
nick, nick_list,
|
||||
popup::{PopupResult, RoomPopup},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
|
|
@ -58,6 +73,8 @@ pub struct EuphRoom {
|
|||
last_msg_sent: Option<oneshot::Receiver<MessageId>>,
|
||||
|
||||
nick_list: ListState<SessionId>,
|
||||
|
||||
mentioned: bool,
|
||||
}
|
||||
|
||||
impl EuphRoom {
|
||||
|
|
@ -66,6 +83,7 @@ impl EuphRoom {
|
|||
server_config: ServerConfig,
|
||||
room_config: cove_config::EuphRoom,
|
||||
vault: EuphRoomVault,
|
||||
tz: TimeZone,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
|
@ -77,9 +95,10 @@ impl EuphRoom {
|
|||
focus: Focus::Chat,
|
||||
state: State::Normal,
|
||||
popups: VecDeque::new(),
|
||||
chat: ChatState::new(vault),
|
||||
chat: ChatState::new(vault, tz),
|
||||
last_msg_sent: None,
|
||||
nick_list: ListState::new(),
|
||||
mentioned: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +121,7 @@ impl EuphRoom {
|
|||
.server_config
|
||||
.clone()
|
||||
.room(self.vault().room().name.clone())
|
||||
.name(format!("{room:?}-{}", next_instance_id))
|
||||
.name(format!("{room:?}-{next_instance_id}"))
|
||||
.human(true)
|
||||
.username(self.room_config.username.clone())
|
||||
.force_username(self.room_config.force_username)
|
||||
|
|
@ -148,6 +167,12 @@ 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)
|
||||
}
|
||||
|
|
@ -266,11 +291,16 @@ impl EuphRoom {
|
|||
joined: &Joined,
|
||||
focus: Focus,
|
||||
) -> BoxedAsync<'a, UiError> {
|
||||
let nick_list_widget = nick_list::widget(nick_list, joined, focus == Focus::NickList)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.border()
|
||||
.desync();
|
||||
let nick_list_widget = nick_list::widget(
|
||||
nick_list,
|
||||
joined,
|
||||
focus == Focus::NickList,
|
||||
chat.nick_emoji(),
|
||||
)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.border()
|
||||
.desync();
|
||||
|
||||
let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat);
|
||||
|
||||
|
|
@ -285,7 +315,7 @@ impl EuphRoom {
|
|||
.boxed_async()
|
||||
}
|
||||
|
||||
async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget<UiError> {
|
||||
async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget<UiError> + use<> {
|
||||
let room_style = Style::new().bold().blue();
|
||||
let mut info = Styled::new(format!("{} ", self.domain()), Style::new().grey())
|
||||
.then(format!("&{}", self.name()), room_style);
|
||||
|
|
@ -484,18 +514,22 @@ impl EuphRoom {
|
|||
false
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
event: &mut InputEvent<'_>,
|
||||
keys: &Keys,
|
||||
) -> RoomResult {
|
||||
if !self.popups.is_empty() {
|
||||
if event.matches(&keys.general.abort) {
|
||||
self.popups.pop_back();
|
||||
return true;
|
||||
return RoomResult::Handled;
|
||||
}
|
||||
// Prevent event from reaching anything below the popup
|
||||
return false;
|
||||
return RoomResult::NotHandled;
|
||||
}
|
||||
|
||||
let result = match &mut self.state {
|
||||
State::Normal => return self.handle_normal_input_event(event, keys).await,
|
||||
State::Normal => return self.handle_normal_input_event(event, keys).await.into(),
|
||||
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),
|
||||
|
|
@ -506,18 +540,24 @@ impl EuphRoom {
|
|||
};
|
||||
|
||||
match result {
|
||||
PopupResult::NotHandled => false,
|
||||
PopupResult::Handled => true,
|
||||
PopupResult::NotHandled => RoomResult::NotHandled,
|
||||
PopupResult::Handled => RoomResult::Handled,
|
||||
PopupResult::Close => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
RoomResult::Handled
|
||||
}
|
||||
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}"),
|
||||
});
|
||||
true
|
||||
RoomResult::Handled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -531,6 +571,35 @@ 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 {
|
||||
|
|
@ -622,3 +691,18 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@ use std::convert::Infallible;
|
|||
use cove_config::{Config, Keys};
|
||||
use cove_input::{InputEvent, KeyBinding, KeyBindingInfo, KeyGroupInfo};
|
||||
use crossterm::style::Stylize;
|
||||
use toss::widgets::{Either2, Join2, Padding, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Either2, Join2, Padding, Text},
|
||||
};
|
||||
|
||||
use super::widgets::{ListBuilder, ListState, Popup};
|
||||
use super::{util, UiError};
|
||||
use super::{
|
||||
UiError, util,
|
||||
widgets::{ListBuilder, ListState, Popup},
|
||||
};
|
||||
|
||||
type Line = Either2<Text, Join2<Padding<Text>, Text>>;
|
||||
type Builder = ListBuilder<'static, Infallible, Line>;
|
||||
|
|
@ -69,7 +73,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> + 'a {
|
||||
) -> impl Widget<UiError> + use<'a> {
|
||||
let mut list_builder = ListBuilder::new();
|
||||
|
||||
for group_info in config.keys.groups() {
|
||||
|
|
|
|||
|
|
@ -1,33 +1,46 @@
|
|||
mod connect;
|
||||
mod delete;
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, hash_map::Entry},
|
||||
iter,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cove_config::{Config, Keys, RoomsSortOrder};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::SessionType;
|
||||
use euphoxide::bot::instance::{Event, ServerConfig};
|
||||
use euphoxide::conn::{self, Joined};
|
||||
use euphoxide::{
|
||||
api::SessionType,
|
||||
bot::instance::{Event, ServerConfig},
|
||||
conn::{self, Joined},
|
||||
};
|
||||
use jiff::tz::TimeZone;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::widgets::{BoxedAsync, Empty, Join2, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{BellState, BoxedAsync, Empty, Join2, Text},
|
||||
};
|
||||
|
||||
use crate::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::vault::{EuphVault, RoomIdentifier, Vault};
|
||||
use crate::version::{NAME, VERSION};
|
||||
use crate::{
|
||||
euph,
|
||||
macros::logging_unwrap,
|
||||
vault::{EuphVault, RoomIdentifier, Vault},
|
||||
version::{NAME, VERSION},
|
||||
};
|
||||
|
||||
use self::connect::{ConnectResult, ConnectState};
|
||||
use self::delete::{DeleteResult, DeleteState};
|
||||
use super::{
|
||||
UiError, UiEvent,
|
||||
euph::room::{EuphRoom, RoomResult},
|
||||
key_bindings, util,
|
||||
widgets::{ListBuilder, ListState},
|
||||
};
|
||||
|
||||
use super::euph::room::EuphRoom;
|
||||
use super::widgets::{ListBuilder, ListState};
|
||||
use super::{key_bindings, util, UiError, UiEvent};
|
||||
use self::{
|
||||
connect::{ConnectResult, ConnectState},
|
||||
delete::{DeleteResult, DeleteState},
|
||||
};
|
||||
|
||||
mod connect;
|
||||
mod delete;
|
||||
|
||||
enum State {
|
||||
ShowList,
|
||||
|
|
@ -73,6 +86,7 @@ impl EuphServer {
|
|||
|
||||
pub struct Rooms {
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
|
@ -81,6 +95,7 @@ pub struct Rooms {
|
|||
|
||||
list: ListState<RoomIdentifier>,
|
||||
order: Order,
|
||||
bell: BellState,
|
||||
|
||||
euph_servers: HashMap<String, EuphServer>,
|
||||
euph_rooms: HashMap<RoomIdentifier, EuphRoom>,
|
||||
|
|
@ -89,16 +104,19 @@ pub struct Rooms {
|
|||
impl Rooms {
|
||||
pub async fn new(
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
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_rooms: HashMap::new(),
|
||||
};
|
||||
|
|
@ -142,6 +160,7 @@ impl Rooms {
|
|||
server.config.clone(),
|
||||
self.config.euph_room(&room.domain, &room.name),
|
||||
self.vault.euph().room(room),
|
||||
self.tz.clone(),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
})
|
||||
|
|
@ -158,6 +177,7 @@ impl Rooms {
|
|||
server.config.clone(),
|
||||
self.config.euph_room(&room.domain, &room.name),
|
||||
self.vault.euph().room(room),
|
||||
self.tz.clone(),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
});
|
||||
|
|
@ -226,7 +246,9 @@ impl Rooms {
|
|||
.retain(|n, r| !r.stopped() || rooms_set.contains(n));
|
||||
|
||||
for room in rooms_set {
|
||||
self.get_or_insert_room(room).await.retain();
|
||||
let room = self.get_or_insert_room(room).await;
|
||||
room.retain();
|
||||
self.bell.ring |= room.retrieve_mentioned();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +258,7 @@ impl Rooms {
|
|||
_ => self.stabilize_rooms().await,
|
||||
}
|
||||
|
||||
match &mut self.state {
|
||||
let widget = match &mut self.state {
|
||||
State::ShowList => Self::rooms_widget(
|
||||
&self.vault,
|
||||
self.config,
|
||||
|
|
@ -279,6 +301,12 @@ impl Rooms {
|
|||
.below(delete.widget())
|
||||
.desync()
|
||||
.boxed_async(),
|
||||
};
|
||||
|
||||
if self.config.bell_on_mention {
|
||||
widget.above(self.bell.widget().desync()).boxed_async()
|
||||
} else {
|
||||
widget
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -417,7 +445,7 @@ impl Rooms {
|
|||
list: &'a mut ListState<RoomIdentifier>,
|
||||
order: Order,
|
||||
euph_rooms: &HashMap<RoomIdentifier, EuphRoom>,
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
) -> impl Widget<UiError> + use<'a> {
|
||||
let version_info = Styled::new_plain("Welcome to ")
|
||||
.then(format!("{NAME} {VERSION}"), Style::new().yellow().bold())
|
||||
.then_plain("!");
|
||||
|
|
@ -508,7 +536,10 @@ impl Rooms {
|
|||
}
|
||||
if event.matches(&keys.rooms.action.connect_autojoin) {
|
||||
for (domain, server) in &self.config.euph.servers {
|
||||
for name in server.rooms.keys() {
|
||||
for (name, room) in &server.rooms {
|
||||
if !room.autojoin {
|
||||
continue;
|
||||
}
|
||||
let id = RoomIdentifier::new(domain.clone(), name.clone());
|
||||
self.connect_to_room(id).await;
|
||||
}
|
||||
|
|
@ -556,8 +587,15 @@ impl Rooms {
|
|||
}
|
||||
State::ShowRoom(name) => {
|
||||
if let Some(room) = self.euph_rooms.get_mut(name) {
|
||||
if room.handle_input_event(event, keys).await {
|
||||
return true;
|
||||
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 event.matches(&keys.general.abort) {
|
||||
self.state = State::ShowList;
|
||||
|
|
@ -571,6 +609,7 @@ impl Rooms {
|
|||
return true;
|
||||
}
|
||||
ConnectResult::Connect(room) => {
|
||||
self.list.move_cursor_to_id(&room);
|
||||
self.connect_to_room(room.clone()).await;
|
||||
self.state = State::ShowRoom(room);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use toss::widgets::{EditorState, Empty, Join2, Join3, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join2, Join3, Text},
|
||||
};
|
||||
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
use crate::vault::RoomIdentifier;
|
||||
use crate::{
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
vault::RoomIdentifier,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
|
|
@ -81,7 +84,7 @@ impl ConnectState {
|
|||
ConnectResult::Unhandled
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
let room_style = Style::new().bold().blue();
|
||||
let domain_style = Style::new().grey();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use toss::widgets::{EditorState, Empty, Join2, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join2, Text},
|
||||
};
|
||||
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
use crate::vault::RoomIdentifier;
|
||||
use crate::{
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
vault::RoomIdentifier,
|
||||
};
|
||||
|
||||
pub struct DeleteState {
|
||||
id: RoomIdentifier,
|
||||
|
|
@ -44,7 +47,7 @@ impl DeleteState {
|
|||
DeleteResult::Unhandled
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
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 ")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
mod list;
|
||||
mod popup;
|
||||
|
||||
pub use self::list::*;
|
||||
pub use self::popup::*;
|
||||
|
||||
mod list;
|
||||
mod popup;
|
||||
|
|
|
|||
|
|
@ -239,6 +239,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use toss::widgets::{Background, Border, Desync, Float, Layer2, Padding, Text};
|
||||
use toss::{Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb};
|
||||
use toss::{
|
||||
Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb,
|
||||
widgets::{Background, Border, Desync, Float, Layer2, Padding, Text},
|
||||
};
|
||||
|
||||
type Body<I> = Background<Border<Padding<I>>>;
|
||||
type Title = Float<Padding<Background<Padding<Text>>>>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
use std::convert::Infallible;
|
||||
use std::env;
|
||||
use std::{convert::Infallible, env};
|
||||
|
||||
use time::{OffsetDateTime, UtcOffset};
|
||||
use tz::{TimeZone, TzError};
|
||||
use jiff::tz::TimeZone;
|
||||
|
||||
pub trait InfallibleExt {
|
||||
type Inner;
|
||||
|
|
@ -26,27 +24,30 @@ impl<T> InfallibleExt for Result<T, Infallible> {
|
|||
///
|
||||
/// 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, TzError> {
|
||||
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);
|
||||
|
||||
match &tz_string {
|
||||
// At the moment, TimeZone::from_posix_tz does not support "localtime"
|
||||
// on Windows, so we handle that case manually.
|
||||
Some("localtime") | None => TimeZone::local(),
|
||||
Some(tz_string) => TimeZone::from_posix_tz(tz_string),
|
||||
let Some(tz_string) = tz_string else {
|
||||
return Ok(TimeZone::system());
|
||||
};
|
||||
|
||||
if tz_string == "localtime" {
|
||||
return Ok(TimeZone::system());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert_to_time_zone(tz: &TimeZone, time: OffsetDateTime) -> Option<OffsetDateTime> {
|
||||
let utc_offset_in_seconds = tz
|
||||
.find_local_time_type(time.unix_timestamp())
|
||||
.ok()?
|
||||
.ut_offset();
|
||||
if let Some(tz_string) = tz_string.strip_prefix(':') {
|
||||
return TimeZone::get(tz_string);
|
||||
}
|
||||
|
||||
let utc_offset = UtcOffset::from_whole_seconds(utc_offset_in_seconds).ok()?;
|
||||
// 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);
|
||||
}
|
||||
|
||||
Some(time.to_offset(utc_offset))
|
||||
TimeZone::get(tz_string)
|
||||
}
|
||||
|
||||
pub fn caesar(text: &str, by: i8) -> String {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
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 tz::TimeZone;
|
||||
use vault::tokio::TokioVault;
|
||||
use vault::Action;
|
||||
|
||||
pub use self::euph::{EuphRoomVault, EuphVault, RoomIdentifier};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Vault {
|
||||
tokio_vault: TokioVault,
|
||||
time_zone: &'static TimeZone,
|
||||
ephemeral: bool,
|
||||
}
|
||||
|
||||
|
|
@ -48,23 +44,18 @@ impl Vault {
|
|||
}
|
||||
}
|
||||
|
||||
fn launch_from_connection(
|
||||
conn: Connection,
|
||||
time_zone: &'static TimeZone,
|
||||
ephemeral: bool,
|
||||
) -> rusqlite::Result<Vault> {
|
||||
fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result<Vault> {
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
conn.pragma_update(None, "trusted_schema", false)?;
|
||||
|
||||
let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?;
|
||||
Ok(Vault {
|
||||
tokio_vault,
|
||||
time_zone,
|
||||
ephemeral,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn launch(path: &Path, time_zone: &'static TimeZone) -> rusqlite::Result<Vault> {
|
||||
pub fn launch(path: &Path) -> rusqlite::Result<Vault> {
|
||||
// If this fails, rusqlite will complain about not being able to open the db
|
||||
// file, which saves me from adding a separate vault error type.
|
||||
let _ = fs::create_dir_all(path.parent().expect("path to file"));
|
||||
|
|
@ -79,10 +70,10 @@ pub fn launch(path: &Path, time_zone: &'static TimeZone) -> rusqlite::Result<Vau
|
|||
conn.pragma_update(None, "locking_mode", "exclusive")?;
|
||||
conn.pragma_update(None, "journal_mode", "wal")?;
|
||||
|
||||
launch_from_connection(conn, time_zone, false)
|
||||
launch_from_connection(conn, false)
|
||||
}
|
||||
|
||||
pub fn launch_in_memory(time_zone: &'static TimeZone) -> rusqlite::Result<Vault> {
|
||||
pub fn launch_in_memory() -> rusqlite::Result<Vault> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
launch_from_connection(conn, time_zone, true)
|
||||
launch_from_connection(conn, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
use std::str::FromStr;
|
||||
use std::{fmt, mem};
|
||||
use std::{fmt, mem, str::FromStr};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cookie::{Cookie, CookieJar};
|
||||
use euphoxide::api::{Message, MessageId, SessionId, SessionView, Snowflake, Time, UserId};
|
||||
use rusqlite::types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef};
|
||||
use rusqlite::{named_params, params, Connection, OptionalExtension, Row, ToSql, Transaction};
|
||||
use time::OffsetDateTime;
|
||||
use rusqlite::{
|
||||
Connection, OptionalExtension, Row, ToSql, Transaction, named_params, params,
|
||||
types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef},
|
||||
};
|
||||
use vault::Action;
|
||||
|
||||
use crate::euph::SmallMessage;
|
||||
use crate::store::{MsgStore, Path, Tree};
|
||||
use crate::{
|
||||
euph::SmallMessage,
|
||||
store::{MsgStore, Path, Tree},
|
||||
};
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +34,7 @@ struct WTime(Time);
|
|||
|
||||
impl ToSql for WTime {
|
||||
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
|
||||
let timestamp = self.0 .0.unix_timestamp();
|
||||
let timestamp = self.0.0;
|
||||
Ok(ToSqlOutput::Owned(Value::Integer(timestamp)))
|
||||
}
|
||||
}
|
||||
|
|
@ -40,9 +42,7 @@ 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(
|
||||
OffsetDateTime::from_unix_timestamp(timestamp).expect("timestamp in range"),
|
||||
)))
|
||||
Ok(Self(Time(timestamp)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,8 +255,6 @@ macro_rules! euph_room_vault_actions {
|
|||
$(
|
||||
struct $struct {
|
||||
room: RoomIdentifier,
|
||||
#[allow(unused)]
|
||||
time_zone: &'static tz::TimeZone,
|
||||
$( $arg: $arg_ty, )*
|
||||
}
|
||||
)*
|
||||
|
|
@ -266,7 +264,6 @@ macro_rules! euph_room_vault_actions {
|
|||
pub async fn $fn(&self, $( $arg: $arg_ty, )* ) -> Result<$res, vault::tokio::Error<rusqlite::Error>> {
|
||||
self.vault.vault.tokio_vault.execute($struct {
|
||||
room: self.room.clone(),
|
||||
time_zone: self.vault.vault.time_zone,
|
||||
$( $arg, )*
|
||||
}).await
|
||||
}
|
||||
|
|
@ -614,7 +611,7 @@ impl Action for GetMsg {
|
|||
let msg = conn
|
||||
.query_row(
|
||||
"
|
||||
SELECT id, parent, time, name, content, seen
|
||||
SELECT id, parent, time, user_id, name, content, seen
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
|
|
@ -626,10 +623,10 @@ impl Action for GetMsg {
|
|||
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
|
||||
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
time: row.get::<_, WTime>(2)?.0,
|
||||
time_zone: self.time_zone,
|
||||
nick: row.get(3)?,
|
||||
content: row.get(4)?,
|
||||
seen: row.get(5)?,
|
||||
user_id: UserId(row.get(3)?),
|
||||
nick: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
seen: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -707,7 +704,7 @@ impl Action for GetTree {
|
|||
AND tree.room = euph_msgs.room
|
||||
AND tree.id = euph_msgs.parent
|
||||
)
|
||||
SELECT id, parent, time, name, content, seen
|
||||
SELECT id, parent, time, user_id, name, content, seen
|
||||
FROM euph_msgs
|
||||
JOIN tree USING (domain, room, id)
|
||||
ORDER BY id ASC
|
||||
|
|
@ -720,10 +717,10 @@ impl Action for GetTree {
|
|||
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
|
||||
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
time: row.get::<_, WTime>(2)?.0,
|
||||
time_zone: self.time_zone,
|
||||
nick: row.get(3)?,
|
||||
content: row.get(4)?,
|
||||
seen: row.get(5)?,
|
||||
user_id: UserId(row.get(3)?),
|
||||
nick: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
seen: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ fn m3(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()>
|
|||
",
|
||||
)?;
|
||||
|
||||
eprintln!(" Recreating indices...");
|
||||
eprintln!(" Recreating indexes...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
CREATE INDEX euph_idx_msgs_domain_room_id_parent
|
||||
|
|
|
|||
47
flake.lock
generated
47
flake.lock
generated
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"nodes": {
|
||||
"naersk": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1698420672,
|
||||
"narHash": "sha256-/TdeHMPRjjdJub7p7+w55vyABrsJlt5QkznPYy55vKA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"rev": "aeb58d5e8faead8980a807c840232697982d47b9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1704371841,
|
||||
"narHash": "sha256-ScUTxDRvgEK6W0hJqzodk4VZM1pqVJO3o/Ru99Oc7mI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "526411af967efacb9f1efefe9c8664bede47b8b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"naersk": "naersk",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
29
flake.nix
29
flake.nix
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
description = "TUI client for euphoria.leet.nu, 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 = ./.;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue