Compare commits
No commits in common. "master" and "v0.7.1" have entirely different histories.
68 changed files with 2012 additions and 3327 deletions
75
.github/workflows/build.yml
vendored
75
.github/workflows/build.yml
vendored
|
|
@ -1,75 +0,0 @@
|
|||
# What software is installed by default:
|
||||
# https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
|
||||
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
- macos-13
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up rust
|
||||
run: rustup update
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
- name: Test
|
||||
run: cargo test --release
|
||||
|
||||
- name: Record target triple
|
||||
run: rustc -vV | awk '/^host/ { print $2 }' > target/release/host
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cove-${{ matrix.os }}
|
||||
path: |
|
||||
target/release/cove
|
||||
target/release/cove.exe
|
||||
target/release/host
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
needs:
|
||||
- build
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Zip artifacts
|
||||
run: |
|
||||
chmod +x cove-ubuntu-22.04/cove
|
||||
chmod +x cove-windows-latest/cove.exe
|
||||
chmod +x cove-macos-latest/cove
|
||||
chmod +x cove-macos-13/cove
|
||||
zip -jr "cove-$(cat cove-ubuntu-22.04/host).zip" cove-ubuntu-22.04/cove
|
||||
zip -jr "cove-$(cat cove-windows-latest/host).zip" cove-windows-latest/cove.exe
|
||||
zip -jr "cove-$(cat cove-macos-latest/host).zip" cove-macos-latest/cove
|
||||
zip -jr "cove-$(cat cove-macos-13/host).zip" cove-macos-13/cove
|
||||
|
||||
- name: Create new release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: Automated release, see [CHANGELOG.md](CHANGELOG.md) for more details.
|
||||
files: "*.zip"
|
||||
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": "crate",
|
||||
"rust-analyzer.imports.granularity.group": "module",
|
||||
"rust-analyzer.imports.group.enable": true,
|
||||
"evenBetterToml.formatter.columnWidth": 100,
|
||||
}
|
||||
|
|
|
|||
137
CHANGELOG.md
137
CHANGELOG.md
|
|
@ -4,135 +4,25 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
Procedure when bumping the version number:
|
||||
|
||||
1. Update dependencies in a separate commit
|
||||
2. Set version number in `Cargo.toml`
|
||||
3. Add new section in this changelog
|
||||
4. Run `cargo run help-config > CONFIG.md`
|
||||
5. Commit with message `Bump version to X.Y.Z`
|
||||
6. Create tag named `vX.Y.Z`
|
||||
7. Push `master` and the new tag
|
||||
7. Fast-forward branch `latest`
|
||||
8. Push `master`, `latest` and the new tag
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Display emoji user id hashes in the nick list
|
||||
- Compile linux binary with older glibc version
|
||||
|
||||
## v0.9.3 - 2025-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- Key bindings for emoji-based user id hashing
|
||||
|
||||
### Fixed
|
||||
|
||||
- `keys.rooms.action.connect_autojoin` connecting to non-autojoin rooms
|
||||
|
||||
## v0.9.2 - 2025-03-14
|
||||
|
||||
### Added
|
||||
|
||||
- `bell_on_mention` config option
|
||||
|
||||
## v0.9.1 - 2025-03-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rendering glitches with unicode-based width estimation
|
||||
|
||||
## v0.9.0 - 2025-02-23
|
||||
|
||||
### Added
|
||||
|
||||
- Unicode-based grapheme width estimation method
|
||||
- `width_estimation_method` config option
|
||||
- `--width-estimation-method` option
|
||||
- Room links are now included in the `I` message links list
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated documentation for `time_zone` config option
|
||||
- When connecting to a room using `n` in the room list, the cursor now moves to that room
|
||||
- Updated list of emoji names
|
||||
|
||||
### Removed
|
||||
|
||||
- Special handling of &rl2dev
|
||||
|
||||
### Fixed
|
||||
|
||||
- Nick color in rare edge cases
|
||||
- Message link list rendering bug
|
||||
|
||||
## v0.8.3 - 2024-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated list of emoji names
|
||||
|
||||
## v0.8.2 - 2024-04-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed `json-stream` export format to `json-lines` (see <https://jsonlines.org/>)
|
||||
- Changed `json-lines` file extension from `.json` to `.jsonl`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Crash when window is too small while empty message editor is visible
|
||||
- Mistakes in output and docs
|
||||
- Cove not cleaning up terminal state properly
|
||||
|
||||
## v0.8.1 - 2024-01-11
|
||||
|
||||
### Added
|
||||
|
||||
- Support for setting window title
|
||||
- More information to room list heading
|
||||
- Key bindings for live caesar cipher de- and encoding
|
||||
|
||||
### Removed
|
||||
|
||||
- Key binding to open present page
|
||||
|
||||
## v0.8.0 - 2024-01-04
|
||||
|
||||
### Added
|
||||
|
||||
- Support for multiple euph server domains
|
||||
- Support for `TZ` environment variable
|
||||
- `time_zone` config option
|
||||
- `--domain` option to `cove export` command
|
||||
- `--domain` option to `cove clear-cookies` command
|
||||
- Domain field to "connect to new room" popup
|
||||
- Welcome info box next to room list
|
||||
|
||||
### Changed
|
||||
|
||||
- The default euph domain is now https://euphoria.leet.nu/ everywhere
|
||||
- The config file format was changed to support multiple euph servers with different domains.
|
||||
Options previously located at `euph.rooms.*` should be reviewed and moved to `euph.servers."euphoria.leet.nu".rooms.*`.
|
||||
- Tweaked F1 popup
|
||||
- Tweaked chat message editor when nick list is foused
|
||||
- Reduced connection timeout from 30 seconds to 10 seconds
|
||||
|
||||
### Fixed
|
||||
|
||||
- Room deletion popup accepting any room name
|
||||
- Duplicated key presses on Windows
|
||||
|
||||
## v0.7.1 - 2023-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated dependencies
|
||||
|
||||
## v0.7.0 - 2023-05-14
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-generated config documentation
|
||||
- in [CONFIG.md](CONFIG.md)
|
||||
- via `help-config` CLI command
|
||||
|
|
@ -140,7 +30,6 @@ Procedure when bumping the version number:
|
|||
- `measure_widths` config option
|
||||
|
||||
### Changed
|
||||
|
||||
- Overhauled widget system and extracted generic widgets to [toss](https://github.com/Garmelon/toss)
|
||||
- Overhauled config system to support auto-generating documentation
|
||||
- Overhauled key binding system to make key bindings configurable
|
||||
|
|
@ -154,18 +43,15 @@ Procedure when bumping the version number:
|
|||
## v0.6.1 - 2023-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved JSON export performance
|
||||
- Always show rooms from config file in room list
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rooms reconnecting instead of showing error popups
|
||||
|
||||
## v0.6.0 - 2023-04-04
|
||||
|
||||
### Added
|
||||
|
||||
- Emoji support
|
||||
- `flake.nix`, making cove available as a nix flake
|
||||
- `json-stream` room export format
|
||||
|
|
@ -173,37 +59,31 @@ Procedure when bumping the version number:
|
|||
- `--verbose` flag
|
||||
|
||||
### Changed
|
||||
|
||||
- Non-export info is now printed to stderr instead of stdout
|
||||
- Recognizes links without scheme (e.g. `euphoria.io` instead of `https://euphoria.io`)
|
||||
- Rooms waiting for reconnect are no longer sorted to bottom in default sort order
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mentions not being stopped by `>`
|
||||
|
||||
## v0.5.2 - 2023-01-14
|
||||
|
||||
### Added
|
||||
|
||||
- Key binding to open present page
|
||||
|
||||
### Changed
|
||||
|
||||
- Always connect to &rl2dev in ephemeral mode
|
||||
- Reduce amount of messages per &rl2dev log request
|
||||
|
||||
## v0.5.1 - 2022-11-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Increase reconnect delay to one minute
|
||||
- Print errors that occurred while cove was running more compactly
|
||||
|
||||
## v0.5.0 - 2022-09-26
|
||||
|
||||
### Added
|
||||
|
||||
- Key bindings to navigate nick list
|
||||
- Room deletion confirmation popup
|
||||
- Message inspection popup
|
||||
|
|
@ -212,12 +92,10 @@ Procedure when bumping the version number:
|
|||
- `rooms_sort_order` config option
|
||||
|
||||
### Changed
|
||||
|
||||
- Use nick changes to detect sessions for nick list
|
||||
- Support Unicode 15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cursor being visible through popups
|
||||
- Cursor in lists when highlighted item moves off-screen
|
||||
- User disappearing from nick list when only one of their sessions disconnects
|
||||
|
|
@ -225,7 +103,6 @@ Procedure when bumping the version number:
|
|||
## v0.4.0 - 2022-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Config file and `--config` cli option
|
||||
- `data_dir` config option
|
||||
- `ephemeral` config option
|
||||
|
|
@ -241,17 +118,14 @@ Procedure when bumping the version number:
|
|||
- Key bindings to view and open links in a message
|
||||
|
||||
### Changed
|
||||
|
||||
- Some key bindings in the rooms list
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rooms being stuck in "Connecting" state
|
||||
|
||||
## v0.3.0 - 2022-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- Account login and logout
|
||||
- Authentication dialog for password-protected rooms
|
||||
- Error popups in rooms when something goes wrong
|
||||
|
|
@ -259,12 +133,10 @@ Procedure when bumping the version number:
|
|||
- Key binding to download more logs
|
||||
|
||||
### Changed
|
||||
|
||||
- Reduced amount of unnecessary redraws
|
||||
- Description of `export` CLI command
|
||||
|
||||
### Fixed
|
||||
|
||||
- Crash when connecting to nonexistent rooms
|
||||
- Crash when connecting to rooms that require authentication
|
||||
- Pasting multi-line strings into the editor
|
||||
|
|
@ -272,18 +144,15 @@ Procedure when bumping the version number:
|
|||
## v0.2.1 - 2022-08-11
|
||||
|
||||
### Added
|
||||
|
||||
- Support for modifiers on special keys via the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Joining new rooms no longer crashes cove
|
||||
- Scrolling when exiting message editor
|
||||
|
||||
## v0.2.0 - 2022-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- New messages are now marked as unseen
|
||||
- Sub-trees can now be folded
|
||||
- Support for pasting text into editors
|
||||
|
|
@ -296,12 +165,10 @@ Procedure when bumping the version number:
|
|||
- Support for exporting multiple/all rooms at once
|
||||
|
||||
### Changed
|
||||
|
||||
- Reorganized export command
|
||||
- Slowed down room history download speed
|
||||
|
||||
### Fixed
|
||||
|
||||
- Chat rendering when deleting and re-joining a room
|
||||
- Spacing in some popups
|
||||
|
||||
|
|
|
|||
143
CONFIG.md
143
CONFIG.md
|
|
@ -8,11 +8,15 @@ Here is an example config that changes a few different options:
|
|||
measure_widths = true
|
||||
rooms_sort_order = "importance"
|
||||
|
||||
[euph.servers."euphoria.leet.nu".rooms]
|
||||
welcome.autojoin = true
|
||||
test.username = "badingle"
|
||||
test.force_username = true
|
||||
private.password = "foobar"
|
||||
[euph.rooms.welcome]
|
||||
autojoin = true
|
||||
|
||||
[euph.rooms.test]
|
||||
username = "badingle"
|
||||
force_username = true
|
||||
|
||||
[euph.rooms.private]
|
||||
password = "foobar"
|
||||
|
||||
[keys]
|
||||
general.abort = ["esc", "ctrl+c"]
|
||||
|
|
@ -20,6 +24,17 @@ general.exit = "ctrl+q"
|
|||
tree.action.fold_tree = "f"
|
||||
```
|
||||
|
||||
If you want to configure lots of rooms, TOML lets you write this in a more
|
||||
compact way:
|
||||
|
||||
```toml
|
||||
[euph.rooms]
|
||||
foo = { autojoin = true }
|
||||
bar = { autojoin = true }
|
||||
baz = { autojoin = true }
|
||||
private = { autojoin = true, password = "foobar" }
|
||||
```
|
||||
|
||||
## Key bindings
|
||||
|
||||
Key bindings are specified as strings or lists of strings. Each string specifies
|
||||
|
|
@ -53,14 +68,6 @@ Available modifiers:
|
|||
|
||||
## Available options
|
||||
|
||||
### `bell_on_mention`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
Ring the bell (character 0x07) when you are mentioned in a room.
|
||||
|
||||
### `data_dir`
|
||||
|
||||
**Required:** no
|
||||
|
|
@ -87,7 +94,7 @@ any options related to the data dir.
|
|||
|
||||
See also the `--ephemeral` command line option.
|
||||
|
||||
### `euph.servers.<domain>.rooms.<room>.autojoin`
|
||||
### `euph.rooms.<room>.autojoin`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** boolean
|
||||
|
|
@ -95,17 +102,17 @@ See also the `--ephemeral` command line option.
|
|||
|
||||
Whether to automatically join this room on startup.
|
||||
|
||||
### `euph.servers.<domain>.rooms.<room>.force_username`
|
||||
### `euph.rooms.<room>.force_username`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
If `euph.servers.<domain>.rooms.<room>.username` is set, this will force
|
||||
cove to set the username even if there is already a different username
|
||||
associated with the current session.
|
||||
If `euph.rooms.<room>.username` is set, this will force cove to set the
|
||||
username even if there is already a different username associated with
|
||||
the current session.
|
||||
|
||||
### `euph.servers.<domain>.rooms.<room>.password`
|
||||
### `euph.rooms.<room>.password`
|
||||
|
||||
**Required:** no
|
||||
**Type:** string
|
||||
|
|
@ -113,7 +120,7 @@ associated with the current session.
|
|||
If set, cove will try once to use this password to authenticate, should
|
||||
the room be password-protected.
|
||||
|
||||
### `euph.servers.<domain>.rooms.<room>.username`
|
||||
### `euph.rooms.<room>.username`
|
||||
|
||||
**Required:** no
|
||||
**Type:** string
|
||||
|
|
@ -329,6 +336,14 @@ Download more messages.
|
|||
|
||||
Change nick.
|
||||
|
||||
### `keys.room.action.present`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** key binding
|
||||
**Default:** `"ctrl+p"`
|
||||
|
||||
Open room's plugh.de/present page.
|
||||
|
||||
### `keys.rooms.action.change_sort_order`
|
||||
|
||||
**Required:** yes
|
||||
|
|
@ -457,14 +472,6 @@ Scroll up half a screen.
|
|||
|
||||
Scroll up one line.
|
||||
|
||||
### `keys.tree.action.decrease_caesar`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** key binding
|
||||
**Default:** `"C"`
|
||||
|
||||
Decrease caesar cipher rotation.
|
||||
|
||||
### `keys.tree.action.fold_tree`
|
||||
|
||||
**Required:** yes
|
||||
|
|
@ -473,14 +480,6 @@ Decrease caesar cipher rotation.
|
|||
|
||||
Fold current message's subtree.
|
||||
|
||||
### `keys.tree.action.increase_caesar`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** key binding
|
||||
**Default:** `"c"`
|
||||
|
||||
Increase caesar cipher rotation.
|
||||
|
||||
### `keys.tree.action.inspect`
|
||||
|
||||
**Required:** yes
|
||||
|
|
@ -537,14 +536,6 @@ Reply to message, inline if possible.
|
|||
|
||||
Reply opposite to normal reply.
|
||||
|
||||
### `keys.tree.action.toggle_nick_emoji`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** key binding
|
||||
**Default:** `"e"`
|
||||
|
||||
Toggle agent id based nick emoji.
|
||||
|
||||
### `keys.tree.action.toggle_seen`
|
||||
|
||||
**Required:** yes
|
||||
|
|
@ -623,13 +614,14 @@ Move to root.
|
|||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
Whether to measure the width of graphemes (i.e. characters) as displayed
|
||||
by the terminal emulator instead of estimating the width.
|
||||
Whether to measure the width of characters as displayed by the terminal
|
||||
emulator instead of guessing the width.
|
||||
|
||||
Enabling this makes rendering a bit slower but more accurate. The screen
|
||||
might also flash when encountering new graphemes.
|
||||
might also flash when encountering new characters (or, more accurately,
|
||||
graphemes).
|
||||
|
||||
See also the `--measure-widths` command line option.
|
||||
See also the `--measure-graphemes` command line option.
|
||||
|
||||
### `offline`
|
||||
|
||||
|
|
@ -650,62 +642,15 @@ See also the `--offline` command line option.
|
|||
**Required:** yes
|
||||
**Type:** string
|
||||
**Values:** `"alphabet"`, `"importance"`
|
||||
**Default:** `"alphabet"`
|
||||
**Default:** `alphabet`
|
||||
|
||||
Initial sort order of rooms list.
|
||||
|
||||
`"alphabet"` sorts rooms in alphabetic order.
|
||||
`alphabet` sorts rooms in alphabetic order.
|
||||
|
||||
`"importance"` sorts rooms by the following criteria (in descending
|
||||
order of priority):
|
||||
`importance` sorts rooms by the following criteria (in descending order
|
||||
of priority):
|
||||
|
||||
1. connected rooms before unconnected rooms
|
||||
2. rooms with unread messages before rooms without
|
||||
3. alphabetic order
|
||||
|
||||
### `time_zone`
|
||||
|
||||
**Required:** no
|
||||
**Type:** string
|
||||
**Default:** `$TZ` or local system time zone
|
||||
|
||||
Time zone that chat timestamps should be displayed in.
|
||||
|
||||
This option can either be the string `"localtime"`, a [POSIX TZ string],
|
||||
or a [tz identifier] from the [tz database].
|
||||
|
||||
When not set or when set to `"localtime"`, cove attempts to use your
|
||||
system's configured time zone, falling back to UTC.
|
||||
|
||||
When the string begins with a colon or doesn't match the a POSIX TZ
|
||||
string format, it is interpreted as a tz identifier and looked up in
|
||||
your system's tz database (or a bundled tz database on Windows).
|
||||
|
||||
If the `TZ` environment variable exists, it overrides this option.
|
||||
|
||||
[POSIX TZ string]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03
|
||||
[tz identifier]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
[tz database]: https://en.wikipedia.org/wiki/Tz_database
|
||||
|
||||
### `width_estimation_method`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** string
|
||||
**Values:** `"legacy"`, `"unicode"`
|
||||
**Default:** `"legacy"`
|
||||
|
||||
How to estimate the width of graphemes (i.e. characters) as displayed by
|
||||
the terminal emulator.
|
||||
|
||||
`"legacy"`: Use a legacy method that should mostly work on most terminal
|
||||
emulators. This method will never be correct in all cases since every
|
||||
terminal emulator handles grapheme widths slightly differently. However,
|
||||
those cases are usually rare (unless you view a lot of emoji).
|
||||
|
||||
`"unicode"`: Use the unicode standard in a best-effort manner to
|
||||
determine grapheme widths. Some terminals (e.g. ghostty) can make use of
|
||||
this.
|
||||
|
||||
This method is used when `measure_widths` is set to `false`.
|
||||
|
||||
See also the `--width-estimation-method` command line option.
|
||||
|
|
|
|||
1339
Cargo.lock
generated
1339
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
67
Cargo.toml
67
Cargo.toml
|
|
@ -1,72 +1,21 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
resolver = "2"
|
||||
members = ["cove", "cove-*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.3"
|
||||
edition = "2024"
|
||||
version = "0.7.1"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.97"
|
||||
async-trait = "0.1.87"
|
||||
clap = { version = "4.5.32", features = ["derive", "deprecated"] }
|
||||
cookie = "0.18.1"
|
||||
crossterm = "0.28.1"
|
||||
directories = "6.0.0"
|
||||
edit = "0.1.5"
|
||||
jiff = "0.2.4"
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.26", features = ["std"] }
|
||||
open = "5.3.2"
|
||||
parking_lot = "0.12.3"
|
||||
proc-macro2 = "1.0.94"
|
||||
quote = "1.0.40"
|
||||
rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
|
||||
rustls = "0.23.23"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
crossterm = "0.27.0"
|
||||
parking_lot = "0.12.1"
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_either = "0.2.1"
|
||||
serde_json = "1.0.140"
|
||||
syn = "2.0.100"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.44.1", features = ["full"] }
|
||||
toml = "0.8.20"
|
||||
unicode-width = "0.2.0"
|
||||
|
||||
[workspace.dependencies.euphoxide]
|
||||
git = "https://github.com/Garmelon/euphoxide.git"
|
||||
tag = "v0.6.1"
|
||||
features = ["bot"]
|
||||
thiserror = "1.0.47"
|
||||
|
||||
[workspace.dependencies.toss]
|
||||
git = "https://github.com/Garmelon/toss.git"
|
||||
tag = "v0.3.4"
|
||||
|
||||
[workspace.dependencies.vault]
|
||||
git = "https://github.com/Garmelon/vault.git"
|
||||
tag = "v0.4.0"
|
||||
features = ["tokio"]
|
||||
|
||||
[workspace.lints]
|
||||
rust.unsafe_code = { level = "forbid", priority = 1 }
|
||||
# Lint groups
|
||||
rust.deprecated_safe = "warn"
|
||||
rust.future_incompatible = "warn"
|
||||
rust.keyword_idents = "warn"
|
||||
rust.rust_2018_idioms = "warn"
|
||||
rust.unused = "warn"
|
||||
# Individual lints
|
||||
rust.non_local_definitions = "warn"
|
||||
rust.redundant_imports = "warn"
|
||||
rust.redundant_lifetimes = "warn"
|
||||
rust.single_use_lifetimes = "warn"
|
||||
rust.unit_bindings = "warn"
|
||||
rust.unnameable_types = "warn"
|
||||
rust.unused_crate_dependencies = "warn"
|
||||
rust.unused_import_braces = "warn"
|
||||
rust.unused_lifetimes = "warn"
|
||||
rust.unused_qualifications = "warn"
|
||||
# Clippy
|
||||
clippy.use_self = "warn"
|
||||
tag = "v0.2.0"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
|
|
|||
65
README.md
65
README.md
|
|
@ -1,17 +1,12 @@
|
|||
# cove
|
||||
|
||||
Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded
|
||||
Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded
|
||||
real-time chat platform.
|
||||
|
||||

|
||||
|
||||
It runs on Linux, Windows, and macOS.
|
||||
|
||||
## Installing cove
|
||||
|
||||
Download a binary of your choice from the
|
||||
[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest).
|
||||
|
||||
## Using cove
|
||||
|
||||
To start cove, simply run `cove` in your terminal. For more info about the
|
||||
|
|
@ -31,3 +26,61 @@ file or via `cove help-config`.
|
|||
When launched, cove prints the location it is loading its config file from. To
|
||||
configure cove, create a config file at that location. This location can be
|
||||
changed via the `--config` command line option.
|
||||
|
||||
## Installation
|
||||
|
||||
At this point, cove is not available via any package manager.
|
||||
|
||||
Cove is available as a Nix Flake. To try it out, you can use
|
||||
```bash
|
||||
$ nix run --override-input nixpkgs nixpkgs github:Garmelon/cove/latest
|
||||
```
|
||||
|
||||
## Manual installation
|
||||
|
||||
This section contains instructions on how to install cove by compiling it yourself.
|
||||
It doesn't assume you know how to program, but it does assume basic familiarity with the command line on your platform of choice.
|
||||
Cove runs in the terminal, after all.
|
||||
|
||||
### Installing rustup
|
||||
|
||||
Cove is written in Rust, so the first step is to install rustup. Either install
|
||||
it from your package manager of choice (if you have one) or use the
|
||||
[installer](https://rustup.rs/).
|
||||
|
||||
Test your installation by running `rustup --version` and `cargo --version`. If
|
||||
rustup is installed correctly, both of these should show a version number.
|
||||
|
||||
Cove is designed on the current version of the stable toolchain. If cove doesn't
|
||||
compile, you can try switching to the stable toolchain and updating it using the
|
||||
following commands:
|
||||
```bash
|
||||
$ rustup default stable
|
||||
$ rustup update
|
||||
```
|
||||
|
||||
### Installing cove
|
||||
|
||||
To install or update to the latest release of cove, run the following command:
|
||||
|
||||
```bash
|
||||
$ cargo install --force --git https://github.com/Garmelon/cove --branch latest
|
||||
```
|
||||
|
||||
If you like to live dangerously and want to install or update to the latest,
|
||||
bleeding-edge, possibly-broken commit from the repo's main branch, run the
|
||||
following command.
|
||||
|
||||
**Warning:** This could corrupt your vault. Make sure to make a backup before
|
||||
running the command.
|
||||
|
||||
```bash
|
||||
$ cargo install --force --git https://github.com/Garmelon/cove
|
||||
```
|
||||
|
||||
To install a specific version of cove, run the following command and substitute
|
||||
in the full version you want to install:
|
||||
|
||||
```bash
|
||||
$ cargo install --force --git https://github.com/Garmelon/cove --tag v0.1.0
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
[package]
|
||||
name = "cove-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
cove-input = { path = "../cove-input" }
|
||||
cove-macro = { path = "../cove-macro" }
|
||||
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
toml = "0.7.6"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Auto-generate markdown documentation.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cove_input::KeyBinding;
|
||||
pub use cove_macro::Document;
|
||||
|
|
@ -16,11 +17,15 @@ Here is an example config that changes a few different options:
|
|||
measure_widths = true
|
||||
rooms_sort_order = "importance"
|
||||
|
||||
[euph.servers."euphoria.leet.nu".rooms]
|
||||
welcome.autojoin = true
|
||||
test.username = "badingle"
|
||||
test.force_username = true
|
||||
private.password = "foobar"
|
||||
[euph.rooms.welcome]
|
||||
autojoin = true
|
||||
|
||||
[euph.rooms.test]
|
||||
username = "badingle"
|
||||
force_username = true
|
||||
|
||||
[euph.rooms.private]
|
||||
password = "foobar"
|
||||
|
||||
[keys]
|
||||
general.abort = ["esc", "ctrl+c"]
|
||||
|
|
@ -28,6 +33,17 @@ general.exit = "ctrl+q"
|
|||
tree.action.fold_tree = "f"
|
||||
```
|
||||
|
||||
If you want to configure lots of rooms, TOML lets you write this in a more
|
||||
compact way:
|
||||
|
||||
```toml
|
||||
[euph.rooms]
|
||||
foo = { autojoin = true }
|
||||
bar = { autojoin = true }
|
||||
baz = { autojoin = true }
|
||||
private = { autojoin = true, password = "foobar" }
|
||||
```
|
||||
|
||||
## Key bindings
|
||||
|
||||
Key bindings are specified as strings or lists of strings. Each string specifies
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ pub struct EuphRoom {
|
|||
/// associated with the current session.
|
||||
pub username: Option<String>,
|
||||
|
||||
/// If `euph.servers.<domain>.rooms.<room>.username` is set, this will force
|
||||
/// cove to set the username even if there is already a different username
|
||||
/// associated with the current session.
|
||||
/// If `euph.rooms.<room>.username` is set, this will force cove to set the
|
||||
/// username even if there is already a different username associated with
|
||||
/// the current session.
|
||||
#[serde(default)]
|
||||
pub force_username: bool,
|
||||
|
||||
|
|
@ -35,13 +35,7 @@ pub struct EuphRoom {
|
|||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct EuphServer {
|
||||
pub struct Euph {
|
||||
#[document(metavar = "room")]
|
||||
pub rooms: HashMap<String, EuphRoom>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Euph {
|
||||
#[document(metavar = "domain")]
|
||||
pub servers: HashMap<String, EuphServer>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ default_bindings! {
|
|||
pub fn nick => ["n"];
|
||||
pub fn more_messages => ["m"];
|
||||
pub fn account => ["A"];
|
||||
pub fn present => ["ctrl+p"];
|
||||
}
|
||||
|
||||
pub mod tree_cursor {
|
||||
|
|
@ -104,9 +105,6 @@ default_bindings! {
|
|||
pub fn mark_older_seen => ["ctrl+s"];
|
||||
pub fn info => ["i"];
|
||||
pub fn links => ["I"];
|
||||
pub fn toggle_nick_emoji => ["e"];
|
||||
pub fn increase_caesar => ["c"];
|
||||
pub fn decrease_caesar => ["C"];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -124,6 +122,7 @@ pub struct General {
|
|||
#[serde(default = "default::general::confirm")]
|
||||
pub confirm: KeyBinding,
|
||||
/// Advance focus.
|
||||
// TODO Mention examples where this is used
|
||||
#[serde(default = "default::general::focus")]
|
||||
pub focus: KeyBinding,
|
||||
/// Show this help.
|
||||
|
|
@ -288,6 +287,9 @@ pub struct RoomAction {
|
|||
/// Manage account.
|
||||
#[serde(default = "default::room_action::account")]
|
||||
pub account: KeyBinding,
|
||||
/// Open room's plugh.de/present page.
|
||||
#[serde(default = "default::room_action::present")]
|
||||
pub present: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
|
|
@ -357,15 +359,6 @@ pub struct TreeAction {
|
|||
/// List links found in message.
|
||||
#[serde(default = "default::tree_action::links")]
|
||||
pub links: KeyBinding,
|
||||
/// Toggle agent id based nick emoji.
|
||||
#[serde(default = "default::tree_action::toggle_nick_emoji")]
|
||||
pub toggle_nick_emoji: KeyBinding,
|
||||
/// Increase caesar cipher rotation.
|
||||
#[serde(default = "default::tree_action::increase_caesar")]
|
||||
pub increase_caesar: KeyBinding,
|
||||
/// Decrease caesar cipher rotation.
|
||||
#[serde(default = "default::tree_action::decrease_caesar")]
|
||||
pub decrease_caesar: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
use std::{
|
||||
fs,
|
||||
io::{self, ErrorKind},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use doc::Document;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::{euph::*, keys::*};
|
||||
#![forbid(unsafe_code)]
|
||||
// Rustc lint groups
|
||||
#![warn(future_incompatible)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
#![warn(unused)]
|
||||
// Rustc lints
|
||||
#![warn(noop_method_call)]
|
||||
#![warn(single_use_lifetimes)]
|
||||
// Clippy lints
|
||||
#![warn(clippy::use_self)]
|
||||
|
||||
pub mod doc;
|
||||
mod euph;
|
||||
mod keys;
|
||||
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{fs, io};
|
||||
|
||||
use doc::Document;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub use crate::euph::*;
|
||||
pub use crate::keys::*;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("failed to read config file")]
|
||||
|
|
@ -21,14 +31,6 @@ pub enum Error {
|
|||
Toml(#[from] toml::de::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Document)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WidthEstimationMethod {
|
||||
#[default]
|
||||
Legacy,
|
||||
Unicode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Config {
|
||||
/// The directory that cove stores its data in when not running in ephemeral
|
||||
|
|
@ -47,34 +49,19 @@ pub struct Config {
|
|||
///
|
||||
/// See also the `--ephemeral` command line option.
|
||||
#[serde(default)]
|
||||
#[document(default = "`false`")]
|
||||
pub ephemeral: bool,
|
||||
|
||||
/// How to estimate the width of graphemes (i.e. characters) as displayed by
|
||||
/// the terminal emulator.
|
||||
///
|
||||
/// `"legacy"`: Use a legacy method that should mostly work on most terminal
|
||||
/// emulators. This method will never be correct in all cases since every
|
||||
/// terminal emulator handles grapheme widths slightly differently. However,
|
||||
/// those cases are usually rare (unless you view a lot of emoji).
|
||||
///
|
||||
/// `"unicode"`: Use the unicode standard in a best-effort manner to
|
||||
/// determine grapheme widths. Some terminals (e.g. ghostty) can make use of
|
||||
/// this.
|
||||
///
|
||||
/// This method is used when `measure_widths` is set to `false`.
|
||||
///
|
||||
/// See also the `--width-estimation-method` command line option.
|
||||
#[serde(default)]
|
||||
pub width_estimation_method: WidthEstimationMethod,
|
||||
|
||||
/// Whether to measure the width of graphemes (i.e. characters) as displayed
|
||||
/// by the terminal emulator instead of estimating the width.
|
||||
/// Whether to measure the width of characters as displayed by the terminal
|
||||
/// emulator instead of guessing the width.
|
||||
///
|
||||
/// Enabling this makes rendering a bit slower but more accurate. The screen
|
||||
/// might also flash when encountering new graphemes.
|
||||
/// might also flash when encountering new characters (or, more accurately,
|
||||
/// graphemes).
|
||||
///
|
||||
/// See also the `--measure-widths` command line option.
|
||||
/// See also the `--measure-graphemes` command line option.
|
||||
#[serde(default)]
|
||||
#[document(default = "`false`")]
|
||||
pub measure_widths: bool,
|
||||
|
||||
/// Whether to start in offline mode.
|
||||
|
|
@ -85,46 +72,23 @@ pub struct Config {
|
|||
///
|
||||
/// See also the `--offline` command line option.
|
||||
#[serde(default)]
|
||||
#[document(default = "`false`")]
|
||||
pub offline: bool,
|
||||
|
||||
/// Initial sort order of rooms list.
|
||||
///
|
||||
/// `"alphabet"` sorts rooms in alphabetic order.
|
||||
/// `alphabet` sorts rooms in alphabetic order.
|
||||
///
|
||||
/// `"importance"` sorts rooms by the following criteria (in descending
|
||||
/// order of priority):
|
||||
/// `importance` sorts rooms by the following criteria (in descending order
|
||||
/// of priority):
|
||||
///
|
||||
/// 1. connected rooms before unconnected rooms
|
||||
/// 2. rooms with unread messages before rooms without
|
||||
/// 3. alphabetic order
|
||||
#[serde(default)]
|
||||
#[document(default = "`alphabet`")]
|
||||
pub rooms_sort_order: RoomsSortOrder,
|
||||
|
||||
/// Ring the bell (character 0x07) when you are mentioned in a room.
|
||||
#[serde(default)]
|
||||
pub bell_on_mention: bool,
|
||||
|
||||
/// Time zone that chat timestamps should be displayed in.
|
||||
///
|
||||
/// This option can either be the string `"localtime"`, a [POSIX TZ string],
|
||||
/// or a [tz identifier] from the [tz database].
|
||||
///
|
||||
/// When not set or when set to `"localtime"`, cove attempts to use your
|
||||
/// system's configured time zone, falling back to UTC.
|
||||
///
|
||||
/// When the string begins with a colon or doesn't match the a POSIX TZ
|
||||
/// string format, it is interpreted as a tz identifier and looked up in
|
||||
/// your system's tz database (or a bundled tz database on Windows).
|
||||
///
|
||||
/// If the `TZ` environment variable exists, it overrides this option.
|
||||
///
|
||||
/// [POSIX TZ string]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03
|
||||
/// [tz identifier]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
/// [tz database]: https://en.wikipedia.org/wiki/Tz_database
|
||||
#[serde(default)]
|
||||
#[document(default = "`$TZ` or local system time zone")]
|
||||
pub time_zone: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub euph: Euph,
|
||||
|
|
@ -143,16 +107,7 @@ impl Config {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn euph_room(&self, domain: &str, name: &str) -> EuphRoom {
|
||||
if let Some(server) = self.euph.servers.get(domain) {
|
||||
if let Some(room) = server.rooms.get(name) {
|
||||
return room.clone();
|
||||
}
|
||||
}
|
||||
EuphRoom::default()
|
||||
}
|
||||
|
||||
pub fn time_zone_ref(&self) -> Option<&str> {
|
||||
self.time_zone.as_ref().map(|s| s as &str)
|
||||
pub fn euph_room(&self, name: &str) -> EuphRoom {
|
||||
self.euph.rooms.get(name).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
[package]
|
||||
name = "cove-input"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
cove-macro = { path = "../cove-macro" }
|
||||
|
||||
crossterm.workspace = true
|
||||
edit.workspace = true
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
serde_either.workspace = true
|
||||
thiserror.workspace = true
|
||||
toss.workspace = true
|
||||
crossterm = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_either = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toss = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
edit = "0.1.4"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
use std::{fmt, num::ParseIntError, str::FromStr};
|
||||
use std::fmt;
|
||||
use std::num::ParseIntError;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
|
||||
use serde::{de::Error, Deserialize, Deserializer};
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_either::SingleOrVec;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -114,7 +117,7 @@ impl KeyPress {
|
|||
"alt" if !self.alt => self.alt = true,
|
||||
"any" if !self.shift && !self.ctrl && !self.alt => self.any = true,
|
||||
m @ ("shift" | "ctrl" | "alt" | "any") => {
|
||||
return Err(ParseKeysError::ConflictingModifier(m.to_string()));
|
||||
return Err(ParseKeysError::ConflictingModifier(m.to_string()))
|
||||
}
|
||||
m => return Err(ParseKeysError::UnknownModifier(m.to_string())),
|
||||
}
|
||||
|
|
@ -148,7 +151,7 @@ impl FromStr for KeyPress {
|
|||
let mut parts = s.split('+');
|
||||
let code = parts.next_back().ok_or(ParseKeysError::NoKeyCode)?;
|
||||
|
||||
let mut keys = Self::parse_key_code(code)?;
|
||||
let mut keys = KeyPress::parse_key_code(code)?;
|
||||
let shift_allowed = !conflicts_with_shift(keys.code);
|
||||
for modifier in parts {
|
||||
keys.parse_modifier(modifier, shift_allowed)?;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
use std::{io, sync::Arc};
|
||||
mod keys;
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cove_macro::KeyGroup;
|
||||
use crossterm::event::{Event, KeyEvent, KeyEventKind};
|
||||
use crossterm::event::{Event, KeyEvent};
|
||||
use parking_lot::FairMutex;
|
||||
use toss::{Frame, Terminal, WidthDb};
|
||||
|
||||
pub use crate::keys::*;
|
||||
|
||||
mod keys;
|
||||
|
||||
pub struct KeyBindingInfo<'a> {
|
||||
pub name: &'static str,
|
||||
pub binding: &'a KeyBinding,
|
||||
|
|
@ -39,7 +40,7 @@ impl<'a> KeyGroupInfo<'a> {
|
|||
}
|
||||
|
||||
pub struct InputEvent<'a> {
|
||||
event: Event,
|
||||
event: crossterm::event::Event,
|
||||
terminal: &'a mut Terminal,
|
||||
crossterm_lock: Arc<FairMutex<()>>,
|
||||
}
|
||||
|
|
@ -57,15 +58,11 @@ impl<'a> InputEvent<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// If the current event represents a key press, returns the [`KeyEvent`]
|
||||
/// associated with that key press.
|
||||
pub fn key_event(&self) -> Option<KeyEvent> {
|
||||
if let Event::Key(event) = &self.event {
|
||||
if matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return Some(*event);
|
||||
}
|
||||
match &self.event {
|
||||
Event::Key(event) => Some(*event),
|
||||
_ => None,
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn paste_event(&self) -> Option<&str> {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
[package]
|
||||
name = "cove-macro"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
proc-macro2.workspace = true
|
||||
quote.workspace = true
|
||||
syn.workspace = true
|
||||
case = "1.0.0"
|
||||
proc-macro2 = "1.0.66"
|
||||
quote = "1.0.33"
|
||||
syn = "2.0.29"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr, spanned::Spanned};
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr};
|
||||
|
||||
use crate::util::{self, SerdeDefault};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{Data, DeriveInput, spanned::Spanned};
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{Data, DeriveInput};
|
||||
|
||||
use crate::util;
|
||||
use crate::util::{self, bail};
|
||||
|
||||
fn decapitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
|
|
@ -33,7 +34,7 @@ pub fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
|
|||
|
||||
let default = util::serde_default(field)?;
|
||||
let Some(default) = default else {
|
||||
return util::bail(field_ident.span(), "must have serde default");
|
||||
return bail(field_ident.span(), "must have serde default");
|
||||
};
|
||||
let default_value = default.value();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
use syn::{DeriveInput, parse_macro_input};
|
||||
#![forbid(unsafe_code)]
|
||||
// Rustc lint groups
|
||||
#![warn(future_incompatible)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
#![warn(unused)]
|
||||
// Rustc lints
|
||||
#![warn(noop_method_call)]
|
||||
#![warn(single_use_lifetimes)]
|
||||
// Clippy lints
|
||||
#![warn(clippy::use_self)]
|
||||
|
||||
use syn::{parse_macro_input, DeriveInput};
|
||||
|
||||
mod document;
|
||||
mod key_group;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
use proc_macro2::{Span, TokenStream};
|
||||
use quote::quote;
|
||||
use syn::{
|
||||
Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type, parse::Parse,
|
||||
punctuated::Punctuated,
|
||||
};
|
||||
use syn::parse::Parse;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::{Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type};
|
||||
|
||||
pub fn bail<T>(span: Span, message: &str) -> syn::Result<T> {
|
||||
Err(syn::Error::new(span, message))
|
||||
|
|
|
|||
|
|
@ -1,32 +1,46 @@
|
|||
[package]
|
||||
name = "cove"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
cove-config = { path = "../cove-config" }
|
||||
cove-input = { path = "../cove-input" }
|
||||
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
clap.workspace = true
|
||||
cookie.workspace = true
|
||||
crossterm.workspace = true
|
||||
directories.workspace = true
|
||||
euphoxide.workspace = true
|
||||
jiff.workspace = true
|
||||
linkify.workspace = true
|
||||
log.workspace = true
|
||||
open.workspace = true
|
||||
parking_lot.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toss.workspace = true
|
||||
unicode-width.workspace = true
|
||||
vault.workspace = true
|
||||
rustls.workspace = true
|
||||
crossterm = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toss = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
anyhow = "1.0.75"
|
||||
async-trait = "0.1.73"
|
||||
clap = { version = "4.4.1", features = ["derive", "deprecated"] }
|
||||
cookie = "0.17.0"
|
||||
directories = "5.0.1"
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.20", features = ["std"] }
|
||||
once_cell = "1.18.0"
|
||||
open = "5.0.0"
|
||||
rusqlite = { version = "0.29.0", features = ["bundled", "time"] }
|
||||
serde_json = "1.0.105"
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
unicode-segmentation = "1.10.1"
|
||||
unicode-width = "0.1.10"
|
||||
|
||||
[dependencies.time]
|
||||
version = "0.3.28"
|
||||
features = ["macros", "formatting", "parsing", "serde"]
|
||||
|
||||
[dependencies.tokio-tungstenite]
|
||||
version = "0.20.0"
|
||||
features = ["rustls-tls-native-roots"]
|
||||
|
||||
[dependencies.euphoxide]
|
||||
git = "https://github.com/Garmelon/euphoxide.git"
|
||||
tag = "v0.4.0"
|
||||
features = ["bot"]
|
||||
|
||||
[dependencies.vault]
|
||||
git = "https://github.com/Garmelon/vault.git"
|
||||
tag = "v0.2.0"
|
||||
features = ["tokio"]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
pub use highlight::*;
|
||||
pub use room::*;
|
||||
pub use small_message::*;
|
||||
pub use util::*;
|
||||
|
||||
mod highlight;
|
||||
mod room;
|
||||
mod small_message;
|
||||
mod util;
|
||||
|
||||
pub use room::*;
|
||||
pub use small_message::*;
|
||||
pub use util::*;
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
use std::ops::Range;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::euph::util;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpanType {
|
||||
Mention,
|
||||
Room,
|
||||
Emoji,
|
||||
}
|
||||
|
||||
fn nick_char(ch: char) -> bool {
|
||||
// Closely following the heim mention regex:
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
|
||||
// `>` has been experimentally confirmed to delimit mentions as well.
|
||||
match ch {
|
||||
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
|
||||
_ => !ch.is_whitespace(),
|
||||
}
|
||||
}
|
||||
|
||||
fn room_char(ch: char) -> bool {
|
||||
// Basically just \w, see also
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
|
||||
ch.is_ascii_alphanumeric() || ch == '_'
|
||||
}
|
||||
|
||||
struct SpanFinder<'a> {
|
||||
content: &'a str,
|
||||
|
||||
span: Option<(SpanType, usize)>,
|
||||
room_or_mention_possible: bool,
|
||||
|
||||
result: Vec<(SpanType, Range<usize>)>,
|
||||
}
|
||||
|
||||
impl<'a> SpanFinder<'a> {
|
||||
fn is_valid_span(&self, span: SpanType, range: Range<usize>) -> bool {
|
||||
let text = &self.content[range.start..range.end];
|
||||
match span {
|
||||
SpanType::Mention => range.len() > 1 && text.starts_with('@'),
|
||||
SpanType::Room => range.len() > 1 && text.starts_with('&'),
|
||||
SpanType::Emoji => {
|
||||
if range.len() <= 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(name) = Some(text)
|
||||
.and_then(|it| it.strip_prefix(':'))
|
||||
.and_then(|it| it.strip_suffix(':'))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
util::EMOJI.get(name).is_some()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_span(&mut self, end: usize) {
|
||||
let Some((span, start)) = self.span else {
|
||||
return;
|
||||
};
|
||||
if self.is_valid_span(span, start..end) {
|
||||
self.result.push((span, start..end));
|
||||
}
|
||||
self.span = None;
|
||||
}
|
||||
|
||||
fn open_span(&mut self, span: SpanType, start: usize) {
|
||||
self.close_span(start);
|
||||
self.span = Some((span, start))
|
||||
}
|
||||
|
||||
fn step(&mut self, idx: usize, char: char) {
|
||||
match (char, self.span) {
|
||||
('@', Some((SpanType::Mention, _))) => {} // Continue the mention
|
||||
('@', _) if self.room_or_mention_possible => self.open_span(SpanType::Mention, idx),
|
||||
('&', _) if self.room_or_mention_possible => self.open_span(SpanType::Room, idx),
|
||||
(':', None) => self.open_span(SpanType::Emoji, idx),
|
||||
(':', Some((SpanType::Emoji, _))) => self.close_span(idx + 1),
|
||||
(c, Some((SpanType::Mention, _))) if !nick_char(c) => self.close_span(idx),
|
||||
(c, Some((SpanType::Room, _))) if !room_char(c) => self.close_span(idx),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// More permissive than the heim web client
|
||||
self.room_or_mention_possible = !char.is_alphanumeric();
|
||||
}
|
||||
|
||||
fn find(content: &'a str) -> Vec<(SpanType, Range<usize>)> {
|
||||
let mut this = Self {
|
||||
content,
|
||||
span: None,
|
||||
room_or_mention_possible: true,
|
||||
result: vec![],
|
||||
};
|
||||
|
||||
for (idx, char) in content.char_indices() {
|
||||
this.step(idx, char);
|
||||
}
|
||||
|
||||
this.close_span(content.len());
|
||||
|
||||
this.result
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_spans(content: &str) -> Vec<(SpanType, Range<usize>)> {
|
||||
SpanFinder::find(content)
|
||||
}
|
||||
|
||||
/// Highlight spans in a string.
|
||||
///
|
||||
/// The list of spans must be non-overlapping and in ascending order.
|
||||
///
|
||||
/// If `exact` is specified, colon-delimited emoji are not replaced with their
|
||||
/// unicode counterparts.
|
||||
pub fn apply_spans(
|
||||
content: &str,
|
||||
spans: &[(SpanType, Range<usize>)],
|
||||
base: Style,
|
||||
exact: bool,
|
||||
) -> Styled {
|
||||
let mut result = Styled::default();
|
||||
let mut i = 0;
|
||||
|
||||
for (span, range) in spans {
|
||||
assert!(i <= range.start);
|
||||
assert!(range.end <= content.len());
|
||||
|
||||
if i < range.start {
|
||||
result = result.then(&content[i..range.start], base);
|
||||
}
|
||||
|
||||
let text = &content[range.start..range.end];
|
||||
result = match span {
|
||||
SpanType::Mention if exact => result.and_then(util::style_mention_exact(text, base)),
|
||||
SpanType::Mention => result.and_then(util::style_mention(text, base)),
|
||||
SpanType::Room => result.then(text, base.blue().bold()),
|
||||
SpanType::Emoji if exact => result.then(text, base.magenta()),
|
||||
SpanType::Emoji => {
|
||||
let name = text.strip_prefix(':').unwrap_or(text);
|
||||
let name = name.strip_suffix(':').unwrap_or(name);
|
||||
if let Some(Some(replacement)) = util::EMOJI.get(name) {
|
||||
result.then(replacement, base)
|
||||
} else {
|
||||
result.then(text, base.magenta())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
i = range.end;
|
||||
}
|
||||
|
||||
if i < content.len() {
|
||||
result = result.then(&content[i..], base);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Highlight an euphoria message's content.
|
||||
///
|
||||
/// If `exact` is specified, colon-delimited emoji are not replaced with their
|
||||
/// unicode counterparts.
|
||||
pub fn highlight(content: &str, base: Style, exact: bool) -> Styled {
|
||||
apply_spans(content, &find_spans(content), base, exact)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::euph::SpanType;
|
||||
|
||||
use super::find_spans;
|
||||
|
||||
#[test]
|
||||
fn mentions() {
|
||||
assert_eq!(find_spans("@foo"), vec![(SpanType::Mention, 0..4)]);
|
||||
assert_eq!(find_spans("&@foo"), vec![(SpanType::Mention, 1..5)]);
|
||||
assert_eq!(find_spans("a @foo b"), vec![(SpanType::Mention, 2..6)]);
|
||||
assert_eq!(find_spans("@@foo@@"), vec![(SpanType::Mention, 0..7)]);
|
||||
assert_eq!(find_spans("a @b@c d"), vec![(SpanType::Mention, 2..6)]);
|
||||
assert_eq!(
|
||||
find_spans("a @b @c d"),
|
||||
vec![(SpanType::Mention, 2..4), (SpanType::Mention, 5..7)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rooms() {
|
||||
assert_eq!(find_spans("&foo"), vec![(SpanType::Room, 0..4)]);
|
||||
assert_eq!(find_spans("@&foo"), vec![(SpanType::Room, 1..5)]);
|
||||
assert_eq!(find_spans("a &foo b"), vec![(SpanType::Room, 2..6)]);
|
||||
assert_eq!(find_spans("&&foo&&"), vec![(SpanType::Room, 1..5)]);
|
||||
assert_eq!(find_spans("a &b&c d"), vec![(SpanType::Room, 2..4)]);
|
||||
assert_eq!(
|
||||
find_spans("a &b &c d"),
|
||||
vec![(SpanType::Room, 2..4), (SpanType::Room, 5..7)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_in_mentions() {
|
||||
assert_eq!(find_spans(" @a:b:c "), vec![(SpanType::Mention, 1..7)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
use std::{convert::Infallible, time::Duration};
|
||||
// TODO Stop if room does not exist (e.g. 404)
|
||||
|
||||
use euphoxide::{
|
||||
api::{
|
||||
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply,
|
||||
Time, UserId, packet::ParsedPacket,
|
||||
},
|
||||
bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig},
|
||||
conn::{self, ConnTx, Joined},
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use euphoxide::api::packet::ParsedPacket;
|
||||
use euphoxide::api::{
|
||||
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply, Time,
|
||||
UserId,
|
||||
};
|
||||
use log::{debug, info, warn};
|
||||
use tokio::{select, sync::oneshot};
|
||||
use euphoxide::bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig};
|
||||
use euphoxide::conn::{self, ConnTx};
|
||||
use log::{debug, error, info, warn};
|
||||
use tokio::select;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::{macros::logging_unwrap, vault::EuphRoomVault};
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
const LOG_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub enum State {
|
||||
Disconnected,
|
||||
|
|
@ -32,13 +35,6 @@ impl State {
|
|||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn joined(&self) -> Option<&Joined> {
|
||||
match self {
|
||||
Self::Connected(_, conn::State::Joined(joined)) => Some(joined),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -69,13 +65,19 @@ impl Room {
|
|||
where
|
||||
F: Fn(Event) + std::marker::Send + Sync + 'static,
|
||||
{
|
||||
// &rl2dev's message history is broken and requesting old messages past
|
||||
// a certain point results in errors. Cove should not keep retrying log
|
||||
// requests when hitting that limit, so &rl2dev is always opened in
|
||||
// ephemeral mode.
|
||||
let ephemeral = vault.vault().vault().ephemeral() || vault.room() == "rl2dev";
|
||||
|
||||
Self {
|
||||
ephemeral: vault.vault().vault().ephemeral(),
|
||||
vault,
|
||||
ephemeral,
|
||||
instance: instance_config.build(on_event),
|
||||
state: State::Disconnected,
|
||||
last_msg_id: None,
|
||||
log_request_canary: None,
|
||||
vault,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,8 +125,7 @@ impl Room {
|
|||
|
||||
let cookies = &*self.instance.config().server.cookies;
|
||||
let cookies = cookies.lock().unwrap().clone();
|
||||
let domain = self.vault.room().domain.clone();
|
||||
logging_unwrap!(self.vault.vault().set_cookies(domain, cookies).await);
|
||||
logging_unwrap!(self.vault.vault().set_cookies(cookies).await);
|
||||
}
|
||||
Event::Packet(_, packet, ConnSnapshot { conn_tx, state }) => {
|
||||
self.state = State::Connected(conn_tx, state);
|
||||
|
|
@ -136,6 +137,7 @@ impl Room {
|
|||
self.log_request_canary = None;
|
||||
}
|
||||
Event::Stopped(_) => {
|
||||
// TODO Remove room somewhere if this happens? If it doesn't already happen during stabilization
|
||||
self.state = State::Stopped;
|
||||
}
|
||||
}
|
||||
|
|
@ -181,9 +183,15 @@ impl Room {
|
|||
None => None,
|
||||
};
|
||||
|
||||
debug!("{:?}: requesting logs", vault.room());
|
||||
debug!("{}: requesting logs", vault.room());
|
||||
|
||||
let _ = conn_tx.send(Log { n: 1000, before }).await;
|
||||
// &rl2dev's message history is broken and requesting old messages past
|
||||
// a certain point results in errors. By reducing the amount of messages
|
||||
// in each log request, we can get closer to this point. Since &rl2dev
|
||||
// is fairly low in activity, this should be fine.
|
||||
let n = if vault.room() == "rl2dev" { 50 } else { 1000 };
|
||||
|
||||
let _ = conn_tx.send(Log { n, before }).await;
|
||||
// The code handling incoming events and replies also handles
|
||||
// `LogReply`s, so we don't need to do anything special here.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,212 @@
|
|||
use std::mem;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::{MessageId, Snowflake, Time, UserId};
|
||||
use jiff::Timestamp;
|
||||
use euphoxide::api::{MessageId, Snowflake, Time};
|
||||
use time::OffsetDateTime;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::{store::Msg, ui::ChatMsg};
|
||||
use crate::store::Msg;
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
use super::util;
|
||||
|
||||
fn nick_char(ch: char) -> bool {
|
||||
// Closely following the heim mention regex:
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
|
||||
// `>` has been experimentally confirmed to delimit mentions as well.
|
||||
match ch {
|
||||
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
|
||||
_ => !ch.is_whitespace(),
|
||||
}
|
||||
}
|
||||
|
||||
fn room_char(ch: char) -> bool {
|
||||
// Basically just \w, see also
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
|
||||
ch.is_ascii_alphanumeric() || ch == '_'
|
||||
}
|
||||
|
||||
enum Span {
|
||||
Nothing,
|
||||
Mention,
|
||||
Room,
|
||||
Emoji,
|
||||
}
|
||||
|
||||
struct Highlighter<'a> {
|
||||
content: &'a str,
|
||||
base_style: Style,
|
||||
exact: bool,
|
||||
|
||||
span: Span,
|
||||
span_start: usize,
|
||||
room_or_mention_possible: bool,
|
||||
|
||||
result: Styled,
|
||||
}
|
||||
|
||||
impl<'a> Highlighter<'a> {
|
||||
/// Does *not* guarantee `self.span_start == idx` after running!
|
||||
fn close_mention(&mut self, idx: usize) {
|
||||
let span_length = idx.saturating_sub(self.span_start);
|
||||
if span_length <= 1 {
|
||||
// We can repurpose the current span
|
||||
self.span = Span::Nothing;
|
||||
return;
|
||||
}
|
||||
|
||||
let text = &self.content[self.span_start..idx]; // Includes @
|
||||
self.result = mem::take(&mut self.result).and_then(if self.exact {
|
||||
util::style_nick_exact(text, self.base_style)
|
||||
} else {
|
||||
util::style_nick(text, self.base_style)
|
||||
});
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
/// Does *not* guarantee `self.span_start == idx` after running!
|
||||
fn close_room(&mut self, idx: usize) {
|
||||
let span_length = idx.saturating_sub(self.span_start);
|
||||
if span_length <= 1 {
|
||||
// We can repurpose the current span
|
||||
self.span = Span::Nothing;
|
||||
return;
|
||||
}
|
||||
|
||||
self.result = mem::take(&mut self.result).then(
|
||||
&self.content[self.span_start..idx],
|
||||
self.base_style.blue().bold(),
|
||||
);
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
// Warning: `idx` is the index of the closing colon.
|
||||
fn close_emoji(&mut self, idx: usize) {
|
||||
let name = &self.content[self.span_start + 1..idx];
|
||||
if let Some(replace) = util::EMOJI.get(name) {
|
||||
match replace {
|
||||
Some(replace) if !self.exact => {
|
||||
self.result = mem::take(&mut self.result).then(replace, self.base_style);
|
||||
}
|
||||
_ => {
|
||||
let text = &self.content[self.span_start..=idx];
|
||||
let style = self.base_style.magenta();
|
||||
self.result = mem::take(&mut self.result).then(text, style);
|
||||
}
|
||||
}
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx + 1;
|
||||
} else {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Emoji;
|
||||
}
|
||||
}
|
||||
|
||||
/// Guarantees `self.span_start == idx` after running.
|
||||
fn close_plain(&mut self, idx: usize) {
|
||||
if self.span_start == idx {
|
||||
// Span has length 0
|
||||
return;
|
||||
}
|
||||
|
||||
self.result =
|
||||
mem::take(&mut self.result).then(&self.content[self.span_start..idx], self.base_style);
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
fn close_span_before_current_char(&mut self, idx: usize, char: char) {
|
||||
match self.span {
|
||||
Span::Mention if !nick_char(char) => self.close_mention(idx),
|
||||
Span::Room if !room_char(char) => self.close_room(idx),
|
||||
Span::Emoji if char == '&' || char == '@' => {
|
||||
self.span = Span::Nothing;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_span_with_current_char(&mut self, idx: usize, char: char) {
|
||||
match self.span {
|
||||
Span::Nothing if char == '@' && self.room_or_mention_possible => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Mention;
|
||||
}
|
||||
Span::Nothing if char == '&' && self.room_or_mention_possible => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Room;
|
||||
}
|
||||
Span::Nothing if char == ':' => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Emoji;
|
||||
}
|
||||
Span::Emoji if char == ':' => self.close_emoji(idx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_final_span(&mut self) {
|
||||
let idx = self.content.len();
|
||||
if self.span_start >= idx {
|
||||
return; // Span has no contents
|
||||
}
|
||||
|
||||
match self.span {
|
||||
Span::Mention => self.close_mention(idx),
|
||||
Span::Room => self.close_room(idx),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.close_plain(idx);
|
||||
}
|
||||
|
||||
fn step(&mut self, idx: usize, char: char) {
|
||||
if self.span_start < idx {
|
||||
self.close_span_before_current_char(idx, char);
|
||||
}
|
||||
|
||||
self.update_span_with_current_char(idx, char);
|
||||
|
||||
// More permissive than the heim web client
|
||||
self.room_or_mention_possible = !char.is_alphanumeric();
|
||||
}
|
||||
|
||||
fn highlight(content: &'a str, base_style: Style, exact: bool) -> Styled {
|
||||
let mut this = Self {
|
||||
content: if exact { content } else { content.trim() },
|
||||
base_style,
|
||||
exact,
|
||||
span: Span::Nothing,
|
||||
span_start: 0,
|
||||
room_or_mention_possible: true,
|
||||
result: Styled::default(),
|
||||
};
|
||||
|
||||
for (idx, char) in (if exact { content } else { content.trim() }).char_indices() {
|
||||
this.step(idx, char);
|
||||
}
|
||||
|
||||
this.close_final_span();
|
||||
|
||||
this.result
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_content(content: &str, base_style: Style, exact: bool) -> Styled {
|
||||
Highlighter::highlight(content, base_style, exact)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmallMessage {
|
||||
pub id: MessageId,
|
||||
pub parent: Option<MessageId>,
|
||||
pub time: Time,
|
||||
pub user_id: UserId,
|
||||
pub nick: String,
|
||||
pub content: String,
|
||||
pub seen: bool,
|
||||
|
|
@ -28,22 +222,22 @@ fn style_me() -> Style {
|
|||
|
||||
fn styled_nick(nick: &str) -> Styled {
|
||||
Styled::new_plain("[")
|
||||
.and_then(super::style_nick(nick, Style::new()))
|
||||
.and_then(util::style_nick(nick, Style::new()))
|
||||
.then_plain("]")
|
||||
}
|
||||
|
||||
fn styled_nick_me(nick: &str) -> Styled {
|
||||
let style = style_me();
|
||||
Styled::new("*", style).and_then(super::style_nick(nick, style))
|
||||
Styled::new("*", style).and_then(util::style_nick(nick, style))
|
||||
}
|
||||
|
||||
fn styled_content(content: &str) -> Styled {
|
||||
super::highlight(content.trim(), Style::new(), false)
|
||||
highlight_content(content.trim(), Style::new(), false)
|
||||
}
|
||||
|
||||
fn styled_content_me(content: &str) -> Styled {
|
||||
let style = style_me();
|
||||
super::highlight(content.trim(), style, false).then("*", style)
|
||||
highlight_content(content.trim(), style, false).then("*", style)
|
||||
}
|
||||
|
||||
fn styled_editor_content(content: &str) -> Styled {
|
||||
|
|
@ -52,7 +246,7 @@ fn styled_editor_content(content: &str) -> Styled {
|
|||
} else {
|
||||
Style::new()
|
||||
};
|
||||
super::highlight(content, style, true)
|
||||
highlight_content(content, style, true)
|
||||
}
|
||||
|
||||
impl Msg for SmallMessage {
|
||||
|
|
@ -73,15 +267,11 @@ impl Msg for SmallMessage {
|
|||
fn last_possible_id() -> Self::Id {
|
||||
MessageId(Snowflake::MAX)
|
||||
}
|
||||
|
||||
fn nick_emoji(&self) -> Option<String> {
|
||||
Some(util::user_id_emoji(&self.user_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMsg for SmallMessage {
|
||||
fn time(&self) -> Option<Timestamp> {
|
||||
Some(self.time.as_timestamp())
|
||||
fn time(&self) -> OffsetDateTime {
|
||||
self.time.0
|
||||
}
|
||||
|
||||
fn styled(&self) -> (Styled, Styled) {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,9 @@
|
|||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use euphoxide::{Emoji, api::UserId};
|
||||
use euphoxide::Emoji;
|
||||
use once_cell::sync::Lazy;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
pub static EMOJI: LazyLock<Emoji> = LazyLock::new(Emoji::load);
|
||||
|
||||
pub static EMOJI_LIST: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
let mut list = EMOJI
|
||||
.0
|
||||
.values()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
list.sort_unstable();
|
||||
list
|
||||
});
|
||||
pub static EMOJI: Lazy<Emoji> = Lazy::new(Emoji::load);
|
||||
|
||||
/// Convert HSL to RGB following [this approach from wikipedia][1].
|
||||
///
|
||||
|
|
@ -72,25 +54,3 @@ pub fn style_nick(nick: &str, base: Style) -> Styled {
|
|||
pub fn style_nick_exact(nick: &str, base: Style) -> Styled {
|
||||
Styled::new(nick, nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn style_mention(mention: &str, base: Style) -> Styled {
|
||||
let nick = mention
|
||||
.strip_prefix('@')
|
||||
.expect("mention must start with @");
|
||||
Styled::new(EMOJI.replace(mention), nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn style_mention_exact(mention: &str, base: Style) -> Styled {
|
||||
let nick = mention
|
||||
.strip_prefix('@')
|
||||
.expect("mention must start with @");
|
||||
Styled::new(mention, nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn user_id_emoji(user_id: &UserId) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
user_id.0.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
let emoji = &EMOJI_LIST[hash as usize % EMOJI_LIST.len()];
|
||||
emoji.clone()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
//! Export logs from the vault to plain text files.
|
||||
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, BufWriter, Write},
|
||||
};
|
||||
|
||||
use crate::vault::{EuphRoomVault, EuphVault, RoomIdentifier};
|
||||
|
||||
mod json;
|
||||
mod text;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
|
||||
use crate::vault::{EuphRoomVault, EuphVault};
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
pub enum Format {
|
||||
/// Human-readable tree-structured messages.
|
||||
Text,
|
||||
/// Array of message objects in the same format as the euphoria API uses.
|
||||
Json,
|
||||
/// Message objects in the same format as the euphoria API uses, one per
|
||||
/// line (https://jsonlines.org/).
|
||||
JsonLines,
|
||||
/// Message objects in the same format as the euphoria API uses, one per line.
|
||||
JsonStream,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
|
|
@ -26,15 +23,14 @@ impl Format {
|
|||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Json => "json",
|
||||
Self::JsonLines => "json lines",
|
||||
Self::JsonStream => "json stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "txt",
|
||||
Self::Json => "json",
|
||||
Self::JsonLines => "jsonl",
|
||||
Self::Json | Self::JsonStream => "json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,10 +43,6 @@ pub struct Args {
|
|||
#[arg(long, short)]
|
||||
all: bool,
|
||||
|
||||
/// Domain to resolve the room names with.
|
||||
#[arg(long, short, default_value = "euphoria.leet.nu")]
|
||||
domain: String,
|
||||
|
||||
/// Format of the output file.
|
||||
#[arg(long, short, value_enum, default_value_t = Format::Text)]
|
||||
format: Format,
|
||||
|
|
@ -82,7 +74,7 @@ async fn export_room<W: Write>(
|
|||
match format {
|
||||
Format::Text => text::export(vault, out).await?,
|
||||
Format::Json => json::export(vault, out).await?,
|
||||
Format::JsonLines => json::export_lines(vault, out).await?,
|
||||
Format::JsonStream => json::export_stream(vault, out).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -93,12 +85,7 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
let rooms = if args.all {
|
||||
let mut rooms = vault
|
||||
.rooms()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|id| id.name)
|
||||
.collect::<Vec<_>>();
|
||||
let mut rooms = vault.rooms().await?;
|
||||
rooms.sort_unstable();
|
||||
rooms
|
||||
} else {
|
||||
|
|
@ -114,14 +101,14 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
|
|||
for room in rooms {
|
||||
if args.out == "-" {
|
||||
eprintln!("Exporting &{room} as {} to stdout", args.format.name());
|
||||
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
|
||||
let vault = vault.room(room);
|
||||
let mut stdout = BufWriter::new(io::stdout());
|
||||
export_room(&vault, &mut stdout, args.format).await?;
|
||||
stdout.flush()?;
|
||||
} else {
|
||||
let out = format_out(&args.out, &room, args.format);
|
||||
eprintln!("Exporting &{room} as {} to {out}", args.format.name());
|
||||
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
|
||||
let vault = vault.room(room);
|
||||
let mut file = BufWriter::new(File::create(out)?);
|
||||
export_room(&vault, &mut file, args.format).await?;
|
||||
file.flush()?;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ pub async fn export<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Re
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn export_lines<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
pub async fn export_stream<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
let mut total = 0;
|
||||
let mut last_msg_id = None;
|
||||
loop {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
use std::io::Write;
|
||||
|
||||
use euphoxide::api::MessageId;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{euph::SmallMessage, store::Tree, vault::EuphRoomVault};
|
||||
use crate::euph::SmallMessage;
|
||||
use crate::store::Tree;
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
||||
const TIME_FORMAT: &[FormatItem<'_>] =
|
||||
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
|
||||
const TIME_EMPTY: &str = " ";
|
||||
|
||||
pub async fn export<W: Write>(vault: &EuphRoomVault, out: &mut W) -> anyhow::Result<()> {
|
||||
|
|
@ -62,7 +67,11 @@ fn write_msg<W: Write>(
|
|||
|
||||
for (i, line) in msg.content.lines().enumerate() {
|
||||
if i == 0 {
|
||||
let time = msg.time.as_timestamp().strftime(TIME_FORMAT);
|
||||
let time = msg
|
||||
.time
|
||||
.0
|
||||
.format(TIME_FORMAT)
|
||||
.expect("time can be formatted");
|
||||
writeln!(file, "{time} {indent_string}[{nick}] {line}")?;
|
||||
} else {
|
||||
writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
use std::{convert::Infallible, sync::Arc, vec};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::vec;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crossterm::style::Stylize;
|
||||
use jiff::Timestamp;
|
||||
use log::{Level, LevelFilter, Log};
|
||||
use parking_lot::Mutex;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore, Path, Tree},
|
||||
ui::ChatMsg,
|
||||
};
|
||||
use crate::store::{Msg, MsgStore, Path, Tree};
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogMsg {
|
||||
id: usize,
|
||||
time: Timestamp,
|
||||
time: OffsetDateTime,
|
||||
level: Level,
|
||||
content: String,
|
||||
}
|
||||
|
|
@ -42,8 +42,8 @@ impl Msg for LogMsg {
|
|||
}
|
||||
|
||||
impl ChatMsg for LogMsg {
|
||||
fn time(&self) -> Option<Timestamp> {
|
||||
Some(self.time)
|
||||
fn time(&self) -> OffsetDateTime {
|
||||
self.time
|
||||
}
|
||||
|
||||
fn styled(&self) -> (Styled, Styled) {
|
||||
|
|
@ -209,7 +209,7 @@ impl Log for Logger {
|
|||
let mut guard = self.messages.lock();
|
||||
let msg = LogMsg {
|
||||
id: guard.len(),
|
||||
time: Timestamp::now(),
|
||||
time: OffsetDateTime::now_utc(),
|
||||
level: record.level(),
|
||||
content: format!("<{}> {}", record.target(), record.args()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// TODO Get rid of this macro as much as possible
|
||||
macro_rules! logging_unwrap {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
|
|
|
|||
110
cove/src/main.rs
110
cove/src/main.rs
|
|
@ -1,23 +1,19 @@
|
|||
#![forbid(unsafe_code)]
|
||||
// Rustc lint groups
|
||||
#![warn(future_incompatible)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
#![warn(unused)]
|
||||
// Rustc lints
|
||||
#![warn(noop_method_call)]
|
||||
#![warn(single_use_lifetimes)]
|
||||
// Clippy lints
|
||||
#![warn(clippy::use_self)]
|
||||
|
||||
// TODO Enable warn(unreachable_pub)?
|
||||
// TODO Remove unnecessary Debug impls and compare compile times
|
||||
// TODO Time zones other than UTC
|
||||
// TODO Invoke external notification command?
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use cove_config::{Config, doc::Document};
|
||||
use directories::{BaseDirs, ProjectDirs};
|
||||
use log::info;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::Terminal;
|
||||
|
||||
use crate::{
|
||||
logger::Logger,
|
||||
ui::Ui,
|
||||
vault::Vault,
|
||||
version::{NAME, VERSION},
|
||||
};
|
||||
|
||||
mod euph;
|
||||
mod export;
|
||||
mod logger;
|
||||
|
|
@ -26,7 +22,21 @@ mod store;
|
|||
mod ui;
|
||||
mod util;
|
||||
mod vault;
|
||||
mod version;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use cookie::CookieJar;
|
||||
use cove_config::doc::Document;
|
||||
use cove_config::Config;
|
||||
use directories::{BaseDirs, ProjectDirs};
|
||||
use log::info;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::Terminal;
|
||||
|
||||
use crate::logger::Logger;
|
||||
use crate::ui::Ui;
|
||||
use crate::vault::Vault;
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
enum Command {
|
||||
|
|
@ -37,21 +47,11 @@ enum Command {
|
|||
/// Compact and clean up vault.
|
||||
Gc,
|
||||
/// Clear euphoria session cookies.
|
||||
ClearCookies {
|
||||
/// Clear cookies for a specific domain only.
|
||||
#[arg(long, short)]
|
||||
domain: Option<String>,
|
||||
},
|
||||
ClearCookies,
|
||||
/// Print config documentation as markdown.
|
||||
HelpConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
enum WidthEstimationMethod {
|
||||
Legacy,
|
||||
Unicode,
|
||||
}
|
||||
|
||||
impl Default for Command {
|
||||
fn default() -> Self {
|
||||
Self::Run
|
||||
|
|
@ -85,11 +85,6 @@ struct Args {
|
|||
#[arg(long, short)]
|
||||
offline: bool,
|
||||
|
||||
/// Method for estimating the width of characters as displayed by the
|
||||
/// terminal emulator.
|
||||
#[arg(long, short)]
|
||||
width_estimation_method: Option<WidthEstimationMethod>,
|
||||
|
||||
/// Measure the width of characters as displayed by the terminal emulator
|
||||
/// instead of guessing the width.
|
||||
#[arg(long, short)]
|
||||
|
|
@ -125,26 +120,18 @@ fn update_config_with_args(config: &mut Config, args: &Args) {
|
|||
}
|
||||
|
||||
config.ephemeral |= args.ephemeral;
|
||||
if let Some(method) = args.width_estimation_method {
|
||||
config.width_estimation_method = match method {
|
||||
WidthEstimationMethod::Legacy => cove_config::WidthEstimationMethod::Legacy,
|
||||
WidthEstimationMethod::Unicode => cove_config::WidthEstimationMethod::Unicode,
|
||||
}
|
||||
}
|
||||
config.measure_widths |= args.measure_widths;
|
||||
config.offline |= args.offline;
|
||||
}
|
||||
|
||||
fn open_vault(config: &Config, dirs: &ProjectDirs) -> anyhow::Result<Vault> {
|
||||
let vault = if config.ephemeral {
|
||||
vault::launch_in_memory()?
|
||||
fn open_vault(config: &Config, dirs: &ProjectDirs) -> rusqlite::Result<Vault> {
|
||||
if config.ephemeral {
|
||||
vault::launch_in_memory()
|
||||
} else {
|
||||
let data_dir = data_dir(config, dirs);
|
||||
eprintln!("Data dir: {}", data_dir.to_string_lossy());
|
||||
vault::launch(&data_dir.join("vault.db"))?
|
||||
};
|
||||
|
||||
Ok(vault)
|
||||
vault::launch(&data_dir.join("vault.db"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -154,11 +141,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
let (logger, logger_guard, logger_rx) = Logger::init(args.verbose);
|
||||
let dirs = ProjectDirs::from("de", "plugh", "cove").expect("failed to find config directory");
|
||||
|
||||
// https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.unwrap();
|
||||
|
||||
// Locate config
|
||||
let config_path = config_path(&args, &dirs);
|
||||
eprintln!("Config file: {}", config_path.to_string_lossy());
|
||||
|
|
@ -172,7 +154,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
Command::Run => run(logger, logger_rx, config, &dirs).await?,
|
||||
Command::Export(args) => export(config, &dirs, args).await?,
|
||||
Command::Gc => gc(config, &dirs).await?,
|
||||
Command::ClearCookies { domain } => clear_cookies(config, &dirs, domain).await?,
|
||||
Command::ClearCookies => clear_cookies(config, &dirs).await?,
|
||||
Command::HelpConfig => help_config(),
|
||||
}
|
||||
|
||||
|
|
@ -191,19 +173,17 @@ async fn run(
|
|||
config: &'static Config,
|
||||
dirs: &ProjectDirs,
|
||||
) -> anyhow::Result<()> {
|
||||
info!("Welcome to {NAME} {VERSION}",);
|
||||
|
||||
let tz = util::load_time_zone(config.time_zone_ref()).context("failed to load time zone")?;
|
||||
info!(
|
||||
"Welcome to {} {}",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let vault = open_vault(config, dirs)?;
|
||||
|
||||
let mut terminal = Terminal::new()?;
|
||||
terminal.set_measuring(config.measure_widths);
|
||||
terminal.set_width_estimation_method(match config.width_estimation_method {
|
||||
cove_config::WidthEstimationMethod::Legacy => toss::WidthEstimationMethod::Legacy,
|
||||
cove_config::WidthEstimationMethod::Unicode => toss::WidthEstimationMethod::Unicode,
|
||||
});
|
||||
Ui::run(config, tz, &mut terminal, vault.clone(), logger, logger_rx).await?;
|
||||
Ui::run(config, &mut terminal, vault.clone(), logger, logger_rx).await?;
|
||||
drop(terminal);
|
||||
|
||||
vault.close().await;
|
||||
|
|
@ -234,15 +214,11 @@ async fn gc(config: &'static Config, dirs: &ProjectDirs) -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_cookies(
|
||||
config: &'static Config,
|
||||
dirs: &ProjectDirs,
|
||||
domain: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn clear_cookies(config: &'static Config, dirs: &ProjectDirs) -> anyhow::Result<()> {
|
||||
let vault = open_vault(config, dirs)?;
|
||||
|
||||
eprintln!("Clearing cookies");
|
||||
vault.euph().clear_cookies(domain).await?;
|
||||
vault.euph().set_cookies(CookieJar::new()).await?;
|
||||
|
||||
vault.close().await;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
use std::{collections::HashMap, fmt::Debug, hash::Hash, vec};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
use std::vec;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
|
|
@ -8,10 +11,6 @@ pub trait Msg {
|
|||
fn parent(&self) -> Option<Self::Id>;
|
||||
fn seen(&self) -> bool;
|
||||
|
||||
fn nick_emoji(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn last_possible_id() -> Self::Id;
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +27,10 @@ impl<I> Path<I> {
|
|||
self.0.iter().take(self.0.len() - 1)
|
||||
}
|
||||
|
||||
pub fn push(&mut self, segment: I) {
|
||||
self.0.push(segment)
|
||||
}
|
||||
|
||||
pub fn first(&self) -> &I {
|
||||
self.0.first().expect("path is empty")
|
||||
}
|
||||
|
|
@ -131,7 +134,6 @@ impl<M: Msg> Tree<M> {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[async_trait]
|
||||
pub trait MsgStore<M: Msg> {
|
||||
type Error;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,3 @@
|
|||
use std::{
|
||||
convert::Infallible,
|
||||
io,
|
||||
sync::{Arc, Weak},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use cove_config::Config;
|
||||
use cove_input::InputEvent;
|
||||
use jiff::tz::TimeZone;
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::{
|
||||
sync::mpsc::{self, UnboundedReceiver, UnboundedSender, error::TryRecvError},
|
||||
task,
|
||||
};
|
||||
use toss::{Terminal, WidgetExt, widgets::BoxedAsync};
|
||||
|
||||
use crate::{
|
||||
logger::{LogMsg, Logger},
|
||||
macros::logging_unwrap,
|
||||
util::InfallibleExt,
|
||||
vault::Vault,
|
||||
};
|
||||
|
||||
pub use self::chat::ChatMsg;
|
||||
use self::{chat::ChatState, rooms::Rooms, widgets::ListState};
|
||||
|
||||
mod chat;
|
||||
mod euph;
|
||||
mod key_bindings;
|
||||
|
|
@ -32,6 +5,30 @@ mod rooms;
|
|||
mod util;
|
||||
mod widgets;
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cove_config::Config;
|
||||
use cove_input::InputEvent;
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
use tokio::task;
|
||||
use toss::widgets::BoxedAsync;
|
||||
use toss::{Terminal, WidgetExt};
|
||||
|
||||
use crate::logger::{LogMsg, Logger};
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::util::InfallibleExt;
|
||||
use crate::vault::Vault;
|
||||
|
||||
pub use self::chat::ChatMsg;
|
||||
use self::chat::ChatState;
|
||||
use self::rooms::Rooms;
|
||||
use self::widgets::ListState;
|
||||
|
||||
/// Time to spend batch processing events before redrawing the screen.
|
||||
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
|
||||
|
||||
|
|
@ -50,7 +47,6 @@ impl From<Infallible> for UiError {
|
|||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::large_enum_variant)]
|
||||
pub enum UiEvent {
|
||||
GraphemeWidthsChanged,
|
||||
LogChanged,
|
||||
|
|
@ -88,7 +84,6 @@ impl Ui {
|
|||
|
||||
pub async fn run(
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
terminal: &mut Terminal,
|
||||
vault: Vault,
|
||||
logger: Logger,
|
||||
|
|
@ -117,8 +112,8 @@ impl Ui {
|
|||
config,
|
||||
event_tx: event_tx.clone(),
|
||||
mode: Mode::Main,
|
||||
rooms: Rooms::new(config, tz.clone(), vault, event_tx.clone()).await,
|
||||
log_chat: ChatState::new(logger, tz),
|
||||
rooms: Rooms::new(config, vault, event_tx.clone()).await,
|
||||
log_chat: ChatState::new(logger),
|
||||
key_bindings_visible: false,
|
||||
key_bindings_list: ListState::new(),
|
||||
};
|
||||
|
|
@ -186,8 +181,9 @@ impl Ui {
|
|||
}
|
||||
|
||||
// Handle events (in batches)
|
||||
let Some(mut event) = event_rx.recv().await else {
|
||||
return Ok(());
|
||||
let mut event = match event_rx.recv().await {
|
||||
Some(event) => event,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
|
||||
loop {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,24 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
use toss::{
|
||||
Styled, WidgetExt,
|
||||
widgets::{BoxedAsync, EditorState},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
util,
|
||||
};
|
||||
|
||||
use super::UiError;
|
||||
|
||||
use self::{cursor::Cursor, tree::TreeViewState};
|
||||
|
||||
mod blocks;
|
||||
mod cursor;
|
||||
mod renderer;
|
||||
mod tree;
|
||||
mod widgets;
|
||||
|
||||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use time::OffsetDateTime;
|
||||
use toss::widgets::{BoxedAsync, EditorState};
|
||||
use toss::{Styled, WidgetExt};
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
|
||||
use self::cursor::Cursor;
|
||||
use self::tree::TreeViewState;
|
||||
|
||||
use super::UiError;
|
||||
|
||||
pub trait ChatMsg {
|
||||
fn time(&self) -> Option<Timestamp>;
|
||||
fn time(&self) -> OffsetDateTime;
|
||||
fn styled(&self) -> (Styled, Styled);
|
||||
fn edit(nick: &str, content: &str) -> (Styled, Styled);
|
||||
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
|
||||
|
|
@ -37,31 +33,23 @@ pub struct ChatState<M: Msg, S: MsgStore<M>> {
|
|||
|
||||
cursor: Cursor<M::Id>,
|
||||
editor: EditorState,
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
|
||||
mode: Mode,
|
||||
tree: TreeViewState<M, S>,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
|
||||
pub fn new(store: S, tz: TimeZone) -> Self {
|
||||
pub fn new(store: S) -> Self {
|
||||
Self {
|
||||
cursor: Cursor::Bottom,
|
||||
editor: EditorState::new(),
|
||||
nick_emoji: false,
|
||||
caesar: 0,
|
||||
|
||||
mode: Mode::Tree,
|
||||
tree: TreeViewState::new(store.clone(), tz),
|
||||
tree: TreeViewState::new(store.clone()),
|
||||
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nick_emoji(&self) -> bool {
|
||||
self.nick_emoji
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
||||
|
|
@ -80,14 +68,7 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
|||
match self.mode {
|
||||
Mode::Tree => self
|
||||
.tree
|
||||
.widget(
|
||||
&mut self.cursor,
|
||||
&mut self.editor,
|
||||
nick,
|
||||
focused,
|
||||
self.nick_emoji,
|
||||
self.caesar,
|
||||
)
|
||||
.widget(&mut self.cursor, &mut self.editor, nick, focused)
|
||||
.boxed_async(),
|
||||
}
|
||||
}
|
||||
|
|
@ -104,7 +85,7 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
|||
S: Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
let reaction = match self.mode {
|
||||
match self.mode {
|
||||
Mode::Tree => {
|
||||
self.tree
|
||||
.handle_input_event(
|
||||
|
|
@ -114,33 +95,9 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
|||
&mut self.editor,
|
||||
can_compose,
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
Ok(match reaction {
|
||||
Reaction::Composed { parent, content } if self.caesar != 0 => {
|
||||
let content = util::caesar(&content, self.caesar);
|
||||
Reaction::Composed { parent, content }
|
||||
}
|
||||
|
||||
Reaction::NotHandled if event.matches(&keys.tree.action.toggle_nick_emoji) => {
|
||||
self.nick_emoji = !self.nick_emoji;
|
||||
Reaction::Handled
|
||||
}
|
||||
|
||||
Reaction::NotHandled if event.matches(&keys.tree.action.increase_caesar) => {
|
||||
self.caesar = (self.caesar + 1).rem_euclid(26);
|
||||
Reaction::Handled
|
||||
}
|
||||
|
||||
Reaction::NotHandled if event.matches(&keys.tree.action.decrease_caesar) => {
|
||||
self.caesar = (self.caesar - 1).rem_euclid(26);
|
||||
Reaction::Handled
|
||||
}
|
||||
|
||||
reaction => reaction,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> Option<&M::Id> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Common rendering logic.
|
||||
|
||||
use std::collections::{VecDeque, vec_deque};
|
||||
use std::collections::{vec_deque, VecDeque};
|
||||
|
||||
use toss::widgets::Predrawn;
|
||||
|
||||
|
|
@ -161,6 +161,14 @@ impl<Id> Blocks<Id> {
|
|||
pub fn shift(&mut self, delta: i32) {
|
||||
self.range = self.range.shifted(delta);
|
||||
}
|
||||
|
||||
pub fn set_top(&mut self, top: i32) {
|
||||
self.shift(top - self.range.top);
|
||||
}
|
||||
|
||||
pub fn set_bottom(&mut self, bottom: i32) {
|
||||
self.shift(bottom - self.range.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Iter<'a, Id> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Common cursor movement logic.
|
||||
|
||||
use std::{collections::HashSet, hash::Hash};
|
||||
use std::collections::HashSet;
|
||||
use std::hash::Hash;
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub trait Renderer<Id> {
|
|||
|
||||
fn blocks(&self) -> &Blocks<Id>;
|
||||
fn blocks_mut(&mut self) -> &mut Blocks<Id>;
|
||||
fn into_blocks(self) -> Blocks<Id>;
|
||||
|
||||
async fn expand_top(&mut self) -> Result<(), Self::Error>;
|
||||
async fn expand_bottom(&mut self) -> Result<(), Self::Error>;
|
||||
|
|
@ -274,6 +275,27 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub fn clamp_scroll_biased_upwards<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = visible_area(r);
|
||||
let blocks = r.blocks().range();
|
||||
|
||||
// Delta that moves blocks.top to the top of the screen. If this is
|
||||
// negative, we need to move the blocks because they're too low.
|
||||
let move_to_top = blocks.top - area.top;
|
||||
|
||||
// Delta that moves blocks.bottom to the bottom of the screen. If this is
|
||||
// positive, we need to move the blocks because they're too high.
|
||||
let move_to_bottom = blocks.bottom - area.bottom;
|
||||
|
||||
// If the screen is higher, the blocks should rather be moved to the top
|
||||
// than the bottom because of the upwards bias.
|
||||
let delta = 0.max(move_to_bottom).min(move_to_top);
|
||||
r.blocks_mut().shift(delta);
|
||||
}
|
||||
|
||||
pub fn clamp_scroll_biased_downwards<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
|
|
|
|||
|
|
@ -2,31 +2,29 @@
|
|||
|
||||
// TODO Focusing on sub-trees
|
||||
|
||||
mod renderer;
|
||||
mod scroll;
|
||||
mod widgets;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb, widgets::EditorState};
|
||||
use toss::widgets::EditorState;
|
||||
use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
ui::{UiError, util},
|
||||
util::InfallibleExt,
|
||||
};
|
||||
|
||||
use super::{ChatMsg, Reaction, cursor::Cursor};
|
||||
use crate::store::{Msg, MsgStore};
|
||||
use crate::ui::{util, ChatMsg, UiError};
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
use self::renderer::{TreeContext, TreeRenderer};
|
||||
|
||||
mod renderer;
|
||||
mod scroll;
|
||||
mod widgets;
|
||||
use super::cursor::Cursor;
|
||||
use super::Reaction;
|
||||
|
||||
pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
tz: TimeZone,
|
||||
|
||||
last_size: Size,
|
||||
last_nick: String,
|
||||
|
|
@ -38,10 +36,9 @@ pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
|
|||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
||||
pub fn new(store: S, tz: TimeZone) -> Self {
|
||||
pub fn new(store: S) -> Self {
|
||||
Self {
|
||||
store,
|
||||
tz,
|
||||
last_size: Size::ZERO,
|
||||
last_nick: String::new(),
|
||||
last_cursor: Cursor::Bottom,
|
||||
|
|
@ -389,8 +386,6 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
|||
editor: &'a mut EditorState,
|
||||
nick: String,
|
||||
focused: bool,
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
) -> TreeView<'a, M, S> {
|
||||
TreeView {
|
||||
state: self,
|
||||
|
|
@ -398,8 +393,6 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
|||
editor,
|
||||
nick,
|
||||
focused,
|
||||
nick_emoji,
|
||||
caesar,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -412,9 +405,6 @@ pub struct TreeView<'a, M: Msg, S: MsgStore<M>> {
|
|||
|
||||
nick: String,
|
||||
focused: bool,
|
||||
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -442,8 +432,6 @@ where
|
|||
size,
|
||||
nick: self.nick.clone(),
|
||||
focused: self.focused,
|
||||
nick_emoji: self.nick_emoji,
|
||||
caesar: self.caesar,
|
||||
last_cursor: self.state.last_cursor.clone(),
|
||||
last_cursor_top: self.state.last_cursor_top,
|
||||
};
|
||||
|
|
@ -451,7 +439,6 @@ where
|
|||
let mut renderer = TreeRenderer::new(
|
||||
context,
|
||||
&self.state.store,
|
||||
&self.state.tz,
|
||||
&mut self.state.folded,
|
||||
self.cursor,
|
||||
self.editor,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
//! A [`Renderer`] for message trees.
|
||||
|
||||
use std::{collections::HashSet, convert::Infallible};
|
||||
use std::collections::HashSet;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{
|
||||
Size, Widget, WidthDb,
|
||||
widgets::{EditorState, Empty, Predrawn, Resize},
|
||||
};
|
||||
use toss::widgets::{EditorState, Empty, Predrawn, Resize};
|
||||
use toss::{Size, Widget, WidthDb};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore, Tree},
|
||||
ui::{
|
||||
ChatMsg,
|
||||
chat::{
|
||||
blocks::{Block, Blocks, Range},
|
||||
cursor::Cursor,
|
||||
renderer::{self, Renderer, overlaps},
|
||||
},
|
||||
},
|
||||
util::InfallibleExt,
|
||||
};
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
use crate::ui::chat::blocks::{Block, Blocks, Range};
|
||||
use crate::ui::chat::cursor::Cursor;
|
||||
use crate::ui::chat::renderer::{self, overlaps, Renderer};
|
||||
use crate::ui::ChatMsg;
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
use super::widgets;
|
||||
|
||||
|
|
@ -80,8 +72,6 @@ pub struct TreeContext<Id> {
|
|||
pub size: Size,
|
||||
pub nick: String,
|
||||
pub focused: bool,
|
||||
pub nick_emoji: bool,
|
||||
pub caesar: i8,
|
||||
pub last_cursor: Cursor<Id>,
|
||||
pub last_cursor_top: i32,
|
||||
}
|
||||
|
|
@ -90,7 +80,6 @@ pub struct TreeRenderer<'a, M: Msg, S: MsgStore<M>> {
|
|||
context: TreeContext<M::Id>,
|
||||
|
||||
store: &'a S,
|
||||
tz: &'a TimeZone,
|
||||
folded: &'a mut HashSet<M::Id>,
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
editor: &'a mut EditorState,
|
||||
|
|
@ -118,7 +107,6 @@ where
|
|||
pub fn new(
|
||||
context: TreeContext<M::Id>,
|
||||
store: &'a S,
|
||||
tz: &'a TimeZone,
|
||||
folded: &'a mut HashSet<M::Id>,
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
editor: &'a mut EditorState,
|
||||
|
|
@ -127,7 +115,6 @@ where
|
|||
Self {
|
||||
context,
|
||||
store,
|
||||
tz,
|
||||
folded,
|
||||
cursor,
|
||||
editor,
|
||||
|
|
@ -161,12 +148,8 @@ where
|
|||
None => TreeBlockId::Bottom,
|
||||
};
|
||||
|
||||
let widget = widgets::editor::<M>(
|
||||
indent,
|
||||
&self.context.nick,
|
||||
self.context.focused,
|
||||
self.editor,
|
||||
);
|
||||
// TODO Unhighlighted version when focusing on nick list
|
||||
let widget = widgets::editor::<M>(indent, &self.context.nick, self.editor);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
let mut block = Block::new(id, widget, false);
|
||||
|
||||
|
|
@ -184,6 +167,7 @@ where
|
|||
None => TreeBlockId::Bottom,
|
||||
};
|
||||
|
||||
// TODO Unhighlighted version when focusing on nick list
|
||||
let widget = widgets::pseudo::<M>(indent, &self.context.nick, self.editor);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
Block::new(id, widget, false)
|
||||
|
|
@ -203,15 +187,7 @@ where
|
|||
};
|
||||
let highlighted = highlighted && self.context.focused;
|
||||
|
||||
let widget = widgets::msg(
|
||||
highlighted,
|
||||
self.tz.clone(),
|
||||
indent,
|
||||
msg,
|
||||
self.context.nick_emoji,
|
||||
self.context.caesar,
|
||||
folded_info,
|
||||
);
|
||||
let widget = widgets::msg(highlighted, indent, msg, folded_info);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
Block::new(TreeBlockId::Msg(msg_id), widget, true)
|
||||
}
|
||||
|
|
@ -446,7 +422,7 @@ where
|
|||
|
||||
pub fn into_visible_blocks(
|
||||
self,
|
||||
) -> impl Iterator<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> + use<M, S> {
|
||||
) -> impl Iterator<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> {
|
||||
let area = renderer::visible_area(&self);
|
||||
self.blocks
|
||||
.into_iter()
|
||||
|
|
@ -480,6 +456,10 @@ where
|
|||
&mut self.blocks
|
||||
}
|
||||
|
||||
fn into_blocks(self) -> TreeBlocks<M::Id> {
|
||||
self.blocks
|
||||
}
|
||||
|
||||
async fn expand_top(&mut self) -> Result<(), Self::Error> {
|
||||
let prev_root_id = if let Some(top_root_id) = &self.top_root_id {
|
||||
self.store.prev_root_id(top_root_id).await?
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
use toss::{WidthDb, widgets::EditorState};
|
||||
use toss::widgets::EditorState;
|
||||
use toss::WidthDb;
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
ui::{ChatMsg, chat::cursor::Cursor},
|
||||
};
|
||||
use crate::store::{Msg, MsgStore};
|
||||
use crate::ui::chat::cursor::Cursor;
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
use super::{
|
||||
TreeViewState,
|
||||
renderer::{TreeContext, TreeRenderer},
|
||||
};
|
||||
use super::renderer::{TreeContext, TreeRenderer};
|
||||
use super::TreeViewState;
|
||||
|
||||
impl<M, S> TreeViewState<M, S>
|
||||
where
|
||||
|
|
@ -22,8 +20,6 @@ where
|
|||
size: self.last_size,
|
||||
nick: self.last_nick.clone(),
|
||||
focused: true,
|
||||
nick_emoji: false,
|
||||
caesar: 0,
|
||||
last_cursor: self.last_cursor.clone(),
|
||||
last_cursor_top: self.last_cursor_top,
|
||||
}
|
||||
|
|
@ -40,7 +36,6 @@ where
|
|||
let mut renderer = TreeRenderer::new(
|
||||
context,
|
||||
&self.store,
|
||||
&self.tz,
|
||||
&mut self.folded,
|
||||
cursor,
|
||||
editor,
|
||||
|
|
@ -68,7 +63,6 @@ where
|
|||
let mut renderer = TreeRenderer::new(
|
||||
context,
|
||||
&self.store,
|
||||
&self.tz,
|
||||
&mut self.folded,
|
||||
cursor,
|
||||
editor,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{
|
||||
Style, Styled, WidgetExt,
|
||||
widgets::{Boxed, EditorState, Join2, Join4, Join5, Text},
|
||||
};
|
||||
use toss::widgets::{Boxed, EditorState, Join2, Join4, Join5, Text};
|
||||
use toss::{Style, Styled, WidgetExt};
|
||||
|
||||
use crate::{
|
||||
store::Msg,
|
||||
ui::{
|
||||
ChatMsg,
|
||||
chat::widgets::{Indent, Seen, Time},
|
||||
},
|
||||
util,
|
||||
};
|
||||
use crate::store::Msg;
|
||||
use crate::ui::chat::widgets::{Indent, Seen, Time};
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
pub const PLACEHOLDER: &str = "[...]";
|
||||
|
||||
|
|
@ -38,10 +30,6 @@ fn style_indent(highlighted: bool) -> Style {
|
|||
}
|
||||
}
|
||||
|
||||
fn style_caesar() -> Style {
|
||||
Style::new().green()
|
||||
}
|
||||
|
||||
fn style_info() -> Style {
|
||||
Style::new().italic().dark_grey()
|
||||
}
|
||||
|
|
@ -56,28 +44,11 @@ fn style_pseudo_highlight() -> Style {
|
|||
|
||||
pub fn msg<M: Msg + ChatMsg>(
|
||||
highlighted: bool,
|
||||
tz: TimeZone,
|
||||
indent: usize,
|
||||
msg: &M,
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
folded_info: Option<usize>,
|
||||
) -> Boxed<'static, Infallible> {
|
||||
let (mut nick, mut content) = msg.styled();
|
||||
|
||||
if nick_emoji {
|
||||
if let Some(emoji) = msg.nick_emoji() {
|
||||
nick = nick.then_plain("(").then_plain(emoji).then_plain(")");
|
||||
}
|
||||
}
|
||||
|
||||
if caesar != 0 {
|
||||
// Apply caesar in inverse because we're decoding
|
||||
let rotated = util::caesar(content.text(), -caesar);
|
||||
content = content
|
||||
.then_plain("\n")
|
||||
.then(format!("{rotated} [rot{caesar}]"), style_caesar());
|
||||
}
|
||||
let (nick, mut content) = msg.styled();
|
||||
|
||||
if let Some(amount) = folded_info {
|
||||
content = content
|
||||
|
|
@ -87,7 +58,7 @@ pub fn msg<M: Msg + ChatMsg>(
|
|||
|
||||
Join5::horizontal(
|
||||
Seen::new(msg.seen()).segment().with_fixed(true),
|
||||
Time::new(msg.time().map(|t| t.to_zoned(tz)), style_time(highlighted))
|
||||
Time::new(Some(msg.time()), style_time(highlighted))
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.with_stretch(true)
|
||||
|
|
@ -145,14 +116,10 @@ pub fn msg_placeholder(
|
|||
pub fn editor<'a, M: ChatMsg>(
|
||||
indent: usize,
|
||||
nick: &str,
|
||||
focus: bool,
|
||||
editor: &'a mut EditorState,
|
||||
) -> Boxed<'a, Infallible> {
|
||||
let (nick, content) = M::edit(nick, editor.text());
|
||||
let editor = editor
|
||||
.widget()
|
||||
.with_highlight(|_| content)
|
||||
.with_focus(focus);
|
||||
let editor = editor.widget().with_highlight(|_| content);
|
||||
|
||||
Join5::horizontal(
|
||||
Seen::new(true).segment().with_fixed(true),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use jiff::Zoned;
|
||||
use toss::{
|
||||
Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb,
|
||||
widgets::{Boxed, Empty, Text},
|
||||
};
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use time::OffsetDateTime;
|
||||
use toss::widgets::{Boxed, Empty, Text};
|
||||
use toss::{Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb};
|
||||
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
|
|
@ -46,15 +46,15 @@ impl<E> Widget<E> for Indent {
|
|||
}
|
||||
}
|
||||
|
||||
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M";
|
||||
const TIME_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day] [hour]:[minute]");
|
||||
const TIME_WIDTH: u16 = 16;
|
||||
|
||||
pub struct Time(Boxed<'static, Infallible>);
|
||||
|
||||
impl Time {
|
||||
pub fn new(time: Option<Zoned>, style: Style) -> Self {
|
||||
pub fn new(time: Option<OffsetDateTime>, style: Style) -> Self {
|
||||
let widget = if let Some(time) = time {
|
||||
let text = time.strftime(TIME_FORMAT).to_string();
|
||||
let text = time.format(TIME_FORMAT).expect("could not format time");
|
||||
Text::new((text, style))
|
||||
.background()
|
||||
.with_style(style)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{api::PersonalAccountView, conn};
|
||||
use toss::{
|
||||
Style, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join3, Join4, Join5, Text},
|
||||
};
|
||||
use euphoxide::api::PersonalAccountView;
|
||||
use euphoxide::conn;
|
||||
use toss::widgets::{EditorState, Empty, Join3, Join4, Join5, Text};
|
||||
use toss::{Style, Widget, WidgetExt};
|
||||
|
||||
use crate::{
|
||||
euph::{self, Room},
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
|
|
@ -35,7 +33,7 @@ impl LoggedOut {
|
|||
}
|
||||
}
|
||||
|
||||
fn widget(&mut self) -> impl Widget<UiError> {
|
||||
fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
let bold = Style::new().bold();
|
||||
Join4::vertical(
|
||||
Text::new(("Not logged in", bold.yellow())).segment(),
|
||||
|
|
@ -68,7 +66,7 @@ impl LoggedOut {
|
|||
pub struct LoggedIn(PersonalAccountView);
|
||||
|
||||
impl LoggedIn {
|
||||
fn widget(&self) -> impl Widget<UiError> + use<> {
|
||||
fn widget(&self) -> impl Widget<UiError> {
|
||||
let bold = Style::new().bold();
|
||||
Join5::vertical(
|
||||
Text::new(("Logged in", bold.green())).segment(),
|
||||
|
|
@ -111,7 +109,7 @@ impl AccountUiState {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
let inner = match self {
|
||||
Self::LoggedOut(logged_out) => logged_out.widget().first2(),
|
||||
Self::LoggedIn(logged_in) => logged_in.widget().second2(),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use toss::{Widget, widgets::EditorState};
|
||||
use toss::widgets::EditorState;
|
||||
use toss::Widget;
|
||||
|
||||
use crate::{
|
||||
euph::Room,
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
use crate::euph::Room;
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ pub fn new() -> EditorState {
|
|||
EditorState::new()
|
||||
}
|
||||
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> {
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
|
||||
Popup::new(
|
||||
editor.widget().with_hidden_default_placeholder(),
|
||||
"Enter password",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{
|
||||
api::{Message, NickEvent, SessionView},
|
||||
conn::SessionInfo,
|
||||
};
|
||||
use toss::{Style, Styled, Widget, widgets::Text};
|
||||
use euphoxide::api::{Message, NickEvent, SessionView};
|
||||
use euphoxide::conn::SessionInfo;
|
||||
use toss::widgets::Text;
|
||||
use toss::{Style, Styled, Widget};
|
||||
|
||||
use crate::ui::{UiError, widgets::Popup};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::UiError;
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ fn message_lines(mut text: Styled, msg: &Message) -> Styled {
|
|||
text
|
||||
}
|
||||
|
||||
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> + use<> {
|
||||
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let text = match session {
|
||||
|
|
@ -108,7 +108,7 @@ pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> + use<> {
|
|||
Popup::new(Text::new(text), "Inspect session")
|
||||
}
|
||||
|
||||
pub fn message_widget(msg: &Message) -> impl Widget<UiError> + use<> {
|
||||
pub fn message_widget(msg: &Message) -> impl Widget<UiError> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let mut text = Styled::new("Message", heading_style).then_plain("\n");
|
||||
|
|
|
|||
|
|
@ -1,31 +1,19 @@
|
|||
use cove_config::{Config, Keys};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::{event::KeyCode, style::Stylize};
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::style::Stylize;
|
||||
use linkify::{LinkFinder, LinkKind};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Join2, Text},
|
||||
};
|
||||
use toss::widgets::{Join2, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
|
||||
use crate::{
|
||||
euph::{self, SpanType},
|
||||
ui::{
|
||||
UiError, key_bindings, util,
|
||||
widgets::{ListBuilder, ListState, Popup},
|
||||
},
|
||||
};
|
||||
use crate::ui::widgets::{ListBuilder, ListState, Popup};
|
||||
use crate::ui::{key_bindings, util, UiError};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Link {
|
||||
Url(String),
|
||||
Room(String),
|
||||
}
|
||||
|
||||
pub struct LinksState {
|
||||
config: &'static Config,
|
||||
links: Vec<Link>,
|
||||
links: Vec<String>,
|
||||
list: ListState<usize>,
|
||||
}
|
||||
|
||||
|
|
@ -33,34 +21,12 @@ const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0
|
|||
|
||||
impl LinksState {
|
||||
pub fn new(config: &'static Config, content: &str) -> Self {
|
||||
let mut links = vec![];
|
||||
|
||||
// Collect URL-like links
|
||||
for link in LinkFinder::new()
|
||||
let links = LinkFinder::new()
|
||||
.url_must_have_scheme(false)
|
||||
.kinds(&[LinkKind::Url])
|
||||
.links(content)
|
||||
{
|
||||
links.push((
|
||||
link.start(),
|
||||
link.end(),
|
||||
Link::Url(link.as_str().to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
// Collect room links
|
||||
for (span, range) in euph::find_spans(content) {
|
||||
if span == SpanType::Room {
|
||||
let name = &content[range.start + 1..range.end];
|
||||
links.push((range.start, range.end, Link::Room(name.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
links.sort();
|
||||
let links = links
|
||||
.into_iter()
|
||||
.map(|(_, _, link)| link)
|
||||
.collect::<Vec<_>>();
|
||||
.map(|l| l.as_str().to_string())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
config,
|
||||
|
|
@ -69,7 +35,7 @@ impl LinksState {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
let style_selected = Style::new().black().on_white();
|
||||
|
||||
let mut list_builder = ListBuilder::new();
|
||||
|
|
@ -80,29 +46,29 @@ impl LinksState {
|
|||
|
||||
for (id, link) in self.links.iter().enumerate() {
|
||||
let link = link.clone();
|
||||
list_builder.add_sel(id, move |selected| {
|
||||
let mut text = Styled::default();
|
||||
|
||||
// Number key indicator
|
||||
text = match NUMBER_KEYS.get(id) {
|
||||
None if selected => text.then(" ", style_selected),
|
||||
None => text.then_plain(" "),
|
||||
Some(key) if selected => text.then(format!("[{key}] "), style_selected.bold()),
|
||||
Some(key) => text.then(format!("[{key}] "), Style::new().dark_grey().bold()),
|
||||
};
|
||||
|
||||
// The link itself
|
||||
text = match link {
|
||||
Link::Url(url) if selected => text.then(url, style_selected),
|
||||
Link::Url(url) => text.then_plain(url),
|
||||
Link::Room(name) if selected => {
|
||||
text.then(format!("&{name}"), style_selected.bold())
|
||||
}
|
||||
Link::Room(name) => text.then(format!("&{name}"), Style::new().blue().bold()),
|
||||
};
|
||||
|
||||
Text::new(text).with_wrap(false)
|
||||
});
|
||||
if let Some(&number_key) = NUMBER_KEYS.get(id) {
|
||||
list_builder.add_sel(id, move |selected| {
|
||||
let text = if selected {
|
||||
Styled::new(format!("[{number_key}]"), style_selected.bold())
|
||||
.then(" ", style_selected)
|
||||
.then(link, style_selected)
|
||||
} else {
|
||||
Styled::new(format!("[{number_key}]"), Style::new().dark_grey().bold())
|
||||
.then_plain(" ")
|
||||
.then_plain(link)
|
||||
};
|
||||
Text::new(text)
|
||||
});
|
||||
} else {
|
||||
list_builder.add_sel(id, move |selected| {
|
||||
let text = if selected {
|
||||
Styled::new(format!(" {link}"), style_selected)
|
||||
} else {
|
||||
Styled::new_plain(format!(" {link}"))
|
||||
};
|
||||
Text::new(text)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let hint_style = Style::new().grey().italic();
|
||||
|
|
@ -126,24 +92,18 @@ impl LinksState {
|
|||
}
|
||||
|
||||
fn open_link_by_id(&self, id: usize) -> PopupResult {
|
||||
match self.links.get(id) {
|
||||
Some(Link::Url(url)) => {
|
||||
// The `http://` or `https://` schema is necessary for
|
||||
// open::that to successfully open the link in the browser.
|
||||
let link = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url.clone()
|
||||
} else {
|
||||
format!("https://{url}")
|
||||
};
|
||||
if let Some(link) = self.links.get(id) {
|
||||
// The `http://` or `https://` schema is necessary for open::that to
|
||||
// successfully open the link in the browser.
|
||||
let link = if link.starts_with("http://") || link.starts_with("https://") {
|
||||
link.clone()
|
||||
} else {
|
||||
format!("https://{link}")
|
||||
};
|
||||
|
||||
if let Err(error) = open::that(&link) {
|
||||
return PopupResult::ErrorOpeningLink { link, error };
|
||||
}
|
||||
if let Err(error) = open::that(&link) {
|
||||
return PopupResult::ErrorOpeningLink { link, error };
|
||||
}
|
||||
|
||||
Some(Link::Room(name)) => return PopupResult::SwitchToRoom { name: name.clone() },
|
||||
|
||||
_ => {}
|
||||
}
|
||||
PopupResult::Handled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use euphoxide::conn::Joined;
|
||||
use toss::{Style, Widget, widgets::EditorState};
|
||||
use toss::widgets::EditorState;
|
||||
use toss::{Style, Widget};
|
||||
|
||||
use crate::{
|
||||
euph::{self, Room},
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ pub fn new(joined: Joined) -> EditorState {
|
|||
EditorState::with_initial_text(joined.session.name)
|
||||
}
|
||||
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> {
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
|
||||
let inner = editor
|
||||
.widget()
|
||||
.with_highlight(|s| euph::style_nick_exact(s, Style::new()));
|
||||
|
|
|
|||
|
|
@ -1,31 +1,22 @@
|
|||
use std::iter;
|
||||
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use euphoxide::{
|
||||
api::{NickEvent, SessionId, SessionType, SessionView, UserId},
|
||||
conn::{Joined, SessionInfo},
|
||||
};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Background, Text},
|
||||
};
|
||||
use euphoxide::api::{NickEvent, SessionId, SessionType, SessionView, UserId};
|
||||
use euphoxide::conn::{Joined, SessionInfo};
|
||||
use toss::widgets::{Background, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
|
||||
use crate::{
|
||||
euph,
|
||||
ui::{
|
||||
UiError,
|
||||
widgets::{ListBuilder, ListState},
|
||||
},
|
||||
};
|
||||
use crate::euph;
|
||||
use crate::ui::widgets::{ListBuilder, ListState};
|
||||
use crate::ui::UiError;
|
||||
|
||||
pub fn widget<'a>(
|
||||
list: &'a mut ListState<SessionId>,
|
||||
joined: &Joined,
|
||||
focused: bool,
|
||||
nick_emoji: bool,
|
||||
) -> impl Widget<UiError> + use<'a> {
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
let mut list_builder = ListBuilder::new();
|
||||
render_rows(&mut list_builder, joined, focused, nick_emoji);
|
||||
render_rows(&mut list_builder, joined, focused);
|
||||
list_builder.build(list)
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +62,6 @@ fn render_rows(
|
|||
list_builder: &mut ListBuilder<'_, SessionId, Background<Text>>,
|
||||
joined: &Joined,
|
||||
focused: bool,
|
||||
nick_emoji: bool,
|
||||
) {
|
||||
let mut people = vec![];
|
||||
let mut bots = vec![];
|
||||
|
|
@ -97,38 +87,10 @@ fn render_rows(
|
|||
lurkers.sort_unstable();
|
||||
nurkers.sort_unstable();
|
||||
|
||||
render_section(
|
||||
list_builder,
|
||||
"People",
|
||||
&people,
|
||||
&joined.session,
|
||||
focused,
|
||||
nick_emoji,
|
||||
);
|
||||
render_section(
|
||||
list_builder,
|
||||
"Bots",
|
||||
&bots,
|
||||
&joined.session,
|
||||
focused,
|
||||
nick_emoji,
|
||||
);
|
||||
render_section(
|
||||
list_builder,
|
||||
"Lurkers",
|
||||
&lurkers,
|
||||
&joined.session,
|
||||
focused,
|
||||
nick_emoji,
|
||||
);
|
||||
render_section(
|
||||
list_builder,
|
||||
"Nurkers",
|
||||
&nurkers,
|
||||
&joined.session,
|
||||
focused,
|
||||
nick_emoji,
|
||||
);
|
||||
render_section(list_builder, "People", &people, &joined.session, focused);
|
||||
render_section(list_builder, "Bots", &bots, &joined.session, focused);
|
||||
render_section(list_builder, "Lurkers", &lurkers, &joined.session, focused);
|
||||
render_section(list_builder, "Nurkers", &nurkers, &joined.session, focused);
|
||||
}
|
||||
|
||||
fn render_section(
|
||||
|
|
@ -137,7 +99,6 @@ fn render_section(
|
|||
sessions: &[HalfSession],
|
||||
own_session: &SessionView,
|
||||
focused: bool,
|
||||
nick_emoji: bool,
|
||||
) {
|
||||
if sessions.is_empty() {
|
||||
return;
|
||||
|
|
@ -155,7 +116,7 @@ fn render_section(
|
|||
list_builder.add_unsel(Text::new(row).background());
|
||||
|
||||
for session in sessions {
|
||||
render_row(list_builder, session, own_session, focused, nick_emoji);
|
||||
render_row(list_builder, session, own_session, focused);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +125,6 @@ fn render_row(
|
|||
session: &HalfSession,
|
||||
own_session: &SessionView,
|
||||
focused: bool,
|
||||
nick_emoji: bool,
|
||||
) {
|
||||
let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() {
|
||||
let name = "lurk".to_string();
|
||||
|
|
@ -198,24 +158,16 @@ fn render_row(
|
|||
" "
|
||||
};
|
||||
|
||||
let emoji = if nick_emoji {
|
||||
format!(" ({})", euph::user_id_emoji(&session.id))
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
list_builder.add_sel(session.session_id.clone(), move |selected| {
|
||||
if focused && selected {
|
||||
let text = Styled::new_plain(owner)
|
||||
.then(name, style_inv)
|
||||
.then(perms, perms_style_inv)
|
||||
.then(emoji, perms_style_inv);
|
||||
.then(perms, perms_style_inv);
|
||||
Text::new(text).background().with_style(style_inv)
|
||||
} else {
|
||||
let text = Styled::new_plain(owner)
|
||||
.then(&name, style)
|
||||
.then_plain(perms)
|
||||
.then_plain(emoji);
|
||||
.then_plain(perms);
|
||||
Text::new(text).background()
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
use std::io;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{Style, Styled, Widget, widgets::Text};
|
||||
use toss::widgets::Text;
|
||||
use toss::{Style, Styled, Widget};
|
||||
|
||||
use crate::ui::{UiError, widgets::Popup};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::UiError;
|
||||
|
||||
pub enum RoomPopup {
|
||||
Error { description: String, reason: String },
|
||||
}
|
||||
|
||||
impl RoomPopup {
|
||||
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> + use<> {
|
||||
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> {
|
||||
let border_style = Style::new().red().bold();
|
||||
let text = Styled::new_plain(description)
|
||||
.then_plain("\n\n")
|
||||
|
|
@ -21,7 +23,7 @@ impl RoomPopup {
|
|||
Popup::new(Text::new(text), ("Error", border_style)).with_border_style(border_style)
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> impl Widget<UiError> + use<> {
|
||||
pub fn widget(&self) -> impl Widget<UiError> {
|
||||
match self {
|
||||
Self::Error {
|
||||
description,
|
||||
|
|
@ -35,6 +37,5 @@ pub enum PopupResult {
|
|||
NotHandled,
|
||||
Handled,
|
||||
Close,
|
||||
SwitchToRoom { name: String },
|
||||
ErrorOpeningLink { link: String, error: io::Error },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,40 +3,25 @@ use std::collections::VecDeque;
|
|||
use cove_config::{Config, Keys};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{
|
||||
api::{Data, Message, MessageId, PacketType, SessionId, packet::ParsedPacket},
|
||||
bot::instance::{ConnSnapshot, Event, ServerConfig},
|
||||
conn::{self, Joined, Joining, SessionInfo},
|
||||
};
|
||||
use jiff::tz::TimeZone;
|
||||
use tokio::sync::{
|
||||
mpsc,
|
||||
oneshot::{self, error::TryRecvError},
|
||||
};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{BoxedAsync, EditorState, Join2, Layer, Text},
|
||||
};
|
||||
use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId};
|
||||
use euphoxide::bot::instance::{Event, ServerConfig};
|
||||
use euphoxide::conn::{self, Joined, Joining, SessionInfo};
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use toss::widgets::{BoxedAsync, EditorState, Join2, Layer, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
|
||||
use crate::{
|
||||
euph::{self, SpanType},
|
||||
macros::logging_unwrap,
|
||||
ui::{
|
||||
UiError, UiEvent,
|
||||
chat::{ChatState, Reaction},
|
||||
util,
|
||||
widgets::ListState,
|
||||
},
|
||||
vault::{EuphRoomVault, RoomIdentifier},
|
||||
};
|
||||
use crate::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::ui::chat::{ChatState, Reaction};
|
||||
use crate::ui::widgets::ListState;
|
||||
use crate::ui::{util, UiError, UiEvent};
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
use super::{
|
||||
account::AccountUiState,
|
||||
auth, inspect,
|
||||
links::LinksState,
|
||||
nick, nick_list,
|
||||
popup::{PopupResult, RoomPopup},
|
||||
};
|
||||
use super::account::AccountUiState;
|
||||
use super::links::LinksState;
|
||||
use super::popup::{PopupResult, RoomPopup};
|
||||
use super::{auth, inspect, nick, nick_list};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
|
|
@ -73,8 +58,6 @@ pub struct EuphRoom {
|
|||
last_msg_sent: Option<oneshot::Receiver<MessageId>>,
|
||||
|
||||
nick_list: ListState<SessionId>,
|
||||
|
||||
mentioned: bool,
|
||||
}
|
||||
|
||||
impl EuphRoom {
|
||||
|
|
@ -83,7 +66,6 @@ impl EuphRoom {
|
|||
server_config: ServerConfig,
|
||||
room_config: cove_config::EuphRoom,
|
||||
vault: EuphRoomVault,
|
||||
tz: TimeZone,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
|
@ -95,10 +77,9 @@ impl EuphRoom {
|
|||
focus: Focus::Chat,
|
||||
state: State::Normal,
|
||||
popups: VecDeque::new(),
|
||||
chat: ChatState::new(vault, tz),
|
||||
chat: ChatState::new(vault),
|
||||
last_msg_sent: None,
|
||||
nick_list: ListState::new(),
|
||||
mentioned: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,12 +87,8 @@ impl EuphRoom {
|
|||
self.chat.store()
|
||||
}
|
||||
|
||||
fn domain(&self) -> &str {
|
||||
&self.vault().room().domain
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.vault().room().name
|
||||
self.vault().room()
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, next_instance_id: &mut usize) {
|
||||
|
|
@ -120,8 +97,8 @@ impl EuphRoom {
|
|||
let instance_config = self
|
||||
.server_config
|
||||
.clone()
|
||||
.room(self.vault().room().name.clone())
|
||||
.name(format!("{room:?}-{next_instance_id}"))
|
||||
.room(self.vault().room().to_string())
|
||||
.name(format!("{room}-{}", next_instance_id))
|
||||
.human(true)
|
||||
.username(self.room_config.username.clone())
|
||||
.force_username(self.room_config.force_username)
|
||||
|
|
@ -151,9 +128,7 @@ impl EuphRoom {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn room_state_joined(&self) -> Option<&Joined> {
|
||||
self.room_state().and_then(|s| s.joined())
|
||||
}
|
||||
// TODO fn room_state_joined(&self) -> Option<&Joined> {}
|
||||
|
||||
pub fn stopped(&self) -> bool {
|
||||
self.room.as_ref().map(|r| r.stopped()).unwrap_or(true)
|
||||
|
|
@ -167,12 +142,6 @@ impl EuphRoom {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn retrieve_mentioned(&mut self) -> bool {
|
||||
let mentioned = self.mentioned;
|
||||
self.mentioned = false;
|
||||
mentioned
|
||||
}
|
||||
|
||||
pub async fn unseen_msgs_count(&self) -> usize {
|
||||
logging_unwrap!(self.vault().unseen_msgs_count().await)
|
||||
}
|
||||
|
|
@ -194,8 +163,9 @@ impl EuphRoom {
|
|||
}
|
||||
|
||||
fn stabilize_focus(&mut self) {
|
||||
if self.room_state_joined().is_none() {
|
||||
self.focus = Focus::Chat; // There is no nick list to focus on
|
||||
match self.room_state() {
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_))) => {}
|
||||
_ => self.focus = Focus::Chat, // There is no nick list to focus on
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,15 +207,17 @@ impl EuphRoom {
|
|||
|
||||
let room_state = self.room.as_ref().map(|room| room.state());
|
||||
let status_widget = self.status_widget(room_state).await;
|
||||
let chat = match room_state.and_then(|s| s.joined()) {
|
||||
Some(joined) => Self::widget_with_nick_list(
|
||||
let chat = if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = room_state
|
||||
{
|
||||
Self::widget_with_nick_list(
|
||||
&mut self.chat,
|
||||
status_widget,
|
||||
&mut self.nick_list,
|
||||
joined,
|
||||
self.focus,
|
||||
),
|
||||
None => Self::widget_without_nick_list(&mut self.chat, status_widget),
|
||||
)
|
||||
} else {
|
||||
Self::widget_without_nick_list(&mut self.chat, status_widget)
|
||||
};
|
||||
|
||||
let mut layers = vec![chat];
|
||||
|
|
@ -291,16 +263,11 @@ impl EuphRoom {
|
|||
joined: &Joined,
|
||||
focus: Focus,
|
||||
) -> BoxedAsync<'a, UiError> {
|
||||
let nick_list_widget = nick_list::widget(
|
||||
nick_list,
|
||||
joined,
|
||||
focus == Focus::NickList,
|
||||
chat.nick_emoji(),
|
||||
)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.border()
|
||||
.desync();
|
||||
let nick_list_widget = nick_list::widget(nick_list, joined, focus == Focus::NickList)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.border()
|
||||
.desync();
|
||||
|
||||
let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat);
|
||||
|
||||
|
|
@ -315,10 +282,9 @@ impl EuphRoom {
|
|||
.boxed_async()
|
||||
}
|
||||
|
||||
async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget<UiError> + use<> {
|
||||
async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget<UiError> {
|
||||
let room_style = Style::new().bold().blue();
|
||||
let mut info = Styled::new(format!("{} ", self.domain()), Style::new().grey())
|
||||
.then(format!("&{}", self.name()), room_style);
|
||||
let mut info = Styled::new(format!("&{}", self.name()), room_style);
|
||||
|
||||
info = match state {
|
||||
None | Some(euph::State::Stopped) => info.then_plain(", archive"),
|
||||
|
|
@ -349,21 +315,14 @@ impl EuphRoom {
|
|||
.then_plain(")");
|
||||
}
|
||||
|
||||
let title = if unseen > 0 {
|
||||
format!("&{} ({unseen})", self.name())
|
||||
} else {
|
||||
format!("&{}", self.name())
|
||||
};
|
||||
|
||||
Text::new(info)
|
||||
.padding()
|
||||
.with_horizontal(1)
|
||||
.border()
|
||||
.title(title)
|
||||
Text::new(info).padding().with_horizontal(1).border()
|
||||
}
|
||||
|
||||
async fn handle_chat_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
|
||||
let can_compose = self.room_state_joined().is_some();
|
||||
let can_compose = matches!(
|
||||
self.room_state(),
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_)))
|
||||
);
|
||||
|
||||
let reaction = self.chat.handle_input_event(event, keys, can_compose).await;
|
||||
let reaction = logging_unwrap!(reaction);
|
||||
|
|
@ -422,6 +381,18 @@ impl EuphRoom {
|
|||
_ => {}
|
||||
}
|
||||
|
||||
// Always applicable
|
||||
if event.matches(&keys.room.action.present) {
|
||||
let link = format!("https://plugh.de/present/{}/", self.name());
|
||||
if let Err(error) = open::that(&link) {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: format!("Failed to open link: {link}"),
|
||||
reason: format!("{error}"),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
|
|
@ -471,7 +442,8 @@ impl EuphRoom {
|
|||
}
|
||||
|
||||
if event.matches(&keys.tree.action.inspect) {
|
||||
if let Some(joined) = self.room_state_joined() {
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state()
|
||||
{
|
||||
if let Some(id) = self.nick_list.selected() {
|
||||
if *id == joined.session.session_id {
|
||||
self.state =
|
||||
|
|
@ -494,9 +466,11 @@ impl EuphRoom {
|
|||
return true;
|
||||
}
|
||||
|
||||
if self.room_state_joined().is_some() && event.matches(&keys.general.focus) {
|
||||
self.focus = Focus::NickList;
|
||||
return true;
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() {
|
||||
if event.matches(&keys.general.focus) {
|
||||
self.focus = Focus::NickList;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Focus::NickList => {
|
||||
|
|
@ -514,22 +488,18 @@ impl EuphRoom {
|
|||
false
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
event: &mut InputEvent<'_>,
|
||||
keys: &Keys,
|
||||
) -> RoomResult {
|
||||
pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
|
||||
if !self.popups.is_empty() {
|
||||
if event.matches(&keys.general.abort) {
|
||||
self.popups.pop_back();
|
||||
return RoomResult::Handled;
|
||||
return true;
|
||||
}
|
||||
// Prevent event from reaching anything below the popup
|
||||
return RoomResult::NotHandled;
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = match &mut self.state {
|
||||
State::Normal => return self.handle_normal_input_event(event, keys).await.into(),
|
||||
State::Normal => return self.handle_normal_input_event(event, keys).await,
|
||||
State::Auth(editor) => auth::handle_input_event(event, keys, &self.room, editor),
|
||||
State::Nick(editor) => nick::handle_input_event(event, keys, &self.room, editor),
|
||||
State::Account(account) => account.handle_input_event(event, keys, &self.room),
|
||||
|
|
@ -540,30 +510,27 @@ impl EuphRoom {
|
|||
};
|
||||
|
||||
match result {
|
||||
PopupResult::NotHandled => RoomResult::NotHandled,
|
||||
PopupResult::Handled => RoomResult::Handled,
|
||||
PopupResult::NotHandled => false,
|
||||
PopupResult::Handled => true,
|
||||
PopupResult::Close => {
|
||||
self.state = State::Normal;
|
||||
RoomResult::Handled
|
||||
true
|
||||
}
|
||||
PopupResult::SwitchToRoom { name } => RoomResult::SwitchToRoom {
|
||||
room: RoomIdentifier {
|
||||
domain: self.vault().room().domain.clone(),
|
||||
name,
|
||||
},
|
||||
},
|
||||
PopupResult::ErrorOpeningLink { link, error } => {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: format!("Failed to open link: {link}"),
|
||||
reason: format!("{error}"),
|
||||
});
|
||||
RoomResult::Handled
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_event(&mut self, event: Event) -> bool {
|
||||
let Some(room) = &self.room else { return false };
|
||||
let room = match &self.room {
|
||||
None => return false,
|
||||
Some(room) => room,
|
||||
};
|
||||
|
||||
if event.config().name != room.instance().config().name {
|
||||
// If we allowed names other than the current one, old instances
|
||||
|
|
@ -571,35 +538,6 @@ impl EuphRoom {
|
|||
return false;
|
||||
}
|
||||
|
||||
if let Event::Packet(
|
||||
_,
|
||||
ParsedPacket {
|
||||
content: Ok(Data::SendEvent(send)),
|
||||
..
|
||||
},
|
||||
ConnSnapshot {
|
||||
state: conn::State::Joined(joined),
|
||||
..
|
||||
},
|
||||
) = &event
|
||||
{
|
||||
let normalized_name = euphoxide::nick::normalize(&joined.session.name);
|
||||
let content = &*send.0.content;
|
||||
for (rtype, rspan) in euph::find_spans(content) {
|
||||
if rtype != SpanType::Mention {
|
||||
continue;
|
||||
}
|
||||
let Some(mention) = content[rspan].strip_prefix('@') else {
|
||||
continue;
|
||||
};
|
||||
let normalized_mention = euphoxide::nick::normalize(mention);
|
||||
if normalized_name == normalized_mention {
|
||||
self.mentioned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We handle the packet internally first because the room event handling
|
||||
// will consume it while we only need a reference.
|
||||
let handled = if let Event::Packet(_, packet, _) = &event {
|
||||
|
|
@ -691,18 +629,3 @@ impl EuphRoom {
|
|||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub enum RoomResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
SwitchToRoom { room: RoomIdentifier },
|
||||
}
|
||||
|
||||
impl From<bool> for RoomResult {
|
||||
fn from(value: bool) -> Self {
|
||||
match value {
|
||||
true => Self::Handled,
|
||||
false => Self::NotHandled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,11 @@ use std::convert::Infallible;
|
|||
use cove_config::{Config, Keys};
|
||||
use cove_input::{InputEvent, KeyBinding, KeyBindingInfo, KeyGroupInfo};
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Either2, Join2, Padding, Text},
|
||||
};
|
||||
use toss::widgets::{Either2, Join2, Padding, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
|
||||
use super::{
|
||||
UiError, util,
|
||||
widgets::{ListBuilder, ListState, Popup},
|
||||
};
|
||||
use super::widgets::{ListBuilder, ListState, Popup};
|
||||
use super::{util, UiError};
|
||||
|
||||
type Line = Either2<Text, Join2<Padding<Text>, Text>>;
|
||||
type Builder = ListBuilder<'static, Infallible, Line>;
|
||||
|
|
@ -73,7 +69,7 @@ fn render_group_info(builder: &mut Builder, group_info: KeyGroupInfo<'_>) {
|
|||
pub fn widget<'a>(
|
||||
list: &'a mut ListState<Infallible>,
|
||||
config: &Config,
|
||||
) -> impl Widget<UiError> + use<'a> {
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
let mut list_builder = ListBuilder::new();
|
||||
|
||||
for group_info in config.keys.groups() {
|
||||
|
|
@ -83,23 +79,7 @@ pub fn widget<'a>(
|
|||
render_group_info(&mut list_builder, group_info);
|
||||
}
|
||||
|
||||
let scroll_info_style = Style::new().grey().italic();
|
||||
let scroll_info = Styled::new("(Scroll with ", scroll_info_style)
|
||||
.and_then(format_binding(&config.keys.cursor.down))
|
||||
.then(" and ", scroll_info_style)
|
||||
.and_then(format_binding(&config.keys.cursor.up))
|
||||
.then(")", scroll_info_style);
|
||||
|
||||
let inner = Join2::vertical(
|
||||
list_builder.build(list).segment(),
|
||||
Text::new(scroll_info)
|
||||
.float()
|
||||
.with_center_h()
|
||||
.segment()
|
||||
.with_growing(false),
|
||||
);
|
||||
|
||||
Popup::new(inner, "Key bindings")
|
||||
Popup::new(list_builder.build(list), "Key bindings")
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
|
|
|
|||
|
|
@ -1,52 +1,30 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet, hash_map::Entry},
|
||||
iter,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cove_config::{Config, Keys, RoomsSortOrder};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{
|
||||
api::SessionType,
|
||||
bot::instance::{Event, ServerConfig},
|
||||
conn::{self, Joined},
|
||||
};
|
||||
use jiff::tz::TimeZone;
|
||||
use euphoxide::api::SessionType;
|
||||
use euphoxide::bot::instance::{Event, ServerConfig};
|
||||
use euphoxide::conn::{self, Joined};
|
||||
use tokio::sync::mpsc;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{BellState, BoxedAsync, Empty, Join2, Text},
|
||||
};
|
||||
use toss::widgets::{BoxedAsync, EditorState, Empty, Join2, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
|
||||
use crate::{
|
||||
euph,
|
||||
macros::logging_unwrap,
|
||||
vault::{EuphVault, RoomIdentifier, Vault},
|
||||
version::{NAME, VERSION},
|
||||
};
|
||||
use crate::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::vault::Vault;
|
||||
|
||||
use super::{
|
||||
UiError, UiEvent,
|
||||
euph::room::{EuphRoom, RoomResult},
|
||||
key_bindings, util,
|
||||
widgets::{ListBuilder, ListState},
|
||||
};
|
||||
|
||||
use self::{
|
||||
connect::{ConnectResult, ConnectState},
|
||||
delete::{DeleteResult, DeleteState},
|
||||
};
|
||||
|
||||
mod connect;
|
||||
mod delete;
|
||||
use super::euph::room::EuphRoom;
|
||||
use super::widgets::{ListBuilder, ListState, Popup};
|
||||
use super::{key_bindings, util, UiError, UiEvent};
|
||||
|
||||
enum State {
|
||||
ShowList,
|
||||
ShowRoom(RoomIdentifier),
|
||||
Connect(ConnectState),
|
||||
Delete(DeleteState),
|
||||
ShowRoom(String),
|
||||
Connect(EditorState),
|
||||
Delete(String, EditorState),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
|
@ -64,70 +42,47 @@ impl Order {
|
|||
}
|
||||
}
|
||||
|
||||
struct EuphServer {
|
||||
config: ServerConfig,
|
||||
next_instance_id: usize,
|
||||
}
|
||||
|
||||
impl EuphServer {
|
||||
async fn new(vault: &EuphVault, domain: String) -> Self {
|
||||
let cookies = logging_unwrap!(vault.cookies(domain.clone()).await);
|
||||
let config = ServerConfig::default()
|
||||
.domain(domain)
|
||||
.cookies(Arc::new(Mutex::new(cookies)))
|
||||
.timeout(Duration::from_secs(10));
|
||||
|
||||
Self {
|
||||
config,
|
||||
next_instance_id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Rooms {
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
||||
state: State,
|
||||
|
||||
list: ListState<RoomIdentifier>,
|
||||
list: ListState<String>,
|
||||
order: Order,
|
||||
bell: BellState,
|
||||
|
||||
euph_servers: HashMap<String, EuphServer>,
|
||||
euph_rooms: HashMap<RoomIdentifier, EuphRoom>,
|
||||
euph_server_config: ServerConfig,
|
||||
euph_next_instance_id: usize,
|
||||
euph_rooms: HashMap<String, EuphRoom>,
|
||||
}
|
||||
|
||||
impl Rooms {
|
||||
pub async fn new(
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
let cookies = logging_unwrap!(vault.euph().cookies().await);
|
||||
let euph_server_config = ServerConfig::default().cookies(Arc::new(Mutex::new(cookies)));
|
||||
|
||||
let mut result = Self {
|
||||
config,
|
||||
tz,
|
||||
vault,
|
||||
ui_event_tx,
|
||||
state: State::ShowList,
|
||||
list: ListState::new(),
|
||||
order: Order::from_rooms_sort_order(config.rooms_sort_order),
|
||||
bell: BellState::new(),
|
||||
euph_servers: HashMap::new(),
|
||||
euph_server_config,
|
||||
euph_next_instance_id: 0,
|
||||
euph_rooms: HashMap::new(),
|
||||
};
|
||||
|
||||
if !config.offline {
|
||||
for (domain, server) in &config.euph.servers {
|
||||
for (name, room) in &server.rooms {
|
||||
if room.autojoin {
|
||||
let id = RoomIdentifier::new(domain.clone(), name.clone());
|
||||
result.connect_to_room(id).await;
|
||||
}
|
||||
for (name, config) in &config.euph.rooms {
|
||||
if config.autojoin {
|
||||
result.connect_to_room(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -135,68 +90,39 @@ impl Rooms {
|
|||
result
|
||||
}
|
||||
|
||||
async fn get_or_insert_server<'a>(
|
||||
vault: &Vault,
|
||||
euph_servers: &'a mut HashMap<String, EuphServer>,
|
||||
domain: String,
|
||||
) -> &'a mut EuphServer {
|
||||
match euph_servers.entry(domain.clone()) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
let server = EuphServer::new(&vault.euph(), domain).await;
|
||||
entry.insert(server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_insert_room(&mut self, room: RoomIdentifier) -> &mut EuphRoom {
|
||||
let server =
|
||||
Self::get_or_insert_server(&self.vault, &mut self.euph_servers, room.domain.clone())
|
||||
.await;
|
||||
|
||||
self.euph_rooms.entry(room.clone()).or_insert_with(|| {
|
||||
fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom {
|
||||
self.euph_rooms.entry(name.clone()).or_insert_with(|| {
|
||||
EuphRoom::new(
|
||||
self.config,
|
||||
server.config.clone(),
|
||||
self.config.euph_room(&room.domain, &room.name),
|
||||
self.vault.euph().room(room),
|
||||
self.tz.clone(),
|
||||
self.euph_server_config.clone(),
|
||||
self.config.euph_room(&name),
|
||||
self.vault.euph().room(name),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn connect_to_room(&mut self, room: RoomIdentifier) {
|
||||
let server =
|
||||
Self::get_or_insert_server(&self.vault, &mut self.euph_servers, room.domain.clone())
|
||||
.await;
|
||||
|
||||
let room = self.euph_rooms.entry(room.clone()).or_insert_with(|| {
|
||||
fn connect_to_room(&mut self, name: String) {
|
||||
let room = self.euph_rooms.entry(name.clone()).or_insert_with(|| {
|
||||
EuphRoom::new(
|
||||
self.config,
|
||||
server.config.clone(),
|
||||
self.config.euph_room(&room.domain, &room.name),
|
||||
self.vault.euph().room(room),
|
||||
self.tz.clone(),
|
||||
self.euph_server_config.clone(),
|
||||
self.config.euph_room(&name),
|
||||
self.vault.euph().room(name),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
});
|
||||
|
||||
room.connect(&mut server.next_instance_id);
|
||||
room.connect(&mut self.euph_next_instance_id);
|
||||
}
|
||||
|
||||
async fn connect_to_all_rooms(&mut self) {
|
||||
for (id, room) in &mut self.euph_rooms {
|
||||
let server =
|
||||
Self::get_or_insert_server(&self.vault, &mut self.euph_servers, id.domain.clone())
|
||||
.await;
|
||||
|
||||
room.connect(&mut server.next_instance_id);
|
||||
fn connect_to_all_rooms(&mut self) {
|
||||
for room in self.euph_rooms.values_mut() {
|
||||
room.connect(&mut self.euph_next_instance_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_from_room(&mut self, room: &RoomIdentifier) {
|
||||
if let Some(room) = self.euph_rooms.get_mut(room) {
|
||||
fn disconnect_from_room(&mut self, name: &str) {
|
||||
if let Some(room) = self.euph_rooms.get_mut(name) {
|
||||
room.disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -216,21 +142,10 @@ impl Rooms {
|
|||
/// - rooms that were deleted from the db.
|
||||
async fn stabilize_rooms(&mut self) {
|
||||
// Collect all rooms from the db and config file
|
||||
let rooms_from_db = logging_unwrap!(self.vault.euph().rooms().await);
|
||||
let rooms_from_config = self
|
||||
.config
|
||||
.euph
|
||||
.servers
|
||||
.iter()
|
||||
.flat_map(|(domain, server)| {
|
||||
server
|
||||
.rooms
|
||||
.keys()
|
||||
.map(|name| RoomIdentifier::new(domain.clone(), name.clone()))
|
||||
});
|
||||
let mut rooms_set = rooms_from_db
|
||||
let rooms = logging_unwrap!(self.vault.euph().rooms().await);
|
||||
let mut rooms_set = rooms
|
||||
.into_iter()
|
||||
.chain(rooms_from_config)
|
||||
.chain(self.config.euph.rooms.keys().cloned())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// Prevent room that is currently being shown from being removed. This
|
||||
|
|
@ -246,9 +161,7 @@ impl Rooms {
|
|||
.retain(|n, r| !r.stopped() || rooms_set.contains(n));
|
||||
|
||||
for room in rooms_set {
|
||||
let room = self.get_or_insert_room(room).await;
|
||||
room.retain();
|
||||
self.bell.ring |= room.retrieve_mentioned();
|
||||
self.get_or_insert_room(room).retain();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,58 +171,96 @@ impl Rooms {
|
|||
_ => self.stabilize_rooms().await,
|
||||
}
|
||||
|
||||
let widget = match &mut self.state {
|
||||
State::ShowList => Self::rooms_widget(
|
||||
&self.vault,
|
||||
self.config,
|
||||
&mut self.list,
|
||||
self.order,
|
||||
&self.euph_rooms,
|
||||
)
|
||||
.await
|
||||
.desync()
|
||||
.boxed_async(),
|
||||
match &mut self.state {
|
||||
State::ShowList => {
|
||||
Self::rooms_widget(self.config, &mut self.list, self.order, &self.euph_rooms)
|
||||
.await
|
||||
.desync()
|
||||
.boxed_async()
|
||||
}
|
||||
|
||||
State::ShowRoom(id) => {
|
||||
State::ShowRoom(name) => {
|
||||
self.euph_rooms
|
||||
.get_mut(id)
|
||||
.get_mut(name)
|
||||
.expect("room exists after stabilization")
|
||||
.widget()
|
||||
.await
|
||||
}
|
||||
|
||||
State::Connect(connect) => Self::rooms_widget(
|
||||
&self.vault,
|
||||
self.config,
|
||||
&mut self.list,
|
||||
self.order,
|
||||
&self.euph_rooms,
|
||||
)
|
||||
.await
|
||||
.below(connect.widget())
|
||||
.desync()
|
||||
.boxed_async(),
|
||||
State::Connect(editor) => {
|
||||
Self::rooms_widget(self.config, &mut self.list, self.order, &self.euph_rooms)
|
||||
.await
|
||||
.below(Self::new_room_widget(editor))
|
||||
.desync()
|
||||
.boxed_async()
|
||||
}
|
||||
|
||||
State::Delete(delete) => Self::rooms_widget(
|
||||
&self.vault,
|
||||
self.config,
|
||||
&mut self.list,
|
||||
self.order,
|
||||
&self.euph_rooms,
|
||||
)
|
||||
.await
|
||||
.below(delete.widget())
|
||||
.desync()
|
||||
.boxed_async(),
|
||||
};
|
||||
|
||||
if self.config.bell_on_mention {
|
||||
widget.above(self.bell.widget().desync()).boxed_async()
|
||||
} else {
|
||||
widget
|
||||
State::Delete(name, editor) => {
|
||||
Self::rooms_widget(self.config, &mut self.list, self.order, &self.euph_rooms)
|
||||
.await
|
||||
.below(Self::delete_room_widget(name, editor))
|
||||
.desync()
|
||||
.boxed_async()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_room_widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
|
||||
let room_style = Style::new().bold().blue();
|
||||
|
||||
let inner = Join2::horizontal(
|
||||
Text::new(("&", room_style)).segment().with_fixed(true),
|
||||
editor
|
||||
.widget()
|
||||
.with_highlight(|s| Styled::new(s, room_style))
|
||||
.segment(),
|
||||
);
|
||||
|
||||
Popup::new(inner, "Connect to")
|
||||
}
|
||||
|
||||
fn delete_room_widget<'a>(
|
||||
name: &str,
|
||||
editor: &'a mut EditorState,
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
let warn_style = Style::new().bold().red();
|
||||
let room_style = Style::new().bold().blue();
|
||||
let text = Styled::new_plain("Are you sure you want to delete ")
|
||||
.then("&", room_style)
|
||||
.then(name, room_style)
|
||||
.then_plain("?\n\n")
|
||||
.then_plain("This will delete the entire room history from your vault. ")
|
||||
.then_plain("To shrink your vault afterwards, run ")
|
||||
.then("cove gc", Style::new().italic().grey())
|
||||
.then_plain(".\n\n")
|
||||
.then_plain("To confirm the deletion, ")
|
||||
.then_plain("enter the full name of the room and press enter:");
|
||||
|
||||
let inner = Join2::vertical(
|
||||
// The Join prevents the text from filling up the entire available
|
||||
// space if the editor is wider than the text.
|
||||
Join2::horizontal(
|
||||
Text::new(text)
|
||||
.resize()
|
||||
.with_max_width(54)
|
||||
.segment()
|
||||
.with_growing(false),
|
||||
Empty::new().segment(),
|
||||
)
|
||||
.segment(),
|
||||
Join2::horizontal(
|
||||
Text::new(("&", room_style)).segment().with_fixed(true),
|
||||
editor
|
||||
.widget()
|
||||
.with_highlight(|s| Styled::new(s, room_style))
|
||||
.segment(),
|
||||
)
|
||||
.segment(),
|
||||
);
|
||||
|
||||
Popup::new(inner, "Delete room").with_border_style(warn_style)
|
||||
}
|
||||
|
||||
fn format_pbln(joined: &Joined) -> String {
|
||||
let mut p = 0_usize;
|
||||
let mut b = 0_usize;
|
||||
|
|
@ -394,45 +345,48 @@ impl Rooms {
|
|||
}
|
||||
}
|
||||
|
||||
fn sort_rooms(rooms: &mut [(&RoomIdentifier, Option<&euph::State>, usize)], order: Order) {
|
||||
fn sort_rooms(rooms: &mut [(&String, Option<&euph::State>, usize)], order: Order) {
|
||||
match order {
|
||||
Order::Alphabet => rooms.sort_unstable_by_key(|(id, _, _)| *id),
|
||||
Order::Importance => rooms
|
||||
.sort_unstable_by_key(|(id, state, unseen)| (state.is_none(), *unseen == 0, *id)),
|
||||
Order::Alphabet => rooms.sort_unstable_by_key(|(name, _, _)| *name),
|
||||
Order::Importance => rooms.sort_unstable_by_key(|(name, state, unseen)| {
|
||||
(state.is_none(), *unseen == 0, *name)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_rows(
|
||||
list_builder: &mut ListBuilder<'_, RoomIdentifier, Text>,
|
||||
config: &Config,
|
||||
list_builder: &mut ListBuilder<'_, String, Text>,
|
||||
order: Order,
|
||||
euph_rooms: &HashMap<RoomIdentifier, EuphRoom>,
|
||||
euph_rooms: &HashMap<String, EuphRoom>,
|
||||
) {
|
||||
if euph_rooms.is_empty() {
|
||||
let style = Style::new().grey().italic();
|
||||
list_builder.add_unsel(Text::new(
|
||||
Styled::new("Press ", style)
|
||||
.and_then(key_bindings::format_binding(&config.keys.general.help))
|
||||
.then(" for key bindings", style),
|
||||
));
|
||||
}
|
||||
|
||||
let mut rooms = vec![];
|
||||
for (id, room) in euph_rooms {
|
||||
for (name, room) in euph_rooms {
|
||||
let state = room.room_state();
|
||||
let unseen = room.unseen_msgs_count().await;
|
||||
rooms.push((id, state, unseen));
|
||||
rooms.push((name, state, unseen));
|
||||
}
|
||||
Self::sort_rooms(&mut rooms, order);
|
||||
for (id, state, unseen) in rooms {
|
||||
let id = id.clone();
|
||||
for (name, state, unseen) in rooms {
|
||||
let name = name.clone();
|
||||
let info = Self::format_room_info(state, unseen);
|
||||
list_builder.add_sel(id.clone(), move |selected| {
|
||||
let domain_style = if selected {
|
||||
Style::new().black().on_white()
|
||||
} else {
|
||||
Style::new().grey()
|
||||
};
|
||||
|
||||
let room_style = if selected {
|
||||
list_builder.add_sel(name.clone(), move |selected| {
|
||||
let style = if selected {
|
||||
Style::new().bold().black().on_white()
|
||||
} else {
|
||||
Style::new().bold().blue()
|
||||
};
|
||||
|
||||
let text = Styled::new(format!("{} ", id.domain), domain_style)
|
||||
.then(format!("&{}", id.name), room_style)
|
||||
.and_then(info);
|
||||
let text = Styled::new(format!("&{name}"), style).and_then(info);
|
||||
|
||||
Text::new(text)
|
||||
});
|
||||
|
|
@ -440,66 +394,29 @@ impl Rooms {
|
|||
}
|
||||
|
||||
async fn rooms_widget<'a>(
|
||||
vault: &Vault,
|
||||
config: &Config,
|
||||
list: &'a mut ListState<RoomIdentifier>,
|
||||
list: &'a mut ListState<String>,
|
||||
order: Order,
|
||||
euph_rooms: &HashMap<RoomIdentifier, EuphRoom>,
|
||||
) -> impl Widget<UiError> + use<'a> {
|
||||
let version_info = Styled::new_plain("Welcome to ")
|
||||
.then(format!("{NAME} {VERSION}"), Style::new().yellow().bold())
|
||||
.then_plain("!");
|
||||
let help_info = Styled::new("Press ", Style::new().grey())
|
||||
.and_then(key_bindings::format_binding(&config.keys.general.help))
|
||||
.then(" for key bindings.", Style::new().grey());
|
||||
let info = Join2::vertical(
|
||||
Text::new(version_info).float().with_center_h().segment(),
|
||||
Text::new(help_info).segment(),
|
||||
)
|
||||
.padding()
|
||||
.with_horizontal(1)
|
||||
.border();
|
||||
|
||||
let mut heading = Styled::new("Rooms", Style::new().bold());
|
||||
let mut title = "Rooms".to_string();
|
||||
|
||||
let total_rooms = euph_rooms.len();
|
||||
let connected_rooms = euph_rooms
|
||||
.iter()
|
||||
.filter(|r| r.1.room_state().is_some())
|
||||
.count();
|
||||
let total_unseen = logging_unwrap!(vault.euph().total_unseen_msgs_count().await);
|
||||
if total_unseen > 0 {
|
||||
heading = heading
|
||||
.then_plain(format!(" ({connected_rooms}/{total_rooms}, "))
|
||||
.then(format!("{total_unseen}"), Style::new().bold().green())
|
||||
.then_plain(")");
|
||||
title.push_str(&format!(" ({total_unseen})"));
|
||||
} else {
|
||||
heading = heading.then_plain(format!(" ({connected_rooms}/{total_rooms})"))
|
||||
}
|
||||
euph_rooms: &HashMap<String, EuphRoom>,
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
let heading_style = Style::new().bold();
|
||||
let heading_text =
|
||||
Styled::new("Rooms", heading_style).then_plain(format!(" ({})", euph_rooms.len()));
|
||||
|
||||
let mut list_builder = ListBuilder::new();
|
||||
Self::render_rows(&mut list_builder, order, euph_rooms).await;
|
||||
Self::render_rows(config, &mut list_builder, order, euph_rooms).await;
|
||||
|
||||
Join2::horizontal(
|
||||
Join2::vertical(
|
||||
Text::new(heading).segment().with_fixed(true),
|
||||
list_builder.build(list).segment(),
|
||||
)
|
||||
.segment(),
|
||||
Join2::vertical(info.segment().with_growing(false), Empty::new().segment())
|
||||
.segment()
|
||||
.with_growing(false),
|
||||
Join2::vertical(
|
||||
Text::new(heading_text).segment().with_fixed(true),
|
||||
list_builder.build(list).segment(),
|
||||
)
|
||||
.title(title)
|
||||
}
|
||||
|
||||
async fn handle_showlist_input_event(
|
||||
&mut self,
|
||||
event: &mut InputEvent<'_>,
|
||||
keys: &Keys,
|
||||
) -> bool {
|
||||
fn room_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn handle_showlist_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
|
||||
// Open room
|
||||
if event.matches(&keys.general.confirm) {
|
||||
if let Some(name) = self.list.selected() {
|
||||
|
|
@ -516,17 +433,17 @@ impl Rooms {
|
|||
// Room actions
|
||||
if event.matches(&keys.rooms.action.connect) {
|
||||
if let Some(name) = self.list.selected() {
|
||||
self.connect_to_room(name.clone()).await;
|
||||
self.connect_to_room(name.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if event.matches(&keys.rooms.action.connect_all) {
|
||||
self.connect_to_all_rooms().await;
|
||||
self.connect_to_all_rooms();
|
||||
return true;
|
||||
}
|
||||
if event.matches(&keys.rooms.action.disconnect) {
|
||||
if let Some(room) = self.list.selected() {
|
||||
self.disconnect_from_room(&room.clone());
|
||||
if let Some(name) = self.list.selected() {
|
||||
self.disconnect_from_room(&name.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -535,20 +452,22 @@ impl Rooms {
|
|||
return true;
|
||||
}
|
||||
if event.matches(&keys.rooms.action.connect_autojoin) {
|
||||
for (domain, server) in &self.config.euph.servers {
|
||||
for (name, room) in &server.rooms {
|
||||
if !room.autojoin {
|
||||
continue;
|
||||
}
|
||||
let id = RoomIdentifier::new(domain.clone(), name.clone());
|
||||
self.connect_to_room(id).await;
|
||||
for (name, options) in &self.config.euph.rooms {
|
||||
if options.autojoin {
|
||||
self.connect_to_room(name.clone());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if event.matches(&keys.rooms.action.disconnect_non_autojoin) {
|
||||
for (id, room) in &mut self.euph_rooms {
|
||||
let autojoin = self.config.euph_room(&id.domain, &id.name).autojoin;
|
||||
for (name, room) in &mut self.euph_rooms {
|
||||
let autojoin = self
|
||||
.config
|
||||
.euph
|
||||
.rooms
|
||||
.get(name)
|
||||
.map(|r| r.autojoin)
|
||||
.unwrap_or(false);
|
||||
if !autojoin {
|
||||
room.disconnect();
|
||||
}
|
||||
|
|
@ -556,12 +475,12 @@ impl Rooms {
|
|||
return true;
|
||||
}
|
||||
if event.matches(&keys.rooms.action.new) {
|
||||
self.state = State::Connect(ConnectState::new());
|
||||
self.state = State::Connect(EditorState::new());
|
||||
return true;
|
||||
}
|
||||
if event.matches(&keys.rooms.action.delete) {
|
||||
if let Some(room) = self.list.selected() {
|
||||
self.state = State::Delete(DeleteState::new(room.clone()));
|
||||
if let Some(name) = self.list.selected() {
|
||||
self.state = State::Delete(name.clone(), EditorState::new());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -581,21 +500,14 @@ impl Rooms {
|
|||
|
||||
match &mut self.state {
|
||||
State::ShowList => {
|
||||
if self.handle_showlist_input_event(event, keys).await {
|
||||
if self.handle_showlist_input_event(event, keys) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
State::ShowRoom(name) => {
|
||||
if let Some(room) = self.euph_rooms.get_mut(name) {
|
||||
match room.handle_input_event(event, keys).await {
|
||||
RoomResult::NotHandled => {}
|
||||
RoomResult::Handled => return true,
|
||||
RoomResult::SwitchToRoom { room } => {
|
||||
self.list.move_cursor_to_id(&room);
|
||||
self.connect_to_room(room.clone()).await;
|
||||
self.state = State::ShowRoom(room);
|
||||
return true;
|
||||
}
|
||||
if room.handle_input_event(event, keys).await {
|
||||
return true;
|
||||
}
|
||||
if event.matches(&keys.general.abort) {
|
||||
self.state = State::ShowList;
|
||||
|
|
@ -603,54 +515,53 @@ impl Rooms {
|
|||
}
|
||||
}
|
||||
}
|
||||
State::Connect(connect) => match connect.handle_input_event(event, keys) {
|
||||
ConnectResult::Close => {
|
||||
State::Connect(editor) => {
|
||||
if event.matches(&keys.general.abort) {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
ConnectResult::Connect(room) => {
|
||||
self.list.move_cursor_to_id(&room);
|
||||
self.connect_to_room(room.clone()).await;
|
||||
self.state = State::ShowRoom(room);
|
||||
if event.matches(&keys.general.confirm) {
|
||||
let name = editor.text().to_string();
|
||||
if !name.is_empty() {
|
||||
self.connect_to_room(name.clone());
|
||||
self.state = State::ShowRoom(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
ConnectResult::Handled => {
|
||||
if util::handle_editor_input_event(editor, event, keys, Self::room_char) {
|
||||
return true;
|
||||
}
|
||||
ConnectResult::Unhandled => {}
|
||||
},
|
||||
State::Delete(delete) => match delete.handle_input_event(event, keys) {
|
||||
DeleteResult::Close => {
|
||||
}
|
||||
State::Delete(name, editor) => {
|
||||
if event.matches(&keys.general.abort) {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
DeleteResult::Delete(room) => {
|
||||
self.euph_rooms.remove(&room);
|
||||
logging_unwrap!(self.vault.euph().room(room).delete().await);
|
||||
if event.matches(&keys.general.confirm) {
|
||||
self.euph_rooms.remove(name);
|
||||
logging_unwrap!(self.vault.euph().room(name.clone()).delete().await);
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
DeleteResult::Handled => {
|
||||
if util::handle_editor_input_event(editor, event, keys, Self::room_char) {
|
||||
return true;
|
||||
}
|
||||
DeleteResult::Unhandled => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn handle_euph_event(&mut self, event: Event) -> bool {
|
||||
let config = event.config();
|
||||
let room_id = RoomIdentifier::new(config.server.domain.clone(), config.room.clone());
|
||||
let Some(room) = self.euph_rooms.get_mut(&room_id) else {
|
||||
let room_name = event.config().room.clone();
|
||||
let Some(room) = self.euph_rooms.get_mut(&room_name) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let handled = room.handle_event(event).await;
|
||||
|
||||
let room_visible = match &self.state {
|
||||
State::ShowRoom(id) => *id == room_id,
|
||||
State::ShowRoom(name) => *name == room_name,
|
||||
_ => true,
|
||||
};
|
||||
handled && room_visible
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join2, Join3, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
vault::RoomIdentifier,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Name,
|
||||
Domain,
|
||||
}
|
||||
|
||||
impl Focus {
|
||||
fn advance(self) -> Self {
|
||||
match self {
|
||||
Self::Name => Self::Domain,
|
||||
Self::Domain => Self::Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConnectState {
|
||||
focus: Focus,
|
||||
name: EditorState,
|
||||
domain: EditorState,
|
||||
}
|
||||
|
||||
pub enum ConnectResult {
|
||||
Close,
|
||||
Connect(RoomIdentifier),
|
||||
Handled,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl ConnectState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
focus: Focus::Name,
|
||||
name: EditorState::new(),
|
||||
domain: EditorState::with_initial_text("euphoria.leet.nu".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> ConnectResult {
|
||||
if event.matches(&keys.general.abort) {
|
||||
return ConnectResult::Close;
|
||||
}
|
||||
|
||||
if event.matches(&keys.general.focus) {
|
||||
self.focus = self.focus.advance();
|
||||
return ConnectResult::Handled;
|
||||
}
|
||||
|
||||
if event.matches(&keys.general.confirm) {
|
||||
let id = RoomIdentifier {
|
||||
domain: self.domain.text().to_string(),
|
||||
name: self.name.text().to_string(),
|
||||
};
|
||||
if !id.domain.is_empty() && !id.name.is_empty() {
|
||||
return ConnectResult::Connect(id);
|
||||
}
|
||||
}
|
||||
|
||||
let handled = match self.focus {
|
||||
Focus::Name => {
|
||||
util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char)
|
||||
}
|
||||
Focus::Domain => {
|
||||
util::handle_editor_input_event(&mut self.domain, event, keys, |c| c != '\n')
|
||||
}
|
||||
};
|
||||
|
||||
if handled {
|
||||
return ConnectResult::Handled;
|
||||
}
|
||||
|
||||
ConnectResult::Unhandled
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
let room_style = Style::new().bold().blue();
|
||||
let domain_style = Style::new().grey();
|
||||
|
||||
let name = Join2::horizontal(
|
||||
Text::new(Styled::new_plain("Room: ").then("&", room_style))
|
||||
.with_wrap(false)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
self.name
|
||||
.widget()
|
||||
.with_highlight(|s| Styled::new(s, room_style))
|
||||
.with_focus(self.focus == Focus::Name)
|
||||
.segment(),
|
||||
);
|
||||
|
||||
let domain = Join3::horizontal(
|
||||
Text::new("Domain:")
|
||||
.with_wrap(false)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Empty::new().with_width(1).segment().with_fixed(true),
|
||||
self.domain
|
||||
.widget()
|
||||
.with_highlight(|s| Styled::new(s, domain_style))
|
||||
.with_focus(self.focus == Focus::Domain)
|
||||
.segment(),
|
||||
);
|
||||
|
||||
let inner = Join2::vertical(
|
||||
name.segment().with_fixed(true),
|
||||
domain.segment().with_fixed(true),
|
||||
);
|
||||
|
||||
Popup::new(inner, "Connect to")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join2, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
vault::RoomIdentifier,
|
||||
};
|
||||
|
||||
pub struct DeleteState {
|
||||
id: RoomIdentifier,
|
||||
name: EditorState,
|
||||
}
|
||||
|
||||
pub enum DeleteResult {
|
||||
Close,
|
||||
Delete(RoomIdentifier),
|
||||
Handled,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl DeleteState {
|
||||
pub fn new(id: RoomIdentifier) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: EditorState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> DeleteResult {
|
||||
if event.matches(&keys.general.abort) {
|
||||
return DeleteResult::Close;
|
||||
}
|
||||
|
||||
if event.matches(&keys.general.confirm) && self.name.text() == self.id.name {
|
||||
return DeleteResult::Delete(self.id.clone());
|
||||
}
|
||||
|
||||
if util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char) {
|
||||
return DeleteResult::Handled;
|
||||
}
|
||||
|
||||
DeleteResult::Unhandled
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
let warn_style = Style::new().bold().red();
|
||||
let room_style = Style::new().bold().blue();
|
||||
let text = Styled::new_plain("Are you sure you want to delete ")
|
||||
.then("&", room_style)
|
||||
.then(&self.id.name, room_style)
|
||||
.then_plain(" on the ")
|
||||
.then(&self.id.domain, Style::new().grey())
|
||||
.then_plain(" server?\n\n")
|
||||
.then_plain("This will delete the entire room history from your vault. ")
|
||||
.then_plain("To shrink your vault afterwards, run ")
|
||||
.then("cove gc", Style::new().italic().grey())
|
||||
.then_plain(".\n\n")
|
||||
.then_plain("To confirm the deletion, ")
|
||||
.then_plain("enter the full name of the room and press enter:");
|
||||
|
||||
let inner = Join2::vertical(
|
||||
// The Join prevents the text from filling up the entire available
|
||||
// space if the editor is wider than the text.
|
||||
Join2::horizontal(
|
||||
Text::new(text)
|
||||
.resize()
|
||||
.with_max_width(54)
|
||||
.segment()
|
||||
.with_growing(false),
|
||||
Empty::new().segment(),
|
||||
)
|
||||
.segment(),
|
||||
Join2::horizontal(
|
||||
Text::new(("&", room_style)).segment().with_fixed(true),
|
||||
self.name
|
||||
.widget()
|
||||
.with_highlight(|s| Styled::new(s, room_style))
|
||||
.segment(),
|
||||
)
|
||||
.segment(),
|
||||
);
|
||||
|
||||
Popup::new(inner, "Delete room").with_border_style(warn_style)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,6 @@ use toss::widgets::EditorState;
|
|||
|
||||
use super::widgets::ListState;
|
||||
|
||||
/// Test if a character is allowed to be typed in a room name.
|
||||
pub fn is_room_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
//////////
|
||||
// List //
|
||||
//////////
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
pub use self::list::*;
|
||||
pub use self::popup::*;
|
||||
|
||||
mod list;
|
||||
mod popup;
|
||||
|
||||
pub use self::list::*;
|
||||
pub use self::popup::*;
|
||||
|
|
|
|||
|
|
@ -239,12 +239,6 @@ impl<Id: Clone + Eq> ListState<Id> {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn move_cursor_to_id(&mut self, id: &Id) {
|
||||
if let Some(new_cursor) = self.selectable_of_id(id) {
|
||||
self.move_cursor_to(new_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
fn fix_cursor(&mut self) {
|
||||
let new_cursor = if let Some(cursor) = &self.cursor {
|
||||
self.selectable_of_id(&cursor.id)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use toss::{
|
||||
Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb,
|
||||
widgets::{Background, Border, Desync, Float, Layer2, Padding, Text},
|
||||
};
|
||||
use toss::widgets::{Background, Border, Desync, Float, Layer2, Padding, Text};
|
||||
use toss::{Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb};
|
||||
|
||||
type Body<I> = Background<Border<Padding<I>>>;
|
||||
type Title = Float<Padding<Background<Padding<Text>>>>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
use std::{convert::Infallible, env};
|
||||
|
||||
use jiff::tz::TimeZone;
|
||||
use std::convert::Infallible;
|
||||
|
||||
pub trait InfallibleExt {
|
||||
type Inner;
|
||||
|
|
@ -15,56 +13,3 @@ impl<T> InfallibleExt for Result<T, Infallible> {
|
|||
self.expect("infallible")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a [`TimeZone`] specified by the `TZ` environment varible, or by the
|
||||
/// provided string if the environment variable does not exist.
|
||||
///
|
||||
/// If a string is provided, it is interpreted in the same format that the `TZ`
|
||||
/// environment variable uses.
|
||||
///
|
||||
/// If no `TZ` environment variable could be found and no string is provided,
|
||||
/// the system local time (or UTC on Windows) is used.
|
||||
pub fn load_time_zone(tz_string: Option<&str>) -> Result<TimeZone, jiff::Error> {
|
||||
let env_string = env::var("TZ").ok();
|
||||
let tz_string = env_string.as_ref().map(|s| s as &str).or(tz_string);
|
||||
|
||||
let Some(tz_string) = tz_string else {
|
||||
return Ok(TimeZone::system());
|
||||
};
|
||||
|
||||
if tz_string == "localtime" {
|
||||
return Ok(TimeZone::system());
|
||||
}
|
||||
|
||||
if let Some(tz_string) = tz_string.strip_prefix(':') {
|
||||
return TimeZone::get(tz_string);
|
||||
}
|
||||
|
||||
// The time zone is either a manually specified string or a file in the tz
|
||||
// database. We'll try to parse it as a manually specified string first
|
||||
// because that doesn't require a fs lookup.
|
||||
if let Ok(tz) = TimeZone::posix(tz_string) {
|
||||
return Ok(tz);
|
||||
}
|
||||
|
||||
TimeZone::get(tz_string)
|
||||
}
|
||||
|
||||
pub fn caesar(text: &str, by: i8) -> String {
|
||||
let by = by.rem_euclid(26) as u8;
|
||||
text.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_lowercase() {
|
||||
let c = c as u8 - b'a';
|
||||
let c = (c + by) % 26;
|
||||
(c + b'a') as char
|
||||
} else if c.is_ascii_uppercase() {
|
||||
let c = c as u8 - b'A';
|
||||
let c = (c + by) % 26;
|
||||
(c + b'A') as char
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
use std::{fs, path::Path};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use vault::{Action, tokio::TokioVault};
|
||||
|
||||
pub use self::euph::{EuphRoomVault, EuphVault, RoomIdentifier};
|
||||
|
||||
mod euph;
|
||||
mod migrate;
|
||||
mod prepare;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use vault::tokio::TokioVault;
|
||||
use vault::Action;
|
||||
|
||||
pub use self::euph::{EuphRoomVault, EuphVault};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Vault {
|
||||
tokio_vault: TokioVault,
|
||||
|
|
@ -48,6 +50,8 @@ fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result
|
|||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
conn.pragma_update(None, "trusted_schema", false)?;
|
||||
|
||||
eprintln!("Opening vault");
|
||||
|
||||
let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?;
|
||||
Ok(Vault {
|
||||
tokio_vault,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,27 @@
|
|||
use std::{fmt, mem, str::FromStr};
|
||||
use std::mem;
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cookie::{Cookie, CookieJar};
|
||||
use euphoxide::api::{Message, MessageId, SessionId, SessionView, Snowflake, Time, UserId};
|
||||
use rusqlite::{
|
||||
Connection, OptionalExtension, Row, ToSql, Transaction, named_params, params,
|
||||
types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef},
|
||||
};
|
||||
use rusqlite::types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef};
|
||||
use rusqlite::{named_params, params, Connection, OptionalExtension, Row, ToSql, Transaction};
|
||||
use time::OffsetDateTime;
|
||||
use vault::Action;
|
||||
|
||||
use crate::{
|
||||
euph::SmallMessage,
|
||||
store::{MsgStore, Path, Tree},
|
||||
};
|
||||
use crate::euph::SmallMessage;
|
||||
use crate::store::{MsgStore, Path, Tree};
|
||||
|
||||
///////////////////
|
||||
// Wrapper types //
|
||||
///////////////////
|
||||
|
||||
/// Wrapper for [`Snowflake`] that implements useful rusqlite traits.
|
||||
struct WSnowflake(Snowflake);
|
||||
|
||||
impl ToSql for WSnowflake {
|
||||
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
|
||||
self.0.0.to_sql()
|
||||
self.0 .0.to_sql()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +36,7 @@ struct WTime(Time);
|
|||
|
||||
impl ToSql for WTime {
|
||||
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
|
||||
let timestamp = self.0.0;
|
||||
let timestamp = self.0 .0.unix_timestamp();
|
||||
Ok(ToSqlOutput::Owned(Value::Integer(timestamp)))
|
||||
}
|
||||
}
|
||||
|
|
@ -42,25 +44,9 @@ impl ToSql for WTime {
|
|||
impl FromSql for WTime {
|
||||
fn column_result(value: ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
|
||||
let timestamp = i64::column_result(value)?;
|
||||
Ok(Self(Time(timestamp)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RoomIdentifier {
|
||||
pub domain: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RoomIdentifier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "&{}@{}", self.name, self.domain)
|
||||
}
|
||||
}
|
||||
|
||||
impl RoomIdentifier {
|
||||
pub fn new(domain: String, name: String) -> Self {
|
||||
Self { domain, name }
|
||||
Ok(Self(Time(
|
||||
OffsetDateTime::from_unix_timestamp(timestamp).expect("timestamp in range"),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,10 +68,10 @@ impl EuphVault {
|
|||
&self.vault
|
||||
}
|
||||
|
||||
pub fn room(&self, room: RoomIdentifier) -> EuphRoomVault {
|
||||
pub fn room(&self, name: String) -> EuphRoomVault {
|
||||
EuphRoomVault {
|
||||
vault: self.clone(),
|
||||
room,
|
||||
room: name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,11 +97,9 @@ macro_rules! euph_vault_actions {
|
|||
}
|
||||
|
||||
euph_vault_actions! {
|
||||
GetCookies : cookies(domain: String) -> CookieJar;
|
||||
SetCookies : set_cookies(domain: String, cookies: CookieJar) -> ();
|
||||
ClearCookies : clear_cookies(domain: Option<String>) -> ();
|
||||
GetRooms : rooms() -> Vec<RoomIdentifier>;
|
||||
GetTotalUnseenMsgsCount : total_unseen_msgs_count() -> usize;
|
||||
GetCookies : cookies() -> CookieJar;
|
||||
SetCookies : set_cookies(cookies: CookieJar) -> ();
|
||||
GetRooms : rooms() -> Vec<String>;
|
||||
}
|
||||
|
||||
impl Action for GetCookies {
|
||||
|
|
@ -128,10 +112,9 @@ impl Action for GetCookies {
|
|||
"
|
||||
SELECT cookie
|
||||
FROM euph_cookies
|
||||
WHERE domain = ?
|
||||
",
|
||||
)?
|
||||
.query_map([self.domain], |row| {
|
||||
.query_map([], |row| {
|
||||
let cookie_str: String = row.get(0)?;
|
||||
Ok(Cookie::from_str(&cookie_str).expect("cookie in db is valid"))
|
||||
})?
|
||||
|
|
@ -154,21 +137,16 @@ impl Action for SetCookies {
|
|||
|
||||
// Since euphoria sets all cookies on every response, we can just delete
|
||||
// all previous cookies.
|
||||
tx.execute(
|
||||
"
|
||||
DELETE FROM euph_cookies
|
||||
WHERE domain = ?",
|
||||
[&self.domain],
|
||||
)?;
|
||||
tx.execute_batch("DELETE FROM euph_cookies")?;
|
||||
|
||||
let mut insert_cookie = tx.prepare(
|
||||
"
|
||||
INSERT INTO euph_cookies (domain, cookie)
|
||||
VALUES (?, ?)
|
||||
INSERT INTO euph_cookies (cookie)
|
||||
VALUES (?)
|
||||
",
|
||||
)?;
|
||||
for cookie in self.cookies.iter() {
|
||||
insert_cookie.execute(params![self.domain, format!("{cookie}")])?;
|
||||
insert_cookie.execute([format!("{cookie}")])?;
|
||||
}
|
||||
drop(insert_cookie);
|
||||
|
||||
|
|
@ -177,57 +155,22 @@ impl Action for SetCookies {
|
|||
}
|
||||
}
|
||||
|
||||
impl Action for ClearCookies {
|
||||
type Output = ();
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
|
||||
if let Some(domain) = self.domain {
|
||||
conn.execute("DELETE FROM euph_cookies WHERE domain = ?", [domain])?;
|
||||
} else {
|
||||
conn.execute_batch("DELETE FROM euph_cookies")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for GetRooms {
|
||||
type Output = Vec<RoomIdentifier>;
|
||||
type Output = Vec<String>;
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
|
||||
conn.prepare(
|
||||
"
|
||||
SELECT domain, room
|
||||
SELECT room
|
||||
FROM euph_rooms
|
||||
",
|
||||
)?
|
||||
.query_map([], |row| {
|
||||
Ok(RoomIdentifier {
|
||||
domain: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
})
|
||||
})?
|
||||
.query_map([], |row| row.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for GetTotalUnseenMsgsCount {
|
||||
type Output = usize;
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
|
||||
conn.prepare(
|
||||
"
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM euph_unseen_counts
|
||||
",
|
||||
)?
|
||||
.query_row([], |row| row.get(0))
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////
|
||||
// EuphRoomVault //
|
||||
///////////////////
|
||||
|
|
@ -235,7 +178,7 @@ impl Action for GetTotalUnseenMsgsCount {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct EuphRoomVault {
|
||||
vault: EuphVault,
|
||||
room: RoomIdentifier,
|
||||
room: String,
|
||||
}
|
||||
|
||||
impl EuphRoomVault {
|
||||
|
|
@ -243,7 +186,7 @@ impl EuphRoomVault {
|
|||
&self.vault
|
||||
}
|
||||
|
||||
pub fn room(&self) -> &RoomIdentifier {
|
||||
pub fn room(&self) -> &str {
|
||||
&self.room
|
||||
}
|
||||
}
|
||||
|
|
@ -254,7 +197,7 @@ macro_rules! euph_room_vault_actions {
|
|||
)* ) => {
|
||||
$(
|
||||
struct $struct {
|
||||
room: RoomIdentifier,
|
||||
room: String,
|
||||
$( $arg: $arg_ty, )*
|
||||
}
|
||||
)*
|
||||
|
|
@ -310,16 +253,12 @@ impl Action for Join {
|
|||
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
|
||||
conn.execute(
|
||||
"
|
||||
INSERT INTO euph_rooms (domain, room, first_joined, last_joined)
|
||||
VALUES (:domain, :room, :time, :time)
|
||||
ON CONFLICT (domain, room) DO UPDATE
|
||||
INSERT INTO euph_rooms (room, first_joined, last_joined)
|
||||
VALUES (:room, :time, :time)
|
||||
ON CONFLICT (room) DO UPDATE
|
||||
SET last_joined = :time
|
||||
",
|
||||
named_params! {
|
||||
":domain": self.room.domain,
|
||||
":room": self.room.name,
|
||||
":time": WTime(self.time),
|
||||
},
|
||||
named_params! {":room": self.room, ":time": WTime(self.time)},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -333,10 +272,9 @@ impl Action for Delete {
|
|||
conn.execute(
|
||||
"
|
||||
DELETE FROM euph_rooms
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
",
|
||||
[&self.room.domain, &self.room.name],
|
||||
[&self.room],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -344,33 +282,29 @@ impl Action for Delete {
|
|||
|
||||
fn insert_msgs(
|
||||
tx: &Transaction<'_>,
|
||||
room: &RoomIdentifier,
|
||||
room: &str,
|
||||
own_user_id: &Option<UserId>,
|
||||
msgs: Vec<Message>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut insert_msg = tx.prepare(
|
||||
"
|
||||
INSERT INTO euph_msgs (
|
||||
domain, room,
|
||||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
room, id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address,
|
||||
seen
|
||||
)
|
||||
VALUES (
|
||||
:domain, :room,
|
||||
:id, :parent, :previous_edit_id, :time, :content, :encryption_key_id, :edited, :deleted, :truncated,
|
||||
:room, :id, :parent, :previous_edit_id, :time, :content, :encryption_key_id, :edited, :deleted, :truncated,
|
||||
:user_id, :name, :server_id, :server_era, :session_id, :is_staff, :is_manager, :client_address, :real_client_address,
|
||||
(:user_id == :own_user_id OR EXISTS(
|
||||
SELECT 1
|
||||
FROM euph_rooms
|
||||
WHERE domain = :domain
|
||||
AND room = :room
|
||||
WHERE room = :room
|
||||
AND :time < first_joined
|
||||
))
|
||||
)
|
||||
ON CONFLICT (domain, room, id) DO UPDATE
|
||||
ON CONFLICT (room, id) DO UPDATE
|
||||
SET
|
||||
domain = :domain,
|
||||
room = :room,
|
||||
id = :id,
|
||||
parent = :parent,
|
||||
|
|
@ -397,8 +331,7 @@ fn insert_msgs(
|
|||
let own_user_id = own_user_id.as_ref().map(|u| &u.0);
|
||||
for msg in msgs {
|
||||
insert_msg.execute(named_params! {
|
||||
":domain": room.domain,
|
||||
":room": room.name,
|
||||
":room": room,
|
||||
":id": WSnowflake(msg.id.0),
|
||||
":parent": msg.parent.map(|id| WSnowflake(id.0)),
|
||||
":previous_edit_id": msg.previous_edit_id.map(WSnowflake),
|
||||
|
|
@ -426,7 +359,7 @@ fn insert_msgs(
|
|||
|
||||
fn add_span(
|
||||
tx: &Transaction<'_>,
|
||||
room: &RoomIdentifier,
|
||||
room: &str,
|
||||
start: Option<MessageId>,
|
||||
end: Option<MessageId>,
|
||||
) -> rusqlite::Result<()> {
|
||||
|
|
@ -436,11 +369,10 @@ fn add_span(
|
|||
"
|
||||
SELECT start, end
|
||||
FROM euph_spans
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
",
|
||||
)?
|
||||
.query_map([&room.domain, &room.name], |row| {
|
||||
.query_map([room], |row| {
|
||||
let start = row.get::<_, Option<WSnowflake>>(0)?.map(|s| MessageId(s.0));
|
||||
let end = row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0));
|
||||
Ok((start, end))
|
||||
|
|
@ -480,23 +412,21 @@ fn add_span(
|
|||
tx.execute(
|
||||
"
|
||||
DELETE FROM euph_spans
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
",
|
||||
[&room.domain, &room.name],
|
||||
[room],
|
||||
)?;
|
||||
|
||||
// Re-insert combined spans for the room
|
||||
let mut stmt = tx.prepare(
|
||||
"
|
||||
INSERT INTO euph_spans (domain, room, start, end)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO euph_spans (room, start, end)
|
||||
VALUES (?, ?, ?)
|
||||
",
|
||||
)?;
|
||||
for (start, end) in result {
|
||||
stmt.execute(params![
|
||||
room.domain,
|
||||
room.name,
|
||||
room,
|
||||
start.map(|id| WSnowflake(id.0)),
|
||||
end.map(|id| WSnowflake(id.0))
|
||||
])?;
|
||||
|
|
@ -555,13 +485,12 @@ impl Action for GetLastSpan {
|
|||
"
|
||||
SELECT start, end
|
||||
FROM euph_spans
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
ORDER BY start DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row([&self.room.domain, &self.room.name], |row| {
|
||||
.query_row([self.room], |row| {
|
||||
Ok((
|
||||
row.get::<_, Option<WSnowflake>>(0)?.map(|s| MessageId(s.0)),
|
||||
row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
|
|
@ -581,12 +510,12 @@ impl Action for GetPath {
|
|||
.prepare(
|
||||
"
|
||||
WITH RECURSIVE
|
||||
path (domain, room, id) AS (
|
||||
VALUES (?, ?, ?)
|
||||
path (room, id) AS (
|
||||
VALUES (?, ?)
|
||||
UNION
|
||||
SELECT domain, room, parent
|
||||
SELECT room, parent
|
||||
FROM euph_msgs
|
||||
JOIN path USING (domain, room, id)
|
||||
JOIN path USING (room, id)
|
||||
)
|
||||
SELECT id
|
||||
FROM path
|
||||
|
|
@ -594,10 +523,9 @@ impl Action for GetPath {
|
|||
ORDER BY id ASC
|
||||
",
|
||||
)?
|
||||
.query_map(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|
||||
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
|
||||
)?
|
||||
.query_map(params![self.room, WSnowflake(self.id.0)], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(Path::new(path))
|
||||
}
|
||||
|
|
@ -611,22 +539,20 @@ impl Action for GetMsg {
|
|||
let msg = conn
|
||||
.query_row(
|
||||
"
|
||||
SELECT id, parent, time, user_id, name, content, seen
|
||||
SELECT id, parent, time, name, content, seen
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND id = ?
|
||||
",
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|
||||
params![self.room, WSnowflake(self.id.0)],
|
||||
|row| {
|
||||
Ok(SmallMessage {
|
||||
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
|
||||
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
time: row.get::<_, WTime>(2)?.0,
|
||||
user_id: UserId(row.get(3)?),
|
||||
nick: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
seen: row.get(6)?,
|
||||
nick: row.get(3)?,
|
||||
content: row.get(4)?,
|
||||
seen: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -646,40 +572,36 @@ impl Action for GetFullMsg {
|
|||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND id = ?
|
||||
"
|
||||
)?;
|
||||
|
||||
let msg = query
|
||||
.query_row(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|
||||
|row| {
|
||||
Ok(Message {
|
||||
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
|
||||
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0),
|
||||
time: row.get::<_, WTime>(3)?.0,
|
||||
content: row.get(4)?,
|
||||
encryption_key_id: row.get(5)?,
|
||||
edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0),
|
||||
deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0),
|
||||
truncated: row.get(8)?,
|
||||
sender: SessionView {
|
||||
id: UserId(row.get(9)?),
|
||||
name: row.get(10)?,
|
||||
server_id: row.get(11)?,
|
||||
server_era: row.get(12)?,
|
||||
session_id: SessionId(row.get(13)?),
|
||||
is_staff: row.get(14)?,
|
||||
is_manager: row.get(15)?,
|
||||
client_address: row.get(16)?,
|
||||
real_client_address: row.get(17)?,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
|
||||
Ok(Message {
|
||||
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
|
||||
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0),
|
||||
time: row.get::<_, WTime>(3)?.0,
|
||||
content: row.get(4)?,
|
||||
encryption_key_id: row.get(5)?,
|
||||
edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0),
|
||||
deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0),
|
||||
truncated: row.get(8)?,
|
||||
sender: SessionView {
|
||||
id: UserId(row.get(9)?),
|
||||
name: row.get(10)?,
|
||||
server_id: row.get(11)?,
|
||||
server_era: row.get(12)?,
|
||||
session_id: SessionId(row.get(13)?),
|
||||
is_staff: row.get(14)?,
|
||||
is_manager: row.get(15)?,
|
||||
client_address: row.get(16)?,
|
||||
real_client_address: row.get(17)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
|
@ -694,36 +616,31 @@ impl Action for GetTree {
|
|||
.prepare(
|
||||
"
|
||||
WITH RECURSIVE
|
||||
tree (domain, room, id) AS (
|
||||
VALUES (?, ?, ?)
|
||||
tree (room, id) AS (
|
||||
VALUES (?, ?)
|
||||
UNION
|
||||
SELECT euph_msgs.domain, euph_msgs.room, euph_msgs.id
|
||||
SELECT euph_msgs.room, euph_msgs.id
|
||||
FROM euph_msgs
|
||||
JOIN tree
|
||||
ON tree.domain = euph_msgs.domain
|
||||
AND tree.room = euph_msgs.room
|
||||
ON tree.room = euph_msgs.room
|
||||
AND tree.id = euph_msgs.parent
|
||||
)
|
||||
SELECT id, parent, time, user_id, name, content, seen
|
||||
SELECT id, parent, time, name, content, seen
|
||||
FROM euph_msgs
|
||||
JOIN tree USING (domain, room, id)
|
||||
JOIN tree USING (room, id)
|
||||
ORDER BY id ASC
|
||||
",
|
||||
)?
|
||||
.query_map(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)],
|
||||
|row| {
|
||||
Ok(SmallMessage {
|
||||
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
|
||||
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
time: row.get::<_, WTime>(2)?.0,
|
||||
user_id: UserId(row.get(3)?),
|
||||
nick: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
seen: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
.query_map(params![self.room, WSnowflake(self.root_id.0)], |row| {
|
||||
Ok(SmallMessage {
|
||||
id: MessageId(row.get::<_, WSnowflake>(0)?.0),
|
||||
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
|
||||
time: row.get::<_, WTime>(2)?.0,
|
||||
nick: row.get(3)?,
|
||||
content: row.get(4)?,
|
||||
seen: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(Tree::new(self.root_id, msgs))
|
||||
}
|
||||
|
|
@ -739,13 +656,12 @@ impl Action for GetFirstRootId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_trees
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row([&self.room.domain, &self.room.name], |row| {
|
||||
.query_row([self.room], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
|
|
@ -763,13 +679,12 @@ impl Action for GetLastRootId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_trees
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row([&self.room.domain, &self.room.name], |row| {
|
||||
.query_row([self.room], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
|
|
@ -787,17 +702,15 @@ impl Action for GetPrevRootId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_trees
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)],
|
||||
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
|
||||
)
|
||||
.query_row(params![self.room, WSnowflake(self.root_id.0)], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(root_id)
|
||||
}
|
||||
|
|
@ -813,17 +726,15 @@ impl Action for GetNextRootId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_trees
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)],
|
||||
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
|
||||
)
|
||||
.query_row(params![self.room, WSnowflake(self.root_id.0)], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(root_id)
|
||||
}
|
||||
|
|
@ -839,13 +750,12 @@ impl Action for GetOldestMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row([&self.room.domain, &self.room.name], |row| {
|
||||
.query_row([self.room], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
|
|
@ -863,13 +773,12 @@ impl Action for GetNewestMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row([&self.room.domain, &self.room.name], |row| {
|
||||
.query_row([self.room], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
|
|
@ -887,17 +796,15 @@ impl Action for GetOlderMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|
||||
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
|
||||
)
|
||||
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
|
@ -912,17 +819,15 @@ impl Action for GetNewerMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|
||||
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
|
||||
)
|
||||
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
|
@ -938,14 +843,13 @@ impl Action for GetOldestUnseenMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND NOT seen
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row([&self.room.domain, &self.room.name], |row| {
|
||||
.query_row([self.room], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
|
|
@ -963,14 +867,13 @@ impl Action for GetNewestUnseenMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND NOT seen
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row([&self.room.domain, &self.room.name], |row| {
|
||||
.query_row([self.room], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
|
|
@ -988,18 +891,16 @@ impl Action for GetOlderUnseenMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND NOT seen
|
||||
AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|
||||
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
|
||||
)
|
||||
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
|
@ -1015,18 +916,16 @@ impl Action for GetNewerUnseenMsgId {
|
|||
"
|
||||
SELECT id
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND NOT seen
|
||||
AND id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row(
|
||||
params![self.room.domain, self.room.name, WSnowflake(self.id.0)],
|
||||
|row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)),
|
||||
)
|
||||
.query_row(params![self.room, WSnowflake(self.id.0)], |row| {
|
||||
row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0))
|
||||
})
|
||||
.optional()?;
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
|
@ -1042,11 +941,10 @@ impl Action for GetUnseenMsgsCount {
|
|||
"
|
||||
SELECT amount
|
||||
FROM euph_unseen_counts
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
",
|
||||
)?
|
||||
.query_row(params![self.room.domain, self.room.name], |row| row.get(0))
|
||||
.query_row(params![self.room], |row| row.get(0))
|
||||
.optional()?
|
||||
.unwrap_or(0);
|
||||
Ok(amount)
|
||||
|
|
@ -1062,16 +960,10 @@ impl Action for SetSeen {
|
|||
"
|
||||
UPDATE euph_msgs
|
||||
SET seen = :seen
|
||||
WHERE domain = :domain
|
||||
AND room = :room
|
||||
WHERE room = :room
|
||||
AND id = :id
|
||||
",
|
||||
named_params! {
|
||||
":domain": self.room.domain,
|
||||
":room": self.room.name,
|
||||
":id": WSnowflake(self.id.0),
|
||||
":seen": self.seen,
|
||||
},
|
||||
named_params! { ":room": self.room, ":id": WSnowflake(self.id.0), ":seen": self.seen },
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1086,17 +978,11 @@ impl Action for SetOlderSeen {
|
|||
"
|
||||
UPDATE euph_msgs
|
||||
SET seen = :seen
|
||||
WHERE domain = :domain
|
||||
AND room = :room
|
||||
WHERE room = :room
|
||||
AND id <= :id
|
||||
AND seen != :seen
|
||||
",
|
||||
named_params! {
|
||||
":domain": self.room.domain,
|
||||
":room": self.room.name,
|
||||
":id": WSnowflake(self.id.0),
|
||||
":seen": self.seen,
|
||||
},
|
||||
named_params! { ":room": self.room, ":id": WSnowflake(self.id.0), ":seen": self.seen },
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1138,13 +1024,12 @@ impl Action for GetChunkAfter {
|
|||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
AND id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?
|
||||
")?
|
||||
.query_map(params![self.room.domain, self.room.name, WSnowflake(id.0), self.amount], row2msg)?
|
||||
.query_map(params![self.room, WSnowflake(id.0), self.amount], row2msg)?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
} else {
|
||||
conn.prepare("
|
||||
|
|
@ -1152,12 +1037,11 @@ impl Action for GetChunkAfter {
|
|||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
|
||||
FROM euph_msgs
|
||||
WHERE domain = ?
|
||||
AND room = ?
|
||||
WHERE room = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?
|
||||
")?
|
||||
.query_map(params![self.room.domain, self.room.name, self.amount], row2msg)?
|
||||
.query_map(params![self.room, self.amount], row2msg)?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
use rusqlite::Transaction;
|
||||
use vault::Migration;
|
||||
|
||||
pub const MIGRATIONS: [Migration; 3] = [m1, m2, m3];
|
||||
|
||||
fn eprint_status(nr: usize, total: usize) {
|
||||
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
|
||||
}
|
||||
pub const MIGRATIONS: [Migration; 2] = [m1, m2];
|
||||
|
||||
fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprint_status(nr, total);
|
||||
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
|
||||
tx.execute_batch(
|
||||
"
|
||||
CREATE TABLE euph_rooms (
|
||||
|
|
@ -71,7 +67,7 @@ fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()>
|
|||
}
|
||||
|
||||
fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprint_status(nr, total);
|
||||
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
|
||||
tx.execute_batch(
|
||||
"
|
||||
ALTER TABLE euph_msgs
|
||||
|
|
@ -82,143 +78,3 @@ fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()>
|
|||
",
|
||||
)
|
||||
}
|
||||
|
||||
fn m3(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprint_status(nr, total);
|
||||
println!(" This migration might take quite a while.");
|
||||
println!(" Aborting it will not corrupt your vault.");
|
||||
|
||||
// Rooms should be identified not just via their name but also their domain.
|
||||
// The domain should be required but there should be no default value.
|
||||
//
|
||||
// To accomplish this, we need to recreate and repopulate all euph related
|
||||
// tables because SQLite's ALTER TABLE is not powerful enough.
|
||||
|
||||
eprintln!(" Preparing tables...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
DROP INDEX euph_idx_msgs_room_id_parent;
|
||||
DROP INDEX euph_idx_msgs_room_parent_id;
|
||||
DROP INDEX euph_idx_msgs_room_id_seen;
|
||||
|
||||
ALTER TABLE euph_rooms RENAME TO old_euph_rooms;
|
||||
ALTER TABLE euph_msgs RENAME TO old_euph_msgs;
|
||||
ALTER TABLE euph_spans RENAME TO old_euph_spans;
|
||||
ALTER TABLE euph_cookies RENAME TO old_euph_cookies;
|
||||
|
||||
CREATE TABLE euph_rooms (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
first_joined INT NOT NULL,
|
||||
last_joined INT NOT NULL,
|
||||
|
||||
PRIMARY KEY (domain, room)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_msgs (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
seen INT NOT NULL,
|
||||
|
||||
-- Message
|
||||
id INT NOT NULL,
|
||||
parent INT,
|
||||
previous_edit_id INT,
|
||||
time INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
encryption_key_id TEXT,
|
||||
edited INT,
|
||||
deleted INT,
|
||||
truncated INT NOT NULL,
|
||||
|
||||
-- SessionView
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
server_id TEXT NOT NULL,
|
||||
server_era TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
is_staff INT NOT NULL,
|
||||
is_manager INT NOT NULL,
|
||||
client_address TEXT,
|
||||
real_client_address TEXT,
|
||||
|
||||
PRIMARY KEY (domain, room, id),
|
||||
FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room)
|
||||
ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_spans (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
start INT,
|
||||
end INT,
|
||||
|
||||
UNIQUE (domain, room, start, end),
|
||||
FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room)
|
||||
ON DELETE CASCADE,
|
||||
CHECK (start IS NULL OR end IS NOT NULL)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_cookies (
|
||||
domain TEXT NOT NULL,
|
||||
cookie TEXT NOT NULL
|
||||
) STRICT;
|
||||
",
|
||||
)?;
|
||||
|
||||
eprintln!(" Migrating data...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
INSERT INTO euph_rooms (domain, room, first_joined, last_joined)
|
||||
SELECT 'euphoria.io', room, first_joined, last_joined
|
||||
FROM old_euph_rooms;
|
||||
|
||||
INSERT INTO euph_msgs (
|
||||
domain, room, seen,
|
||||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
|
||||
)
|
||||
SELECT
|
||||
'euphoria.io', room, seen,
|
||||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
|
||||
FROM old_euph_msgs;
|
||||
|
||||
INSERT INTO euph_spans (domain, room, start, end)
|
||||
SELECT 'euphoria.io', room, start, end
|
||||
FROM old_euph_spans;
|
||||
|
||||
INSERT INTO euph_cookies (domain, cookie)
|
||||
SELECT 'euphoria.io', cookie
|
||||
FROM old_euph_cookies;
|
||||
",
|
||||
)?;
|
||||
|
||||
eprintln!(" Recreating indexes...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
CREATE INDEX euph_idx_msgs_domain_room_id_parent
|
||||
ON euph_msgs (domain, room, id, parent);
|
||||
|
||||
CREATE INDEX euph_idx_msgs_domain_room_parent_id
|
||||
ON euph_msgs (domain, room, parent, id);
|
||||
|
||||
CREATE INDEX euph_idx_msgs_domain_room_id_seen
|
||||
ON euph_msgs (domain, room, id, seen);
|
||||
",
|
||||
)?;
|
||||
|
||||
eprintln!(" Cleaning up loose ends...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
DROP TABLE old_euph_rooms;
|
||||
DROP TABLE old_euph_msgs;
|
||||
DROP TABLE old_euph_spans;
|
||||
DROP TABLE old_euph_cookies;
|
||||
|
||||
ANALYZE;
|
||||
",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,28 @@
|
|||
use rusqlite::Connection;
|
||||
|
||||
pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
||||
eprintln!("Preparing vault");
|
||||
|
||||
// Cache ids of tree roots.
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TEMPORARY TABLE euph_trees (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
id INT NOT NULL,
|
||||
|
||||
PRIMARY KEY (domain, room, id)
|
||||
PRIMARY KEY (room, id)
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO euph_trees (domain, room, id)
|
||||
SELECT domain, room, id
|
||||
INSERT INTO euph_trees (room, id)
|
||||
SELECT room, id
|
||||
FROM euph_msgs
|
||||
WHERE parent IS NULL
|
||||
UNION
|
||||
SELECT domain, room, parent
|
||||
SELECT room, parent
|
||||
FROM euph_msgs
|
||||
WHERE parent IS NOT NULL
|
||||
AND NOT EXISTS(
|
||||
SELECT *
|
||||
FROM euph_msgs AS parents
|
||||
WHERE parents.domain = euph_msgs.domain
|
||||
AND parents.room = euph_msgs.room
|
||||
WHERE parents.room = euph_msgs.room
|
||||
AND parents.id = euph_msgs.parent
|
||||
);
|
||||
|
||||
|
|
@ -34,16 +30,15 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
AFTER DELETE ON main.euph_rooms
|
||||
BEGIN
|
||||
DELETE FROM euph_trees
|
||||
WHERE domain = old.domain
|
||||
AND room = old.room;
|
||||
WHERE room = old.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER et_insert_msg_without_parent
|
||||
AFTER INSERT ON main.euph_msgs
|
||||
WHEN new.parent IS NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO euph_trees (domain, room, id)
|
||||
VALUES (new.domain, new.room, new.id);
|
||||
INSERT OR IGNORE INTO euph_trees (room, id)
|
||||
VALUES (new.room, new.id);
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER et_insert_msg_with_parent
|
||||
|
|
@ -51,18 +46,16 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
WHEN new.parent IS NOT NULL
|
||||
BEGIN
|
||||
DELETE FROM euph_trees
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room
|
||||
WHERE room = new.room
|
||||
AND id = new.id;
|
||||
|
||||
INSERT OR IGNORE INTO euph_trees (domain, room, id)
|
||||
INSERT OR IGNORE INTO euph_trees (room, id)
|
||||
SELECT *
|
||||
FROM (VALUES (new.domain, new.room, new.parent))
|
||||
FROM (VALUES (new.room, new.parent))
|
||||
WHERE NOT EXISTS(
|
||||
SELECT *
|
||||
FROM euph_msgs
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room
|
||||
WHERE room = new.room
|
||||
AND id = new.parent
|
||||
AND parent IS NOT NULL
|
||||
);
|
||||
|
|
@ -74,37 +67,35 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
conn.execute_batch(
|
||||
"
|
||||
CREATE TEMPORARY TABLE euph_unseen_counts (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (domain, room)
|
||||
PRIMARY KEY (room)
|
||||
) STRICT;
|
||||
|
||||
-- There must be an entry for every existing room.
|
||||
INSERT INTO euph_unseen_counts (domain, room, amount)
|
||||
SELECT domain, room, 0
|
||||
INSERT INTO euph_unseen_counts (room, amount)
|
||||
SELECT room, 0
|
||||
FROM euph_rooms;
|
||||
|
||||
INSERT OR REPLACE INTO euph_unseen_counts (domain, room, amount)
|
||||
SELECT domain, room, COUNT(*)
|
||||
INSERT OR REPLACE INTO euph_unseen_counts (room, amount)
|
||||
SELECT room, COUNT(*)
|
||||
FROM euph_msgs
|
||||
WHERE NOT seen
|
||||
GROUP BY domain, room;
|
||||
GROUP BY room;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_insert_room
|
||||
AFTER INSERT ON main.euph_rooms
|
||||
BEGIN
|
||||
INSERT INTO euph_unseen_counts (domain, room, amount)
|
||||
VALUES (new.domain, new.room, 0);
|
||||
INSERT INTO euph_unseen_counts (room, amount)
|
||||
VALUES (new.room, 0);
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_delete_room
|
||||
AFTER DELETE ON main.euph_rooms
|
||||
BEGIN
|
||||
DELETE FROM euph_unseen_counts
|
||||
WHERE domain = old.domain
|
||||
AND room = old.room;
|
||||
WHERE room = old.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_insert_msg
|
||||
|
|
@ -113,8 +104,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
BEGIN
|
||||
UPDATE euph_unseen_counts
|
||||
SET amount = amount + 1
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room;
|
||||
WHERE room = new.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_update_msg
|
||||
|
|
@ -123,8 +113,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
BEGIN
|
||||
UPDATE euph_unseen_counts
|
||||
SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room;
|
||||
WHERE room = new.room;
|
||||
END;
|
||||
",
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
pub const NAME: &str = env!("CARGO_PKG_NAME");
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
47
flake.lock
generated
Normal file
47
flake.lock
generated
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"nodes": {
|
||||
"naersk": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1679567394,
|
||||
"narHash": "sha256-ZvLuzPeARDLiQUt6zSZFGOs+HZmE+3g4QURc8mkBsfM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"rev": "88cd22380154a2c36799fe8098888f0f59861a15",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1680643271,
|
||||
"narHash": "sha256-m76rYcvqs+NzTyETfxh1o/9gKdBuJ/Hl+PI/kp73mDw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "246567a3ad88e3119c2001e2fe78be233474cde0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"naersk": "naersk",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
29
flake.nix
Normal file
29
flake.nix
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
description = "TUI client for euphoria.io, a threaded real-time chat platform";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs";
|
||||
|
||||
naersk.url = "github:nix-community/naersk";
|
||||
naersk.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, naersk }:
|
||||
let forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
||||
in {
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
naersk' = pkgs.callPackage naersk { };
|
||||
cargoToml = pkgs.lib.importTOML ./Cargo.toml;
|
||||
in
|
||||
{
|
||||
default = naersk'.buildPackage {
|
||||
name = "cove";
|
||||
version = cargoToml.workspace.package.version;
|
||||
root = ./.;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue