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,
"rust-analyzer.cargo.features": "all",
"rust-analyzer.imports.granularity.enforce": true,
"rust-analyzer.imports.granularity.group": "module",
"rust-analyzer.imports.granularity.group": "crate",
"rust-analyzer.imports.group.enable": true,
"evenBetterToml.formatter.columnWidth": 100,
}

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

143
CONFIG.md
View file

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

1343
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,72 @@
[workspace]
resolver = "2"
resolver = "3"
members = ["cove", "cove-*"]
[workspace.package]
version = "0.7.1"
edition = "2021"
version = "0.9.3"
edition = "2024"
[workspace.dependencies]
crossterm = "0.27.0"
parking_lot = "0.12.1"
serde = { version = "1.0.188", features = ["derive"] }
anyhow = "1.0.97"
async-trait = "0.1.87"
clap = { version = "4.5.32", features = ["derive", "deprecated"] }
cookie = "0.18.1"
crossterm = "0.28.1"
directories = "6.0.0"
edit = "0.1.5"
jiff = "0.2.4"
linkify = "0.10.0"
log = { version = "0.4.26", features = ["std"] }
open = "5.3.2"
parking_lot = "0.12.3"
proc-macro2 = "1.0.94"
quote = "1.0.40"
rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
rustls = "0.23.23"
serde = { version = "1.0.219", features = ["derive"] }
serde_either = "0.2.1"
thiserror = "1.0.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]
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."*"]
opt-level = 3

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,22 @@
use std::convert::Infallible;
use std::sync::Arc;
use std::vec;
use std::{convert::Infallible, sync::Arc, vec};
use async_trait::async_trait;
use crossterm::style::Stylize;
use jiff::Timestamp;
use log::{Level, LevelFilter, Log};
use parking_lot::Mutex;
use time::OffsetDateTime;
use tokio::sync::mpsc;
use toss::{Style, Styled};
use crate::store::{Msg, MsgStore, Path, Tree};
use crate::ui::ChatMsg;
use crate::{
store::{Msg, MsgStore, Path, Tree},
ui::ChatMsg,
};
#[derive(Debug, Clone)]
pub struct LogMsg {
id: usize,
time: OffsetDateTime,
time: Timestamp,
level: Level,
content: String,
}
@ -42,8 +42,8 @@ impl Msg for LogMsg {
}
impl ChatMsg for LogMsg {
fn time(&self) -> OffsetDateTime {
self.time
fn time(&self) -> Option<Timestamp> {
Some(self.time)
}
fn styled(&self) -> (Styled, Styled) {
@ -209,7 +209,7 @@ impl Log for Logger {
let mut guard = self.messages.lock();
let msg = LogMsg {
id: guard.len(),
time: OffsetDateTime::now_utc(),
time: Timestamp::now(),
level: record.level(),
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 {
($e:expr) => {
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 Time zones other than UTC
// TODO Invoke external notification command?
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use cove_config::{Config, doc::Document};
use directories::{BaseDirs, ProjectDirs};
use log::info;
use tokio::sync::mpsc;
use toss::Terminal;
use crate::{
logger::Logger,
ui::Ui,
vault::Vault,
version::{NAME, VERSION},
};
mod euph;
mod export;
mod logger;
@ -22,21 +26,7 @@ mod store;
mod ui;
mod util;
mod vault;
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;
mod version;
#[derive(Debug, clap::Parser)]
enum Command {
@ -47,11 +37,21 @@ enum Command {
/// Compact and clean up vault.
Gc,
/// Clear euphoria session cookies.
ClearCookies,
ClearCookies {
/// Clear cookies for a specific domain only.
#[arg(long, short)]
domain: Option<String>,
},
/// Print config documentation as markdown.
HelpConfig,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum WidthEstimationMethod {
Legacy,
Unicode,
}
impl Default for Command {
fn default() -> Self {
Self::Run
@ -85,6 +85,11 @@ struct Args {
#[arg(long, short)]
offline: bool,
/// Method for estimating the width of characters as displayed by the
/// terminal emulator.
#[arg(long, short)]
width_estimation_method: Option<WidthEstimationMethod>,
/// Measure the width of characters as displayed by the terminal emulator
/// instead of guessing the width.
#[arg(long, short)]
@ -120,18 +125,26 @@ fn update_config_with_args(config: &mut Config, args: &Args) {
}
config.ephemeral |= args.ephemeral;
if let Some(method) = args.width_estimation_method {
config.width_estimation_method = match method {
WidthEstimationMethod::Legacy => cove_config::WidthEstimationMethod::Legacy,
WidthEstimationMethod::Unicode => cove_config::WidthEstimationMethod::Unicode,
}
}
config.measure_widths |= args.measure_widths;
config.offline |= args.offline;
}
fn open_vault(config: &Config, dirs: &ProjectDirs) -> rusqlite::Result<Vault> {
if config.ephemeral {
vault::launch_in_memory()
fn open_vault(config: &Config, dirs: &ProjectDirs) -> anyhow::Result<Vault> {
let vault = if config.ephemeral {
vault::launch_in_memory()?
} else {
let data_dir = data_dir(config, dirs);
eprintln!("Data dir: {}", data_dir.to_string_lossy());
vault::launch(&data_dir.join("vault.db"))
}
vault::launch(&data_dir.join("vault.db"))?
};
Ok(vault)
}
#[tokio::main]
@ -141,6 +154,11 @@ async fn main() -> anyhow::Result<()> {
let (logger, logger_guard, logger_rx) = Logger::init(args.verbose);
let dirs = ProjectDirs::from("de", "plugh", "cove").expect("failed to find config directory");
// https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.unwrap();
// Locate config
let config_path = config_path(&args, &dirs);
eprintln!("Config file: {}", config_path.to_string_lossy());
@ -154,7 +172,7 @@ async fn main() -> anyhow::Result<()> {
Command::Run => run(logger, logger_rx, config, &dirs).await?,
Command::Export(args) => export(config, &dirs, args).await?,
Command::Gc => gc(config, &dirs).await?,
Command::ClearCookies => clear_cookies(config, &dirs).await?,
Command::ClearCookies { domain } => clear_cookies(config, &dirs, domain).await?,
Command::HelpConfig => help_config(),
}
@ -173,17 +191,19 @@ async fn run(
config: &'static Config,
dirs: &ProjectDirs,
) -> anyhow::Result<()> {
info!(
"Welcome to {} {}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
);
info!("Welcome to {NAME} {VERSION}",);
let tz = util::load_time_zone(config.time_zone_ref()).context("failed to load time zone")?;
let vault = open_vault(config, dirs)?;
let mut terminal = Terminal::new()?;
terminal.set_measuring(config.measure_widths);
Ui::run(config, &mut terminal, vault.clone(), logger, logger_rx).await?;
terminal.set_width_estimation_method(match config.width_estimation_method {
cove_config::WidthEstimationMethod::Legacy => toss::WidthEstimationMethod::Legacy,
cove_config::WidthEstimationMethod::Unicode => toss::WidthEstimationMethod::Unicode,
});
Ui::run(config, tz, &mut terminal, vault.clone(), logger, logger_rx).await?;
drop(terminal);
vault.close().await;
@ -214,11 +234,15 @@ async fn gc(config: &'static Config, dirs: &ProjectDirs) -> anyhow::Result<()> {
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)?;
eprintln!("Clearing cookies");
vault.euph().set_cookies(CookieJar::new()).await?;
vault.euph().clear_cookies(domain).await?;
vault.close().await;
Ok(())

View file

@ -1,7 +1,4 @@
use std::collections::HashMap;
use std::fmt::Debug;
use std::hash::Hash;
use std::vec;
use std::{collections::HashMap, fmt::Debug, hash::Hash, vec};
use async_trait::async_trait;
@ -11,6 +8,10 @@ pub trait Msg {
fn parent(&self) -> Option<Self::Id>;
fn seen(&self) -> bool;
fn nick_emoji(&self) -> Option<String> {
None
}
fn last_possible_id() -> Self::Id;
}
@ -27,10 +28,6 @@ impl<I> Path<I> {
self.0.iter().take(self.0.len() - 1)
}
pub fn push(&mut self, segment: I) {
self.0.push(segment)
}
pub fn first(&self) -> &I {
self.0.first().expect("path is empty")
}
@ -134,6 +131,7 @@ impl<M: Msg> Tree<M> {
}
}
#[allow(dead_code)]
#[async_trait]
pub trait MsgStore<M: Msg> {
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 euph;
mod key_bindings;
@ -5,30 +32,6 @@ mod rooms;
mod util;
mod widgets;
use std::convert::Infallible;
use std::io;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use cove_config::Config;
use cove_input::InputEvent;
use parking_lot::FairMutex;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task;
use toss::widgets::BoxedAsync;
use toss::{Terminal, WidgetExt};
use crate::logger::{LogMsg, Logger};
use crate::macros::logging_unwrap;
use crate::util::InfallibleExt;
use crate::vault::Vault;
pub use self::chat::ChatMsg;
use self::chat::ChatState;
use self::rooms::Rooms;
use self::widgets::ListState;
/// Time to spend batch processing events before redrawing the screen.
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
@ -47,6 +50,7 @@ impl From<Infallible> for UiError {
}
}
#[expect(clippy::large_enum_variant)]
pub enum UiEvent {
GraphemeWidthsChanged,
LogChanged,
@ -84,6 +88,7 @@ impl Ui {
pub async fn run(
config: &'static Config,
tz: TimeZone,
terminal: &mut Terminal,
vault: Vault,
logger: Logger,
@ -112,8 +117,8 @@ impl Ui {
config,
event_tx: event_tx.clone(),
mode: Mode::Main,
rooms: Rooms::new(config, vault, event_tx.clone()).await,
log_chat: ChatState::new(logger),
rooms: Rooms::new(config, tz.clone(), vault, event_tx.clone()).await,
log_chat: ChatState::new(logger, tz),
key_bindings_visible: false,
key_bindings_list: ListState::new(),
};
@ -181,9 +186,8 @@ impl Ui {
}
// Handle events (in batches)
let mut event = match event_rx.recv().await {
Some(event) => event,
None => return Ok(()),
let Some(mut event) = event_rx.recv().await else {
return Ok(());
};
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
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 cursor;
mod renderer;
mod tree;
mod widgets;
use cove_config::Keys;
use cove_input::InputEvent;
use time::OffsetDateTime;
use toss::widgets::{BoxedAsync, EditorState};
use toss::{Styled, WidgetExt};
use crate::store::{Msg, MsgStore};
use self::cursor::Cursor;
use self::tree::TreeViewState;
use super::UiError;
pub trait ChatMsg {
fn time(&self) -> OffsetDateTime;
fn time(&self) -> Option<Timestamp>;
fn styled(&self) -> (Styled, Styled);
fn edit(nick: &str, content: &str) -> (Styled, Styled);
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
@ -33,23 +37,31 @@ pub struct ChatState<M: Msg, S: MsgStore<M>> {
cursor: Cursor<M::Id>,
editor: EditorState,
nick_emoji: bool,
caesar: i8,
mode: Mode,
tree: TreeViewState<M, S>,
}
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
pub fn new(store: S) -> Self {
pub fn new(store: S, tz: TimeZone) -> Self {
Self {
cursor: Cursor::Bottom,
editor: EditorState::new(),
nick_emoji: false,
caesar: 0,
mode: Mode::Tree,
tree: TreeViewState::new(store.clone()),
tree: TreeViewState::new(store.clone(), tz),
store,
}
}
pub fn nick_emoji(&self) -> bool {
self.nick_emoji
}
}
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
@ -68,7 +80,14 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
match self.mode {
Mode::Tree => self
.tree
.widget(&mut self.cursor, &mut self.editor, nick, focused)
.widget(
&mut self.cursor,
&mut self.editor,
nick,
focused,
self.nick_emoji,
self.caesar,
)
.boxed_async(),
}
}
@ -85,7 +104,7 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
S: Send + Sync,
S::Error: Send,
{
match self.mode {
let reaction = match self.mode {
Mode::Tree => {
self.tree
.handle_input_event(
@ -95,9 +114,33 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
&mut self.editor,
can_compose,
)
.await
.await?
}
}
};
Ok(match reaction {
Reaction::Composed { parent, content } if self.caesar != 0 => {
let content = util::caesar(&content, self.caesar);
Reaction::Composed { parent, content }
}
Reaction::NotHandled if event.matches(&keys.tree.action.toggle_nick_emoji) => {
self.nick_emoji = !self.nick_emoji;
Reaction::Handled
}
Reaction::NotHandled if event.matches(&keys.tree.action.increase_caesar) => {
self.caesar = (self.caesar + 1).rem_euclid(26);
Reaction::Handled
}
Reaction::NotHandled if event.matches(&keys.tree.action.decrease_caesar) => {
self.caesar = (self.caesar - 1).rem_euclid(26);
Reaction::Handled
}
reaction => reaction,
})
}
pub fn cursor(&self) -> Option<&M::Id> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
mod list;
mod popup;
pub use self::list::*;
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) {
let new_cursor = if let Some(cursor) = &self.cursor {
self.selectable_of_id(&cursor.id)

View file

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

View file

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

View file

@ -1,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 migrate;
mod prepare;
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use vault::tokio::TokioVault;
use vault::Action;
pub use self::euph::{EuphRoomVault, EuphVault};
#[derive(Debug, Clone)]
pub struct Vault {
tokio_vault: TokioVault,
@ -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, "trusted_schema", false)?;
eprintln!("Opening vault");
let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?;
Ok(Vault {
tokio_vault,

View file

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

View file

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