├── .github └── workflows │ ├── release-plz.yml │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── config ├── example.config.toml └── tuisky.config.schema.json └── src ├── action.rs ├── app.rs ├── backend.rs ├── backend ├── config.rs ├── types.rs ├── watch.rs ├── watches.rs └── watches │ ├── feed.rs │ ├── pinned_feeds.rs │ ├── post_thread.rs │ └── preferences.rs ├── bin └── main.rs ├── components.rs ├── components ├── column.rs ├── main.rs ├── modals.rs ├── modals │ ├── embed.rs │ ├── embed_images.rs │ ├── embed_record.rs │ └── types.rs ├── views.rs └── views │ ├── feed.rs │ ├── login.rs │ ├── menu.rs │ ├── new_post.rs │ ├── post.rs │ ├── root.rs │ ├── types.rs │ └── utils.rs ├── config.rs ├── lib.rs ├── tui.rs ├── types.rs └── utils.rs /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: Release-plz 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | release-plz: 14 | name: Release-plz 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Install Rust toolchain 22 | uses: dtolnay/rust-toolchain@stable 23 | - name: Run release-plz 24 | uses: release-plz/action@v0.5 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Build 19 | run: cargo build --verbose 20 | - name: Run tests 21 | run: cargo test --verbose 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.2.1](https://github.com/sugyan/tuisky/compare/v0.2.0...v0.2.1) - 2025-04-05 10 | 11 | ### Fixed 12 | 13 | - Update bsky-sdk version ([#56](https://github.com/sugyan/tuisky/pull/56)) 14 | 15 | ## [0.2.0](https://github.com/sugyan/tuisky/compare/v0.1.6...v0.2.0) - 2025-01-31 16 | 17 | ### Other 18 | 19 | - added support for suspend on non-windows ([#51](https://github.com/sugyan/tuisky/pull/51)) 20 | 21 | ## [0.1.6](https://github.com/sugyan/tuisky/compare/v0.1.5...v0.1.6) - 2025-01-31 22 | 23 | ### Fixed 24 | 25 | - fix [#49](https://github.com/sugyan/tuisky/pull/49) ([#52](https://github.com/sugyan/tuisky/pull/52)) 26 | 27 | ## [0.1.5](https://github.com/sugyan/tuisky/compare/v0.1.4...v0.1.5) - 2024-11-14 28 | 29 | ### Fixed 30 | 31 | - Remove std::os::unix::fs::MetadataExt ([#41](https://github.com/sugyan/tuisky/pull/41)) 32 | 33 | ## [0.1.4](https://github.com/sugyan/tuisky/compare/v0.1.3...v0.1.4) - 2024-11-14 34 | 35 | ### Other 36 | 37 | - Fix default config path ([#38](https://github.com/sugyan/tuisky/pull/38)) 38 | - Update README 39 | 40 | ## [0.1.3](https://github.com/sugyan/tuisky/compare/v0.1.2...v0.1.3) - 2024-10-31 41 | 42 | ### Added 43 | 44 | - Add `service` input to login form ([#32](https://github.com/sugyan/tuisky/pull/32)) 45 | 46 | ### Other 47 | 48 | - Update dependencies ([#34](https://github.com/sugyan/tuisky/pull/34)) 49 | 50 | ## [0.1.2](https://github.com/sugyan/tuisky/compare/v0.1.1...v0.1.2) - 2024-09-20 51 | 52 | ### Added 53 | 54 | - Add quote counts ([#30](https://github.com/sugyan/tuisky/pull/30)) 55 | 56 | ### Fixed 57 | 58 | - Update dependencies ([#28](https://github.com/sugyan/tuisky/pull/28)) 59 | 60 | ## [0.1.1](https://github.com/sugyan/tuisky/compare/v0.1.0...v0.1.1) - 2024-08-15 61 | 62 | ### Added 63 | - Use RichText with auto detection ([#26](https://github.com/sugyan/tuisky/pull/26)) 64 | 65 | ## [0.1.0](https://github.com/sugyan/tuisky/compare/v0.0.5...v0.1.0) - 2024-08-16 66 | 67 | ### Added 68 | - Update dependencies ([#25](https://github.com/sugyan/tuisky/pull/25)) 69 | - Add embed modals to NewPost ([#24](https://github.com/sugyan/tuisky/pull/24)) 70 | 71 | ### Other 72 | - add AUR instructions ([#23](https://github.com/sugyan/tuisky/pull/23)) 73 | - Update README.md 74 | 75 | ## [0.0.5](https://github.com/sugyan/tuisky/compare/v0.0.4...v0.0.5) - 2024-07-24 76 | 77 | ### Added 78 | - Add menu component ([#21](https://github.com/sugyan/tuisky/pull/21)) 79 | - Use env logger ([#20](https://github.com/sugyan/tuisky/pull/20)) 80 | - Update counts display ([#19](https://github.com/sugyan/tuisky/pull/19)) 81 | - Update NewPostViewComponent, remove Escape action ([#18](https://github.com/sugyan/tuisky/pull/18)) 82 | - Update login view, remove tui-prompts ([#17](https://github.com/sugyan/tuisky/pull/17)) 83 | - Add NewPost component ([#16](https://github.com/sugyan/tuisky/pull/16)) 84 | - Add pinned list view ([#14](https://github.com/sugyan/tuisky/pull/14)) 85 | 86 | ## [0.0.4](https://github.com/sugyan/tuisky/compare/v0.0.3...v0.0.4) - 2024-07-11 87 | 88 | ### Added 89 | - Add default keybindings ([#13](https://github.com/sugyan/tuisky/pull/13)) 90 | - Show labels and langs ([#12](https://github.com/sugyan/tuisky/pull/12)) 91 | - Update watchers ([#11](https://github.com/sugyan/tuisky/pull/11)) 92 | - Add facets rows and actions to post view ([#8](https://github.com/sugyan/tuisky/pull/8)) 93 | 94 | ### Fixed 95 | - Fix PostViewComponent::deactivate ([#10](https://github.com/sugyan/tuisky/pull/10)) 96 | 97 | ## [0.0.3](https://github.com/sugyan/tuisky/compare/v0.0.2...v0.0.3) - 2024-07-08 98 | 99 | ### Added 100 | - Add post view ([#7](https://github.com/sugyan/tuisky/pull/7)) 101 | - Update config serialize/deserialize for keybindings ([#5](https://github.com/sugyan/tuisky/pull/5)) 102 | 103 | ## [0.0.2](https://github.com/sugyan/tuisky/compare/v0.0.1...v0.0.2) - 2024-07-04 104 | 105 | ### Added 106 | - Configuration with files ([#4](https://github.com/sugyan/tuisky/pull/4)) 107 | - Use IndexMap for watching feed_views ([#3](https://github.com/sugyan/tuisky/pull/3)) 108 | - Update FeedViewComponent ([#2](https://github.com/sugyan/tuisky/pull/2)) 109 | 110 | ### Other 111 | - Update dependencies 112 | - Update README.md 113 | - Add release-plz workflow 114 | - Create rust.yml 115 | - Update README 116 | - Update README 117 | - Revert "Downgrade ratatui to publish" 118 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tuisky" 3 | version = "0.2.1" 4 | authors = ["sugyan "] 5 | edition = "2021" 6 | rust-version = "1.80" 7 | description = "TUI client for Bluesky" 8 | readme = "README.md" 9 | repository = "https://github.com/sugyan/tuisky" 10 | license = "MIT" 11 | keywords = ["tui", "atproto", "bluesky", "atrium"] 12 | exclude = ["/config"] 13 | 14 | [dependencies] 15 | bsky-sdk = "0.1.18" 16 | chrono = { version = "0.4.38", default-features = false } 17 | clap = { version = "4.5.8", features = ["derive"] } 18 | color-eyre = "0.6.3" 19 | crossterm = { version = "0.28.1", features = ["event-stream", "serde"] } 20 | directories = "5.0.1" 21 | env_logger = "0.11.3" 22 | futures-util = "0.3.30" 23 | image = { version = "0.25.2", default-features = false, features = ["jpeg", "png"] } 24 | indexmap = "2.2.6" 25 | log = "0.4.22" 26 | open = "5.2.0" 27 | ratatui = "0.29" 28 | regex = "1.10.6" 29 | serde = { version = "1.0.203", features = ["derive"] } 30 | serde_json = "1.0.117" 31 | textwrap = "0.16.1" 32 | tokio = { version = "1.38.0", features = [ 33 | "macros", 34 | "rt-multi-thread", 35 | "sync", 36 | "time", 37 | ] } 38 | toml = "0.8.14" 39 | tui-textarea = "0.7.0" 40 | 41 | [target.'cfg(not(windows))'.dependencies] 42 | signal-hook = "0.3.17" 43 | 44 | [dev-dependencies] 45 | ipld-core = "0.4.0" 46 | 47 | [[bin]] 48 | name = "tuisky" 49 | path = "src/bin/main.rs" 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Yoshihiro Sugi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tuisky: TUI client for Bluesky 2 | 3 | [![](https://img.shields.io/crates/v/tuisky)](https://crates.io/crates/tuisky) 4 | [![](https://img.shields.io/crates/l/atrium-api)](https://github.com/sugyan/tuisky/blob/main/LICENSE) 5 | 6 | ![out](https://github.com/user-attachments/assets/814291e9-8ed7-4bdf-ab4f-f62799f0c5c6) 7 | 8 | ## Features 9 | 10 | - [x] Multiple columns, multiple session management 11 | - [x] Select from pinned feeds 12 | - [x] Auto refresh rows 13 | - [x] Auto save & restore app data 14 | - [x] Post texts 15 | - [x] Embed images 16 | - [x] Embed record 17 | - [ ] Embed external links 18 | - [ ] Reply to post 19 | - [ ] Notifications, Chat, ... 20 | - [x] Configure with files 21 | - [ ] ... and more 22 | 23 | ## Installation 24 | 25 | ``` 26 | cargo install tuisky 27 | ``` 28 | 29 | ### AUR 30 | 31 | You can install `tuisky` from the [AUR](https://aur.archlinux.org/packages/tuisky) with using an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers). 32 | 33 | ``` 34 | paru -S tuisky 35 | ``` 36 | 37 | ### X-CMD 38 | 39 | If you are a user of [x-cmd](https://x-cmd.com), you can run: 40 | 41 | ``` 42 | x install tuisky 43 | ``` 44 | 45 | ## Usage 46 | 47 | ``` 48 | Usage: tuisky [OPTIONS] 49 | 50 | Options: 51 | -c, --config Path to the configuration file 52 | -n, --num-columns Maximum number of columns to display. The number of columns will be determined by the terminal width 53 | -h, --help Print help 54 | -V, --version Print version 55 | ``` 56 | 57 | ### Default key bindings 58 | 59 | Global: 60 | 61 | - `Ctrl-q`: Quit 62 | - `Ctrl-o`: Focus next column 63 | 64 | Column: 65 | 66 | - `Down`: Next item 67 | - `Up`: Prev item 68 | - `Enter`: Select item 69 | - `Backspace`: Back to previous view 70 | - `Ctrl-r`: Refresh current view 71 | - `Ctrl-x`: Open/Close menu 72 | 73 | 74 | ### Configuration with toml file 75 | 76 | Various settings can be read from a file. 77 | 78 | ``` 79 | tuisky --config path/to/config.toml 80 | ``` 81 | 82 | ```toml 83 | [keybindings.global] 84 | Ctrl-c = "Quit" 85 | 86 | [keybindings.column] 87 | Ctrl-n = "NextItem" 88 | Ctrl-p = "PrevItem" 89 | 90 | [watcher.intervals] 91 | feed = 20 92 | ``` 93 | 94 | The config schema can be referenced by [JSON Schema](./config/tuisky.config.schema.json). 95 | -------------------------------------------------------------------------------- /config/example.config.toml: -------------------------------------------------------------------------------- 1 | num_columns = 2 2 | 3 | [keybindings.global] 4 | Ctrl-c = "Quit" 5 | 6 | [keybindings.column] 7 | Ctrl-n = "NextItem" 8 | Ctrl-p = "PrevItem" 9 | 10 | [watcher.intervals] 11 | feed = 20 12 | -------------------------------------------------------------------------------- /config/tuisky.config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "num_columns": { 5 | "type": "integer", 6 | "minimum": 1 7 | }, 8 | "keybindings": { 9 | "$ref": "#/$defs/keybindings" 10 | }, 11 | "watcher": { 12 | "$ref": "#/$defs/watcher" 13 | } 14 | }, 15 | "required": [], 16 | "$defs": { 17 | "keybindings": { 18 | "type": "object", 19 | "properties": { 20 | "global": { 21 | "type": "object", 22 | "patternProperties": { 23 | "^Ctrl-[a-z]$": { 24 | "type": "string", 25 | "enum": [ 26 | "NextFocus", 27 | "PrevFocus", 28 | "Quit" 29 | ] 30 | } 31 | }, 32 | "additionalProperties": false 33 | }, 34 | "column": { 35 | "type": "object", 36 | "patternProperties": { 37 | "^(Ctrl-[a-z]|Shift-[A-Z]|[ -@\\[-~]|Backspace|Enter|Left|Right|Up|Down|Home|End|PageUp|PageDown|Tab|BackTab|Delete|Insert|Esc)$": { 38 | "type": "string", 39 | "enum": [ 40 | "NextItem", 41 | "PrevItem", 42 | "Enter", 43 | "Back", 44 | "Refresh", 45 | "NewPost", 46 | "Menu" 47 | ] 48 | } 49 | }, 50 | "additionalProperties": false 51 | } 52 | }, 53 | "additionalProperties": false 54 | }, 55 | "watcher": { 56 | "type": "object", 57 | "properties": { 58 | "intervals": { 59 | "$ref": "#/$defs/watcher/intervals" 60 | } 61 | }, 62 | "intervals": { 63 | "type": "object", 64 | "properties": { 65 | "preferences": { 66 | "type": "integer", 67 | "minimum": 1 68 | }, 69 | "feed": { 70 | "type": "integer", 71 | "minimum": 1 72 | }, 73 | "post_thread": { 74 | "type": "integer", 75 | "minimum": 1 76 | } 77 | }, 78 | "additionalProperties": false 79 | }, 80 | "additionalProperties": false 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/action.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::components::main::MainComponent; 2 | use crate::components::Component; 3 | use crate::config::Config; 4 | use crate::tui::{io, Tui}; 5 | use crate::types::{Action, Event}; 6 | use color_eyre::Result; 7 | use crossterm::event::KeyEvent; 8 | use ratatui::backend::CrosstermBackend; 9 | use ratatui::Terminal; 10 | use tokio::sync::mpsc; 11 | 12 | pub struct App { 13 | config: Config, 14 | components: Vec>, 15 | } 16 | 17 | impl App { 18 | pub fn new(config: Config) -> Self { 19 | log::debug!("App::new({config:?})"); 20 | Self { 21 | config, 22 | components: Vec::new(), 23 | } 24 | } 25 | pub async fn run(&mut self) -> Result<()> { 26 | let (action_tx, mut action_rx) = mpsc::unbounded_channel(); 27 | 28 | // Setup terminal 29 | let terminal = Terminal::new(CrosstermBackend::new(io()))?; 30 | log::debug!("terminal size: {}", terminal.size()?); 31 | let mut tui = Tui::new(terminal); 32 | tui.start()?; 33 | 34 | // Create main component 35 | let mut main_component = MainComponent::new(self.config.clone(), action_tx.clone()); 36 | 37 | // Setup components 38 | main_component.register_action_handler(action_tx.clone())?; 39 | for component in self.components.iter_mut() { 40 | component.register_action_handler(action_tx.clone())?; 41 | } 42 | main_component.register_config_handler(self.config.clone())?; 43 | for component in self.components.iter_mut() { 44 | component.register_config_handler(self.config.clone())?; 45 | } 46 | main_component.init(tui.size()?)?; 47 | for component in self.components.iter_mut() { 48 | component.init(tui.size()?)?; 49 | } 50 | 51 | // Initial render 52 | action_tx.send(Action::Render)?; 53 | 54 | // Main loop 55 | let mut should_quit = false; 56 | #[cfg(not(windows))] 57 | let mut should_suspend = false; 58 | loop { 59 | if let Some(e) = tui.next_event().await { 60 | if let Some(action) = self.handle_events(e.clone()) { 61 | action_tx.send(action)?; 62 | } 63 | if let Some(action) = main_component.handle_events(Some(e.clone()))? { 64 | action_tx.send(action)?; 65 | } 66 | for component in self.components.iter_mut() { 67 | if let Some(action) = component.handle_events(Some(e.clone()))? { 68 | action_tx.send(action)?; 69 | } 70 | } 71 | } 72 | while let Ok(action) = action_rx.try_recv() { 73 | if !matches!(action, Action::Tick(_) | Action::Render) { 74 | log::info!("Action {action:?}"); 75 | } 76 | match action { 77 | Action::Quit => should_quit = true, 78 | #[cfg(not(windows))] 79 | Action::Suspend => should_suspend = true, 80 | #[cfg(not(windows))] 81 | Action::Resume => { 82 | should_suspend = false; 83 | tui.clear()?; 84 | } 85 | Action::Tick(i) => { 86 | // TODO 87 | if i % 60 == 0 { 88 | main_component.save().await?; 89 | } 90 | } 91 | Action::Render => { 92 | tui.draw(|f| { 93 | // render main components to the left side 94 | if let Err(e) = main_component.draw(f, f.area()) { 95 | action_tx 96 | .send(Action::Error(format!("failed to draw: {e}"))) 97 | .ok(); 98 | } 99 | for component in self.components.iter_mut() { 100 | if let Err(e) = component.draw(f, f.area()) { 101 | action_tx 102 | .send(Action::Error(format!("failed to draw: {e}"))) 103 | .ok(); 104 | } 105 | } 106 | })?; 107 | } 108 | _ => { 109 | if let Some(action) = main_component.update(action.clone())? { 110 | action_tx.send(action)?; 111 | } 112 | for component in self.components.iter_mut() { 113 | if let Some(action) = component.update(action.clone())? { 114 | action_tx.send(action)?; 115 | } 116 | } 117 | } 118 | } 119 | } 120 | #[cfg(not(windows))] 121 | if should_suspend { 122 | tui.suspend()?; 123 | action_tx.send(Action::Resume)?; 124 | action_tx.send(Action::Render)?; 125 | tui.start()?; 126 | } 127 | if should_quit { 128 | break main_component.save().await?; 129 | } 130 | } 131 | tui.end()?; 132 | Ok(()) 133 | } 134 | fn handle_events(&mut self, event: Event) -> Option { 135 | match event { 136 | Event::Tick(i) => return Some(Action::Tick(i)), 137 | Event::Key(key_event) => { 138 | if let Some(action) = self.handle_key_events(key_event) { 139 | return Some(action); 140 | } 141 | } 142 | Event::Error(e) => log::error!("Event::Error: {e}"), 143 | _ => {} 144 | } 145 | None 146 | } 147 | fn handle_key_events(&mut self, key_event: KeyEvent) -> Option { 148 | self.config 149 | .keybindings 150 | .global 151 | .get(&key_event.into()) 152 | .map(Into::into) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/backend.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod types; 3 | mod watch; 4 | mod watches; 5 | 6 | pub use watch::{Watch, Watcher}; 7 | -------------------------------------------------------------------------------- /src/backend/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] 4 | pub struct Config { 5 | pub intervals: Intervals, 6 | } 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 9 | #[serde(default)] 10 | pub struct Intervals { 11 | pub preferences: u64, 12 | pub feed: u64, 13 | pub post_thread: u64, 14 | } 15 | 16 | impl Default for Intervals { 17 | fn default() -> Self { 18 | Self { 19 | preferences: 600, 20 | feed: 30, 21 | post_thread: 60, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/backend/types.rs: -------------------------------------------------------------------------------- 1 | use bsky_sdk::api::app::bsky::actor::defs::SavedFeed; 2 | use bsky_sdk::api::app::bsky::feed::defs::GeneratorView; 3 | use bsky_sdk::api::app::bsky::graph::defs::ListView; 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct PinnedFeed { 7 | #[allow(dead_code)] 8 | pub saved_feed: SavedFeed, 9 | pub info: FeedSourceInfo, 10 | } 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub enum FeedSourceInfo { 14 | Feed(Box), 15 | List(Box), 16 | Timeline(String), 17 | } 18 | -------------------------------------------------------------------------------- /src/backend/watch.rs: -------------------------------------------------------------------------------- 1 | use super::config::Config; 2 | use bsky_sdk::BskyAgent; 3 | use std::sync::Arc; 4 | use tokio::sync::watch; 5 | 6 | pub trait Watch { 7 | type Output; 8 | 9 | fn subscribe(&self) -> watch::Receiver; 10 | fn unsubscribe(&self); 11 | fn refresh(&self); 12 | } 13 | 14 | pub struct Watcher { 15 | pub agent: Arc, 16 | pub(crate) config: Config, 17 | } 18 | 19 | impl Watcher { 20 | pub fn new(agent: Arc, config: Config) -> Self { 21 | Self { agent, config } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/backend/watches.rs: -------------------------------------------------------------------------------- 1 | mod feed; 2 | mod pinned_feeds; 3 | mod post_thread; 4 | mod preferences; 5 | -------------------------------------------------------------------------------- /src/backend/watches/feed.rs: -------------------------------------------------------------------------------- 1 | use super::super::types::FeedSourceInfo; 2 | use super::super::{Watch, Watcher}; 3 | use bsky_sdk::api::app::bsky::feed::defs::{ 4 | FeedViewPost, FeedViewPostReasonRefs, PostViewEmbedRefs, ReplyRefParentRefs, 5 | }; 6 | use bsky_sdk::api::types::string::Cid; 7 | use bsky_sdk::api::types::Union; 8 | use bsky_sdk::moderation::decision::DecisionContext; 9 | use bsky_sdk::preference::{FeedViewPreference, FeedViewPreferenceData}; 10 | use bsky_sdk::Result; 11 | use bsky_sdk::{preference::Preferences, BskyAgent}; 12 | use indexmap::IndexMap; 13 | use std::sync::Arc; 14 | use std::time::Duration; 15 | use tokio::sync::{broadcast, watch, Mutex}; 16 | use tokio::time; 17 | 18 | impl Watcher { 19 | pub fn feed(&self, feed_info: FeedSourceInfo) -> impl Watch> { 20 | let (tx, _) = broadcast::channel(1); 21 | FeedWatcher { 22 | feed_info, 23 | agent: self.agent.clone(), 24 | preferences: self.preferences(), 25 | period: Duration::from_secs(self.config.intervals.feed), 26 | tx, 27 | current: Default::default(), 28 | } 29 | } 30 | } 31 | 32 | pub struct FeedWatcher { 33 | feed_info: FeedSourceInfo, 34 | agent: Arc, 35 | preferences: W, 36 | period: Duration, 37 | tx: broadcast::Sender<()>, 38 | current: Arc>>, 39 | } 40 | 41 | impl Watch for FeedWatcher 42 | where 43 | W: Watch, 44 | { 45 | type Output = Vec; 46 | 47 | fn subscribe(&self) -> tokio::sync::watch::Receiver { 48 | let (tx, rx) = watch::channel(Default::default()); 49 | let updater = Updater { 50 | agent: self.agent.clone(), 51 | current: self.current.clone(), 52 | feed_info: Arc::new(self.feed_info.clone()), 53 | tx, 54 | }; 55 | let (mut preferences, mut quit) = (self.preferences.subscribe(), self.tx.subscribe()); 56 | let mut interval = time::interval(self.period); 57 | tokio::spawn(async move { 58 | // skip the first tick 59 | interval.tick().await; 60 | loop { 61 | let tick = interval.tick(); 62 | tokio::select! { 63 | changed = preferences.changed() => { 64 | if changed.is_ok() { 65 | let preferences = preferences.borrow_and_update().clone(); 66 | let updater = updater.clone(); 67 | tokio::spawn(async move { 68 | updater.clone().update(&preferences).await; 69 | }); 70 | } else { 71 | break log::warn!("preferences channel closed"); 72 | } 73 | } 74 | _ = tick => { 75 | let preferences = preferences.borrow().clone(); 76 | let updater = updater.clone(); 77 | tokio::spawn(async move { 78 | updater.update(&preferences).await; 79 | }); 80 | } 81 | _ = quit.recv() => { 82 | break; 83 | } 84 | } 85 | } 86 | log::debug!("quit"); 87 | }); 88 | rx 89 | } 90 | fn unsubscribe(&self) { 91 | if let Err(e) = self.tx.send(()) { 92 | log::error!("failed to send quit: {e}"); 93 | } 94 | self.preferences.unsubscribe(); 95 | } 96 | fn refresh(&self) { 97 | self.preferences.refresh(); 98 | } 99 | } 100 | 101 | #[derive(Clone)] 102 | struct Updater { 103 | agent: Arc, 104 | current: Arc>>, 105 | feed_info: Arc, 106 | tx: watch::Sender>, 107 | } 108 | 109 | impl Updater { 110 | async fn update(&self, preferences: &Preferences) { 111 | match self.calculate_feed(preferences).await { 112 | Ok(feed) => { 113 | self.tx.send(feed).ok(); 114 | } 115 | Err(e) => { 116 | log::error!("failed to get feed view posts: {e}"); 117 | } 118 | } 119 | } 120 | async fn calculate_feed(&self, preferences: &Preferences) -> Result> { 121 | // TODO: It should not be necessary to get moderator every time unless moderation_prefs has been changed? 122 | let (moderator, feed) = tokio::join!(self.agent.moderator(preferences), self.get_feed()); 123 | let moderator = moderator?; 124 | let mut feed = feed?; 125 | feed.reverse(); 126 | let mut ret = { 127 | let mut feed_map = self.current.lock().await; 128 | update_feeds(&feed, &mut feed_map); 129 | feed_map.values().rev().cloned().collect::>() 130 | }; 131 | // filter by moderator 132 | ret.retain(|feed_view_post| { 133 | let decision = moderator.moderate_post(&feed_view_post.post); 134 | let ui = decision.ui(DecisionContext::ContentList); 135 | // TODO: use other results? 136 | !ui.filter() 137 | }); 138 | // filter by preferences (following timeline only) 139 | if matches!(self.feed_info.as_ref(), FeedSourceInfo::Timeline(_)) { 140 | let pref = if let Some(pref) = preferences.feed_view_prefs.get("home") { 141 | pref.clone() 142 | } else { 143 | FeedViewPreferenceData::default().into() 144 | }; 145 | ret.retain(|feed_view_post| filter_feed(feed_view_post, &pref)); 146 | } 147 | Ok(ret) 148 | } 149 | async fn get_feed(&self) -> Result> { 150 | Ok(match self.feed_info.as_ref() { 151 | FeedSourceInfo::Feed(generator_view) => { 152 | self.agent 153 | .api 154 | .app 155 | .bsky 156 | .feed 157 | .get_feed( 158 | bsky_sdk::api::app::bsky::feed::get_feed::ParametersData { 159 | cursor: None, 160 | feed: generator_view.uri.clone(), 161 | limit: 30.try_into().ok(), 162 | } 163 | .into(), 164 | ) 165 | .await? 166 | .data 167 | .feed 168 | } 169 | FeedSourceInfo::List(list_view) => { 170 | self.agent 171 | .api 172 | .app 173 | .bsky 174 | .feed 175 | .get_list_feed( 176 | bsky_sdk::api::app::bsky::feed::get_list_feed::ParametersData { 177 | cursor: None, 178 | limit: 30.try_into().ok(), 179 | list: list_view.uri.clone(), 180 | } 181 | .into(), 182 | ) 183 | .await? 184 | .data 185 | .feed 186 | } 187 | FeedSourceInfo::Timeline(_) => { 188 | self.agent 189 | .api 190 | .app 191 | .bsky 192 | .feed 193 | .get_timeline( 194 | bsky_sdk::api::app::bsky::feed::get_timeline::ParametersData { 195 | algorithm: None, 196 | cursor: None, 197 | limit: 30.try_into().ok(), 198 | } 199 | .into(), 200 | ) 201 | .await? 202 | .data 203 | .feed 204 | } 205 | }) 206 | } 207 | } 208 | 209 | fn update_feeds(feed: &[FeedViewPost], feed_map: &mut IndexMap) { 210 | for post in feed { 211 | if let Some(entry) = feed_map.get_mut(&post.post.cid) { 212 | // Is the feed view a new repost? 213 | if match (&entry.reason, &post.reason) { 214 | ( 215 | Some(Union::Refs(FeedViewPostReasonRefs::ReasonRepost(curr))), 216 | Some(Union::Refs(FeedViewPostReasonRefs::ReasonRepost(next))), 217 | ) => curr.indexed_at < next.indexed_at, 218 | (None, Some(_)) => true, 219 | _ => false, 220 | } { 221 | // Remove the old entry 222 | feed_map.swap_remove(&post.post.cid); 223 | } else { 224 | // Just update the post 225 | entry.post = post.post.clone(); 226 | continue; 227 | } 228 | } 229 | feed_map.insert(post.post.cid.clone(), post.clone()); 230 | } 231 | } 232 | 233 | fn filter_feed(feed_view_post: &FeedViewPost, pref: &FeedViewPreference) -> bool { 234 | // is repost? 235 | if matches!( 236 | &feed_view_post.reason, 237 | Some(Union::Refs(FeedViewPostReasonRefs::ReasonRepost(_))) 238 | ) { 239 | return !pref.hide_reposts; 240 | } 241 | // is reply? 242 | if let Some(reply) = &feed_view_post.reply { 243 | let is_self_reply = matches!(&reply.parent, 244 | Union::Refs(ReplyRefParentRefs::PostView(post_view)) 245 | if post_view.author.did == feed_view_post.post.author.did 246 | ); 247 | if pref.hide_replies { 248 | return is_self_reply; 249 | } 250 | if feed_view_post.post.like_count.unwrap_or_default() < pref.hide_replies_by_like_count { 251 | return is_self_reply; 252 | } 253 | if pref.hide_replies_by_unfollowed { 254 | return matches!(&reply.parent, 255 | Union::Refs(ReplyRefParentRefs::PostView(parent)) 256 | if parent.author.viewer.as_ref().map(|viewer| viewer.following.is_some()).unwrap_or_default() 257 | ); 258 | } 259 | } 260 | // is quote post? 261 | else if matches!( 262 | &feed_view_post.post.embed, 263 | Some(Union::Refs( 264 | PostViewEmbedRefs::AppBskyEmbedRecordView(_) 265 | | PostViewEmbedRefs::AppBskyEmbedRecordWithMediaView(_) 266 | )) 267 | ) { 268 | return !pref.hide_quote_posts; 269 | } 270 | true 271 | } 272 | 273 | #[cfg(test)] 274 | mod tests { 275 | use super::*; 276 | use bsky_sdk::api::app::bsky::actor::defs::{ProfileViewBasic, ProfileViewBasicData}; 277 | use bsky_sdk::api::app::bsky::feed::defs::{FeedViewPostData, PostViewData, ReasonRepostData}; 278 | use bsky_sdk::api::types::{string::Datetime, Unknown}; 279 | use std::collections::BTreeMap; 280 | 281 | fn feed_view_post(cid: Cid, reason_indexed_at: Option) -> FeedViewPost { 282 | fn profile_view_basic() -> ProfileViewBasic { 283 | ProfileViewBasicData { 284 | associated: None, 285 | avatar: None, 286 | created_at: None, 287 | did: "did:fake:post.test".parse().expect("invalid did"), 288 | display_name: None, 289 | handle: "post.test".parse().expect("invalid handle"), 290 | labels: None, 291 | viewer: None, 292 | } 293 | .into() 294 | } 295 | 296 | FeedViewPostData { 297 | feed_context: None, 298 | post: PostViewData { 299 | author: profile_view_basic(), 300 | cid, 301 | embed: None, 302 | indexed_at: Datetime::now(), 303 | labels: None, 304 | like_count: None, 305 | quote_count: None, 306 | record: Unknown::Object(BTreeMap::new()), 307 | reply_count: None, 308 | repost_count: None, 309 | threadgate: None, 310 | uri: String::new(), 311 | viewer: None, 312 | } 313 | .into(), 314 | reason: reason_indexed_at.map(|indexed_at| { 315 | Union::Refs(FeedViewPostReasonRefs::ReasonRepost(Box::new( 316 | ReasonRepostData { 317 | by: profile_view_basic(), 318 | indexed_at, 319 | } 320 | .into(), 321 | ))) 322 | }), 323 | reply: None, 324 | } 325 | .into() 326 | } 327 | 328 | #[test] 329 | fn update_feed_views() { 330 | let cids = [ 331 | "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" 332 | .parse::() 333 | .expect("invalid cid"), 334 | "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz3a" 335 | .parse::() 336 | .expect("invalid cid"), 337 | "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz4a" 338 | .parse::() 339 | .expect("invalid cid"), 340 | ]; 341 | let mut feed_map = IndexMap::new(); 342 | // Empty feeds 343 | update_feeds(&Vec::new(), &mut feed_map); 344 | assert_eq!(feed_map.len(), 0); 345 | // New feed 346 | update_feeds(&[feed_view_post(cids[0].clone(), None)], &mut feed_map); 347 | assert_eq!(feed_map.len(), 1); 348 | // Duplicate feed 349 | update_feeds(&[feed_view_post(cids[0].clone(), None)], &mut feed_map); 350 | assert_eq!(feed_map.len(), 1); 351 | // Duplicated and new feed 352 | update_feeds( 353 | &[ 354 | feed_view_post(cids[0].clone(), None), 355 | feed_view_post(cids[1].clone(), None), 356 | ], 357 | &mut feed_map, 358 | ); 359 | assert_eq!(feed_map.len(), 2); 360 | assert_eq!(feed_map[0].post.cid, cids[0]); 361 | assert_eq!(feed_map[1].post.cid, cids[1]); 362 | // New and duplicated feed 363 | update_feeds( 364 | &[ 365 | feed_view_post(cids[2].clone(), None), 366 | feed_view_post(cids[1].clone(), None), 367 | ], 368 | &mut feed_map, 369 | ); 370 | assert_eq!(feed_map.len(), 3); 371 | assert_eq!(feed_map[0].post.cid, cids[0]); 372 | assert_eq!(feed_map[1].post.cid, cids[1]); 373 | assert_eq!(feed_map[2].post.cid, cids[2]); 374 | // Duplicated, but updated feed 375 | update_feeds( 376 | &[ 377 | feed_view_post(cids[1].clone(), Some(Datetime::now())), 378 | feed_view_post(cids[2].clone(), None), 379 | ], 380 | &mut feed_map, 381 | ); 382 | assert_eq!(feed_map.len(), 3); 383 | println!("{:?}", feed_map.keys().collect::>()); 384 | assert_eq!(feed_map[0].post.cid, cids[0]); 385 | assert_eq!(feed_map[1].post.cid, cids[2]); 386 | assert_eq!(feed_map[2].post.cid, cids[1]); 387 | assert!(feed_map[0].reason.is_none()); 388 | assert!(feed_map[1].reason.is_none()); 389 | assert!(feed_map[2].reason.is_some()); 390 | // Duplicated, but updated feed 391 | update_feeds( 392 | &[ 393 | feed_view_post(cids[0].clone(), Some(Datetime::now())), 394 | feed_view_post(cids[1].clone(), Some(Datetime::now())), 395 | ], 396 | &mut feed_map, 397 | ); 398 | assert_eq!(feed_map.len(), 3); 399 | println!("{:?}", feed_map.keys().collect::>()); 400 | assert_eq!(feed_map[1].post.cid, cids[2]); 401 | assert_eq!(feed_map[0].post.cid, cids[0]); 402 | assert_eq!(feed_map[2].post.cid, cids[1]); 403 | assert!(feed_map[0].reason.is_some()); 404 | assert!(feed_map[1].reason.is_none()); 405 | assert!(feed_map[2].reason.is_some()); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/backend/watches/pinned_feeds.rs: -------------------------------------------------------------------------------- 1 | use super::super::types::{FeedSourceInfo, PinnedFeed}; 2 | use super::super::{Watch, Watcher}; 3 | use bsky_sdk::api::app::bsky::actor::defs::SavedFeed; 4 | use bsky_sdk::preference::Preferences; 5 | use bsky_sdk::{BskyAgent, Result}; 6 | use futures_util::future; 7 | use std::collections::HashMap; 8 | use std::sync::Arc; 9 | use tokio::sync::watch::Sender; 10 | use tokio::sync::{broadcast, watch}; 11 | 12 | impl Watcher { 13 | pub fn pinned_feeds(&self) -> impl Watch> { 14 | let (tx, _) = broadcast::channel(1); 15 | PinnedFeedsWatcher { 16 | agent: self.agent.clone(), 17 | preferences: self.preferences(), 18 | tx, 19 | } 20 | } 21 | } 22 | 23 | pub struct PinnedFeedsWatcher { 24 | agent: Arc, 25 | preferences: W, 26 | tx: broadcast::Sender<()>, 27 | } 28 | 29 | impl Watch for PinnedFeedsWatcher 30 | where 31 | W: Watch, 32 | { 33 | type Output = Vec; 34 | 35 | fn subscribe(&self) -> tokio::sync::watch::Receiver { 36 | let (tx, rx) = watch::channel(Default::default()); 37 | let agent = self.agent.clone(); 38 | let mut quit = self.tx.subscribe(); 39 | let mut preferences = self.preferences.subscribe(); 40 | tokio::spawn(async move { 41 | loop { 42 | tokio::select! { 43 | changed = preferences.changed() => { 44 | if changed.is_ok() { 45 | let saved_feeds = preferences.borrow_and_update().saved_feeds.clone(); 46 | let (agent, tx) = (agent.clone(), tx.clone()); 47 | tokio::spawn(async move { 48 | update(&agent, &saved_feeds, &tx).await; 49 | }); 50 | } else { 51 | break log::warn!("preferences channel closed"); 52 | } 53 | } 54 | _ = quit.recv() => { 55 | break; 56 | } 57 | } 58 | } 59 | }); 60 | rx 61 | } 62 | fn unsubscribe(&self) { 63 | if let Err(e) = self.tx.send(()) { 64 | log::error!("failed to send quit: {e}"); 65 | } 66 | self.preferences.unsubscribe(); 67 | } 68 | fn refresh(&self) { 69 | self.preferences.refresh(); 70 | } 71 | } 72 | 73 | async fn update(agent: &BskyAgent, saved_feeds: &[SavedFeed], tx: &Sender>) { 74 | match collect_feeds(agent, saved_feeds).await { 75 | Ok(feeds) => { 76 | tx.send(feeds).ok(); 77 | } 78 | Err(e) => { 79 | log::error!("failed to collect feeds: {e}"); 80 | } 81 | } 82 | } 83 | 84 | async fn collect_feeds(agent: &BskyAgent, saved_feeds: &[SavedFeed]) -> Result> { 85 | let (mut feeds, mut lists) = (Vec::new(), Vec::new()); 86 | for feed in saved_feeds.iter().filter(|feed| feed.pinned) { 87 | match feed.r#type.as_str() { 88 | "feed" => feeds.push(feed.value.clone()), 89 | "list" => lists.push(feed.value.clone()), 90 | _ => {} 91 | } 92 | } 93 | let mut resolved = HashMap::new(); 94 | // resolve feeds 95 | if !feeds.is_empty() { 96 | for feed_generator in agent 97 | .api 98 | .app 99 | .bsky 100 | .feed 101 | .get_feed_generators( 102 | bsky_sdk::api::app::bsky::feed::get_feed_generators::ParametersData { feeds } 103 | .into(), 104 | ) 105 | .await? 106 | .data 107 | .feeds 108 | { 109 | resolved.insert( 110 | feed_generator.uri.clone(), 111 | FeedSourceInfo::Feed(Box::new(feed_generator)), 112 | ); 113 | } 114 | } 115 | // resolve lists 116 | let mut handles = Vec::new(); 117 | for list in lists { 118 | let agent = agent.clone(); 119 | handles.push(tokio::spawn(async move { 120 | agent 121 | .api 122 | .app 123 | .bsky 124 | .graph 125 | .get_list( 126 | bsky_sdk::api::app::bsky::graph::get_list::ParametersData { 127 | cursor: None, 128 | limit: 1.try_into().ok(), 129 | list, 130 | } 131 | .into(), 132 | ) 133 | .await 134 | })); 135 | } 136 | for result in future::join_all(handles).await.into_iter().flatten() { 137 | let list_view = result?.data.list; 138 | resolved.insert( 139 | list_view.data.uri.clone(), 140 | FeedSourceInfo::List(Box::new(list_view)), 141 | ); 142 | } 143 | 144 | let mut ret = Vec::new(); 145 | for saved_feed in saved_feeds { 146 | match saved_feed.r#type.as_str() { 147 | "feed" | "list" => { 148 | if let Some(info) = resolved.remove(&saved_feed.value) { 149 | ret.push(PinnedFeed { 150 | saved_feed: saved_feed.clone(), 151 | info, 152 | }); 153 | } 154 | } 155 | "timeline" => { 156 | ret.push(PinnedFeed { 157 | saved_feed: saved_feed.clone(), 158 | info: FeedSourceInfo::Timeline(saved_feed.value.clone()), 159 | }); 160 | } 161 | _ => {} 162 | } 163 | } 164 | Ok(ret) 165 | } 166 | -------------------------------------------------------------------------------- /src/backend/watches/post_thread.rs: -------------------------------------------------------------------------------- 1 | use super::super::{Watch, Watcher}; 2 | use bsky_sdk::api::app::bsky::feed::defs::NotFoundPostData; 3 | use bsky_sdk::api::app::bsky::feed::get_post_thread::OutputThreadRefs; 4 | use bsky_sdk::api::types::Union; 5 | use bsky_sdk::preference::Preferences; 6 | use bsky_sdk::{BskyAgent, Result}; 7 | use std::sync::Arc; 8 | use std::time::Duration; 9 | use tokio::sync::{broadcast, watch}; 10 | use tokio::time; 11 | 12 | impl Watcher { 13 | pub fn post_thread(&self, uri: String) -> impl Watch> { 14 | let (tx, _) = broadcast::channel(1); 15 | PostThreadWatcher { 16 | uri, 17 | agent: self.agent.clone(), 18 | preferences: self.preferences(), 19 | period: Duration::from_secs(self.config.intervals.post_thread), 20 | tx, 21 | } 22 | } 23 | } 24 | 25 | pub struct PostThreadWatcher { 26 | uri: String, 27 | agent: Arc, 28 | preferences: W, 29 | period: Duration, 30 | tx: broadcast::Sender<()>, 31 | } 32 | 33 | impl Watch for PostThreadWatcher 34 | where 35 | W: Watch, 36 | { 37 | type Output = Union; 38 | 39 | fn subscribe(&self) -> watch::Receiver> { 40 | let init = Union::Refs(OutputThreadRefs::AppBskyFeedDefsNotFoundPost(Box::new( 41 | NotFoundPostData { 42 | not_found: true, 43 | uri: String::new(), 44 | } 45 | .into(), 46 | ))); 47 | let (tx, rx) = watch::channel(init); 48 | let updater = Updater { 49 | agent: self.agent.clone(), 50 | uri: self.uri.clone(), 51 | tx: tx.clone(), 52 | }; 53 | let (mut preferences, mut quit) = (self.preferences.subscribe(), self.tx.subscribe()); 54 | let mut interval = time::interval(self.period); 55 | tokio::spawn(async move { 56 | loop { 57 | let tick = interval.tick(); 58 | tokio::select! { 59 | changed = preferences.changed() => { 60 | if changed.is_ok() { 61 | let updater = updater.clone(); 62 | tokio::spawn(async move { 63 | updater.update().await; 64 | }); 65 | } else { 66 | break log::warn!("preferences channel closed"); 67 | } 68 | } 69 | _ = tick => { 70 | let updater = updater.clone(); 71 | tokio::spawn(async move { 72 | updater.update().await; 73 | }); 74 | } 75 | _ = quit.recv() => { 76 | break; 77 | } 78 | } 79 | } 80 | }); 81 | rx 82 | } 83 | fn unsubscribe(&self) { 84 | if let Err(e) = self.tx.send(()) { 85 | log::error!("failed to send quit: {e}"); 86 | } 87 | self.preferences.unsubscribe(); 88 | } 89 | fn refresh(&self) { 90 | self.preferences.refresh(); 91 | } 92 | } 93 | 94 | #[derive(Clone)] 95 | struct Updater { 96 | agent: Arc, 97 | uri: String, 98 | tx: watch::Sender>, 99 | } 100 | 101 | impl Updater { 102 | async fn update(&self) { 103 | match self.get_post_thread().await { 104 | Ok(thread) => { 105 | if let Err(e) = self.tx.send(thread.clone()) { 106 | log::warn!("failed to send post thread: {e}"); 107 | } 108 | } 109 | Err(e) => { 110 | log::warn!("failed to get post thread: {e}"); 111 | } 112 | } 113 | } 114 | async fn get_post_thread(&self) -> Result> { 115 | Ok(self 116 | .agent 117 | .api 118 | .app 119 | .bsky 120 | .feed 121 | .get_post_thread( 122 | bsky_sdk::api::app::bsky::feed::get_post_thread::ParametersData { 123 | depth: 10.try_into().ok(), 124 | parent_height: None, 125 | uri: self.uri.clone(), 126 | } 127 | .into(), 128 | ) 129 | .await? 130 | .data 131 | .thread) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/backend/watches/preferences.rs: -------------------------------------------------------------------------------- 1 | use super::super::{Watch, Watcher}; 2 | use bsky_sdk::{preference::Preferences, BskyAgent}; 3 | use std::{sync::Arc, time::Duration}; 4 | use tokio::sync::{broadcast, watch}; 5 | use tokio::time; 6 | 7 | impl Watcher { 8 | pub fn preferences(&self) -> impl Watch { 9 | let (tx, _) = broadcast::channel(1); 10 | PreferencesWatcher { 11 | agent: self.agent.clone(), 12 | period: Duration::from_secs(self.config.intervals.preferences), 13 | tx, 14 | } 15 | } 16 | } 17 | 18 | #[derive(Debug, Clone)] 19 | enum Command { 20 | Quit, 21 | Refresh, 22 | } 23 | 24 | struct PreferencesWatcher { 25 | agent: Arc, 26 | period: Duration, 27 | tx: broadcast::Sender, 28 | } 29 | 30 | impl Watch for PreferencesWatcher { 31 | type Output = Preferences; 32 | 33 | fn subscribe(&self) -> watch::Receiver { 34 | let agent = self.agent.clone(); 35 | let mut command = self.tx.subscribe(); 36 | let mut interval = time::interval(self.period); 37 | let (tx, rx) = watch::channel(Preferences::default()); 38 | tokio::spawn(async move { 39 | loop { 40 | let tick = interval.tick(); 41 | let (agent, tx) = (agent.clone(), tx.clone()); 42 | tokio::select! { 43 | Ok(command) = command.recv() => { 44 | match command { 45 | Command::Refresh => { 46 | tokio::spawn(async move { 47 | update(&agent, &tx).await; 48 | }); 49 | } 50 | Command::Quit => { 51 | break; 52 | } 53 | } 54 | } 55 | _ = tick => { 56 | tokio::spawn(async move { 57 | update(&agent, &tx).await; 58 | }); 59 | } 60 | } 61 | } 62 | }); 63 | rx 64 | } 65 | fn unsubscribe(&self) { 66 | if let Err(e) = self.tx.send(Command::Quit) { 67 | log::error!("failed to send quit command: {e}"); 68 | } 69 | } 70 | fn refresh(&self) { 71 | if let Err(e) = self.tx.send(Command::Refresh) { 72 | log::error!("failed to send refresh command: {e}"); 73 | } 74 | } 75 | } 76 | 77 | async fn update(agent: &BskyAgent, tx: &watch::Sender) { 78 | if let Ok(preferences) = agent.get_preferences(true).await { 79 | agent.configure_labelers_from_preferences(&preferences); 80 | tx.send(preferences).ok(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/bin/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use color_eyre::Result; 3 | use std::path::PathBuf; 4 | use std::{env, fs}; 5 | use tuisky::app::App; 6 | use tuisky::config::Config; 7 | use tuisky::utils::{get_config_dir, initialize_panic_handler}; 8 | 9 | #[derive(Parser, Debug)] 10 | #[command(version, about, long_about = None)] 11 | /// TUI Client for Bluesky. 12 | struct Args { 13 | /// Path to the configuration file. 14 | #[arg(short, long)] 15 | config: Option, 16 | /// Maximum number of columns to display. 17 | /// The number of columns will be determined by the terminal width. 18 | #[arg(short, long)] 19 | num_columns: Option, 20 | } 21 | 22 | impl Args { 23 | fn config_path(&self) -> Result { 24 | if let Some(path) = &self.config { 25 | Ok(path.clone()) 26 | } else { 27 | Self::default_config_path() 28 | } 29 | } 30 | fn default_config_path() -> Result { 31 | let config_dir = get_config_dir()?; 32 | fs::create_dir_all(&config_dir)?; 33 | Ok(config_dir.join("config.toml")) 34 | } 35 | } 36 | 37 | fn init_logger() { 38 | let mut builder = env_logger::Builder::from_default_env(); 39 | if env::var("RUST_LOG").is_err() { 40 | builder.filter_level(log::LevelFilter::Off); 41 | } 42 | builder.init(); 43 | } 44 | 45 | #[tokio::main] 46 | async fn main() -> Result<()> { 47 | let args = Args::parse(); 48 | let mut config = if args.config_path()?.exists() { 49 | toml::from_str(&fs::read_to_string(args.config_path()?)?)? 50 | } else { 51 | Config::default() 52 | }; 53 | config.set_default_keybindings(); 54 | if let Some(num_columns) = args.num_columns { 55 | config.num_columns = Some(num_columns); 56 | } 57 | 58 | init_logger(); 59 | 60 | initialize_panic_handler()?; 61 | 62 | App::new(config).run().await 63 | } 64 | -------------------------------------------------------------------------------- /src/components.rs: -------------------------------------------------------------------------------- 1 | pub mod column; 2 | pub mod main; 3 | pub mod modals; 4 | pub mod views; 5 | 6 | use crate::config::Config; 7 | use crate::types::{Action, Event}; 8 | use color_eyre::eyre::Result; 9 | use crossterm::event::{KeyEvent, MouseEvent}; 10 | use ratatui::layout::{Rect, Size}; 11 | use ratatui::Frame; 12 | use tokio::sync::mpsc::UnboundedSender; 13 | 14 | /// `Component` is a trait that represents a visual and interactive element of the user interface. 15 | /// Implementors of this trait can be registered with the main application loop and will be able to receive events, 16 | /// update state, and be rendered on the screen. 17 | pub trait Component { 18 | /// Register an action handler that can send actions for processing if necessary. 19 | /// 20 | /// # Arguments 21 | /// 22 | /// * `tx` - An unbounded sender that can send actions. 23 | /// 24 | /// # Returns 25 | /// 26 | /// * `Result<()>` - An Ok result or an error. 27 | #[allow(unused_variables)] 28 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 29 | Ok(()) 30 | } 31 | /// Register a configuration handler that provides configuration settings if necessary. 32 | /// 33 | /// # Arguments 34 | /// 35 | /// * `config` - Configuration settings. 36 | /// 37 | /// # Returns 38 | /// 39 | /// * `Result<()>` - An Ok result or an error. 40 | #[allow(unused_variables)] 41 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 42 | Ok(()) 43 | } 44 | /// Initialize the component with a specified area if necessary. 45 | /// 46 | /// # Arguments 47 | /// 48 | /// * `area` - Rectangular area to initialize the component within. 49 | /// 50 | /// # Returns 51 | /// 52 | /// * `Result<()>` - An Ok result or an error. 53 | #[allow(unused_variables)] 54 | fn init(&mut self, size: Size) -> Result<()> { 55 | Ok(()) 56 | } 57 | /// Handle incoming events and produce actions if necessary. 58 | /// 59 | /// # Arguments 60 | /// 61 | /// * `event` - An optional event to be processed. 62 | /// 63 | /// # Returns 64 | /// 65 | /// * `Result>` - An action to be processed or none. 66 | fn handle_events(&mut self, event: Option) -> Result> { 67 | let r = match event { 68 | Some(Event::Key(key_event)) => self.handle_key_events(key_event)?, 69 | Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?, 70 | _ => None, 71 | }; 72 | Ok(r) 73 | } 74 | /// Handle key events and produce actions if necessary. 75 | /// 76 | /// # Arguments 77 | /// 78 | /// * `key` - A key event to be processed. 79 | /// 80 | /// # Returns 81 | /// 82 | /// * `Result>` - An action to be processed or none. 83 | #[allow(unused_variables)] 84 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 85 | Ok(None) 86 | } 87 | /// Handle mouse events and produce actions if necessary. 88 | /// 89 | /// # Arguments 90 | /// 91 | /// * `mouse` - A mouse event to be processed. 92 | /// 93 | /// # Returns 94 | /// 95 | /// * `Result>` - An action to be processed or none. 96 | #[allow(unused_variables)] 97 | fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result> { 98 | Ok(None) 99 | } 100 | /// Update the state of the component based on a received action. (REQUIRED) 101 | /// 102 | /// # Arguments 103 | /// 104 | /// * `action` - An action that may modify the state of the component. 105 | /// 106 | /// # Returns 107 | /// 108 | /// * `Result>` - An action to be processed or none. 109 | #[allow(unused_variables)] 110 | fn update(&mut self, action: Action) -> Result> { 111 | Ok(None) 112 | } 113 | /// Render the component on the screen. (REQUIRED) 114 | /// 115 | /// # Arguments 116 | /// 117 | /// * `f` - A frame used for rendering. 118 | /// * `area` - The area in which the component should be drawn. 119 | /// 120 | /// # Returns 121 | /// 122 | /// * `Result<()>` - An Ok result or an error. 123 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()>; 124 | } 125 | -------------------------------------------------------------------------------- /src/components/column.rs: -------------------------------------------------------------------------------- 1 | use super::views::types::{Action as ViewAction, Transition, View}; 2 | use super::views::{ 3 | FeedViewComponent, LoginComponent, MenuViewComponent, NewPostViewComponent, PostViewComponent, 4 | RootComponent, ViewComponent, 5 | }; 6 | use super::Component; 7 | use crate::backend::Watcher; 8 | use crate::config::Config; 9 | use crate::types::{Action, IdType}; 10 | use bsky_sdk::agent::config::Config as AgentConfig; 11 | use bsky_sdk::api::agent::atp_agent::AtpSession; 12 | use bsky_sdk::BskyAgent; 13 | use color_eyre::{eyre, Result}; 14 | use crossterm::event::KeyEvent; 15 | use ratatui::layout::{Rect, Size}; 16 | use ratatui::Frame; 17 | use std::sync::atomic::{AtomicU32, Ordering}; 18 | use std::sync::{Arc, RwLock}; 19 | use tokio::sync::mpsc::{self, UnboundedSender}; 20 | 21 | static COUNTER: AtomicU32 = AtomicU32::new(0); 22 | 23 | pub struct ColumnComponent { 24 | pub id: IdType, 25 | pub watcher: Option>, 26 | pub views: Vec>, 27 | menu: MenuViewComponent, 28 | pub is_menu_active: bool, 29 | config: Config, 30 | action_tx: UnboundedSender, 31 | view_tx: UnboundedSender, 32 | session: Arc>>, 33 | } 34 | 35 | impl ColumnComponent { 36 | pub fn new(config: Config, action_tx: UnboundedSender) -> Self { 37 | let id = COUNTER.fetch_add(1, Ordering::SeqCst); 38 | let (view_tx, mut view_rx) = mpsc::unbounded_channel(); 39 | let tx = action_tx.clone(); 40 | tokio::spawn(async move { 41 | while let Some(action) = view_rx.recv().await { 42 | match action { 43 | ViewAction::Login(agent) => { 44 | if let Err(e) = tx.send(Action::Login((id, agent))) { 45 | log::error!("failed to send login action: {e}"); 46 | } 47 | } 48 | _ => { 49 | if let Err(e) = tx.send(Action::View((id, action))) { 50 | log::error!("failed to send view action: {e}"); 51 | } 52 | } 53 | } 54 | } 55 | }); 56 | Self { 57 | id, 58 | watcher: None, 59 | views: Vec::new(), 60 | menu: MenuViewComponent::new(view_tx.clone(), &config.keybindings), 61 | is_menu_active: false, 62 | config, 63 | action_tx, 64 | view_tx, 65 | session: Arc::new(RwLock::new(None)), 66 | } 67 | } 68 | pub fn init_with_config(&mut self, config: &AgentConfig) -> Result<()> { 69 | let config = config.clone(); 70 | let (id, tx) = (self.id, self.action_tx.clone()); 71 | tokio::spawn(async move { 72 | let Ok(agent) = BskyAgent::builder().config(config).build().await else { 73 | return log::error!("failed to build agent from config"); 74 | }; 75 | if let Err(e) = tx.send(Action::Login((id, Box::new(agent)))) { 76 | log::error!("failed to send transition action: {e}"); 77 | } 78 | }); 79 | Ok(()) 80 | } 81 | pub fn title(&self) -> String { 82 | if let Some(session) = self.session.read().ok().as_ref().and_then(|s| s.as_ref()) { 83 | format!(" {} ", session.handle.as_str()) 84 | } else { 85 | format!(" id: {} ", self.id) 86 | } 87 | } 88 | pub(crate) fn transition(&mut self, transition: &Transition) -> Result> { 89 | match transition { 90 | Transition::Push(view) => { 91 | if let Some(current) = self.views.last_mut() { 92 | current.deactivate()?; 93 | } 94 | let mut next = self.view(view)?; 95 | next.as_mut().activate()?; 96 | self.views.push(next); 97 | } 98 | Transition::Pop => { 99 | if let Some(mut view) = self.views.pop() { 100 | view.deactivate()?; 101 | } 102 | if let Some(current) = self.views.last_mut() { 103 | current.activate()?; 104 | } 105 | } 106 | Transition::Replace(view) => { 107 | if let Some(mut current) = self.views.pop() { 108 | current.deactivate()?; 109 | } 110 | let mut next = self.view(view)?; 111 | next.as_mut().activate()?; 112 | self.views.push(next); 113 | } 114 | } 115 | Ok(Some(Action::Render)) 116 | } 117 | fn view(&self, view: &View) -> Result> { 118 | let watcher = self 119 | .watcher 120 | .as_ref() 121 | .ok_or_else(|| eyre::eyre!("watcher not initialized"))?; 122 | Ok(match view { 123 | View::Login => Box::new(LoginComponent::new(self.view_tx.clone())), 124 | View::Root => Box::new(RootComponent::new(self.view_tx.clone(), watcher.clone())), 125 | View::NewPost => Box::new(NewPostViewComponent::new( 126 | self.view_tx.clone(), 127 | watcher.agent.clone(), 128 | )), 129 | View::Feed(info) => Box::new(FeedViewComponent::new( 130 | self.view_tx.clone(), 131 | watcher.clone(), 132 | info.as_ref().clone(), 133 | )), 134 | View::Post(boxed) => { 135 | let (post_view, reply) = boxed.as_ref(); 136 | Box::new(PostViewComponent::new( 137 | self.view_tx.clone(), 138 | watcher.clone(), 139 | post_view.clone(), 140 | reply.clone(), 141 | self.session 142 | .read() 143 | .ok() 144 | .as_ref() 145 | .and_then(|s| s.as_ref()) 146 | .cloned(), 147 | )) 148 | } 149 | }) 150 | } 151 | } 152 | 153 | impl Component for ColumnComponent { 154 | fn init(&mut self, _size: Size) -> Result<()> { 155 | self.views = vec![Box::new(LoginComponent::new(self.view_tx.clone()))]; 156 | Ok(()) 157 | } 158 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 159 | if !self.is_menu_active { 160 | if let Some(view) = self.views.last_mut() { 161 | if let Some(action) = view.handle_key_events(key)? { 162 | return Ok(Some(Action::View((self.id, action)))); 163 | } 164 | } 165 | } 166 | if let Some(action) = self.config.keybindings.column.get(&key.into()) { 167 | Ok(Some(Action::View((self.id, action.into())))) 168 | } else { 169 | Ok(None) 170 | } 171 | } 172 | fn update(&mut self, action: Action) -> Result> { 173 | match action { 174 | Action::View((id, view_action)) if id == self.id => { 175 | match view_action { 176 | ViewAction::Render => { 177 | return Ok(Some(Action::Render)); 178 | } 179 | ViewAction::NewPost if self.watcher.is_some() => { 180 | if !self 181 | .views 182 | .last() 183 | .map(|view| view.view() == View::NewPost) 184 | .unwrap_or_default() 185 | { 186 | return self.transition(&Transition::Push(Box::new(View::NewPost))); 187 | } 188 | } 189 | ViewAction::Menu if self.watcher.is_some() => { 190 | self.is_menu_active = !self.is_menu_active; 191 | return Ok(Some(Action::Render)); 192 | } 193 | _ => {} 194 | } 195 | if self.is_menu_active { 196 | if let Ok(Some(action)) = self.menu.update(view_action.clone()) { 197 | return Ok(Some(Action::View((self.id, action)))); 198 | } 199 | } 200 | return if let Some(view) = self.views.last_mut() { 201 | let result = view.update(view_action); 202 | match &result { 203 | Ok(Some(ViewAction::Logout)) => { 204 | if let Ok(mut session) = self.session.write() { 205 | session.take(); 206 | } 207 | self.watcher.take(); 208 | self.views = vec![Box::new(LoginComponent::new(self.view_tx.clone()))]; 209 | return Ok(Some(Action::Render)); 210 | } 211 | Ok(Some(ViewAction::Transition(transition))) => { 212 | return self.transition(transition); 213 | } 214 | _ => {} 215 | } 216 | result.map(|action| action.map(|a| Action::View((self.id, a)))) 217 | } else { 218 | Ok(None) 219 | }; 220 | } 221 | Action::Login((id, agent)) if id == self.id => { 222 | { 223 | let agent = agent.clone(); 224 | let session = self.session.clone(); 225 | tokio::spawn(async move { 226 | if let Some(output) = agent.get_session().await { 227 | if let Ok(mut session) = session.write() { 228 | session.replace(output); 229 | } 230 | } 231 | }); 232 | } 233 | self.watcher = Some(Arc::new(Watcher::new( 234 | Arc::new(*agent), 235 | self.config.watcher.clone(), 236 | ))); 237 | return self.transition(&Transition::Replace(Box::new(View::Root))); 238 | } 239 | _ => {} 240 | } 241 | Ok(None) 242 | } 243 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 244 | if let Some(view) = self.views.last_mut() { 245 | view.draw(f, area)?; 246 | } 247 | if self.is_menu_active { 248 | self.menu.draw(f, area)?; 249 | } 250 | Ok(()) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/components/main.rs: -------------------------------------------------------------------------------- 1 | use super::column::ColumnComponent; 2 | use super::Component; 3 | use crate::config::Config; 4 | use crate::types::Action; 5 | use crate::utils::get_data_dir; 6 | use bsky_sdk::agent::config::Config as AgentConfig; 7 | use color_eyre::Result; 8 | use crossterm::event::KeyEvent; 9 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect, Size}; 10 | use ratatui::style::{Style, Stylize}; 11 | use ratatui::widgets::{Block, BorderType}; 12 | use ratatui::Frame; 13 | use serde::{Deserialize, Serialize}; 14 | use std::fs::{create_dir_all, File}; 15 | use std::path::PathBuf; 16 | use tokio::sync::mpsc::UnboundedSender; 17 | 18 | #[derive(Debug, Default, Serialize, Deserialize)] 19 | struct AppData { 20 | views: Vec, 21 | } 22 | 23 | #[derive(Debug, Default, Serialize, Deserialize)] 24 | struct ViewData { 25 | agent: Option, 26 | } 27 | 28 | #[derive(Default)] 29 | struct State { 30 | selected: Option, 31 | } 32 | 33 | pub struct MainComponent { 34 | config: Config, 35 | action_tx: UnboundedSender, 36 | columns: Vec, 37 | state: State, 38 | } 39 | 40 | impl MainComponent { 41 | pub fn new(config: Config, action_tx: UnboundedSender) -> Self { 42 | Self { 43 | config, 44 | action_tx, 45 | columns: Vec::new(), 46 | state: State { selected: None }, 47 | } 48 | } 49 | pub async fn save(&self) -> Result<()> { 50 | let mut appdata = AppData { 51 | views: Vec::with_capacity(self.columns.len()), 52 | }; 53 | for view in &self.columns { 54 | let config = if let Some(w) = &view.watcher { 55 | Some(w.agent.to_config().await) 56 | } else { 57 | None 58 | }; 59 | appdata.views.push(ViewData { agent: config }); 60 | } 61 | let path = Self::appdata_path()?; 62 | serde_json::to_writer_pretty(File::create(&path)?, &appdata)?; 63 | log::info!("saved appdata to: {path:?}"); 64 | Ok(()) 65 | } 66 | fn load() -> Result { 67 | let path = Self::appdata_path()?; 68 | let appdata = serde_json::from_reader::<_, AppData>(File::open(&path)?)?; 69 | log::info!("loaded appdata from {path:?}"); 70 | Ok(appdata) 71 | } 72 | fn appdata_path() -> Result { 73 | let data_dir = get_data_dir()?; 74 | create_dir_all(&data_dir)?; 75 | Ok(data_dir.join("appdata.json")) 76 | } 77 | } 78 | 79 | impl Component for MainComponent { 80 | fn init(&mut self, size: Size) -> Result<()> { 81 | let appdata = if let Ok(appdata) = Self::load() { 82 | appdata 83 | } else { 84 | log::warn!("failed to load appdata, using default"); 85 | AppData::default() 86 | }; 87 | 88 | let auto_num = usize::from(size.width) / 75; 89 | let num_columns = self 90 | .config 91 | .num_columns 92 | .map_or(auto_num, |n| n.min(auto_num)); 93 | 94 | for i in 0..num_columns { 95 | let mut column = ColumnComponent::new(self.config.clone(), self.action_tx.clone()); 96 | if let Some(config) = appdata.views.get(i).and_then(|view| view.agent.as_ref()) { 97 | column.init_with_config(config)?; 98 | } else { 99 | column.init(size)?; 100 | } 101 | self.columns.push(column); 102 | } 103 | if !self.columns.is_empty() { 104 | self.state.selected = Some(0); 105 | } 106 | Ok(()) 107 | } 108 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 109 | if let Some(selected) = self.state.selected { 110 | self.columns[selected].handle_key_events(key) 111 | } else { 112 | Ok(None) 113 | } 114 | } 115 | fn update(&mut self, action: Action) -> Result> { 116 | match action { 117 | Action::NextFocus => { 118 | if let Some(selected) = self.state.selected { 119 | self.columns[selected].is_menu_active = false; 120 | } 121 | self.state.selected = 122 | Some(self.state.selected.map_or(0, |s| s + 1) % self.columns.len()); 123 | return Ok(Some(Action::Render)); 124 | } 125 | Action::PrevFocus => { 126 | if let Some(selected) = self.state.selected { 127 | self.columns[selected].is_menu_active = false; 128 | } 129 | self.state.selected = Some( 130 | self.state 131 | .selected 132 | .map_or(0, |s| s + self.columns.len() - 1) 133 | % self.columns.len(), 134 | ); 135 | return Ok(Some(Action::Render)); 136 | } 137 | _ => { 138 | for column in self.columns.iter_mut() { 139 | if let Some(action) = column.update(action.clone())? { 140 | return Ok(Some(action)); 141 | } 142 | } 143 | } 144 | } 145 | Ok(None) 146 | } 147 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 148 | let layout = Layout::default() 149 | .direction(Direction::Horizontal) 150 | .constraints(self.columns.iter().map(|_| Constraint::Fill(1))) 151 | .split(area); 152 | for (i, (area, view)) in layout.iter().zip(self.columns.iter_mut()).enumerate() { 153 | let mut block = Block::bordered() 154 | .title(view.title()) 155 | .title_alignment(Alignment::Center); 156 | if self.state.selected == Some(i) { 157 | block = block 158 | .border_type(BorderType::Double) 159 | .border_style(Style::default().reset().bold()); 160 | } 161 | view.draw(f, block.inner(*area))?; 162 | f.render_widget(block, *area); 163 | } 164 | Ok(()) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/components/modals.rs: -------------------------------------------------------------------------------- 1 | mod embed; 2 | mod embed_images; 3 | mod embed_record; 4 | pub mod types; 5 | 6 | pub use self::embed::EmbedModalComponent; 7 | use self::types::Action; 8 | use super::views::types::Action as ViewsAction; 9 | use color_eyre::Result; 10 | use crossterm::event::KeyEvent; 11 | use ratatui::layout::Rect; 12 | use ratatui::Frame; 13 | 14 | pub trait ModalComponent { 15 | #[allow(unused_variables)] 16 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 17 | Ok(None) 18 | } 19 | #[allow(unused_variables)] 20 | fn update(&mut self, action: ViewsAction) -> Result> { 21 | Ok(None) 22 | } 23 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()>; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/modals/embed.rs: -------------------------------------------------------------------------------- 1 | use super::super::views::types::Action as ViewsAction; 2 | use super::embed_images::EmbedImagesModalComponent; 3 | use super::embed_record::EmbedRecordModalComponent; 4 | use super::types::{Data, EmbedData, ImageData}; 5 | use super::{Action, ModalComponent}; 6 | use bsky_sdk::api::com::atproto::repo::strong_ref; 7 | use color_eyre::Result; 8 | use crossterm::event::KeyEvent; 9 | use ratatui::layout::{Constraint, Layout, Margin, Rect}; 10 | use ratatui::style::{Color, Style, Stylize}; 11 | use ratatui::text::{Line, Text}; 12 | use ratatui::widgets::{Block, BorderType, Clear, List, ListState, Padding}; 13 | use ratatui::Frame; 14 | use tokio::sync::mpsc::UnboundedSender; 15 | 16 | pub struct EmbedModalComponent { 17 | action_tx: UnboundedSender, 18 | embeds_state: ListState, 19 | actions_state: ListState, 20 | record: Option, 21 | images: Vec, 22 | child: Option>, 23 | } 24 | 25 | impl EmbedModalComponent { 26 | pub fn new(action_tx: UnboundedSender, init: Option) -> Self { 27 | let (record, images) = if let Some(data) = init { 28 | (data.record, data.images) 29 | } else { 30 | (None, Vec::new()) 31 | }; 32 | Self { 33 | action_tx, 34 | embeds_state: Default::default(), 35 | actions_state: Default::default(), 36 | record, 37 | images, 38 | child: None, 39 | } 40 | } 41 | } 42 | 43 | impl ModalComponent for EmbedModalComponent { 44 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 45 | if let Some(child) = self.child.as_mut() { 46 | child.handle_key_events(key) 47 | } else { 48 | Ok(None) 49 | } 50 | } 51 | fn update(&mut self, action: ViewsAction) -> Result> { 52 | if let Some(child) = self.child.as_mut() { 53 | return Ok(match child.update(action)? { 54 | Some(Action::Ok(data)) => { 55 | match data { 56 | Data::Image((image, index)) => { 57 | if let Some(i) = index { 58 | self.images[i] = image; 59 | } else { 60 | self.images.push(image) 61 | } 62 | } 63 | Data::Record(strong_ref) => { 64 | self.record = Some(strong_ref); 65 | } 66 | _ => { 67 | // TODO 68 | } 69 | } 70 | self.child = None; 71 | Some(Action::Render) 72 | } 73 | Some(Action::Delete(index)) => { 74 | match index { 75 | Some(i) => { 76 | self.images.remove(i); 77 | } 78 | None => self.record = None, 79 | } 80 | self.child = None; 81 | self.embeds_state.select(None); 82 | Some(Action::Render) 83 | } 84 | Some(Action::Cancel) => { 85 | self.child = None; 86 | Some(Action::Render) 87 | } 88 | action => action, 89 | }); 90 | } 91 | Ok(match action { 92 | ViewsAction::NextItem => { 93 | match (self.embeds_state.selected(), self.actions_state.selected()) { 94 | (Some(i), None) => { 95 | if i == usize::from(self.record.is_some()) + self.images.len() - 1 { 96 | self.embeds_state.select(None); 97 | self.actions_state.select_first(); 98 | } else { 99 | self.embeds_state.select_next(); 100 | } 101 | } 102 | (None, Some(i)) => { 103 | self.actions_state.select(Some((i + 1).min(3))); 104 | } 105 | _ => { 106 | self.actions_state.select_first(); 107 | } 108 | } 109 | Some(Action::Render) 110 | } 111 | ViewsAction::PrevItem => { 112 | match (self.embeds_state.selected(), self.actions_state.selected()) { 113 | (Some(i), None) => { 114 | self.embeds_state.select(Some(i.max(1) - 1)); 115 | } 116 | (None, Some(0)) => { 117 | if usize::from(self.record.is_some()) + self.images.len() > 0 { 118 | self.actions_state.select(None); 119 | self.embeds_state.select_last(); 120 | } 121 | } 122 | (None, Some(i)) => { 123 | self.actions_state.select(Some(i - 1)); 124 | } 125 | _ => { 126 | self.actions_state.select_last(); 127 | } 128 | } 129 | Some(Action::Render) 130 | } 131 | ViewsAction::Enter => { 132 | match self.embeds_state.selected() { 133 | Some(0) if self.record.is_some() => { 134 | if let Some(record) = &self.record { 135 | self.child = Some(Box::new(EmbedRecordModalComponent::new( 136 | self.action_tx.clone(), 137 | Some(record.uri.clone()), 138 | ))); 139 | } 140 | } 141 | Some(i) => { 142 | let i = i - usize::from(self.record.is_some()); 143 | self.child = Some(Box::new(EmbedImagesModalComponent::new(Some(( 144 | i, 145 | self.images[i].clone(), 146 | ))))); 147 | } 148 | None => {} 149 | } 150 | match self.actions_state.selected() { 151 | Some(0) if self.images.len() < 4 => { 152 | self.child = Some(Box::new(EmbedImagesModalComponent::new(None))); 153 | } 154 | Some(1) => { 155 | // TODO: Add external 156 | } 157 | Some(2) => { 158 | self.child = Some(Box::new(EmbedRecordModalComponent::new( 159 | self.action_tx.clone(), 160 | None, 161 | ))); 162 | } 163 | Some(3) => { 164 | return Ok(Some(Action::Ok(Data::Embed(EmbedData { 165 | images: self.images.clone(), 166 | record: self.record.clone(), 167 | })))); 168 | } 169 | _ => {} 170 | } 171 | Some(Action::Render) 172 | } 173 | ViewsAction::Back => Some(Action::Cancel), 174 | _ => None, 175 | }) 176 | } 177 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 178 | let area = area.inner(Margin { 179 | horizontal: 2, 180 | vertical: 4, 181 | }); 182 | let [area] = Layout::vertical([Constraint::Max(2 + 2 * 4 + 2 + 3 + 1 + 2)]).areas(area); 183 | 184 | let block = Block::bordered().title("Embed"); 185 | let inner = block.inner(area); 186 | f.render_widget(Clear, area); 187 | f.render_widget(block, area); 188 | 189 | let [embeds, actions] = Layout::vertical([ 190 | Constraint::Length( 191 | 2 + 2 * u16::from(self.record.is_some()) + 2 * self.images.len() as u16, 192 | ), 193 | Constraint::Length(4), 194 | ]) 195 | .areas(inner); 196 | 197 | let mut embed_items = Vec::new(); 198 | if let Some(record) = &self.record { 199 | embed_items.push(Text::from(vec![ 200 | Line::from(format!("record: {}", record.uri)), 201 | Line::from(format!(" {}", record.cid.as_ref())).dim(), 202 | ])); 203 | } 204 | for (i, image) in self.images.iter().enumerate() { 205 | embed_items.push(Text::from(vec![ 206 | Line::from(format!("image{}: {}", i + 1, image.path)), 207 | Line::from(format!(" {}", image.alt)).dim(), 208 | ])); 209 | } 210 | f.render_stateful_widget( 211 | List::new(embed_items) 212 | .block( 213 | Block::bordered() 214 | .border_type(BorderType::Rounded) 215 | .border_style(Color::Yellow), 216 | ) 217 | .highlight_style(Style::reset().reversed()), 218 | embeds, 219 | &mut self.embeds_state, 220 | ); 221 | f.render_stateful_widget( 222 | List::new([ 223 | Line::from("Add images"), 224 | Line::from("Add external").dim(), 225 | Line::from("Add record"), 226 | Line::from("OK").centered().blue(), 227 | ]) 228 | .block(Block::default().padding(Padding::horizontal(1))) 229 | .highlight_style(Style::default().reversed()), 230 | actions, 231 | &mut self.actions_state, 232 | ); 233 | if let Some(child) = self.child.as_mut() { 234 | child.draw(f, area)?; 235 | } 236 | Ok(()) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/components/modals/embed_images.rs: -------------------------------------------------------------------------------- 1 | use super::super::views::types::Action as ViewsAction; 2 | use super::types::{Action, Data, ImageData}; 3 | use super::ModalComponent; 4 | use color_eyre::Result; 5 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 6 | use image::ImageReader; 7 | use ratatui::layout::{Constraint, Layout, Margin, Rect}; 8 | use ratatui::style::{Color, Style, Stylize}; 9 | use ratatui::text::Line; 10 | use ratatui::widgets::{Block, Clear}; 11 | use ratatui::Frame; 12 | use std::path::PathBuf; 13 | use tui_textarea::TextArea; 14 | 15 | pub struct Image { 16 | pub path: TextArea<'static>, 17 | pub alt: TextArea<'static>, 18 | } 19 | 20 | enum Focus { 21 | Path, 22 | Alt, 23 | Ok, 24 | Delete, 25 | } 26 | 27 | impl Focus { 28 | fn next(&self, delete: bool) -> Self { 29 | match self { 30 | Self::Path => Self::Alt, 31 | Self::Alt => Self::Ok, 32 | Self::Ok if delete => Self::Delete, 33 | Self::Ok => Self::Ok, 34 | Self::Delete => Self::Delete, 35 | } 36 | } 37 | fn prev(&self, _: bool) -> Self { 38 | match self { 39 | Self::Path => Self::Path, 40 | Self::Alt => Self::Path, 41 | Self::Ok => Self::Alt, 42 | Self::Delete => Self::Ok, 43 | } 44 | } 45 | } 46 | 47 | enum State { 48 | None, 49 | Ok, 50 | Error, 51 | } 52 | 53 | pub struct EmbedImagesModalComponent { 54 | image: Image, 55 | focus: Focus, 56 | state: State, 57 | index: Option, 58 | } 59 | 60 | impl EmbedImagesModalComponent { 61 | pub fn new(init: Option<(usize, ImageData)>) -> Self { 62 | let (mut path, mut alt) = if let Some((_, init)) = &init { 63 | ( 64 | TextArea::new(vec![init.path.clone()]), 65 | TextArea::new(init.alt.lines().map(String::from).collect()), 66 | ) 67 | } else { 68 | (TextArea::default(), TextArea::default()) 69 | }; 70 | path.set_block(Block::bordered().title("Path")); 71 | path.set_cursor_line_style(Style::default()); 72 | alt.set_block(Block::bordered().title("Alt").dim()); 73 | alt.set_cursor_line_style(Style::default()); 74 | alt.set_cursor_style(Style::default()); 75 | let image = Image { path, alt }; 76 | 77 | let mut ret = Self { 78 | image, 79 | focus: Focus::Path, 80 | state: State::None, 81 | index: init.map(|(i, _)| i), 82 | }; 83 | ret.check_path(); 84 | ret 85 | } 86 | fn check_path(&mut self) { 87 | if let Some(block) = self.image.path.block() { 88 | let block = block.clone(); 89 | let path = PathBuf::from(self.image.path.lines().join("")); 90 | self.state = if let Ok(metadata) = path.metadata() { 91 | if metadata.is_file() 92 | && metadata.len() <= 1_000_000 93 | && ImageReader::open(path) 94 | .ok() 95 | .and_then(|reader| reader.decode().ok()) 96 | .is_some() 97 | { 98 | State::Ok 99 | } else { 100 | State::Error 101 | } 102 | } else { 103 | State::None 104 | }; 105 | self.image.path.set_block(match self.state { 106 | State::None => block.border_style(Color::Reset), 107 | State::Ok => block.border_style(Color::Green), 108 | State::Error => block.border_style(Color::Red), 109 | }); 110 | } 111 | } 112 | fn current_textarea(&mut self) -> Option<&mut TextArea<'static>> { 113 | match self.focus { 114 | Focus::Path => Some(&mut self.image.path), 115 | Focus::Alt => Some(&mut self.image.alt), 116 | _ => None, 117 | } 118 | } 119 | fn update_focus(&mut self, focus: Focus) { 120 | if let Some(curr) = self.current_textarea() { 121 | curr.set_cursor_style(Style::default()); 122 | if let Some(block) = curr.block() { 123 | curr.set_block(block.clone().dim()); 124 | } 125 | } 126 | self.focus = focus; 127 | if let Some(curr) = self.current_textarea() { 128 | curr.set_cursor_style(Style::default().reversed()); 129 | if let Some(block) = curr.block() { 130 | curr.set_block(block.clone().reset()); 131 | } 132 | } 133 | } 134 | } 135 | 136 | impl ModalComponent for EmbedImagesModalComponent { 137 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 138 | match self.focus { 139 | Focus::Path => { 140 | if matches!( 141 | (key.code, key.modifiers), 142 | (KeyCode::Enter, _) | (KeyCode::Char('m'), KeyModifiers::CONTROL) 143 | ) { 144 | return Ok(None); 145 | } 146 | let cursor = self.image.path.cursor(); 147 | return Ok(if self.image.path.input(key) { 148 | self.check_path(); 149 | Some(Action::Render) 150 | } else if self.image.path.cursor() != cursor { 151 | Some(Action::Render) 152 | } else { 153 | None 154 | }); 155 | } 156 | Focus::Alt => { 157 | let cursor = self.image.alt.cursor(); 158 | return Ok( 159 | if self.image.alt.input(key) || self.image.alt.cursor() != cursor { 160 | Some(Action::Render) 161 | } else { 162 | None 163 | }, 164 | ); 165 | } 166 | _ => {} 167 | } 168 | Ok(None) 169 | } 170 | fn update(&mut self, action: ViewsAction) -> Result> { 171 | Ok(match action { 172 | ViewsAction::NextItem => { 173 | self.update_focus(self.focus.next(self.index.is_some())); 174 | Some(Action::Render) 175 | } 176 | ViewsAction::PrevItem => { 177 | self.update_focus(self.focus.prev(self.index.is_some())); 178 | Some(Action::Render) 179 | } 180 | ViewsAction::Enter => match self.focus { 181 | Focus::Ok => { 182 | if let State::Ok = self.state { 183 | Some(Action::Ok(Data::Image(( 184 | ImageData { 185 | path: self.image.path.lines().join(""), 186 | alt: self.image.alt.lines().join("\n"), 187 | }, 188 | self.index, 189 | )))) 190 | } else { 191 | None 192 | } 193 | } 194 | Focus::Delete => Some(Action::Delete(self.index)), 195 | _ => self.update(ViewsAction::NextItem)?, 196 | }, 197 | ViewsAction::Back => Some(Action::Cancel), 198 | _ => None, 199 | }) 200 | } 201 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 202 | let area = area.inner(Margin { 203 | horizontal: 2, 204 | vertical: 1, 205 | }); 206 | let [area] = Layout::vertical([Constraint::Max(11)]).areas(area); 207 | 208 | let block = Block::bordered().title("Embed image"); 209 | let inner = block.inner(area); 210 | f.render_widget(Clear, area); 211 | f.render_widget(block, area); 212 | 213 | let mut constraints = vec![ 214 | Constraint::Length(3), 215 | Constraint::Length(4), 216 | Constraint::Length(1), 217 | ]; 218 | if self.index.is_some() { 219 | constraints.push(Constraint::Length(1)); 220 | } 221 | let layout = Layout::vertical(constraints).split(inner); 222 | let mut line = Line::from("OK").centered(); 223 | line = match self.state { 224 | State::Ok => line.blue(), 225 | _ => line.dim(), 226 | }; 227 | if let Focus::Ok = self.focus { 228 | line = line.reversed(); 229 | } 230 | f.render_widget(&self.image.path, layout[0]); 231 | f.render_widget(&self.image.alt, layout[1]); 232 | f.render_widget(line, layout[2]); 233 | if let Some(area) = layout.get(3) { 234 | f.render_widget( 235 | Line::from("Delete") 236 | .centered() 237 | .red() 238 | .patch_style(match self.focus { 239 | Focus::Delete => Style::default().reversed(), 240 | _ => Style::default(), 241 | }), 242 | *area, 243 | ) 244 | } 245 | Ok(()) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/components/modals/embed_record.rs: -------------------------------------------------------------------------------- 1 | use super::super::views::types::Action as ViewsAction; 2 | use super::types::{Action, Data}; 3 | use super::ModalComponent; 4 | use bsky_sdk::agent::config::Config; 5 | use bsky_sdk::api::com::atproto::repo::strong_ref; 6 | use bsky_sdk::api::types::string::{AtIdentifier, Cid, Nsid, RecordKey}; 7 | use bsky_sdk::BskyAgent; 8 | use color_eyre::Result; 9 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 10 | use ratatui::layout::{Constraint, Layout, Margin, Rect}; 11 | use ratatui::style::{Color, Style, Stylize}; 12 | use ratatui::text::Line; 13 | use ratatui::widgets::{Block, Clear}; 14 | use ratatui::Frame; 15 | use regex::Regex; 16 | use std::ops::Deref; 17 | use std::sync::{Arc, LazyLock, Mutex}; 18 | use tokio::sync::mpsc::UnboundedSender; 19 | use tui_textarea::TextArea; 20 | 21 | static RE_AT_URI: LazyLock = LazyLock::new(|| { 22 | // const aturiRegex = 23 | // /^at:\/\/(?[a-zA-Z0-9._:%-]+)(\/(?[a-zA-Z0-9-.]+)(\/(?[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/ 24 | Regex::new( 25 | r"^at:\/\/(?[a-zA-Z0-9._:%-]+)(\/(?[a-zA-Z0-9-.]+)(\/(?[a-zA-Z0-9.\-_:~]{1,512})))$" 26 | ).expect("invalid regex") 27 | }); 28 | 29 | const PUBLIC_API_ENDPOINT: &str = "https://public.api.bsky.app"; 30 | 31 | enum Focus { 32 | None, 33 | Input, 34 | Ok, 35 | Delete, 36 | } 37 | 38 | impl Focus { 39 | fn next(&self, delete: bool) -> Self { 40 | match self { 41 | Self::None => Self::Input, 42 | Self::Input => Self::Ok, 43 | Self::Ok if delete => Self::Delete, 44 | Self::Ok => Self::Ok, 45 | Self::Delete => Self::Delete, 46 | } 47 | } 48 | fn prev(&self, _: bool) -> Self { 49 | match self { 50 | Self::None => Self::Input, 51 | Self::Input => Self::Input, 52 | Self::Ok => Self::Input, 53 | Self::Delete => Self::Ok, 54 | } 55 | } 56 | } 57 | 58 | #[derive(Debug, Clone)] 59 | enum State { 60 | None, 61 | Ok(Cid), 62 | Error(String), 63 | } 64 | 65 | pub struct EmbedRecordModalComponent { 66 | action_tx: UnboundedSender, 67 | input: TextArea<'static>, 68 | record: Option, 69 | focus: Focus, 70 | state: Arc>, 71 | } 72 | 73 | impl EmbedRecordModalComponent { 74 | pub fn new(action_tx: UnboundedSender, init: Option) -> Self { 75 | let mut input = if let Some(data) = &init { 76 | TextArea::new(vec![data.clone()]) 77 | } else { 78 | TextArea::default() 79 | }; 80 | input.set_block(Block::bordered().title("Path")); 81 | input.set_cursor_line_style(Style::default()); 82 | Self { 83 | action_tx, 84 | input, 85 | record: init, 86 | focus: Focus::Input, 87 | state: Arc::new(Mutex::new(State::None)), 88 | } 89 | } 90 | fn get_record(&self, uri: &str) -> Option<&str> { 91 | let Some(captures) = RE_AT_URI.captures(uri) else { 92 | return Some("invalid at uri"); 93 | }; 94 | let (Some(authority), Some(collection), Some(rkey)) = ( 95 | captures.name("authority"), 96 | captures.name("collection"), 97 | captures.name("rkey"), 98 | ) else { 99 | return Some("missing authority, collection, or rkey"); 100 | }; 101 | let (Ok(repo), Ok(collection), Ok(rkey)) = ( 102 | authority.as_str().parse::(), 103 | collection.as_str().parse::(), 104 | rkey.as_str().parse::(), 105 | ) else { 106 | return Some("invalid authority or collection"); 107 | }; 108 | let action_tx = self.action_tx.clone(); 109 | let state = self.state.clone(); 110 | tokio::spawn(async move { 111 | *state.lock().unwrap() = match Self::try_get_record(collection, repo, rkey).await { 112 | Ok(cid) => State::Ok(cid), 113 | Err(_) => State::Error("failed to get record".into()), 114 | }; 115 | if let Err(e) = action_tx.send(ViewsAction::Render) { 116 | log::error!("failed to send render event: {e}"); 117 | } 118 | }); 119 | None 120 | } 121 | fn update_focus(&mut self, focus: Focus) { 122 | if let Focus::Input = self.focus { 123 | self.input.set_cursor_style(Style::default()); 124 | if let Some(block) = self.input.block() { 125 | self.input.set_block(block.clone().dim()); 126 | } 127 | } 128 | self.focus = focus; 129 | if let Focus::Input = self.focus { 130 | self.input.set_cursor_style(Style::default().reversed()); 131 | if let Some(block) = self.input.block() { 132 | self.input.set_block(block.clone().reset()); 133 | } 134 | } 135 | } 136 | async fn try_get_record(collection: Nsid, repo: AtIdentifier, rkey: RecordKey) -> Result { 137 | let agent = BskyAgent::builder() 138 | .config(Config { 139 | endpoint: PUBLIC_API_ENDPOINT.to_string(), 140 | ..Default::default() 141 | }) 142 | .build() 143 | .await?; 144 | let output = agent 145 | .api 146 | .com 147 | .atproto 148 | .repo 149 | .get_record( 150 | bsky_sdk::api::com::atproto::repo::get_record::ParametersData { 151 | cid: None, 152 | collection, 153 | repo, 154 | rkey, 155 | } 156 | .into(), 157 | ) 158 | .await?; 159 | Ok(output.data.cid.expect("missing cid")) 160 | } 161 | } 162 | 163 | impl ModalComponent for EmbedRecordModalComponent { 164 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 165 | if matches!(self.focus, Focus::Input) 166 | && !matches!( 167 | (key.code, key.modifiers), 168 | (KeyCode::Enter, _) | (KeyCode::Char('m'), KeyModifiers::CONTROL) 169 | ) 170 | { 171 | let cursor = self.input.cursor(); 172 | return Ok(if self.input.input(key) { 173 | *self.state.lock().unwrap() = State::None; 174 | Some(Action::Render) 175 | } else if self.input.cursor() != cursor { 176 | Some(Action::Render) 177 | } else { 178 | None 179 | }); 180 | } 181 | Ok(None) 182 | } 183 | fn update(&mut self, action: ViewsAction) -> Result> { 184 | Ok(match action { 185 | ViewsAction::NextItem => { 186 | self.update_focus(self.focus.next(self.record.is_some())); 187 | Some(Action::Render) 188 | } 189 | ViewsAction::PrevItem => { 190 | self.update_focus(self.focus.prev(self.record.is_some())); 191 | Some(Action::Render) 192 | } 193 | ViewsAction::Enter => match self.focus { 194 | Focus::Ok => { 195 | let uri = self.input.lines().join(""); 196 | let mut state = self.state.lock().unwrap(); 197 | if let State::Ok(cid) = state.deref() { 198 | Some(Action::Ok(Data::Record( 199 | strong_ref::MainData { 200 | cid: cid.clone(), 201 | uri, 202 | } 203 | .into(), 204 | ))) 205 | } else { 206 | self.focus = Focus::None; 207 | if let Some(err) = self.get_record(&uri) { 208 | *state = State::Error(err.into()); 209 | } 210 | Some(Action::Render) 211 | } 212 | } 213 | Focus::Delete => Some(Action::Delete(None)), 214 | _ => self.update(ViewsAction::NextItem)?, 215 | }, 216 | ViewsAction::Back => Some(Action::Cancel), 217 | _ => None, 218 | }) 219 | } 220 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 221 | let area = area.inner(Margin { 222 | horizontal: 2, 223 | vertical: 1, 224 | }); 225 | let [area] = Layout::vertical([Constraint::Max(8)]).areas(area); 226 | 227 | let block = Block::bordered().title("Embed record"); 228 | let inner = block.inner(area); 229 | f.render_widget(Clear, area); 230 | f.render_widget(block, area); 231 | 232 | let mut constraints = vec![ 233 | Constraint::Length(3), 234 | Constraint::Length(1), 235 | Constraint::Length(1), 236 | ]; 237 | if self.record.is_some() { 238 | constraints.push(Constraint::Length(1)); 239 | } 240 | let layout = Layout::vertical(constraints).split(inner); 241 | 242 | let state = self.state.lock().unwrap().clone(); 243 | if let Some(block) = self.input.block() { 244 | let block = block.clone(); 245 | self.input.set_block(match &state { 246 | State::None => block.border_style(Color::Reset), 247 | State::Ok(_) => block.border_style(Color::Green), 248 | State::Error(_) => block.border_style(Color::Red), 249 | }); 250 | } 251 | f.render_widget(&self.input, layout[0]); 252 | f.render_widget( 253 | match &state { 254 | State::None => Line::from(""), 255 | State::Ok(cid) => Line::from(format!("CID: {}", cid.as_ref())).bold(), 256 | State::Error(err) => Line::from(err.clone()).red(), 257 | }, 258 | layout[1], 259 | ); 260 | f.render_widget( 261 | Line::from(match &state { 262 | State::Ok(_) => "OK", 263 | _ => "Get Record", 264 | }) 265 | .centered() 266 | .blue() 267 | .patch_style(if let Focus::Ok = self.focus { 268 | Style::default().reversed() 269 | } else { 270 | Style::default() 271 | }), 272 | layout[2], 273 | ); 274 | if let Some(area) = layout.get(3) { 275 | f.render_widget( 276 | Line::from("Delete").centered().red().patch_style( 277 | if let Focus::Delete = self.focus { 278 | Style::default().reversed() 279 | } else { 280 | Style::default() 281 | }, 282 | ), 283 | *area, 284 | ) 285 | } 286 | 287 | Ok(()) 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/components/modals/types.rs: -------------------------------------------------------------------------------- 1 | use bsky_sdk::api::com::atproto::repo::strong_ref; 2 | 3 | #[derive(Clone, Default, PartialEq, Eq)] 4 | pub struct EmbedData { 5 | pub images: Vec, 6 | pub record: Option, 7 | } 8 | 9 | #[derive(Clone, PartialEq, Eq)] 10 | pub struct ImageData { 11 | pub path: String, 12 | pub alt: String, 13 | } 14 | 15 | #[derive(Clone)] 16 | pub enum Data { 17 | Embed(EmbedData), 18 | Image((ImageData, Option)), 19 | Record(strong_ref::Main), 20 | } 21 | 22 | #[derive(Clone)] 23 | pub enum Action { 24 | Ok(Data), 25 | Delete(Option), 26 | Cancel, 27 | Render, 28 | } 29 | -------------------------------------------------------------------------------- /src/components/views.rs: -------------------------------------------------------------------------------- 1 | mod feed; 2 | mod login; 3 | mod menu; 4 | mod new_post; 5 | mod post; 6 | mod root; 7 | pub mod types; 8 | mod utils; 9 | 10 | pub use self::feed::FeedViewComponent; 11 | pub use self::login::LoginComponent; 12 | pub use self::menu::MenuViewComponent; 13 | pub use self::new_post::NewPostViewComponent; 14 | pub use self::post::PostViewComponent; 15 | pub use self::root::RootComponent; 16 | use self::types::{Action, View}; 17 | use color_eyre::Result; 18 | use crossterm::event::KeyEvent; 19 | use ratatui::{layout::Rect, Frame}; 20 | 21 | pub trait ViewComponent { 22 | fn view(&self) -> View; 23 | fn activate(&mut self) -> Result<()> { 24 | Ok(()) 25 | } 26 | fn deactivate(&mut self) -> Result<()> { 27 | Ok(()) 28 | } 29 | #[allow(unused_variables)] 30 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 31 | Ok(None) 32 | } 33 | #[allow(unused_variables)] 34 | fn update(&mut self, action: Action) -> Result> { 35 | Ok(None) 36 | } 37 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()>; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/views/feed.rs: -------------------------------------------------------------------------------- 1 | use super::types::{Action, Data, Transition, View}; 2 | use super::utils::{counts, profile_name, profile_name_as_str}; 3 | use super::ViewComponent; 4 | use crate::backend::types::FeedSourceInfo; 5 | use crate::backend::{Watch, Watcher}; 6 | use bsky_sdk::api::app::bsky::feed::defs::{ 7 | FeedViewPost, FeedViewPostReasonRefs, PostViewEmbedRefs, ReplyRefParentRefs, 8 | }; 9 | use bsky_sdk::api::app::bsky::feed::post; 10 | use bsky_sdk::api::types::{TryFromUnknown, Union}; 11 | use chrono::Local; 12 | use color_eyre::Result; 13 | use ratatui::layout::{Constraint, Layout, Rect}; 14 | use ratatui::style::{Color, Style, Stylize}; 15 | use ratatui::text::{Line, Span, Text}; 16 | use ratatui::widgets::{Block, Borders, List, ListState, Padding, Paragraph}; 17 | use ratatui::Frame; 18 | use std::sync::Arc; 19 | use textwrap::Options; 20 | use tokio::sync::mpsc::UnboundedSender; 21 | use tokio::sync::oneshot; 22 | 23 | pub struct FeedViewComponent { 24 | items: Vec, 25 | state: ListState, 26 | action_tx: UnboundedSender, 27 | feed_info: FeedSourceInfo, 28 | watcher: Box>>, 29 | quit: Option>, 30 | } 31 | 32 | impl FeedViewComponent { 33 | pub fn new( 34 | action_tx: UnboundedSender, 35 | watcher: Arc, 36 | feed_info: FeedSourceInfo, 37 | ) -> Self { 38 | let watcher = Box::new(watcher.feed(feed_info.clone())); 39 | Self { 40 | items: Vec::new(), 41 | state: ListState::default(), 42 | action_tx, 43 | feed_info, 44 | watcher, 45 | quit: None, 46 | } 47 | } 48 | fn lines(feed_view_post: &FeedViewPost, area: Rect) -> Option> { 49 | let Ok(record) = post::Record::try_from_unknown(feed_view_post.post.record.clone()) else { 50 | return None; 51 | }; 52 | let mut lines = Vec::new(); 53 | { 54 | let mut spans = [ 55 | vec![ 56 | Span::from( 57 | feed_view_post 58 | .post 59 | .indexed_at 60 | .as_ref() 61 | .with_timezone(&Local) 62 | .format("%Y-%m-%d %H:%M:%S %z") 63 | .to_string(), 64 | ) 65 | .green(), 66 | Span::from(": "), 67 | ], 68 | profile_name(&feed_view_post.post.author), 69 | ] 70 | .concat(); 71 | if let Some(labels) = feed_view_post 72 | .post 73 | .author 74 | .labels 75 | .as_ref() 76 | .filter(|v| !v.is_empty()) 77 | { 78 | spans.push(Span::from(" ")); 79 | spans.push(format!("[{} labels]", labels.len()).magenta()); 80 | } 81 | lines.push(Line::from(spans)); 82 | } 83 | if let Some(Union::Refs(FeedViewPostReasonRefs::ReasonRepost(repost))) = 84 | &feed_view_post.reason 85 | { 86 | lines.push( 87 | Line::from(format!(" Reposted by {}", profile_name_as_str(&repost.by))).blue(), 88 | ); 89 | } 90 | if let Some(reply) = &feed_view_post.reply { 91 | if let Union::Refs(ReplyRefParentRefs::PostView(post_view)) = &reply.parent { 92 | lines.push(Line::from( 93 | [ 94 | vec![Span::from(" Reply to ").blue()], 95 | profile_name(&post_view.author), 96 | ] 97 | .concat(), 98 | )); 99 | } 100 | } 101 | lines.extend( 102 | textwrap::wrap( 103 | &record.text, 104 | Options::new(usize::from(area.width) - 2) 105 | .initial_indent(" ") 106 | .subsequent_indent(" "), 107 | ) 108 | .iter() 109 | .map(|s| Line::from(s.to_string())), 110 | ); 111 | if let Some(embed) = &feed_view_post.post.embed { 112 | let content = match embed { 113 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedImagesView(images)) => { 114 | format!("{} images", images.images.len()) 115 | } 116 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedExternalView(_)) => { 117 | String::from("external") 118 | } 119 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordView(_)) => String::from("record"), 120 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordWithMediaView(_)) => { 121 | String::from("recordWithMedia") 122 | } 123 | _ => String::from("unknown"), 124 | }; 125 | lines.push(Line::from(format!(" Embedded {content}")).yellow()); 126 | } 127 | lines.push(Line::from( 128 | [vec![Span::from(" ")], counts(&feed_view_post.post, 5)].concat(), 129 | )); 130 | Some(lines) 131 | } 132 | } 133 | 134 | impl ViewComponent for FeedViewComponent { 135 | fn view(&self) -> View { 136 | View::Feed(Box::new(self.feed_info.clone())) 137 | } 138 | fn activate(&mut self) -> Result<()> { 139 | let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe()); 140 | let (quit_tx, mut quit_rx) = oneshot::channel(); 141 | self.quit = Some(quit_tx); 142 | tokio::spawn(async move { 143 | loop { 144 | tokio::select! { 145 | changed = rx.changed() => { 146 | match changed { 147 | Ok(()) => { 148 | if let Err(e) = tx.send(Action::Update(Box::new(Data::Feed( 149 | rx.borrow_and_update().clone(), 150 | )))) { 151 | log::error!("failed to send update action: {e}"); 152 | } 153 | } 154 | Err(e) => { 155 | log::warn!("changed channel error: {e}"); 156 | break; 157 | } 158 | } 159 | } 160 | _ = &mut quit_rx => { 161 | break; 162 | } 163 | } 164 | } 165 | log::debug!("subscription finished"); 166 | }); 167 | Ok(()) 168 | } 169 | fn deactivate(&mut self) -> Result<()> { 170 | if let Some(tx) = self.quit.take() { 171 | if tx.send(()).is_err() { 172 | log::error!("failed to send quit signal"); 173 | } 174 | } 175 | self.watcher.unsubscribe(); 176 | Ok(()) 177 | } 178 | fn update(&mut self, action: Action) -> Result> { 179 | match action { 180 | Action::NextItem if !self.items.is_empty() => { 181 | self.state.select(Some( 182 | self.state 183 | .selected() 184 | .map(|s| (s + 1).min(self.items.len() - 1)) 185 | .unwrap_or_default(), 186 | )); 187 | return Ok(Some(Action::Render)); 188 | } 189 | Action::PrevItem if !self.items.is_empty() => { 190 | self.state.select(Some( 191 | self.state 192 | .selected() 193 | .map(|s| s.max(1) - 1) 194 | .unwrap_or_default(), 195 | )); 196 | return Ok(Some(Action::Render)); 197 | } 198 | Action::Enter => { 199 | if let Some(feed_view_post) = self.state.selected().and_then(|i| self.items.get(i)) 200 | { 201 | return Ok(Some(Action::Transition(Transition::Push(Box::new( 202 | View::Post(Box::new(( 203 | feed_view_post.post.clone(), 204 | feed_view_post 205 | .reply 206 | .as_ref() 207 | .and_then(|reply| match &reply.parent { 208 | Union::Refs(ReplyRefParentRefs::PostView(post_view)) => { 209 | Some(post_view.as_ref().clone()) 210 | } 211 | _ => None, 212 | }), 213 | ))), 214 | ))))); 215 | } 216 | } 217 | Action::Back => return Ok(Some(Action::Transition(Transition::Pop))), 218 | Action::Refresh => { 219 | self.watcher.refresh(); 220 | } 221 | Action::Update(data) => { 222 | let Data::Feed(feed) = data.as_ref() else { 223 | return Ok(None); 224 | }; 225 | log::debug!("update feed view: {}", feed.len()); 226 | // TODO: update state.selected 227 | let select = if let Some(cid) = self 228 | .state 229 | .selected() 230 | .and_then(|i| self.items.get(i)) 231 | .map(|feed_view_post| feed_view_post.post.cid.as_ref()) 232 | { 233 | feed.iter() 234 | .position(|feed_view_post| feed_view_post.post.cid.as_ref() == cid) 235 | } else { 236 | None 237 | }; 238 | self.items.clone_from(feed); 239 | self.state.select(select); 240 | return Ok(Some(Action::Render)); 241 | } 242 | _ => {} 243 | } 244 | Ok(None) 245 | } 246 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 247 | let header = Paragraph::new(match &self.feed_info { 248 | FeedSourceInfo::Feed(generator_view) => Line::from(vec![ 249 | Span::from(generator_view.display_name.clone()).bold(), 250 | Span::from(" "), 251 | Span::from(format!( 252 | "by {}", 253 | profile_name_as_str(&generator_view.creator) 254 | )) 255 | .gray(), 256 | ]), 257 | FeedSourceInfo::List(list_view) => Line::from(vec![ 258 | Span::from(list_view.name.clone()).bold(), 259 | Span::from(" "), 260 | Span::from(format!("by {}", profile_name_as_str(&list_view.creator))).gray(), 261 | ]), 262 | FeedSourceInfo::Timeline(_) => Line::from("Following").bold(), 263 | }) 264 | .bold() 265 | .block( 266 | Block::default() 267 | .borders(Borders::BOTTOM) 268 | .border_style(Color::Gray) 269 | .padding(Padding::horizontal(1)), 270 | ); 271 | let mut items = Vec::new(); 272 | for feed_view_post in &self.items { 273 | if let Some(lines) = Self::lines(feed_view_post, area) { 274 | items.push(Text::from(lines)); 275 | } 276 | } 277 | 278 | let layout = 279 | Layout::vertical([Constraint::Length(2), Constraint::Percentage(100)]).split(area); 280 | f.render_widget(header, layout[0]); 281 | f.render_stateful_widget( 282 | List::new(items) 283 | .highlight_style(Style::default().reset().reversed()) 284 | .block(Block::default().padding(Padding::horizontal(1))), 285 | layout[1], 286 | &mut self.state, 287 | ); 288 | Ok(()) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /src/components/views/login.rs: -------------------------------------------------------------------------------- 1 | use super::types::{Action, View}; 2 | use super::ViewComponent; 3 | use bsky_sdk::agent::config::Config; 4 | use bsky_sdk::BskyAgent; 5 | use color_eyre::Result; 6 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 7 | use ratatui::layout::{Constraint, Layout, Rect}; 8 | use ratatui::style::{Style, Stylize}; 9 | use ratatui::text::Line; 10 | use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; 11 | use ratatui::Frame; 12 | use std::sync::{Arc, RwLock}; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | use tui_textarea::TextArea; 15 | 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 17 | enum Focus { 18 | Service, 19 | Identifier, 20 | Password, 21 | Submit, 22 | } 23 | 24 | impl Focus { 25 | fn next(&self) -> Self { 26 | match self { 27 | Self::Service => Self::Identifier, 28 | Self::Identifier => Self::Password, 29 | Self::Password => Self::Submit, 30 | Self::Submit => Self::Service, 31 | } 32 | } 33 | fn prev(&self) -> Self { 34 | match self { 35 | Self::Service => Self::Submit, 36 | Self::Identifier => Self::Service, 37 | Self::Password => Self::Identifier, 38 | Self::Submit => Self::Password, 39 | } 40 | } 41 | } 42 | 43 | pub struct LoginComponent { 44 | service: TextArea<'static>, 45 | identifier: TextArea<'static>, 46 | password: TextArea<'static>, 47 | focus: Focus, 48 | error_message: Arc>>, 49 | action_tx: UnboundedSender, 50 | } 51 | 52 | impl LoginComponent { 53 | pub fn new(action_tx: UnboundedSender) -> Self { 54 | let mut service = TextArea::new(vec![String::from("https://bsky.social")]); 55 | service.set_block( 56 | Block::bordered() 57 | .title("Service") 58 | .border_style(Style::default().dim()), 59 | ); 60 | service.set_cursor_line_style(Style::default()); 61 | service.set_cursor_style(Style::default()); 62 | let mut identifier = TextArea::default(); 63 | identifier.set_block( 64 | Block::bordered() 65 | .title("Identifier") 66 | .border_style(Style::default()), 67 | ); 68 | identifier.set_cursor_line_style(Style::default()); 69 | let mut password = TextArea::default(); 70 | password.set_mask_char('*'); 71 | password.set_block( 72 | Block::bordered() 73 | .title("Password") 74 | .border_style(Style::default().dim()), 75 | ); 76 | password.set_cursor_line_style(Style::default()); 77 | password.set_cursor_style(Style::default()); 78 | Self { 79 | service, 80 | identifier, 81 | password, 82 | focus: Focus::Identifier, 83 | error_message: Arc::new(RwLock::new(None)), 84 | action_tx, 85 | } 86 | } 87 | fn current_textarea(&mut self) -> Option<&mut TextArea<'static>> { 88 | match self.focus { 89 | Focus::Service => Some(&mut self.service), 90 | Focus::Identifier => Some(&mut self.identifier), 91 | Focus::Password => Some(&mut self.password), 92 | Focus::Submit => None, 93 | } 94 | } 95 | fn update_focus(&mut self, focus: Focus) { 96 | if let Some(textarea) = self.current_textarea() { 97 | textarea.set_cursor_style(Style::default()); 98 | if let Some(block) = textarea.block().cloned() { 99 | textarea.set_block(block.border_style(Style::default().dim())); 100 | } 101 | } 102 | self.focus = focus; 103 | if let Some(textarea) = self.current_textarea() { 104 | textarea.set_cursor_style(Style::default().reversed()); 105 | if let Some(block) = textarea.block().cloned() { 106 | textarea.set_block(block.border_style(Style::default())); 107 | } 108 | } 109 | } 110 | fn login(&self) -> Result<()> { 111 | let service = self.service.lines().join(""); 112 | let identifier = self.identifier.lines().join(""); 113 | let password = self.password.lines().join(""); 114 | let error_message = Arc::clone(&self.error_message); 115 | let action_tx = self.action_tx.clone(); 116 | tokio::spawn(async move { 117 | let Ok(agent) = BskyAgent::builder() 118 | .config(Config { 119 | endpoint: service, 120 | ..Default::default() 121 | }) 122 | .build() 123 | .await 124 | else { 125 | return log::error!("failed to build agent"); 126 | }; 127 | if agent 128 | .api 129 | .com 130 | .atproto 131 | .server 132 | .describe_server() 133 | .await 134 | .is_err() 135 | { 136 | log::warn!("describe server failed"); 137 | if let Ok(mut message) = error_message.write() { 138 | message.replace(String::from("failed to connect to server")); 139 | } 140 | if let Err(e) = action_tx.send(Action::Render) { 141 | log::error!("failed to send render event: {e}"); 142 | } 143 | return; 144 | } 145 | match agent.login(identifier, password).await { 146 | Ok(session) => { 147 | log::info!("login succeeded: {session:?}"); 148 | if let Err(e) = action_tx.send(Action::Login(Box::new(agent))) { 149 | log::error!("failed to send login event: {e}"); 150 | } 151 | } 152 | Err(e) => { 153 | log::warn!("login failed: {e}"); 154 | if let Ok(mut message) = error_message.write() { 155 | message.replace(e.to_string()); 156 | } 157 | } 158 | } 159 | if let Err(e) = action_tx.send(Action::Render) { 160 | log::error!("failed to send render event: {e}"); 161 | } 162 | }); 163 | Ok(()) 164 | } 165 | } 166 | 167 | impl ViewComponent for LoginComponent { 168 | fn view(&self) -> View { 169 | View::Login 170 | } 171 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 172 | if let Some(textarea) = self.current_textarea() { 173 | Ok(match (key.code, key.modifiers) { 174 | (KeyCode::Enter, _) | (KeyCode::Char('m'), KeyModifiers::CONTROL) => { 175 | Some(Action::Enter) 176 | } 177 | _ => { 178 | let cursor = textarea.cursor(); 179 | if textarea.input(key) || textarea.cursor() != cursor { 180 | Some(Action::Render) 181 | } else { 182 | None 183 | } 184 | } 185 | }) 186 | } else { 187 | Ok(None) 188 | } 189 | } 190 | fn update(&mut self, action: Action) -> Result> { 191 | match action { 192 | Action::NextItem => { 193 | self.update_focus(self.focus.next()); 194 | Ok(Some(Action::Render)) 195 | } 196 | Action::PrevItem => { 197 | self.update_focus(self.focus.prev()); 198 | Ok(Some(Action::Render)) 199 | } 200 | Action::Enter => { 201 | match self.focus { 202 | Focus::Submit => { 203 | self.login()?; 204 | } 205 | _ => { 206 | self.update_focus(self.focus.next()); 207 | } 208 | } 209 | Ok(Some(Action::Render)) 210 | } 211 | _ => Ok(None), 212 | } 213 | } 214 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 215 | let block = Block::default().padding(Padding::proportional(2)); 216 | let layout = Layout::vertical([ 217 | Constraint::Length(3), 218 | Constraint::Length(3), 219 | Constraint::Length(3), 220 | Constraint::Length(1), 221 | Constraint::Length(1), 222 | Constraint::Length(2), 223 | ]) 224 | .split(block.inner(area)); 225 | 226 | let mut submit = Line::from("Submit").blue().centered(); 227 | if self.focus == Focus::Submit { 228 | submit = submit.reversed(); 229 | } 230 | f.render_widget(&self.service, layout[0]); 231 | f.render_widget(&self.identifier, layout[1]); 232 | f.render_widget(&self.password, layout[2]); 233 | f.render_widget(submit, layout[3]); 234 | if let Ok(message) = self.error_message.read() { 235 | if let Some(s) = message.as_ref() { 236 | f.render_widget( 237 | Paragraph::new(s.as_str()) 238 | .style(Style::default().red()) 239 | .wrap(Wrap::default()), 240 | layout[5], 241 | ); 242 | } 243 | } 244 | Ok(()) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/components/views/menu.rs: -------------------------------------------------------------------------------- 1 | use super::types::Action; 2 | use crate::config::{ColumnAction, Key, Keybindings}; 3 | use color_eyre::Result; 4 | use ratatui::layout::Rect; 5 | use ratatui::style::{Style, Stylize}; 6 | use ratatui::text::{Line, Span}; 7 | use ratatui::widgets::{Block, Clear, List, ListItem, ListState}; 8 | use ratatui::Frame; 9 | use tokio::sync::mpsc::UnboundedSender; 10 | 11 | enum MenuAction { 12 | NewPost(Vec), 13 | Refresh(Vec), 14 | Back(Vec), 15 | } 16 | 17 | impl<'a> From<&'a MenuAction> for ListItem<'a> { 18 | fn from(action: &'a MenuAction) -> Self { 19 | match action { 20 | MenuAction::NewPost(v) if !v.is_empty() => Self::from(Line::from(vec![ 21 | Span::from("New Post ").reset(), 22 | Span::from(format!("({})", v.join(", "))).dim(), 23 | ])), 24 | MenuAction::NewPost(_) => Self::from("New Post".reset()), 25 | MenuAction::Refresh(v) if !v.is_empty() => Self::from(Line::from(vec![ 26 | Span::from("Refresh ").reset(), 27 | Span::from(format!("({})", v.join(", "))).dim(), 28 | ])), 29 | MenuAction::Refresh(_) => Self::from("Refresh".reset()), 30 | MenuAction::Back(v) if !v.is_empty() => Self::from(Line::from(vec![ 31 | Span::from("Back ").reset(), 32 | Span::from(format!("({})", v.join(", "))).dim(), 33 | ])), 34 | MenuAction::Back(_) => Self::from("Back".reset()), 35 | } 36 | } 37 | } 38 | 39 | pub struct MenuViewComponent { 40 | action_tx: UnboundedSender, 41 | items: Vec, 42 | state: ListState, 43 | } 44 | 45 | impl MenuViewComponent { 46 | pub fn new(action_tx: UnboundedSender, keybindings: &Keybindings) -> Self { 47 | let mut keys = vec![Vec::new(); 3]; 48 | for (k, v) in &keybindings.column { 49 | match v { 50 | ColumnAction::NewPost => keys[0].push(k), 51 | ColumnAction::Refresh => keys[1].push(k), 52 | ColumnAction::Back => keys[2].push(k), 53 | _ => {} 54 | } 55 | } 56 | keys.iter_mut().for_each(|v| v.sort()); 57 | let to_string = |v: &[&Key]| { 58 | v.iter() 59 | .filter_map(|k| serde_json::to_string(k).ok()) 60 | .map(|s| s.trim_matches('"').to_string()) 61 | .collect::>() 62 | }; 63 | Self { 64 | action_tx, 65 | items: vec![ 66 | MenuAction::NewPost(to_string(&keys[0])), 67 | MenuAction::Refresh(to_string(&keys[1])), 68 | MenuAction::Back(to_string(&keys[2])), 69 | ], 70 | state: ListState::default().with_selected(Some(0)), 71 | } 72 | } 73 | pub fn update(&mut self, action: Action) -> Result> { 74 | match action { 75 | Action::NextItem => { 76 | if let Some(selected) = self.state.selected() { 77 | self.state 78 | .select(Some((selected + 1).min(self.items.len() - 1))); 79 | return Ok(Some(Action::Render)); 80 | } 81 | } 82 | Action::PrevItem => { 83 | if let Some(selected) = self.state.selected() { 84 | self.state.select(Some(selected.max(1) - 1)); 85 | return Ok(Some(Action::Render)); 86 | } 87 | } 88 | Action::Enter => { 89 | if let Some(selected) = self.state.selected() { 90 | let action = match self.items[selected] { 91 | MenuAction::NewPost(_) => Action::NewPost, 92 | MenuAction::Refresh(_) => Action::Refresh, 93 | MenuAction::Back(_) => Action::Back, 94 | }; 95 | self.action_tx.send(action).ok(); 96 | return Ok(Some(Action::Menu)); 97 | } 98 | } 99 | Action::Update(_) | Action::Render => { 100 | return Ok(None); 101 | } 102 | _ => {} 103 | } 104 | Ok(Some(Action::Menu)) 105 | } 106 | pub fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 107 | let area = Rect::new(area.x, area.y, area.width, self.items.len() as u16 + 2); 108 | f.render_widget(Clear, area); 109 | f.render_stateful_widget( 110 | List::new(&self.items) 111 | .block(Block::bordered().title("Menu").dim()) 112 | .highlight_style(Style::default().reversed()), 113 | area, 114 | &mut self.state, 115 | ); 116 | Ok(()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/components/views/new_post.rs: -------------------------------------------------------------------------------- 1 | use super::super::modals::types::{Action as ModalAction, Data, EmbedData}; 2 | use super::super::modals::{EmbedModalComponent, ModalComponent}; 3 | use super::types::{Action, Transition, View}; 4 | use super::ViewComponent; 5 | use bsky_sdk::api::app::bsky::embed::{self, record_with_media}; 6 | use bsky_sdk::api::app::bsky::feed::post::{RecordData, RecordEmbedRefs}; 7 | use bsky_sdk::api::com::atproto::repo::{create_record, strong_ref}; 8 | use bsky_sdk::api::types::string::{Datetime, Language}; 9 | use bsky_sdk::api::types::Union; 10 | use bsky_sdk::rich_text::RichText; 11 | use bsky_sdk::BskyAgent; 12 | use color_eyre::Result; 13 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 14 | use futures_util::future; 15 | use image::{ImageFormat, ImageReader}; 16 | use ratatui::layout::{Constraint, Layout}; 17 | use ratatui::style::{Color, Style, Stylize}; 18 | use ratatui::text::{Line, Text}; 19 | use ratatui::widgets::{Block, Borders, Padding}; 20 | use ratatui::{layout::Rect, widgets::Paragraph, Frame}; 21 | use std::fs::File; 22 | use std::io::{BufReader, Cursor, Read}; 23 | use std::num::NonZeroU64; 24 | use std::sync::Arc; 25 | use tokio::sync::mpsc::UnboundedSender; 26 | use tui_textarea::TextArea; 27 | 28 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 29 | enum Focus { 30 | None, 31 | Text, 32 | Embed, 33 | Langs, 34 | Submit, 35 | } 36 | 37 | impl Focus { 38 | fn next(&self) -> Self { 39 | match self { 40 | Self::None => Self::Text, 41 | Self::Text => Self::Embed, 42 | Self::Embed => Self::Langs, 43 | Self::Langs => Self::Submit, 44 | Self::Submit => Self::Text, 45 | } 46 | } 47 | fn prev(&self) -> Self { 48 | match self { 49 | Self::None => Self::Text, 50 | Self::Text => Self::Submit, 51 | Self::Embed => Self::Text, 52 | Self::Langs => Self::Embed, 53 | Self::Submit => Self::Langs, 54 | } 55 | } 56 | } 57 | 58 | pub struct NewPostViewComponent { 59 | action_tx: UnboundedSender, 60 | agent: Arc, 61 | text: TextArea<'static>, 62 | embed: Option, 63 | langs: TextArea<'static>, 64 | focus: Focus, 65 | text_len: usize, 66 | modals: Option>, 67 | } 68 | 69 | impl NewPostViewComponent { 70 | pub fn new(action_tx: UnboundedSender, agent: Arc) -> Self { 71 | let mut text = TextArea::default(); 72 | text.set_block(Block::bordered().title("Text")); 73 | text.set_cursor_line_style(Style::default()); 74 | let mut langs = TextArea::default(); 75 | langs.set_block(Block::bordered().title("Langs").dim()); 76 | langs.set_cursor_line_style(Style::default()); 77 | langs.set_cursor_style(Style::default()); 78 | Self { 79 | action_tx, 80 | agent, 81 | text, 82 | embed: None, 83 | langs, 84 | focus: Focus::Text, 85 | text_len: 0, 86 | modals: None, 87 | } 88 | } 89 | fn current_textarea(&mut self) -> Option<&mut TextArea<'static>> { 90 | match self.focus { 91 | Focus::Text => Some(&mut self.text), 92 | Focus::Langs => Some(&mut self.langs), 93 | _ => None, 94 | } 95 | } 96 | fn update_focus(&mut self, focus: Focus) { 97 | if let Some(curr) = self.current_textarea() { 98 | curr.set_cursor_style(Style::default()); 99 | if let Some(block) = curr.block() { 100 | curr.set_block(block.clone().dim()); 101 | } 102 | } 103 | self.focus = focus; 104 | if let Some(curr) = self.current_textarea() { 105 | curr.set_cursor_style(Style::default().reversed()); 106 | if let Some(block) = curr.block() { 107 | curr.set_block(block.clone().reset()); 108 | } 109 | } 110 | } 111 | fn create_post_record(&self) -> Result<()> { 112 | let tx = self.action_tx.clone(); 113 | let agent = self.agent.clone(); 114 | let text = self.text.lines().join("\n"); 115 | let embed_data = self.embed.clone(); 116 | let langs = Some( 117 | self.langs 118 | .lines() 119 | .join("") 120 | .split(',') 121 | .map(str::trim) 122 | .filter_map(|s| s.parse::().ok()) 123 | .collect::>(), 124 | ) 125 | .filter(|v| !v.is_empty()); 126 | tokio::spawn(async move { 127 | match Self::try_create_post_record(&agent, embed_data, langs, text).await { 128 | Ok(output) => { 129 | log::info!("Post created: {output:?}"); 130 | if let Err(e) = tx.send(Action::Transition(Transition::Pop)) { 131 | log::error!("failed to send event: {e}"); 132 | } 133 | } 134 | Err(e) => { 135 | // TODO: show error message 136 | log::error!("failed to create post: {e}"); 137 | } 138 | } 139 | }); 140 | Ok(()) 141 | } 142 | async fn try_create_post_record( 143 | agent: &BskyAgent, 144 | embed_data: Option, 145 | langs: Option>, 146 | text: String, 147 | ) -> Result { 148 | let rich_text = RichText::new_with_detect_facets(text).await?; 149 | let embed = if let Some(data) = embed_data { 150 | let mut handles = Vec::new(); 151 | for image in data.images { 152 | let mut file = File::open(&image.path)?; 153 | let mut buf = Vec::new(); 154 | file.read_to_end(&mut buf)?; 155 | if buf.len() > 1_000_000 { 156 | log::warn!("image too large: {}", image.path); 157 | continue; 158 | } 159 | let (width, height) = ImageReader::with_format( 160 | BufReader::new(Cursor::new(&buf)), 161 | ImageFormat::from_path(&image.path).unwrap(), 162 | ) 163 | .into_dimensions()?; 164 | let aspect_ratio = Some( 165 | embed::defs::AspectRatioData { 166 | width: NonZeroU64::new(width.into()).unwrap(), 167 | height: NonZeroU64::new(height.into()).unwrap(), 168 | } 169 | .into(), 170 | ); 171 | let agent = agent.clone(); 172 | handles.push(async move { 173 | agent 174 | .api 175 | .com 176 | .atproto 177 | .repo 178 | .upload_blob(buf) 179 | .await 180 | .map(|output| embed::images::ImageData { 181 | alt: image.alt, 182 | aspect_ratio, 183 | image: output.data.blob, 184 | }) 185 | }); 186 | } 187 | let mut images = embed::images::MainData { images: Vec::new() }; 188 | for image in future::join_all(handles).await { 189 | images.images.push(image?.into()); 190 | } 191 | Some(if let Some(record) = data.record { 192 | let record_data = embed::record::MainData { 193 | record: strong_ref::MainData { 194 | cid: record.data.cid, 195 | uri: record.data.uri, 196 | } 197 | .into(), 198 | }; 199 | if images.images.is_empty() { 200 | Union::Refs(RecordEmbedRefs::AppBskyEmbedRecordMain(Box::new( 201 | record_data.into(), 202 | ))) 203 | } else { 204 | Union::Refs(RecordEmbedRefs::AppBskyEmbedRecordWithMediaMain(Box::new( 205 | embed::record_with_media::MainData { 206 | media: Union::Refs( 207 | record_with_media::MainMediaRefs::AppBskyEmbedImagesMain(Box::new( 208 | images.into(), 209 | )), 210 | ), 211 | record: record_data.into(), 212 | } 213 | .into(), 214 | ))) 215 | } 216 | } else { 217 | Union::Refs(RecordEmbedRefs::AppBskyEmbedImagesMain(Box::new( 218 | images.into(), 219 | ))) 220 | }) 221 | } else { 222 | None 223 | }; 224 | Ok(agent 225 | .create_record(RecordData { 226 | created_at: Datetime::now(), 227 | embed, 228 | entities: None, 229 | facets: rich_text.facets, 230 | labels: None, 231 | langs, 232 | reply: None, 233 | tags: None, 234 | text: rich_text.text, 235 | }) 236 | .await?) 237 | } 238 | } 239 | 240 | impl ViewComponent for NewPostViewComponent { 241 | fn view(&self) -> View { 242 | View::NewPost 243 | } 244 | fn handle_key_events(&mut self, key: KeyEvent) -> Result> { 245 | if let Some(modal) = self.modals.as_mut() { 246 | return Ok(match modal.handle_key_events(key)? { 247 | Some(ModalAction::Render) => Some(Action::Render), 248 | _ => None, 249 | }); 250 | } 251 | let focus = self.focus; 252 | if let Some(textarea) = self.current_textarea() { 253 | if focus == Focus::Text { 254 | let cursor = textarea.cursor(); 255 | let result = textarea.input(key) || textarea.cursor() != cursor; 256 | self.text_len = RichText::new(self.text.lines().join("\n"), None).grapheme_len(); 257 | if let Some(block) = self.text.block() { 258 | let mut block = block.clone(); 259 | block = match self.text_len { 260 | 0 => block.border_style(Color::Reset), 261 | 1..=300 => block.border_style(Color::Green), 262 | _ => block.border_style(Color::Red), 263 | }; 264 | self.text.set_block(block); 265 | } 266 | return Ok(if result { Some(Action::Render) } else { None }); 267 | } else if matches!( 268 | (key.code, key.modifiers), 269 | (KeyCode::Enter, _) | (KeyCode::Char('m'), KeyModifiers::CONTROL) 270 | ) { 271 | return Ok(Some(Action::Enter)); 272 | } else { 273 | let cursor = textarea.cursor(); 274 | return Ok(if textarea.input(key) { 275 | if self.focus == Focus::Langs { 276 | if let Some(block) = self.langs.block() { 277 | let mut block = block.clone(); 278 | if self 279 | .langs 280 | .lines() 281 | .join("") 282 | .split(',') 283 | .map(str::trim) 284 | .all(|s| s.parse::().is_ok()) 285 | { 286 | block = block.border_style(Color::Green); 287 | } else { 288 | block = block.border_style(Color::Red); 289 | } 290 | self.langs.set_block(block); 291 | } 292 | } 293 | Some(Action::Render) 294 | } else if textarea.cursor() != cursor { 295 | Some(Action::Render) 296 | } else { 297 | None 298 | }); 299 | } 300 | } 301 | Ok(None) 302 | } 303 | fn update(&mut self, action: Action) -> Result> { 304 | if let Some(modal) = self.modals.as_mut() { 305 | return Ok(match modal.update(action)? { 306 | Some(ModalAction::Ok(Data::Embed(embed))) => { 307 | self.embed = if embed != EmbedData::default() { 308 | Some(embed) 309 | } else { 310 | None 311 | }; 312 | self.modals = None; 313 | Some(Action::Render) 314 | } 315 | Some(ModalAction::Cancel) => { 316 | self.modals = None; 317 | Some(Action::Render) 318 | } 319 | Some(ModalAction::Render) => Some(Action::Render), 320 | _ => None, 321 | }); 322 | } 323 | match action { 324 | Action::NextItem => { 325 | self.update_focus(self.focus.next()); 326 | Ok(Some(Action::Render)) 327 | } 328 | Action::PrevItem => { 329 | self.update_focus(self.focus.prev()); 330 | Ok(Some(Action::Render)) 331 | } 332 | Action::Enter if self.focus == Focus::Embed => { 333 | self.modals = Some(Box::new(EmbedModalComponent::new( 334 | self.action_tx.clone(), 335 | self.embed.clone(), 336 | ))); 337 | Ok(Some(Action::Render)) 338 | } 339 | Action::Enter if self.focus == Focus::Submit => { 340 | self.focus = Focus::None; 341 | self.create_post_record()?; 342 | Ok(Some(Action::Render)) 343 | } 344 | Action::Back => { 345 | // TODO: confirm to discard the draft 346 | Ok(Some(Action::Transition(Transition::Pop))) 347 | } 348 | Action::Transition(_) => Ok(Some(action)), 349 | _ => Ok(None), 350 | } 351 | } 352 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 353 | let [paragraph, text_len, text, embed, langs, submit] = Layout::vertical([ 354 | Constraint::Length(2), 355 | Constraint::Length(1), 356 | Constraint::Length(8), 357 | Constraint::Length(1 + self.embed.is_some() as u16), 358 | Constraint::Length(3), 359 | Constraint::Length(1), 360 | ]) 361 | .areas(area); 362 | let mut embed_lines = vec![Line::from("+ Embed")]; 363 | if let Some(embed) = &self.embed { 364 | let mut line = Line::from(match (embed.record.is_some(), embed.images.len()) { 365 | (true, 0) => " a record".into(), 366 | (true, 1) => " a record with 1 image".into(), 367 | (true, len) => format!(" a record with {len} images"), 368 | (false, len) => format!(" {len} images"), 369 | }); 370 | if self.focus != Focus::Embed { 371 | line = line.yellow(); 372 | } 373 | embed_lines.push(line); 374 | } 375 | let mut embed_text = Text::from(embed_lines); 376 | if self.focus == Focus::Embed { 377 | embed_text = embed_text.reversed(); 378 | } 379 | let mut submit_line = Line::from("Post").centered().blue(); 380 | if self.focus == Focus::Submit { 381 | submit_line = submit_line.reversed(); 382 | } 383 | f.render_widget( 384 | Paragraph::new("New post").bold().block( 385 | Block::default() 386 | .borders(Borders::BOTTOM) 387 | .border_style(Color::Gray) 388 | .padding(Padding::horizontal(1)), 389 | ), 390 | paragraph, 391 | ); 392 | f.render_widget( 393 | Line::from(format!("{} ", 300 - self.text_len as isize)) 394 | .right_aligned() 395 | .gray(), 396 | text_len, 397 | ); 398 | f.render_widget(&self.text, text); 399 | f.render_widget(embed_text, embed); 400 | f.render_widget(&self.langs, langs); 401 | f.render_widget(submit_line, submit); 402 | 403 | for modal in self.modals.iter_mut() { 404 | modal.draw(f, area)?; 405 | } 406 | Ok(()) 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/components/views/post.rs: -------------------------------------------------------------------------------- 1 | use super::types::{Action, Data, Transition, View}; 2 | use super::utils::{counts, profile_name, profile_name_as_str}; 3 | use super::ViewComponent; 4 | use crate::backend::{Watch, Watcher}; 5 | use bsky_sdk::api::agent::atp_agent::AtpSession; 6 | use bsky_sdk::api::app::bsky::actor::defs::ProfileViewBasic; 7 | use bsky_sdk::api::app::bsky::embed::record::{self, ViewRecordRefs}; 8 | use bsky_sdk::api::app::bsky::embed::record_with_media::ViewMediaRefs; 9 | use bsky_sdk::api::app::bsky::embed::{external, images}; 10 | use bsky_sdk::api::app::bsky::feed::defs::{ 11 | PostView, PostViewData, PostViewEmbedRefs, ThreadViewPostParentRefs, ViewerStateData, 12 | }; 13 | use bsky_sdk::api::app::bsky::feed::get_post_thread::OutputThreadRefs; 14 | use bsky_sdk::api::app::bsky::feed::post; 15 | use bsky_sdk::api::app::bsky::richtext::facet::MainFeaturesItem; 16 | use bsky_sdk::api::types::string::Datetime; 17 | use bsky_sdk::api::types::{TryFromUnknown, Union}; 18 | use bsky_sdk::{api, BskyAgent}; 19 | use chrono::Local; 20 | use color_eyre::Result; 21 | use indexmap::IndexSet; 22 | use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect}; 23 | use ratatui::style::{Color, Style, Stylize}; 24 | use ratatui::text::{Line, Span, Text}; 25 | use ratatui::widgets::{ 26 | Block, Borders, Cell, List, ListItem, ListState, Padding, Paragraph, Row, Table, TableState, 27 | }; 28 | use ratatui::Frame; 29 | use std::sync::Arc; 30 | use tokio::sync::mpsc::UnboundedSender; 31 | use tokio::sync::oneshot; 32 | 33 | #[derive(Debug, Clone)] 34 | enum PostAction { 35 | Profile(ProfileViewBasic), 36 | Reply, 37 | Repost, 38 | Like, 39 | Unlike(String), 40 | Delete, 41 | Open(String), 42 | ViewRecord(record::ViewRecord), 43 | } 44 | 45 | impl<'a> From<&'a PostAction> for ListItem<'a> { 46 | fn from(action: &'a PostAction) -> Self { 47 | match action { 48 | PostAction::Profile(profile) => Self::from(Line::from(vec![ 49 | Span::from("Show "), 50 | Span::from(profile_name_as_str(profile)).bold(), 51 | Span::from("'s profile"), 52 | ])) 53 | .dim(), 54 | PostAction::Reply => Self::from("Reply").dim(), 55 | PostAction::Repost => Self::from("Repost").dim(), 56 | PostAction::Like => Self::from("Like"), 57 | PostAction::Unlike(_) => Self::from("Unlike"), 58 | PostAction::Delete => Self::from("Delete").red(), 59 | PostAction::Open(uri) => Self::from(format!("Open {uri}")), 60 | PostAction::ViewRecord(view_record) => Self::from(Line::from(vec![ 61 | Span::from("Show "), 62 | Span::from("embedded record").yellow(), 63 | Span::from(" "), 64 | Span::from(view_record.uri.as_str()).underlined(), 65 | ])), 66 | } 67 | } 68 | } 69 | 70 | pub struct PostViewComponent { 71 | post_view: PostView, 72 | reply: Option, 73 | actions: Vec, 74 | table_state: TableState, 75 | list_state: ListState, 76 | action_tx: UnboundedSender, 77 | agent: Arc, 78 | watcher: Box>>, 79 | quit: Option>, 80 | session: Option, 81 | } 82 | 83 | impl PostViewComponent { 84 | pub fn new( 85 | action_tx: UnboundedSender, 86 | watcher: Arc, 87 | post_view: PostView, 88 | reply: Option, 89 | session: Option, 90 | ) -> Self { 91 | let actions = Self::post_view_actions(&post_view, &session); 92 | let agent = watcher.agent.clone(); 93 | let watcher = Box::new(watcher.post_thread(post_view.uri.clone())); 94 | Self { 95 | post_view, 96 | reply, 97 | actions, 98 | table_state: TableState::default(), 99 | list_state: ListState::default(), 100 | action_tx, 101 | agent, 102 | watcher, 103 | quit: None, 104 | session, 105 | } 106 | } 107 | fn post_view_actions(post_view: &PostView, session: &Option) -> Vec { 108 | let mut liked = None; 109 | if let Some(viewer) = &post_view.viewer { 110 | liked = viewer.like.as_ref(); 111 | } 112 | let mut actions = vec![ 113 | PostAction::Profile(post_view.author.clone()), 114 | PostAction::Reply, 115 | PostAction::Repost, 116 | if let Some(uri) = liked { 117 | PostAction::Unlike(uri.clone()) 118 | } else { 119 | PostAction::Like 120 | }, 121 | ]; 122 | if Some(&post_view.author.did) == session.as_ref().map(|s| &s.data.did) { 123 | actions.push(PostAction::Delete); 124 | } 125 | let mut links = IndexSet::new(); 126 | if let Ok(record) = post::Record::try_from_unknown(post_view.record.clone()) { 127 | if let Some(facets) = &record.facets { 128 | for facet in facets { 129 | for feature in &facet.features { 130 | match feature { 131 | Union::Refs(MainFeaturesItem::Mention(_)) => { 132 | // TODO 133 | } 134 | Union::Refs(MainFeaturesItem::Link(link)) => { 135 | links.insert(link.uri.clone()); 136 | } 137 | Union::Refs(MainFeaturesItem::Tag(_)) => { 138 | // TODO 139 | } 140 | _ => {} 141 | } 142 | } 143 | } 144 | } 145 | } 146 | if let Some(embed) = &post_view.embed { 147 | match embed { 148 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedImagesView(images)) => { 149 | for image in &images.images { 150 | links.insert(image.fullsize.clone()); 151 | } 152 | } 153 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedExternalView(external)) => { 154 | links.insert(external.external.uri.clone()); 155 | } 156 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordView(record)) => { 157 | actions.extend(Self::record_actions(record)); 158 | } 159 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordWithMediaView( 160 | record_with_media, 161 | )) => { 162 | match &record_with_media.media { 163 | Union::Refs(ViewMediaRefs::AppBskyEmbedImagesView(images)) => { 164 | for image in &images.images { 165 | links.insert(image.fullsize.clone()); 166 | } 167 | } 168 | Union::Refs(ViewMediaRefs::AppBskyEmbedExternalView(external)) => { 169 | links.insert(external.external.uri.clone()); 170 | } 171 | _ => {} 172 | } 173 | actions.extend(Self::record_actions(&record_with_media.record)); 174 | } 175 | _ => {} 176 | } 177 | } 178 | [ 179 | actions, 180 | links.into_iter().map(PostAction::Open).collect::>(), 181 | ] 182 | .concat() 183 | } 184 | fn record_actions(record: &record::View) -> Vec { 185 | let mut actions = Vec::new(); 186 | match &record.record { 187 | Union::Refs(ViewRecordRefs::ViewRecord(view_record)) => { 188 | actions.push(PostAction::ViewRecord(view_record.as_ref().clone())); 189 | } 190 | Union::Refs(ViewRecordRefs::AppBskyFeedDefsGeneratorView(_)) => { 191 | // TODO 192 | } 193 | Union::Refs(ViewRecordRefs::AppBskyGraphDefsListView(_)) => { 194 | // TODO 195 | } 196 | Union::Refs(ViewRecordRefs::AppBskyLabelerDefsLabelerView(_)) => { 197 | // TODO 198 | } 199 | Union::Refs(ViewRecordRefs::AppBskyGraphDefsStarterPackViewBasic(_)) => { 200 | // TODO 201 | } 202 | _ => {} 203 | } 204 | actions 205 | } 206 | fn post_view_rows(post_view: &PostView, width: u16) -> Option> { 207 | let Ok(record) = post::Record::try_from_unknown(post_view.record.clone()) else { 208 | return None; 209 | }; 210 | let mut author_lines = vec![Line::from(post_view.author.handle.as_str())]; 211 | if let Some(display_name) = post_view 212 | .author 213 | .display_name 214 | .as_ref() 215 | .filter(|s| !s.is_empty()) 216 | { 217 | author_lines.push(Line::from(display_name.as_str()).bold()); 218 | } 219 | if let Some(labels) = post_view.author.labels.as_ref().filter(|v| !v.is_empty()) { 220 | for label in labels { 221 | let mut spans = vec![Span::from(label.val.as_str()).magenta()]; 222 | if !label.uri.ends_with("/self") { 223 | spans.extend([Span::from(" "), format!("by {}", label.src.as_ref()).dim()]); 224 | } 225 | author_lines.push(Line::from(spans)); 226 | } 227 | } 228 | let text_lines = textwrap::wrap(&record.text, usize::from(width)); 229 | let mut rows = vec![ 230 | Row::new(vec![ 231 | Cell::from("CID:".gray().into_right_aligned_line()), 232 | Cell::from(post_view.cid.as_ref().to_string()), 233 | ]), 234 | Row::new(vec![ 235 | Cell::from("IndexedAt:".gray().into_right_aligned_line()), 236 | Cell::from( 237 | post_view 238 | .indexed_at 239 | .as_ref() 240 | .with_timezone(&Local) 241 | .format("%Y-%m-%d %H:%M:%S %z") 242 | .to_string(), 243 | ) 244 | .green(), 245 | ]), 246 | Row::default().height(author_lines.len() as u16).cells(vec![ 247 | Cell::from("Author:".gray().into_right_aligned_line()), 248 | Cell::from(Text::from(author_lines)), 249 | ]), 250 | Row::new(vec![ 251 | Cell::from("Counts:".gray().into_right_aligned_line()), 252 | Cell::from(Line::from(counts(post_view, 0))), 253 | ]), 254 | Row::default().height(text_lines.len() as u16).cells(vec![ 255 | Cell::from("Text:".gray().into_right_aligned_line()), 256 | Cell::from( 257 | text_lines 258 | .iter() 259 | .map(|s| Line::from(s.to_string())) 260 | .collect::>(), 261 | ), 262 | ]), 263 | ]; 264 | if let Some(langs) = record.langs.as_ref().filter(|v| !v.is_empty()) { 265 | rows.push(Row::new(vec![ 266 | Cell::from("Langs:".gray().into_right_aligned_line()), 267 | Cell::from( 268 | langs 269 | .iter() 270 | .map(|lang| lang.as_ref().to_string()) 271 | .collect::>() 272 | .join(", "), 273 | ), 274 | ])); 275 | } 276 | if let Some(labels) = post_view.labels.as_ref().filter(|v| !v.is_empty()) { 277 | let mut lines = Vec::new(); 278 | for label in labels { 279 | let mut spans = vec![Span::from(label.val.as_str()).magenta()]; 280 | if !label.uri.ends_with("/self") { 281 | spans.extend([Span::from(" "), format!("by {}", label.src.as_ref()).dim()]); 282 | } 283 | lines.push(Line::from(spans)); 284 | } 285 | rows.push(Row::default().height(lines.len() as u16).cells(vec![ 286 | Cell::from("Labels:".gray().into_right_aligned_line()), 287 | Cell::from(lines), 288 | ])); 289 | } 290 | if let Some(facets) = &record.facets { 291 | let lines = facets 292 | .iter() 293 | .map(|f| { 294 | Line::from(vec![ 295 | Span::from(format!("[{}-{}] ", f.index.byte_start, f.index.byte_end)) 296 | .cyan(), 297 | Span::from( 298 | f.features 299 | .iter() 300 | .map(|f| match f { 301 | Union::Refs(MainFeaturesItem::Mention(mention)) => { 302 | format!("Mention({})", mention.did.as_ref()) 303 | } 304 | Union::Refs(MainFeaturesItem::Link(link)) => { 305 | format!("Link({})", link.uri) 306 | } 307 | Union::Refs(MainFeaturesItem::Tag(tag)) => { 308 | format!("Tag({})", tag.tag) 309 | } 310 | Union::Unknown(_) => String::from("Unknown"), 311 | }) 312 | .collect::>() 313 | .join(", "), 314 | ), 315 | ]) 316 | }) 317 | .collect::>(); 318 | rows.push(Row::default().height(facets.len() as u16).cells(vec![ 319 | Cell::from("Facets".gray().into_right_aligned_line()), 320 | Cell::from(lines), 321 | ])); 322 | } 323 | if let Some(embed) = &post_view.embed { 324 | let mut lines = Vec::new(); 325 | match embed { 326 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedImagesView(images)) => { 327 | lines.push(Line::from("images").yellow()); 328 | lines.extend(Self::images_lines(images)) 329 | } 330 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedExternalView(external)) => { 331 | lines.push(Line::from("external").yellow()); 332 | lines.extend(Self::external_lines(external)); 333 | } 334 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordView(record)) => { 335 | lines.push(Line::from("record").yellow()); 336 | lines.extend(Self::record_lines(record, width)); 337 | } 338 | Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordWithMediaView( 339 | record_with_media, 340 | )) => { 341 | lines.push(Line::from("recordWithMedia").yellow()); 342 | match &record_with_media.media { 343 | Union::Refs(ViewMediaRefs::AppBskyEmbedImagesView(images)) => { 344 | lines.extend(Self::images_lines(images)) 345 | } 346 | Union::Refs(ViewMediaRefs::AppBskyEmbedExternalView(external)) => { 347 | lines.extend(Self::external_lines(external)); 348 | } 349 | _ => {} 350 | } 351 | lines.extend(Self::record_lines(&record_with_media.record, width)); 352 | } 353 | _ => {} 354 | } 355 | rows.push(Row::default().height(lines.len() as u16).cells(vec![ 356 | Cell::from("Embed:".gray().into_right_aligned_line()), 357 | Cell::from(lines), 358 | ])) 359 | } 360 | Some(rows) 361 | } 362 | fn images_lines(images: &images::View) -> Vec { 363 | images 364 | .images 365 | .iter() 366 | .map(|image| { 367 | Line::from(vec![ 368 | Span::from(format!("[{}](", image.alt)), 369 | Span::from(image.fullsize.as_str()).underlined(), 370 | Span::from(")"), 371 | ]) 372 | }) 373 | .collect() 374 | } 375 | fn external_lines(external: &external::View) -> Vec { 376 | vec![ 377 | Line::from( 378 | Span::from(external.external.uri.as_str()) 379 | .dim() 380 | .underlined(), 381 | ), 382 | Line::from(external.external.title.as_str()).bold(), 383 | Line::from(external.external.description.as_str()), 384 | ] 385 | } 386 | fn record_lines(record: &record::View, width: u16) -> Vec { 387 | match &record.record { 388 | Union::Refs(ViewRecordRefs::ViewRecord(view_record)) => { 389 | if let Ok(record) = post::Record::try_from_unknown(view_record.value.clone()) { 390 | return [ 391 | vec![ 392 | Line::from( 393 | view_record 394 | .indexed_at 395 | .as_ref() 396 | .with_timezone(&Local) 397 | .format("%Y-%m-%d %H:%M:%S %z") 398 | .to_string(), 399 | ) 400 | .green(), 401 | Line::from(profile_name(&view_record.author)), 402 | ], 403 | textwrap::wrap(&record.text, usize::from(width)) 404 | .iter() 405 | .map(|s| Line::from(s.to_string())) 406 | .collect::>(), 407 | ] 408 | .concat(); 409 | } 410 | } 411 | Union::Refs(ViewRecordRefs::AppBskyFeedDefsGeneratorView(_)) => { 412 | // TODO 413 | } 414 | Union::Refs(ViewRecordRefs::AppBskyGraphDefsListView(_)) => { 415 | // TODO 416 | } 417 | Union::Refs(ViewRecordRefs::AppBskyLabelerDefsLabelerView(_)) => { 418 | // TODO 419 | } 420 | Union::Refs(ViewRecordRefs::AppBskyGraphDefsStarterPackViewBasic(_)) => { 421 | // TODO 422 | } 423 | _ => {} 424 | } 425 | Vec::new() 426 | } 427 | } 428 | 429 | impl ViewComponent for PostViewComponent { 430 | fn view(&self) -> View { 431 | View::Post(Box::new((self.post_view.clone(), self.reply.clone()))) 432 | } 433 | fn activate(&mut self) -> Result<()> { 434 | let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe()); 435 | let (quit_tx, mut quit_rx) = oneshot::channel(); 436 | self.quit = Some(quit_tx); 437 | tokio::spawn(async move { 438 | loop { 439 | tokio::select! { 440 | changed = rx.changed() => { 441 | if changed.is_ok() { 442 | if let Err(e) = tx.send(Action::Update(Box::new(Data::PostThread( 443 | rx.borrow_and_update().clone(), 444 | )))) { 445 | log::error!("failed to send update action: {e}"); 446 | } 447 | } else { 448 | break log::warn!("post thread channel closed"); 449 | } 450 | } 451 | _ = &mut quit_rx => { 452 | break; 453 | } 454 | } 455 | } 456 | log::debug!("subscription finished"); 457 | }); 458 | Ok(()) 459 | } 460 | fn deactivate(&mut self) -> Result<()> { 461 | if let Some(tx) = self.quit.take() { 462 | if tx.send(()).is_err() { 463 | log::error!("failed to send quit signal"); 464 | } 465 | } 466 | self.watcher.unsubscribe(); 467 | Ok(()) 468 | } 469 | fn update(&mut self, action: Action) -> Result> { 470 | match action { 471 | Action::NextItem => { 472 | self.list_state.select(Some( 473 | self.list_state 474 | .selected() 475 | .map_or(0, |s| (s + 1) % self.actions.len()), 476 | )); 477 | return Ok(Some(Action::Render)); 478 | } 479 | Action::PrevItem => { 480 | self.list_state.select(Some( 481 | self.list_state 482 | .selected() 483 | .map_or(0, |s| (s + self.actions.len() - 1) % self.actions.len()), 484 | )); 485 | return Ok(Some(Action::Render)); 486 | } 487 | Action::Enter => { 488 | if let Some(action) = self.list_state.selected().and_then(|i| self.actions.get(i)) { 489 | match action { 490 | PostAction::Like => { 491 | let (agent, tx) = (self.agent.clone(), self.action_tx.clone()); 492 | let mut viewer = self.post_view.viewer.clone().unwrap_or( 493 | ViewerStateData { 494 | embedding_disabled: None, 495 | like: None, 496 | pinned: None, 497 | reply_disabled: None, 498 | repost: None, 499 | thread_muted: None, 500 | } 501 | .into(), 502 | ); 503 | let record_data = api::app::bsky::feed::like::RecordData { 504 | created_at: Datetime::now(), 505 | subject: api::com::atproto::repo::strong_ref::MainData { 506 | cid: self.post_view.cid.clone(), 507 | uri: self.post_view.uri.clone(), 508 | } 509 | .into(), 510 | }; 511 | tokio::spawn(async move { 512 | match agent.create_record(record_data).await { 513 | Ok(output) => { 514 | log::info!("created like record: {}", output.cid.as_ref()); 515 | viewer.like = Some(output.uri.clone()); 516 | tx.send(Action::Update(Box::new(Data::ViewerState(Some( 517 | viewer, 518 | ))))) 519 | .ok(); 520 | } 521 | Err(e) => { 522 | log::error!("failed to create like record: {e}"); 523 | } 524 | } 525 | }); 526 | } 527 | PostAction::Unlike(uri) => { 528 | let (agent, tx) = (self.agent.clone(), self.action_tx.clone()); 529 | let mut viewer = self.post_view.viewer.clone(); 530 | let at_uri = uri.clone(); 531 | tokio::spawn(async move { 532 | match agent.delete_record(at_uri).await { 533 | Ok(_) => { 534 | log::info!("deleted like record"); 535 | if let Some(viewer) = viewer.as_mut() { 536 | viewer.like = None; 537 | } 538 | tx.send(Action::Update(Box::new(Data::ViewerState( 539 | viewer, 540 | )))) 541 | .ok(); 542 | } 543 | Err(e) => { 544 | log::error!("failed to create like record: {e}"); 545 | } 546 | } 547 | }); 548 | } 549 | PostAction::Delete => { 550 | // TODO: confirmation dialog 551 | let (agent, tx) = (self.agent.clone(), self.action_tx.clone()); 552 | let at_uri = self.post_view.uri.clone(); 553 | tokio::spawn(async move { 554 | match agent.delete_record(at_uri).await { 555 | Ok(_) => { 556 | log::info!("deleted record"); 557 | tx.send(Action::Transition(Transition::Pop)).ok(); 558 | } 559 | Err(e) => { 560 | log::error!("failed to delete record: {e}"); 561 | } 562 | } 563 | }); 564 | } 565 | PostAction::Open(uri) => { 566 | if let Err(e) = open::that(uri) { 567 | log::error!("failed to open: {e}"); 568 | } 569 | } 570 | PostAction::ViewRecord(view_record) => { 571 | return Ok(Some(Action::Transition(Transition::Push(Box::new( 572 | View::Post(Box::new(( 573 | PostViewData { 574 | author: view_record.author.clone(), 575 | cid: view_record.cid.clone(), 576 | embed: None, 577 | indexed_at: view_record.indexed_at.clone(), 578 | labels: view_record.labels.clone(), 579 | like_count: view_record.like_count, 580 | quote_count: view_record.quote_count, 581 | record: view_record.value.clone(), 582 | reply_count: view_record.reply_count, 583 | repost_count: view_record.repost_count, 584 | threadgate: None, 585 | uri: view_record.uri.clone(), 586 | viewer: None, 587 | } 588 | .into(), 589 | None, 590 | ))), 591 | ))))); 592 | } 593 | _ => { 594 | // TODO 595 | } 596 | } 597 | } 598 | } 599 | Action::Back => { 600 | return Ok(Some(Action::Transition(Transition::Pop))); 601 | } 602 | Action::Refresh => { 603 | self.watcher.refresh(); 604 | } 605 | Action::Update(data) => { 606 | match data.as_ref() { 607 | Data::PostThread(Union::Refs( 608 | OutputThreadRefs::AppBskyFeedDefsThreadViewPost(thread_view), 609 | )) => { 610 | self.post_view = thread_view.post.clone(); 611 | if let Some(Union::Refs(ThreadViewPostParentRefs::ThreadViewPost(parent))) = 612 | &thread_view.parent 613 | { 614 | self.reply = Some(parent.post.clone()); 615 | } 616 | } 617 | Data::ViewerState(viewer) => { 618 | let diff = i64::from( 619 | viewer 620 | .as_ref() 621 | .map(|v| v.like.is_some()) 622 | .unwrap_or_default(), 623 | ) - i64::from( 624 | self.post_view 625 | .viewer 626 | .as_ref() 627 | .map(|v| v.like.is_some()) 628 | .unwrap_or_default(), 629 | ); 630 | self.post_view.like_count = 631 | Some(self.post_view.like_count.unwrap_or_default() + diff); 632 | self.post_view.viewer.clone_from(viewer); 633 | } 634 | _ => return Ok(None), 635 | } 636 | self.actions = Self::post_view_actions(&self.post_view, &self.session); 637 | return Ok(Some(Action::Render)); 638 | } 639 | Action::Transition(_) => { 640 | return Ok(Some(action)); 641 | } 642 | _ => {} 643 | } 644 | Ok(None) 645 | } 646 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 647 | let widths = [Constraint::Length(11), Constraint::Percentage(100)]; 648 | let width = Layout::horizontal(widths).split(area.inner(Margin::new(1, 0)))[1].width; 649 | 650 | let mut rows = Vec::new(); 651 | if let Some(reply) = &self.reply { 652 | if let Some(r) = Self::post_view_rows(reply, width) { 653 | rows.push(Row::new([" Reply to".blue()])); 654 | rows.extend(r); 655 | rows.push(Row::new([" --------- ".blue()])); 656 | } 657 | } 658 | self.table_state.select(Some(rows.len())); 659 | if let Some(r) = Self::post_view_rows(&self.post_view, width) { 660 | rows.extend(r); 661 | } 662 | 663 | let layout = Layout::vertical([ 664 | Constraint::Length(2), 665 | Constraint::Percentage(100), 666 | Constraint::Min(10), 667 | ]) 668 | .split(area); 669 | f.render_widget( 670 | Paragraph::new(self.post_view.uri.as_str()).bold().block( 671 | Block::default() 672 | .borders(Borders::BOTTOM) 673 | .border_style(Color::Gray) 674 | .padding(Padding::horizontal(1)), 675 | ), 676 | layout[0], 677 | ); 678 | f.render_stateful_widget(Table::new(rows, widths), layout[1], &mut self.table_state); 679 | f.render_stateful_widget( 680 | List::new(&self.actions) 681 | .highlight_style(Style::default().reversed()) 682 | .block( 683 | Block::default() 684 | .title("Actions") 685 | .title_alignment(Alignment::Center) 686 | .borders(Borders::TOP) 687 | .border_style(Color::Gray) 688 | .padding(Padding::horizontal(1)), 689 | ), 690 | layout[2], 691 | &mut self.list_state, 692 | ); 693 | Ok(()) 694 | } 695 | } 696 | -------------------------------------------------------------------------------- /src/components/views/root.rs: -------------------------------------------------------------------------------- 1 | use super::types::{Action, Transition, View}; 2 | use super::utils::profile_name_as_str; 3 | use super::ViewComponent; 4 | use crate::backend::types::{FeedSourceInfo, PinnedFeed}; 5 | use crate::backend::{Watch, Watcher}; 6 | use crate::components::views::types::Data; 7 | use color_eyre::Result; 8 | use ratatui::style::{Style, Stylize}; 9 | use ratatui::text::{Line, Span, Text}; 10 | use ratatui::widgets::{Block, List, ListState, Padding}; 11 | use ratatui::{layout::Rect, Frame}; 12 | use std::sync::Arc; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | use tokio::sync::oneshot; 15 | 16 | pub struct RootComponent { 17 | items: Vec, 18 | state: ListState, 19 | action_tx: UnboundedSender, 20 | watcher: Box>>, 21 | quit: Option>, 22 | } 23 | 24 | impl RootComponent { 25 | pub fn new(action_tx: UnboundedSender, watcher: Arc) -> Self { 26 | Self { 27 | items: Vec::new(), 28 | state: ListState::default(), 29 | action_tx, 30 | watcher: Box::new(watcher.pinned_feeds()), 31 | quit: None, 32 | } 33 | } 34 | } 35 | 36 | impl ViewComponent for RootComponent { 37 | fn view(&self) -> View { 38 | View::Root 39 | } 40 | fn activate(&mut self) -> Result<()> { 41 | let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe()); 42 | let (quit_tx, mut quit_rx) = oneshot::channel(); 43 | self.quit = Some(quit_tx); 44 | tokio::spawn(async move { 45 | loop { 46 | tokio::select! { 47 | changed = rx.changed() => { 48 | match changed { 49 | Ok(()) => { 50 | if let Err(e) = tx.send(Action::Update(Box::new(Data::SavedFeeds( 51 | rx.borrow_and_update().clone(), 52 | )))) { 53 | log::error!("failed to send update action: {e}"); 54 | } 55 | } 56 | Err(e) => { 57 | log::warn!("changed channel error: {e}"); 58 | break; 59 | } 60 | } 61 | } 62 | _ = &mut quit_rx => { 63 | break; 64 | } 65 | } 66 | } 67 | log::debug!("subscription finished"); 68 | }); 69 | Ok(()) 70 | } 71 | fn deactivate(&mut self) -> Result<()> { 72 | if let Some(tx) = self.quit.take() { 73 | if tx.send(()).is_err() { 74 | log::error!("failed to send quit signal"); 75 | } 76 | } 77 | self.watcher.unsubscribe(); 78 | Ok(()) 79 | } 80 | fn update(&mut self, action: Action) -> Result> { 81 | match action { 82 | Action::NextItem if !self.items.is_empty() => { 83 | self.state.select(Some( 84 | self.state 85 | .selected() 86 | .map(|s| (s + 1).min(self.items.len())) 87 | .unwrap_or_default(), 88 | )); 89 | return Ok(Some(Action::Render)); 90 | } 91 | Action::PrevItem if !self.items.is_empty() => { 92 | self.state.select(Some( 93 | self.state 94 | .selected() 95 | .map(|s| s.max(1) - 1) 96 | .unwrap_or_default(), 97 | )); 98 | return Ok(Some(Action::Render)); 99 | } 100 | Action::Enter if !self.items.is_empty() => { 101 | if let Some(index) = self.state.selected() { 102 | if index == self.items.len() { 103 | self.deactivate()?; 104 | return Ok(Some(Action::Logout)); 105 | } 106 | if let Some(feed) = self.items.get(index) { 107 | return Ok(Some(Action::Transition(Transition::Push(Box::new( 108 | View::Feed(Box::new(feed.info.clone())), 109 | ))))); 110 | } 111 | } 112 | } 113 | Action::Refresh => { 114 | self.watcher.refresh(); 115 | } 116 | Action::Update(data) => { 117 | let Data::SavedFeeds(feeds) = data.as_ref() else { 118 | return Ok(None); 119 | }; 120 | self.items.clone_from(feeds); 121 | if self.state.selected().is_none() && !self.items.is_empty() { 122 | self.state.select(Some(0)); 123 | } 124 | return Ok(Some(Action::Render)); 125 | } 126 | _ => {} 127 | } 128 | Ok(None) 129 | } 130 | fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { 131 | let mut items = self 132 | .items 133 | .iter() 134 | .map(|feed| match &feed.info { 135 | FeedSourceInfo::Feed(generator_view) => Text::from(vec![ 136 | Line::from(vec![ 137 | Span::from("[feed]").blue(), 138 | Span::from(" "), 139 | Span::from(generator_view.display_name.clone()).bold(), 140 | Span::from(" "), 141 | Span::from(format!( 142 | "by {}", 143 | profile_name_as_str(&generator_view.creator) 144 | )) 145 | .gray(), 146 | ]), 147 | Line::from(format!( 148 | " {}", 149 | generator_view.description.as_deref().unwrap_or_default() 150 | )) 151 | .dim(), 152 | ]), 153 | FeedSourceInfo::List(list_view) => Text::from(vec![ 154 | Line::from(vec![ 155 | Span::from("[list]").yellow(), 156 | Span::from(" "), 157 | Span::from(list_view.name.as_str()).bold(), 158 | Span::from(" "), 159 | Span::from(format!("by {}", profile_name_as_str(&list_view.creator))) 160 | .gray(), 161 | ]), 162 | Line::from(format!( 163 | " {}", 164 | list_view.description.as_deref().unwrap_or_default() 165 | )) 166 | .dim(), 167 | ]), 168 | FeedSourceInfo::Timeline(_) => Text::from(vec![ 169 | Line::from(vec![ 170 | Span::from("[timeline]").green(), 171 | Span::from(" "), 172 | Span::from("Following").bold(), 173 | ]), 174 | Line::from(" Your following feed").dim(), 175 | ]), 176 | }) 177 | .collect::>(); 178 | if !items.is_empty() { 179 | items.push(Text::from("Sign out").red()); 180 | } 181 | f.render_stateful_widget( 182 | List::new(items) 183 | .block(Block::default().padding(Padding::uniform(1))) 184 | .highlight_style(Style::default().reset().reversed()), 185 | area, 186 | &mut self.state, 187 | ); 188 | Ok(()) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/components/views/types.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::types::{FeedSourceInfo, PinnedFeed}; 2 | use bsky_sdk::api::app::bsky::feed::defs::{FeedViewPost, PostView, ViewerState}; 3 | use bsky_sdk::api::app::bsky::feed::get_post_thread::OutputThreadRefs; 4 | use bsky_sdk::api::types::Union; 5 | use bsky_sdk::BskyAgent; 6 | use std::fmt::{Debug, Formatter, Result}; 7 | 8 | #[derive(Clone)] 9 | pub enum Action { 10 | Render, 11 | NextItem, 12 | PrevItem, 13 | Enter, 14 | Back, 15 | Refresh, 16 | NewPost, 17 | Menu, 18 | Login(Box), 19 | Logout, 20 | Update(Box), 21 | Transition(Transition), 22 | } 23 | 24 | impl Debug for Action { 25 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 26 | match self { 27 | Action::Render => write!(f, "Render"), 28 | Action::NextItem => write!(f, "NextItem"), 29 | Action::PrevItem => write!(f, "PrevItem"), 30 | Action::Enter => write!(f, "Enter"), 31 | Action::Back => write!(f, "Back"), 32 | Action::Refresh => write!(f, "Refresh"), 33 | Action::NewPost => write!(f, "NewPost"), 34 | Action::Menu => write!(f, "Menu"), 35 | Action::Login(_) => write!(f, "Login"), 36 | Action::Logout => write!(f, "Logout"), 37 | Action::Update(_) => write!(f, "Update"), 38 | Action::Transition(arg) => f.debug_tuple("Transition").field(arg).finish(), 39 | } 40 | } 41 | } 42 | 43 | #[derive(Debug, Clone)] 44 | pub enum Data { 45 | SavedFeeds(Vec), 46 | Feed(Vec), 47 | PostThread(Union), 48 | ViewerState(Option), 49 | } 50 | 51 | #[derive(Debug, Clone)] 52 | pub enum Transition { 53 | Push(Box), 54 | Pop, 55 | Replace(Box), 56 | } 57 | 58 | #[derive(Debug, Clone, PartialEq, Eq)] 59 | pub enum View { 60 | Login, 61 | Root, 62 | NewPost, 63 | Feed(Box), 64 | Post(Box<(PostView, Option)>), 65 | } 66 | -------------------------------------------------------------------------------- /src/components/views/utils.rs: -------------------------------------------------------------------------------- 1 | use bsky_sdk::api::app::bsky::actor::defs::{ProfileView, ProfileViewBasic}; 2 | use bsky_sdk::api::app::bsky::feed::defs::PostView; 3 | use ratatui::style::{Style, Stylize}; 4 | use ratatui::text::Span; 5 | 6 | pub trait Profile { 7 | fn display_name(&self) -> Option<&str>; 8 | fn handle(&self) -> &str; 9 | } 10 | 11 | impl Profile for ProfileView { 12 | fn display_name(&self) -> Option<&str> { 13 | self.display_name.as_deref().filter(|s| !s.is_empty()) 14 | } 15 | fn handle(&self) -> &str { 16 | self.handle.as_str() 17 | } 18 | } 19 | 20 | impl Profile for ProfileViewBasic { 21 | fn display_name(&self) -> Option<&str> { 22 | self.display_name.as_deref().filter(|s| !s.is_empty()) 23 | } 24 | fn handle(&self) -> &str { 25 | self.handle.as_str() 26 | } 27 | } 28 | 29 | pub fn profile_name_as_str(author: &dyn Profile) -> &str { 30 | author.display_name().unwrap_or(author.handle()) 31 | } 32 | 33 | pub fn profile_name(author: &dyn Profile) -> Vec { 34 | if let Some(display_name) = author.display_name() { 35 | vec![ 36 | Span::from(display_name.to_string()).bold(), 37 | Span::from(" "), 38 | format!("@{}", author.handle()).gray(), 39 | ] 40 | } else { 41 | vec![format!("@{}", author.handle()).bold()] 42 | } 43 | } 44 | 45 | pub fn counts(post_view: &PostView, pad: usize) -> Vec { 46 | let (mut reposted, mut liked) = (false, false); 47 | if let Some(viewer) = &post_view.viewer { 48 | reposted = viewer.repost.is_some(); 49 | liked = viewer.like.is_some(); 50 | } 51 | let (replies, reposts, quotes, likes) = ( 52 | post_view.reply_count.unwrap_or_default(), 53 | post_view.repost_count.unwrap_or_default(), 54 | post_view.quote_count.unwrap_or_default(), 55 | post_view.like_count.unwrap_or_default(), 56 | ); 57 | let style = |b| { 58 | if b { 59 | Style::default() 60 | } else { 61 | Style::default().dim() 62 | } 63 | }; 64 | vec![ 65 | Span::from(format!("{replies:pad$} replies")).style(style(replies > 0)), 66 | Span::from(", ").dim(), 67 | Span::from(format!("{reposts:pad$}")).style(if reposted { 68 | Style::default().green() 69 | } else { 70 | style(reposts > 0) 71 | }), 72 | Span::from(" reposts").style(style(reposts > 0)), 73 | Span::from(", ").dim(), 74 | Span::from(format!("{quotes:pad$}")).style(style(reposts > 0)), 75 | Span::from(" quotes").style(style(reposts > 0)), 76 | Span::from(", ").dim(), 77 | Span::from(format!("{likes:pad$}")).style(if liked { 78 | Style::default().red() 79 | } else { 80 | style(likes > 0) 81 | }), 82 | Span::from(" likes").style(style(likes > 0)), 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::config::Config as WatcherConfig; 2 | use crate::components::views::types::Action as ViewAction; 3 | use crate::types::Action as AppAction; 4 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 5 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 6 | use std::collections::HashMap; 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] 9 | pub struct Config { 10 | pub num_columns: Option, 11 | #[serde(default)] 12 | pub keybindings: Keybindings, 13 | #[serde(default)] 14 | pub watcher: WatcherConfig, 15 | } 16 | 17 | impl Config { 18 | pub fn set_default_keybindings(&mut self) { 19 | // global: Ctrl-q to Quit 20 | self.keybindings 21 | .global 22 | .entry(Key(KeyCode::Char('q'), KeyModifiers::CONTROL)) 23 | .or_insert(GlobalAction::Quit); 24 | // global: Ctrl-o to NextFocus 25 | self.keybindings 26 | .global 27 | .entry(Key(KeyCode::Char('o'), KeyModifiers::CONTROL)) 28 | .or_insert(GlobalAction::NextFocus); 29 | // global: Ctrl-z to Suspend 30 | #[cfg(not(windows))] 31 | self.keybindings 32 | .global 33 | .entry(Key(KeyCode::Char('z'), KeyModifiers::CONTROL)) 34 | .or_insert(GlobalAction::Suspend); 35 | // column: Down to NextItem 36 | self.keybindings 37 | .column 38 | .entry(Key(KeyCode::Down, KeyModifiers::NONE)) 39 | .or_insert(ColumnAction::NextItem); 40 | // column: Up to PrevItem 41 | self.keybindings 42 | .column 43 | .entry(Key(KeyCode::Up, KeyModifiers::NONE)) 44 | .or_insert(ColumnAction::PrevItem); 45 | // column: Enter to Enter 46 | self.keybindings 47 | .column 48 | .entry(Key(KeyCode::Enter, KeyModifiers::NONE)) 49 | .or_insert(ColumnAction::Enter); 50 | // column: Backspace to Back 51 | self.keybindings 52 | .column 53 | .entry(Key(KeyCode::Backspace, KeyModifiers::NONE)) 54 | .or_insert(ColumnAction::Back); 55 | // column: Ctrl-r to Refresh 56 | self.keybindings 57 | .column 58 | .entry(Key(KeyCode::Char('r'), KeyModifiers::CONTROL)) 59 | .or_insert(ColumnAction::Refresh); 60 | self.keybindings 61 | .column 62 | .entry(Key(KeyCode::Char('x'), KeyModifiers::CONTROL)) 63 | .or_insert(ColumnAction::Menu); 64 | } 65 | } 66 | 67 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] 68 | pub struct Keybindings { 69 | pub global: HashMap, 70 | pub column: HashMap, 71 | } 72 | 73 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 74 | pub struct Key(KeyCode, KeyModifiers); 75 | 76 | impl From for Key { 77 | fn from(event: KeyEvent) -> Self { 78 | Self(event.code, event.modifiers) 79 | } 80 | } 81 | 82 | #[allow(clippy::non_canonical_partial_ord_impl)] 83 | impl PartialOrd for Key { 84 | fn partial_cmp(&self, other: &Self) -> Option { 85 | match self.1.partial_cmp(&other.1) { 86 | Some(std::cmp::Ordering::Equal) => self.0.partial_cmp(&other.0), 87 | o => o, 88 | } 89 | } 90 | } 91 | 92 | impl Ord for Key { 93 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 94 | self.partial_cmp(other).unwrap_or(std::cmp::Ordering::Equal) 95 | } 96 | } 97 | 98 | impl Serialize for Key { 99 | fn serialize(&self, serializer: S) -> Result 100 | where 101 | S: Serializer, 102 | { 103 | let key_code = match self.0 { 104 | KeyCode::Char(c) => c.to_string(), 105 | _ => format!("{:?}", self.0), 106 | }; 107 | if self.1 == KeyModifiers::NONE { 108 | key_code.serialize(serializer) 109 | } else { 110 | let modifier = match self.1 { 111 | KeyModifiers::CONTROL => "Ctrl", 112 | KeyModifiers::SHIFT => "Shift", 113 | _ => return Err(serde::ser::Error::custom("unsupported key modifier")), 114 | }; 115 | format!("{modifier}-{key_code}").serialize(serializer) 116 | } 117 | } 118 | } 119 | 120 | impl<'de> Deserialize<'de> for Key { 121 | fn deserialize(deserializer: D) -> Result 122 | where 123 | D: Deserializer<'de>, 124 | { 125 | let s = String::deserialize(deserializer)?; 126 | if let Some((modifier, code)) = s.split_once('-') { 127 | let mut chars = code.chars(); 128 | if let (Some(c), None) = (chars.next(), chars.next()) { 129 | Ok(Self( 130 | KeyCode::Char(c), 131 | match modifier { 132 | "Ctrl" => KeyModifiers::CONTROL, 133 | "Shift" => KeyModifiers::SHIFT, 134 | _ => return Err(serde::de::Error::custom("invalid key modifier")), 135 | }, 136 | )) 137 | } else { 138 | Err(serde::de::Error::custom("invalid key")) 139 | } 140 | } else { 141 | let key_code = match s.as_str() { 142 | "Backspace" => KeyCode::Backspace, 143 | "Enter" => KeyCode::Enter, 144 | "Left" => KeyCode::Left, 145 | "Right" => KeyCode::Right, 146 | "Up" => KeyCode::Up, 147 | "Down" => KeyCode::Down, 148 | "Home" => KeyCode::Home, 149 | "End" => KeyCode::End, 150 | "PageUp" => KeyCode::PageUp, 151 | "PageDown" => KeyCode::PageDown, 152 | "Tab" => KeyCode::Tab, 153 | "BackTab" => KeyCode::BackTab, 154 | "Delete" => KeyCode::Delete, 155 | "Insert" => KeyCode::Insert, 156 | "Esc" => KeyCode::Esc, 157 | _ if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()), 158 | _ => return Err(serde::de::Error::custom("unsupported key code")), 159 | }; 160 | Ok(Self(key_code, KeyModifiers::NONE)) 161 | } 162 | } 163 | } 164 | 165 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 166 | pub enum GlobalAction { 167 | NextFocus, 168 | PrevFocus, 169 | Quit, 170 | #[cfg(not(windows))] 171 | Suspend, 172 | } 173 | 174 | impl From<&GlobalAction> for AppAction { 175 | fn from(action: &GlobalAction) -> Self { 176 | match action { 177 | GlobalAction::NextFocus => Self::NextFocus, 178 | GlobalAction::PrevFocus => Self::PrevFocus, 179 | GlobalAction::Quit => Self::Quit, 180 | #[cfg(not(windows))] 181 | GlobalAction::Suspend => Self::Suspend, 182 | } 183 | } 184 | } 185 | 186 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 187 | pub enum ColumnAction { 188 | NextItem, 189 | PrevItem, 190 | Enter, 191 | Back, 192 | Refresh, 193 | NewPost, 194 | Menu, 195 | } 196 | 197 | impl From<&ColumnAction> for ViewAction { 198 | fn from(action: &ColumnAction) -> Self { 199 | match action { 200 | ColumnAction::NextItem => Self::NextItem, 201 | ColumnAction::PrevItem => Self::PrevItem, 202 | ColumnAction::Enter => Self::Enter, 203 | ColumnAction::Back => Self::Back, 204 | ColumnAction::Refresh => Self::Refresh, 205 | ColumnAction::NewPost => Self::NewPost, 206 | ColumnAction::Menu => Self::Menu, 207 | } 208 | } 209 | } 210 | 211 | #[cfg(test)] 212 | mod tests { 213 | use super::*; 214 | use crate::backend::config::Intervals; 215 | 216 | #[test] 217 | fn deserialize_empty() { 218 | let config = toml::from_str::("").expect("failed to deserialize config"); 219 | assert_eq!(config, Config::default()); 220 | } 221 | 222 | #[test] 223 | fn deserialize() { 224 | let input = r#" 225 | [keybindings.global] 226 | Ctrl-c = "Quit" 227 | 228 | [keybindings.column] 229 | Ctrl-n = "NextItem" 230 | Ctrl-p = "PrevItem" 231 | Left = "Back" 232 | 233 | [watcher.intervals] 234 | feed = 20 235 | "#; 236 | let config = toml::from_str::(input).expect("failed to deserialize config"); 237 | assert_eq!( 238 | config, 239 | Config { 240 | num_columns: None, 241 | keybindings: Keybindings { 242 | global: HashMap::from_iter([( 243 | Key(KeyCode::Char('c'), KeyModifiers::CONTROL), 244 | GlobalAction::Quit 245 | )]), 246 | column: HashMap::from_iter([ 247 | ( 248 | Key(KeyCode::Char('n'), KeyModifiers::CONTROL), 249 | ColumnAction::NextItem 250 | ), 251 | ( 252 | Key(KeyCode::Char('p'), KeyModifiers::CONTROL), 253 | ColumnAction::PrevItem 254 | ), 255 | (Key(KeyCode::Left, KeyModifiers::NONE), ColumnAction::Back) 256 | ]), 257 | }, 258 | watcher: WatcherConfig { 259 | intervals: Intervals { 260 | preferences: 600, 261 | feed: 20, 262 | post_thread: 60, 263 | } 264 | } 265 | } 266 | ) 267 | } 268 | 269 | #[test] 270 | fn serialize() { 271 | let config = Config { 272 | num_columns: None, 273 | keybindings: Keybindings { 274 | global: HashMap::from_iter([( 275 | Key(KeyCode::Char('c'), KeyModifiers::CONTROL), 276 | GlobalAction::Quit, 277 | )]), 278 | column: HashMap::new(), 279 | }, 280 | watcher: WatcherConfig { 281 | intervals: Intervals { 282 | feed: 10, 283 | preferences: 10, 284 | post_thread: 180, 285 | }, 286 | }, 287 | }; 288 | let s = toml::to_string(&config).expect("failed to serialize config"); 289 | let deserialized = toml::from_str::(&s).expect("failed to deserialize config"); 290 | assert_eq!(deserialized, config); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | pub mod app; 3 | mod backend; 4 | mod components; 5 | pub mod config; 6 | mod tui; 7 | mod types; 8 | pub mod utils; 9 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use crate::types::Event; 2 | use color_eyre::Result; 3 | use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind}; 4 | use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; 5 | use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; 6 | use crossterm::{cursor, execute}; 7 | use futures_util::{FutureExt, StreamExt}; 8 | use ratatui::backend::Backend; 9 | use ratatui::Terminal; 10 | use std::io::{stdout, Write}; 11 | use std::ops::{Deref, DerefMut}; 12 | use std::time::Duration; 13 | use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; 14 | use tokio::task::JoinHandle; 15 | use tokio::time; 16 | 17 | pub fn io() -> impl Write { 18 | stdout() 19 | } 20 | 21 | pub struct Tui 22 | where 23 | B: Backend, 24 | { 25 | terminal: Terminal, 26 | task: Option>, 27 | event_tx: UnboundedSender, 28 | event_rx: UnboundedReceiver, 29 | } 30 | 31 | impl Tui 32 | where 33 | B: Backend, 34 | { 35 | pub fn new(terminal: Terminal) -> Self { 36 | let (event_tx, event_rx) = mpsc::unbounded_channel(); 37 | Self { 38 | terminal, 39 | task: None, 40 | event_tx, 41 | event_rx, 42 | } 43 | } 44 | pub fn start(&mut self) -> Result<()> { 45 | init()?; 46 | let event_tx = self.event_tx.clone(); 47 | self.task = Some(tokio::spawn(async move { 48 | let mut reader = EventStream::new(); 49 | let mut tick_interval = time::interval(Duration::from_secs(1)); 50 | let mut tick = 0; 51 | loop { 52 | let event = reader.next().fuse(); 53 | let tick_tick = tick_interval.tick(); 54 | tokio::select! { 55 | e = event => Self::handle_crossterm_event(e, &event_tx), 56 | _ = tick_tick => { 57 | tick += 1; 58 | if let Err(e) = event_tx.send(Event::Tick(tick)) { 59 | log::error!("failed to send tick event: {e}"); 60 | } 61 | }, 62 | } 63 | } 64 | })); 65 | Ok(()) 66 | } 67 | #[cfg(not(windows))] 68 | pub fn suspend(&mut self) -> Result<()> { 69 | restore()?; 70 | signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 71 | Ok(()) 72 | } 73 | #[cfg(not(windows))] 74 | pub fn clear(&mut self) -> Result<()> { 75 | self.terminal.clear()?; 76 | Ok(()) 77 | } 78 | pub fn end(&mut self) -> Result<()> { 79 | restore()?; 80 | Ok(()) 81 | } 82 | pub async fn next_event(&mut self) -> Option { 83 | self.event_rx.recv().await 84 | } 85 | fn handle_crossterm_event( 86 | event: Option>, 87 | tx: &UnboundedSender, 88 | ) { 89 | match event { 90 | Some(Ok(event)) => match event { 91 | CrosstermEvent::Mouse(mouse) => { 92 | tx.send(Event::Mouse(mouse)).unwrap(); 93 | } 94 | CrosstermEvent::Key(key) if key.kind != KeyEventKind::Release => { 95 | tx.send(Event::Key(key)).unwrap(); 96 | } 97 | _ => { 98 | // TODO 99 | } 100 | }, 101 | Some(Err(err)) => { 102 | tx.send(Event::Error(err.to_string())).unwrap(); 103 | } 104 | _ => {} 105 | } 106 | } 107 | } 108 | 109 | impl Deref for Tui 110 | where 111 | B: Backend, 112 | { 113 | type Target = Terminal; 114 | 115 | fn deref(&self) -> &Self::Target { 116 | &self.terminal 117 | } 118 | } 119 | 120 | impl DerefMut for Tui 121 | where 122 | B: Backend, 123 | { 124 | fn deref_mut(&mut self) -> &mut Self::Target { 125 | &mut self.terminal 126 | } 127 | } 128 | 129 | /// Initialize the terminal 130 | fn init() -> Result<()> { 131 | execute!(io(), EnterAlternateScreen, cursor::Hide)?; 132 | enable_raw_mode()?; 133 | Ok(()) 134 | } 135 | 136 | /// Restore the terminal to its original state 137 | pub(crate) fn restore() -> Result<()> { 138 | execute!(io(), LeaveAlternateScreen, cursor::Show)?; 139 | disable_raw_mode()?; 140 | Ok(()) 141 | } 142 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use crate::components::views::types::Action as ViewAction; 2 | use bsky_sdk::BskyAgent; 3 | use crossterm::event::{KeyEvent, MouseEvent}; 4 | use std::fmt::{Debug, Formatter, Result}; 5 | 6 | pub type IdType = u32; 7 | 8 | #[derive(Clone)] 9 | pub enum Action { 10 | Error(String), 11 | Quit, 12 | #[cfg(not(windows))] 13 | Suspend, 14 | #[cfg(not(windows))] 15 | Resume, 16 | Tick(usize), 17 | Render, 18 | NextFocus, 19 | PrevFocus, 20 | View((IdType, ViewAction)), 21 | Login((IdType, Box)), 22 | } 23 | 24 | impl Debug for Action { 25 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 26 | match self { 27 | Self::Error(arg) => f.debug_tuple("Error").field(arg).finish(), 28 | Self::Quit => write!(f, "Quit"), 29 | Self::Tick(arg) => f.debug_tuple("Tick").field(arg).finish(), 30 | Self::Render => write!(f, "Render"), 31 | Self::NextFocus => write!(f, "NextFocus"), 32 | Self::PrevFocus => write!(f, "PrevFocus"), 33 | Self::View(arg) => f.debug_tuple("View").field(arg).finish(), 34 | Self::Login((arg, _)) => f.debug_tuple("Login").field(arg).finish(), 35 | #[cfg(not(windows))] 36 | Self::Suspend => write!(f, "Suspend"), 37 | #[cfg(not(windows))] 38 | Self::Resume => write!(f, "Resume"), 39 | } 40 | } 41 | } 42 | 43 | #[derive(Debug, Clone)] 44 | pub enum Event { 45 | Tick(usize), 46 | Key(KeyEvent), 47 | Mouse(MouseEvent), 48 | Error(String), 49 | } 50 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::tui; 2 | use color_eyre::{config::HookBuilder, eyre, Result}; 3 | use directories::ProjectDirs; 4 | use std::{panic, path::PathBuf, process}; 5 | 6 | pub fn initialize_panic_handler() -> Result<()> { 7 | let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); 8 | eyre_hook.install()?; 9 | 10 | let panic_hook = panic_hook.into_panic_hook(); 11 | panic::set_hook(Box::new(move |panic_info| { 12 | tui::restore().expect("failed to restore terminal"); 13 | panic_hook(panic_info); 14 | process::exit(1); 15 | })); 16 | Ok(()) 17 | } 18 | 19 | pub fn get_data_dir() -> Result { 20 | Ok(project_dirs()?.data_dir().to_path_buf()) 21 | } 22 | 23 | pub fn get_config_dir() -> Result { 24 | Ok(project_dirs()?.config_dir().to_path_buf()) 25 | } 26 | 27 | fn project_dirs() -> Result { 28 | ProjectDirs::from("com", "sugyan", "tuisky") 29 | .ok_or_else(|| eyre::eyre!("failed to get project directories")) 30 | } 31 | --------------------------------------------------------------------------------