Compare commits

...

109 commits

Author SHA1 Message Date
10214f3369 Fix clippy warning 2025-06-27 11:03:06 +02:00
2ca6190d97 Use older ubuntu runner for older glibc version 2025-05-31 15:08:24 +02:00
67e77c8880 Show emoji hash in nick list 2025-05-31 15:06:55 +02:00
b70d7548da Bump version to 0.9.3 2025-05-31 13:54:07 +02:00
732d462775 Add user id based emoji hash
Helpful in scenarios where you want to disambiguate people based on
their user id at a glance.
2025-05-31 13:39:34 +02:00
40de073799 Silence clippy warning 2025-05-31 13:11:50 +02:00
8b928184e8 Fix autojoin key connecting to non-autojoin rooms 2025-04-08 23:52:27 +02:00
ca0f0b6c31 Bump version to 0.9.2 2025-03-14 15:13:14 +01:00
74fbf386b2 Update dependencies 2025-03-14 15:08:05 +01:00
a17630aeaa Ring bell when mentioned 2025-03-08 19:34:51 +01:00
496cdde18d Bump version to 0.9.1 2025-03-01 14:38:41 +01:00
6157ca5088 Update dependencies 2025-03-01 14:26:18 +01:00
30c344031a Fix rendering glitches with unicode-based width estimation 2025-02-28 14:32:30 +01:00
4cf6a15577 Bump version to 0.9.0 2025-02-24 00:00:15 +01:00
b207e91c25 Update dependencies 2025-02-24 00:00:15 +01:00
676c92752d Remove flake
A bit annoying to keep up-to-date since I don't use it myself.
2025-02-24 00:00:15 +01:00
cc436bbb3a Mention --width-estimation-method in config docs 2025-02-24 00:00:15 +01:00
56896a861e Mention release binaries in readme 2025-02-24 00:00:15 +01:00
03b91ec1cd Mention ghostty in config docs 2025-02-23 23:08:17 +01:00
cab37cb633 Fix non-replaceable emoji being rendered without colons 2025-02-23 22:55:47 +01:00
967293db37 Fix links wrapping into oblivion 2025-02-23 22:54:55 +01:00
972e4938aa Run tests in CI 2025-02-23 22:46:09 +01:00
b64f56fce5 Fix nick color in rare edge cases 2025-02-23 22:41:25 +01:00
b4c4a89625 Fix mention color of non-ascii nicks
The old code included the @ in mention color computations. If the nick
consisted only of weird unicode characters, this resulted in an
incorrect color being computed.
2025-02-23 22:03:43 +01:00
9435fbece6 Fix mention highlighting 2025-02-23 21:57:02 +01:00
315db43010 Fix /me not being grey and italic 2025-02-23 21:46:32 +01:00
24c8c92070 Update emoji 2025-02-23 21:42:13 +01:00
bf9a9d640b Navigate to rooms using message links list 2025-02-23 21:32:44 +01:00
8040b82ff1 Refactor euph message content highlighting 2025-02-23 20:49:43 +01:00
17185ea536 Add unicode-based grapheme width estimation method 2025-02-23 18:37:17 +01:00
900a686d0d Fix changelog heading
"Updated" does not conform to Keep a Changelog.
2025-02-23 04:28:21 +01:00
2fa1bec421 Remove special rl2dev code 2025-02-23 04:27:22 +01:00
e750f81b11 Drop once_cell dependency
It's now part of the standard library :D
2025-02-22 16:42:26 +01:00
866176dab6 Move cursor to new room in room list 2025-02-22 12:33:59 +01:00
bf11e055b6 Reformat changelog
There should always be space around headlines and lists in markdown
documents.
2025-02-22 12:33:59 +01:00
6c884f3077 Update RPIT lifetime bounds for the 2024 edition
When the bound matches the implicit bound, i.e. when you'd just write

    impl ... + use<'_>

then it can be omitted. My gut instinct is to always write the bound
explicitly, but maybe that'll harm readability once I'm more used to how
bounds work now.

Anyways, always try to keep the bound as small as possible, ideally just

    impl ... + use<>
2025-02-21 19:18:01 +01:00
d29e3e6651 Set up GitHub CI 2025-02-21 15:02:21 +01:00
fbc64de607 Merge and reorder imports
This change brings them in-line with the default settings of
rust-analyzer. Originally, I disliked this style, but by now, I've grown
used to it and prefer it slightly. Also, I like staying on the default
path since I usually don't need to check the imports to see if
rust-analyzer messed anything up (except for omitting the self:: when
importing modules defined in the current module, grr :D).

I've also come around to the idea of trailing mod definitions instead of
putting them at the top: I would also put module definitions that
contain code instead of referencing another file below the imports. It
feels weird to have code above imports. So it just makes sense to do the
same for all types of mod definitions.

If rustfmt ever gains stable support for grouping imports, I'll probably
use that instead.
2025-02-21 12:46:25 +01:00
816d8f86a3 Migrate rustfmt style to 2024 edition 2025-02-21 12:17:57 +01:00
25d2cc7c98 Migrate to 2024 edition 2025-02-21 12:17:57 +01:00
f45e66f572 Fix or ignore 2024 edition migration lints 2025-02-21 12:11:58 +01:00
bd43fe060b Update dependencies
Except rusqlite and vault, because newer sqlite versions appear to
result in a *very* big performance drop. I suspect the query planner,
though I really have no idea.
2025-02-21 00:41:01 +01:00
e1ba15cb9e Update time_zone docs 2025-02-21 00:32:44 +01:00
edc4219258 Update euphoxide and jiff 2025-02-21 00:31:50 +01:00
55d4321770 Fix outdated reference in config docs
Thanks, JRF!
2025-02-20 19:40:29 +01:00
e80d41cc47 Fix panic due to rustls 2024-12-04 21:17:11 +01:00
f471b9ce00 Switch to jiff from time 2024-12-04 21:08:06 +01:00
2ecc482533 Configure all deps in workspace 2024-12-04 20:20:17 +01:00
cff933b0bf Add and fix more lints 2024-12-04 20:14:41 +01:00
e43b27acfd Satisfy clippy lint 2024-11-06 22:04:57 +01:00
461cc37d88 Remove unused method 2024-11-06 22:01:56 +01:00
106a047b78 Fix clippy lint about lints 2024-11-06 22:01:56 +01:00
7aba041c9f Bump version to 0.8.3 2024-05-20 19:18:13 +02:00
19242a658e Update dependencies 2024-05-20 19:12:41 +02:00
db734c5740 Bump version to 0.8.2 2024-04-25 20:45:00 +02:00
d3666674b2 Update dependencies 2024-04-25 20:41:04 +02:00
c2cfa6e527 Remove todos 2024-04-25 20:38:43 +02:00
3a3d42bcf3 Fix clippy warning 2024-04-25 20:38:43 +02:00
db529688e9 Use cargo workspace lints 2024-04-25 20:38:21 +02:00
131b581880 Change json-stream export format to json-lines 2024-03-08 22:19:21 +01:00
50be653244 Fix incorrect cli option reference 2024-01-27 13:21:59 +01:00
998a2f2ffd Fix crash when window too small with msg editor visible 2024-01-14 12:41:48 +01:00
a5b33440c5 Fix spelling of "indexes" 2024-01-12 22:21:44 +01:00
133681fc62 Bump version to v0.8.1 2024-01-11 11:25:45 +01:00
37e4c6b845 Update dependencies 2024-01-11 11:22:34 +01:00
ee7121b04e Implement live caesar cipher 2024-01-05 23:29:41 +01:00
ab81c89854 Set window title
The title includes the amount of unseen messages in the room or room
list. The room list heading also now contains the amount of connected
rooms as well as the amount of unseen messages in total (if any).
2024-01-05 15:07:20 +01:00
ed7bd3ddb4 Remove key binding to open present page 2024-01-04 23:50:35 +01:00
6352fcf639 Bump version to v0.8.0 2024-01-04 13:55:20 +01:00
a0b029b353 Update dependencies 2024-01-04 13:52:18 +01:00
dadbb205e5 Fix cargo doc warnings 2024-01-03 23:51:27 +01:00
e0948c4f1c Fix duplicated key presses on windows 2024-01-03 23:51:20 +01:00
88ba77b955 Move domains in front of room names in the UI
The reasoning behind this change in the room list is that putting the
domain (which rarely changes) in front of the room name (which often
changes) is a lot more readable. It also moves it away from the status
info parentheses, making them more obvious again.

The reasoning for the individual room view is consistency. Putting the
domain at the end here looked fine, but putting it in front matches the
room list and still looks fine.
2024-01-03 18:56:29 +01:00
2d2dab11ba Reduce connection timout to 10 seconds 2024-01-03 18:13:15 +01:00
a8570cf9c5 Make TZ env var override time_zone config option 2024-01-03 17:03:00 +01:00
f94eb00dcd Clean up config docs 2024-01-03 16:30:08 +01:00
f555414716 Improve tz load error message very slightly 2024-01-03 02:53:04 +01:00
1189e3eb7b Display chat time stamps in configured time zone 2024-01-03 02:20:59 +01:00
43a8b91dca Load time zone when opening vault 2024-01-03 02:06:33 +01:00
c286e0244c Add time_zone config option 2024-01-03 02:05:53 +01:00
956d3013ea Remove unused code 2024-01-02 18:25:31 +01:00
a1b0e151ff Update changelog 2024-01-02 18:21:55 +01:00
1bf4035c57 Remove message editor cursor when unfocused 2024-01-02 17:52:32 +01:00
cc1a2866eb Simplify retrieving Joined from room::State 2024-01-02 17:32:59 +01:00
5bbf389dbe Remove some old todos 2024-01-02 17:31:36 +01:00
b302229d7c Mention that F1 popup can be scrolled 2024-01-02 17:17:30 +01:00
2deecc2084 Add welcome info box next to room list 2024-01-02 17:03:41 +01:00
a522b09f79 Use let-else in some more places 2024-01-02 15:43:47 +01:00
4e6e413f3d Fix not being able to enter . in domain field 2024-01-02 15:27:32 +01:00
72310d87f5 Update changelog 2024-01-02 15:02:15 +01:00
85cf99387e Extract room deletion popup into module 2024-01-02 13:58:49 +01:00
970bc07ed9 Support choosing domain in room connection popup 2024-01-02 13:51:51 +01:00
f4967731a1 Fix room deletion popup not checking entered name
From the looks of it, I accidentally broke it in v0.7.0 in commit
9bc6931fae.
2024-01-02 13:01:14 +01:00
13a4fa0938 Support domain in room deletion popup 2024-01-02 12:53:59 +01:00
2a10a7a39f Add todos 2024-01-02 12:36:54 +01:00
6a2f9de85b Update changelog 2024-01-02 12:36:20 +01:00
fdbd6e0c55 Simplify getting Joined room state 2024-01-01 19:45:32 +01:00
5995d06cad Support domain in config file 2024-01-01 19:35:46 +01:00
c094f526a5 Fix room names and domains being swapped in room list 2024-01-01 13:06:48 +01:00
60fdc40a21 Fix incorrect room name in url
I naïvely implemented Display for RoomIdentifier, which lead to me
passing room names like "&test@euphoria.leet.nu" to euphoxide instead of
"test". Of course, no room of that name exists, so every attempted
connection failed.

To figure this out, I manually relaxed the verbosity filters to let
through all euphoxide log messages and recompiled.

Only implementing Debug led to compile errors whereever I misused the
Disply instance, so at least the bug fix was nice and easy once I knew
what happened.
2024-01-01 02:18:32 +01:00
78bbfac2f3 Fix domain errors in UI 2024-01-01 02:18:32 +01:00
708d66b256 Support domain when clearing cookies 2024-01-01 01:38:22 +01:00
1f1795f111 Support domain when exporting room logs 2024-01-01 01:38:22 +01:00
2bbfca7002 Respect domain in euph room 2024-01-01 01:38:22 +01:00
da1d23646a Migrate euph vault to respect domain 2024-01-01 01:37:59 +01:00
6b7ab3584a Switch domain mentions to euphoria.leet.nu 2023-12-31 20:15:13 +01:00
076c8f1a72 Include domain in temporary tables 2023-12-31 19:35:57 +01:00
c6a1dd8632 Migrate vault identify rooms by their name and domain 2023-12-31 19:35:52 +01:00
39a4f29a2a Update dependencies 2023-12-28 20:05:18 +01:00
68 changed files with 3329 additions and 2014 deletions

75
.github/workflows/build.yml vendored Normal file
View file

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

View file

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

View file

@ -4,25 +4,135 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Procedure when bumping the version number: Procedure when bumping the version number:
1. Update dependencies in a separate commit 1. Update dependencies in a separate commit
2. Set version number in `Cargo.toml` 2. Set version number in `Cargo.toml`
3. Add new section in this changelog 3. Add new section in this changelog
4. Run `cargo run help-config > CONFIG.md` 4. Run `cargo run help-config > CONFIG.md`
5. Commit with message `Bump version to X.Y.Z` 5. Commit with message `Bump version to X.Y.Z`
6. Create tag named `vX.Y.Z` 6. Create tag named `vX.Y.Z`
7. Fast-forward branch `latest` 7. Push `master` and the new tag
8. Push `master`, `latest` and the new tag
## Unreleased ## 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 ## v0.7.1 - 2023-08-31
### Changed ### Changed
- Updated dependencies - Updated dependencies
## v0.7.0 - 2023-05-14 ## v0.7.0 - 2023-05-14
### Added ### Added
- Auto-generated config documentation - Auto-generated config documentation
- in [CONFIG.md](CONFIG.md) - in [CONFIG.md](CONFIG.md)
- via `help-config` CLI command - via `help-config` CLI command
@ -30,6 +140,7 @@ Procedure when bumping the version number:
- `measure_widths` config option - `measure_widths` config option
### Changed ### Changed
- Overhauled widget system and extracted generic widgets to [toss](https://github.com/Garmelon/toss) - Overhauled widget system and extracted generic widgets to [toss](https://github.com/Garmelon/toss)
- Overhauled config system to support auto-generating documentation - Overhauled config system to support auto-generating documentation
- Overhauled key binding system to make key bindings configurable - Overhauled key binding system to make key bindings configurable
@ -43,15 +154,18 @@ Procedure when bumping the version number:
## v0.6.1 - 2023-04-10 ## v0.6.1 - 2023-04-10
### Changed ### Changed
- Improved JSON export performance - Improved JSON export performance
- Always show rooms from config file in room list - Always show rooms from config file in room list
### Fixed ### Fixed
- Rooms reconnecting instead of showing error popups - Rooms reconnecting instead of showing error popups
## v0.6.0 - 2023-04-04 ## v0.6.0 - 2023-04-04
### Added ### Added
- Emoji support - Emoji support
- `flake.nix`, making cove available as a nix flake - `flake.nix`, making cove available as a nix flake
- `json-stream` room export format - `json-stream` room export format
@ -59,31 +173,37 @@ Procedure when bumping the version number:
- `--verbose` flag - `--verbose` flag
### Changed ### Changed
- Non-export info is now printed to stderr instead of stdout - Non-export info is now printed to stderr instead of stdout
- Recognizes links without scheme (e.g. `euphoria.io` instead of `https://euphoria.io`) - 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 - Rooms waiting for reconnect are no longer sorted to bottom in default sort order
### Fixed ### Fixed
- Mentions not being stopped by `>` - Mentions not being stopped by `>`
## v0.5.2 - 2023-01-14 ## v0.5.2 - 2023-01-14
### Added ### Added
- Key binding to open present page - Key binding to open present page
### Changed ### Changed
- Always connect to &rl2dev in ephemeral mode - Always connect to &rl2dev in ephemeral mode
- Reduce amount of messages per &rl2dev log request - Reduce amount of messages per &rl2dev log request
## v0.5.1 - 2022-11-27 ## v0.5.1 - 2022-11-27
### Changed ### Changed
- Increase reconnect delay to one minute - Increase reconnect delay to one minute
- Print errors that occurred while cove was running more compactly - Print errors that occurred while cove was running more compactly
## v0.5.0 - 2022-09-26 ## v0.5.0 - 2022-09-26
### Added ### Added
- Key bindings to navigate nick list - Key bindings to navigate nick list
- Room deletion confirmation popup - Room deletion confirmation popup
- Message inspection popup - Message inspection popup
@ -92,10 +212,12 @@ Procedure when bumping the version number:
- `rooms_sort_order` config option - `rooms_sort_order` config option
### Changed ### Changed
- Use nick changes to detect sessions for nick list - Use nick changes to detect sessions for nick list
- Support Unicode 15 - Support Unicode 15
### Fixed ### Fixed
- Cursor being visible through popups - Cursor being visible through popups
- Cursor in lists when highlighted item moves off-screen - Cursor in lists when highlighted item moves off-screen
- User disappearing from nick list when only one of their sessions disconnects - User disappearing from nick list when only one of their sessions disconnects
@ -103,6 +225,7 @@ Procedure when bumping the version number:
## v0.4.0 - 2022-09-01 ## v0.4.0 - 2022-09-01
### Added ### Added
- Config file and `--config` cli option - Config file and `--config` cli option
- `data_dir` config option - `data_dir` config option
- `ephemeral` config option - `ephemeral` config option
@ -118,14 +241,17 @@ Procedure when bumping the version number:
- Key bindings to view and open links in a message - Key bindings to view and open links in a message
### Changed ### Changed
- Some key bindings in the rooms list - Some key bindings in the rooms list
### Fixed ### Fixed
- Rooms being stuck in "Connecting" state - Rooms being stuck in "Connecting" state
## v0.3.0 - 2022-08-22 ## v0.3.0 - 2022-08-22
### Added ### Added
- Account login and logout - Account login and logout
- Authentication dialog for password-protected rooms - Authentication dialog for password-protected rooms
- Error popups in rooms when something goes wrong - Error popups in rooms when something goes wrong
@ -133,10 +259,12 @@ Procedure when bumping the version number:
- Key binding to download more logs - Key binding to download more logs
### Changed ### Changed
- Reduced amount of unnecessary redraws - Reduced amount of unnecessary redraws
- Description of `export` CLI command - Description of `export` CLI command
### Fixed ### Fixed
- Crash when connecting to nonexistent rooms - Crash when connecting to nonexistent rooms
- Crash when connecting to rooms that require authentication - Crash when connecting to rooms that require authentication
- Pasting multi-line strings into the editor - Pasting multi-line strings into the editor
@ -144,15 +272,18 @@ Procedure when bumping the version number:
## v0.2.1 - 2022-08-11 ## v0.2.1 - 2022-08-11
### Added ### Added
- Support for modifiers on special keys via the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) - Support for modifiers on special keys via the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
### Fixed ### Fixed
- Joining new rooms no longer crashes cove - Joining new rooms no longer crashes cove
- Scrolling when exiting message editor - Scrolling when exiting message editor
## v0.2.0 - 2022-08-10 ## v0.2.0 - 2022-08-10
### Added ### Added
- New messages are now marked as unseen - New messages are now marked as unseen
- Sub-trees can now be folded - Sub-trees can now be folded
- Support for pasting text into editors - Support for pasting text into editors
@ -165,10 +296,12 @@ Procedure when bumping the version number:
- Support for exporting multiple/all rooms at once - Support for exporting multiple/all rooms at once
### Changed ### Changed
- Reorganized export command - Reorganized export command
- Slowed down room history download speed - Slowed down room history download speed
### Fixed ### Fixed
- Chat rendering when deleting and re-joining a room - Chat rendering when deleting and re-joining a room
- Spacing in some popups - Spacing in some popups

143
CONFIG.md
View file

@ -8,15 +8,11 @@ Here is an example config that changes a few different options:
measure_widths = true measure_widths = true
rooms_sort_order = "importance" rooms_sort_order = "importance"
[euph.rooms.welcome] [euph.servers."euphoria.leet.nu".rooms]
autojoin = true welcome.autojoin = true
test.username = "badingle"
[euph.rooms.test] test.force_username = true
username = "badingle" private.password = "foobar"
force_username = true
[euph.rooms.private]
password = "foobar"
[keys] [keys]
general.abort = ["esc", "ctrl+c"] general.abort = ["esc", "ctrl+c"]
@ -24,17 +20,6 @@ general.exit = "ctrl+q"
tree.action.fold_tree = "f" 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
Key bindings are specified as strings or lists of strings. Each string specifies Key bindings are specified as strings or lists of strings. Each string specifies
@ -68,6 +53,14 @@ Available modifiers:
## Available options ## 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` ### `data_dir`
**Required:** no **Required:** no
@ -94,7 +87,7 @@ any options related to the data dir.
See also the `--ephemeral` command line option. See also the `--ephemeral` command line option.
### `euph.rooms.<room>.autojoin` ### `euph.servers.<domain>.rooms.<room>.autojoin`
**Required:** yes **Required:** yes
**Type:** boolean **Type:** boolean
@ -102,17 +95,17 @@ See also the `--ephemeral` command line option.
Whether to automatically join this room on startup. Whether to automatically join this room on startup.
### `euph.rooms.<room>.force_username` ### `euph.servers.<domain>.rooms.<room>.force_username`
**Required:** yes **Required:** yes
**Type:** boolean **Type:** boolean
**Default:** `false` **Default:** `false`
If `euph.rooms.<room>.username` is set, this will force cove to set the If `euph.servers.<domain>.rooms.<room>.username` is set, this will force
username even if there is already a different username associated with cove to set the username even if there is already a different username
the current session. associated with the current session.
### `euph.rooms.<room>.password` ### `euph.servers.<domain>.rooms.<room>.password`
**Required:** no **Required:** no
**Type:** string **Type:** string
@ -120,7 +113,7 @@ the current session.
If set, cove will try once to use this password to authenticate, should If set, cove will try once to use this password to authenticate, should
the room be password-protected. the room be password-protected.
### `euph.rooms.<room>.username` ### `euph.servers.<domain>.rooms.<room>.username`
**Required:** no **Required:** no
**Type:** string **Type:** string
@ -336,14 +329,6 @@ Download more messages.
Change nick. 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` ### `keys.rooms.action.change_sort_order`
**Required:** yes **Required:** yes
@ -472,6 +457,14 @@ Scroll up half a screen.
Scroll up one line. 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` ### `keys.tree.action.fold_tree`
**Required:** yes **Required:** yes
@ -480,6 +473,14 @@ Scroll up one line.
Fold current message's subtree. 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` ### `keys.tree.action.inspect`
**Required:** yes **Required:** yes
@ -536,6 +537,14 @@ Reply to message, inline if possible.
Reply opposite to normal reply. 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` ### `keys.tree.action.toggle_seen`
**Required:** yes **Required:** yes
@ -614,14 +623,13 @@ Move to root.
**Type:** boolean **Type:** boolean
**Default:** `false` **Default:** `false`
Whether to measure the width of characters as displayed by the terminal Whether to measure the width of graphemes (i.e. characters) as displayed
emulator instead of guessing the width. by the terminal emulator instead of estimating the width.
Enabling this makes rendering a bit slower but more accurate. The screen Enabling this makes rendering a bit slower but more accurate. The screen
might also flash when encountering new characters (or, more accurately, might also flash when encountering new graphemes.
graphemes).
See also the `--measure-graphemes` command line option. See also the `--measure-widths` command line option.
### `offline` ### `offline`
@ -642,15 +650,62 @@ See also the `--offline` command line option.
**Required:** yes **Required:** yes
**Type:** string **Type:** string
**Values:** `"alphabet"`, `"importance"` **Values:** `"alphabet"`, `"importance"`
**Default:** `alphabet` **Default:** `"alphabet"`
Initial sort order of rooms list. 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 `"importance"` sorts rooms by the following criteria (in descending
of priority): order of priority):
1. connected rooms before unconnected rooms 1. connected rooms before unconnected rooms
2. rooms with unread messages before rooms without 2. rooms with unread messages before rooms without
3. alphabetic order 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.

1343
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,12 +1,17 @@
# cove # cove
Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded
real-time chat platform. real-time chat platform.
![A very meta screenshot](screenshot.png) ![A very meta screenshot](screenshot.png)
It runs on Linux, Windows, and macOS. 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 ## Using cove
To start cove, simply run `cove` in your terminal. For more info about the To start cove, simply run `cove` in your terminal. For more info about the
@ -26,61 +31,3 @@ file or via `cove help-config`.
When launched, cove prints the location it is loading its config file from. To 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 configure cove, create a config file at that location. This location can be
changed via the `--config` command line option. 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
```

0
cove-config/CONFIG.md Normal file
View file

View file

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

View file

@ -1,7 +1,6 @@
//! Auto-generate markdown documentation. //! Auto-generate markdown documentation.
use std::collections::HashMap; use std::{collections::HashMap, path::PathBuf};
use std::path::PathBuf;
use cove_input::KeyBinding; use cove_input::KeyBinding;
pub use cove_macro::Document; pub use cove_macro::Document;
@ -17,15 +16,11 @@ Here is an example config that changes a few different options:
measure_widths = true measure_widths = true
rooms_sort_order = "importance" rooms_sort_order = "importance"
[euph.rooms.welcome] [euph.servers."euphoria.leet.nu".rooms]
autojoin = true welcome.autojoin = true
test.username = "badingle"
[euph.rooms.test] test.force_username = true
username = "badingle" private.password = "foobar"
force_username = true
[euph.rooms.private]
password = "foobar"
[keys] [keys]
general.abort = ["esc", "ctrl+c"] general.abort = ["esc", "ctrl+c"]
@ -33,17 +28,6 @@ general.exit = "ctrl+q"
tree.action.fold_tree = "f" 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
Key bindings are specified as strings or lists of strings. Each string specifies Key bindings are specified as strings or lists of strings. Each string specifies

View file

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

View file

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

View file

@ -1,28 +1,18 @@
#![forbid(unsafe_code)] use std::{
// Rustc lint groups fs,
#![warn(future_incompatible)] io::{self, ErrorKind},
#![warn(rust_2018_idioms)] path::{Path, PathBuf},
#![warn(unused)] };
// Rustc lints
#![warn(noop_method_call)] use doc::Document;
#![warn(single_use_lifetimes)] use serde::{Deserialize, Serialize};
// Clippy lints
#![warn(clippy::use_self)] pub use crate::{euph::*, keys::*};
pub mod doc; pub mod doc;
mod euph; mod euph;
mod keys; 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)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("failed to read config file")] #[error("failed to read config file")]
@ -31,6 +21,14 @@ pub enum Error {
Toml(#[from] toml::de::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)] #[derive(Debug, Default, Deserialize, Document)]
pub struct Config { pub struct Config {
/// The directory that cove stores its data in when not running in ephemeral /// The directory that cove stores its data in when not running in ephemeral
@ -49,19 +47,34 @@ pub struct Config {
/// ///
/// See also the `--ephemeral` command line option. /// See also the `--ephemeral` command line option.
#[serde(default)] #[serde(default)]
#[document(default = "`false`")]
pub ephemeral: bool, pub ephemeral: bool,
/// Whether to measure the width of characters as displayed by the terminal /// How to estimate the width of graphemes (i.e. characters) as displayed by
/// emulator instead of guessing the width. /// the terminal emulator.
///
/// `"legacy"`: Use a legacy method that should mostly work on most terminal
/// emulators. This method will never be correct in all cases since every
/// terminal emulator handles grapheme widths slightly differently. However,
/// those cases are usually rare (unless you view a lot of emoji).
///
/// `"unicode"`: Use the unicode standard in a best-effort manner to
/// determine grapheme widths. Some terminals (e.g. ghostty) can make use of
/// this.
///
/// This method is used when `measure_widths` is set to `false`.
///
/// See also the `--width-estimation-method` command line option.
#[serde(default)]
pub width_estimation_method: WidthEstimationMethod,
/// Whether to measure the width of graphemes (i.e. characters) as displayed
/// by the terminal emulator instead of estimating the width.
/// ///
/// Enabling this makes rendering a bit slower but more accurate. The screen /// Enabling this makes rendering a bit slower but more accurate. The screen
/// might also flash when encountering new characters (or, more accurately, /// might also flash when encountering new graphemes.
/// graphemes).
/// ///
/// See also the `--measure-graphemes` command line option. /// See also the `--measure-widths` command line option.
#[serde(default)] #[serde(default)]
#[document(default = "`false`")]
pub measure_widths: bool, pub measure_widths: bool,
/// Whether to start in offline mode. /// Whether to start in offline mode.
@ -72,23 +85,46 @@ pub struct Config {
/// ///
/// See also the `--offline` command line option. /// See also the `--offline` command line option.
#[serde(default)] #[serde(default)]
#[document(default = "`false`")]
pub offline: bool, pub offline: bool,
/// Initial sort order of rooms list. /// 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 /// `"importance"` sorts rooms by the following criteria (in descending
/// of priority): /// order of priority):
/// ///
/// 1. connected rooms before unconnected rooms /// 1. connected rooms before unconnected rooms
/// 2. rooms with unread messages before rooms without /// 2. rooms with unread messages before rooms without
/// 3. alphabetic order /// 3. alphabetic order
#[serde(default)] #[serde(default)]
#[document(default = "`alphabet`")]
pub rooms_sort_order: RoomsSortOrder, 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)] #[serde(default)]
#[document(no_default)] #[document(no_default)]
pub euph: Euph, pub euph: Euph,
@ -107,7 +143,16 @@ impl Config {
}) })
} }
pub fn euph_room(&self, name: &str) -> EuphRoom { pub fn euph_room(&self, domain: &str, name: &str) -> EuphRoom {
self.euph.rooms.get(name).cloned().unwrap_or_default() 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)
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,4 @@
#![forbid(unsafe_code)] use syn::{DeriveInput, parse_macro_input};
// 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 document;
mod key_group; mod key_group;

View file

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

View file

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

View file

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

211
cove/src/euph/highlight.rs Normal file
View file

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

View file

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

View file

@ -1,212 +1,18 @@
use std::mem;
use crossterm::style::Stylize; use crossterm::style::Stylize;
use euphoxide::api::{MessageId, Snowflake, Time}; use euphoxide::api::{MessageId, Snowflake, Time, UserId};
use time::OffsetDateTime; use jiff::Timestamp;
use toss::{Style, Styled}; use toss::{Style, Styled};
use crate::store::Msg; use crate::{store::Msg, ui::ChatMsg};
use crate::ui::ChatMsg;
use super::util; 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)] #[derive(Debug, Clone)]
pub struct SmallMessage { pub struct SmallMessage {
pub id: MessageId, pub id: MessageId,
pub parent: Option<MessageId>, pub parent: Option<MessageId>,
pub time: Time, pub time: Time,
pub user_id: UserId,
pub nick: String, pub nick: String,
pub content: String, pub content: String,
pub seen: bool, pub seen: bool,
@ -222,22 +28,22 @@ fn style_me() -> Style {
fn styled_nick(nick: &str) -> Styled { fn styled_nick(nick: &str) -> Styled {
Styled::new_plain("[") Styled::new_plain("[")
.and_then(util::style_nick(nick, Style::new())) .and_then(super::style_nick(nick, Style::new()))
.then_plain("]") .then_plain("]")
} }
fn styled_nick_me(nick: &str) -> Styled { fn styled_nick_me(nick: &str) -> Styled {
let style = style_me(); let style = style_me();
Styled::new("*", style).and_then(util::style_nick(nick, style)) Styled::new("*", style).and_then(super::style_nick(nick, style))
} }
fn styled_content(content: &str) -> Styled { fn styled_content(content: &str) -> Styled {
highlight_content(content.trim(), Style::new(), false) super::highlight(content.trim(), Style::new(), false)
} }
fn styled_content_me(content: &str) -> Styled { fn styled_content_me(content: &str) -> Styled {
let style = style_me(); let style = style_me();
highlight_content(content.trim(), style, false).then("*", style) super::highlight(content.trim(), style, false).then("*", style)
} }
fn styled_editor_content(content: &str) -> Styled { fn styled_editor_content(content: &str) -> Styled {
@ -246,7 +52,7 @@ fn styled_editor_content(content: &str) -> Styled {
} else { } else {
Style::new() Style::new()
}; };
highlight_content(content, style, true) super::highlight(content, style, true)
} }
impl Msg for SmallMessage { impl Msg for SmallMessage {
@ -267,11 +73,15 @@ impl Msg for SmallMessage {
fn last_possible_id() -> Self::Id { fn last_possible_id() -> Self::Id {
MessageId(Snowflake::MAX) MessageId(Snowflake::MAX)
} }
fn nick_emoji(&self) -> Option<String> {
Some(util::user_id_emoji(&self.user_id))
}
} }
impl ChatMsg for SmallMessage { impl ChatMsg for SmallMessage {
fn time(&self) -> OffsetDateTime { fn time(&self) -> Option<Timestamp> {
self.time.0 Some(self.time.as_timestamp())
} }
fn styled(&self) -> (Styled, Styled) { fn styled(&self) -> (Styled, Styled) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,6 @@ pub trait Renderer<Id> {
fn blocks(&self) -> &Blocks<Id>; fn blocks(&self) -> &Blocks<Id>;
fn blocks_mut(&mut self) -> &mut 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_top(&mut self) -> Result<(), Self::Error>;
async fn expand_bottom(&mut self) -> Result<(), Self::Error>; async fn expand_bottom(&mut self) -> Result<(), Self::Error>;
@ -275,27 +274,6 @@ 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) pub fn clamp_scroll_biased_downwards<Id, R>(r: &mut R)
where where
R: Renderer<Id>, R: Renderer<Id>,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,11 +5,15 @@ use std::convert::Infallible;
use cove_config::{Config, Keys}; use cove_config::{Config, Keys};
use cove_input::{InputEvent, KeyBinding, KeyBindingInfo, KeyGroupInfo}; use cove_input::{InputEvent, KeyBinding, KeyBindingInfo, KeyGroupInfo};
use crossterm::style::Stylize; use crossterm::style::Stylize;
use toss::widgets::{Either2, Join2, Padding, Text}; use toss::{
use toss::{Style, Styled, Widget, WidgetExt}; Style, Styled, Widget, WidgetExt,
widgets::{Either2, Join2, Padding, Text},
};
use super::widgets::{ListBuilder, ListState, Popup}; use super::{
use super::{util, UiError}; UiError, util,
widgets::{ListBuilder, ListState, Popup},
};
type Line = Either2<Text, Join2<Padding<Text>, Text>>; type Line = Either2<Text, Join2<Padding<Text>, Text>>;
type Builder = ListBuilder<'static, Infallible, Line>; type Builder = ListBuilder<'static, Infallible, Line>;
@ -69,7 +73,7 @@ fn render_group_info(builder: &mut Builder, group_info: KeyGroupInfo<'_>) {
pub fn widget<'a>( pub fn widget<'a>(
list: &'a mut ListState<Infallible>, list: &'a mut ListState<Infallible>,
config: &Config, config: &Config,
) -> impl Widget<UiError> + 'a { ) -> impl Widget<UiError> + use<'a> {
let mut list_builder = ListBuilder::new(); let mut list_builder = ListBuilder::new();
for group_info in config.keys.groups() { for group_info in config.keys.groups() {
@ -79,7 +83,23 @@ pub fn widget<'a>(
render_group_info(&mut list_builder, group_info); render_group_info(&mut list_builder, group_info);
} }
Popup::new(list_builder.build(list), "Key bindings") 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")
} }
pub fn handle_input_event( pub fn handle_input_event(

View file

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

View file

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

View file

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

View file

@ -5,6 +5,11 @@ use toss::widgets::EditorState;
use super::widgets::ListState; 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 // // List //
////////// //////////

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

2
cove/src/version.rs Normal file
View file

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

47
flake.lock generated
View file

@ -1,47 +0,0 @@
{
"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
}

View file

@ -1,29 +0,0 @@
{
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 = ./.;
};
}
);
};
}