diff --git a/Cargo.lock b/Cargo.lock index 4cb40ca..91f0a39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,9 +212,9 @@ dependencies = [ [[package]] name = "bsky-sdk" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9af10953d2abf113e9f9d2f20bd85ed65d54a8e506606362abb2800ad7d5251" +checksum = "69c09c0009e5a225fc0bde961fc02b0077518b05c438300a9ad12185377a9289" dependencies = [ "anyhow", "async-trait", @@ -555,6 +555,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -562,6 +577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -570,6 +586,23 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + [[package]] name = "futures-macro" version = "0.3.30" @@ -581,21 +614,37 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + [[package]] name = "futures-task" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -627,6 +676,12 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "hashbrown" version = "0.14.5" @@ -844,6 +899,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -1173,6 +1237,15 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "proc-macro-crate" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +dependencies = [ + "toml_edit 0.21.1", +] + [[package]] name = "proc-macro2" version = "1.0.85" @@ -1208,24 +1281,34 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.26.3" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +checksum = "d16546c5b5962abf8ce6e2881e722b4e0ae3b6f1a08a26ae3573c55853ca68d3" dependencies = [ "bitflags", "cassowary", "compact_str", "crossterm", - "itertools", + "itertools 0.13.0", "lru", "paste", "stability", "strum", + "strum_macros", "unicode-segmentation", "unicode-truncate", "unicode-width", ] +[[package]] +name = "ratatui-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c61408e431e111a6fecee4270953cdbef8a0b363fd215a5dadd9b93ce0797d" +dependencies = [ + "ratatui", +] + [[package]] name = "redox_syscall" version = "0.5.1" @@ -1275,6 +1358,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.5" @@ -1314,12 +1403,51 @@ dependencies = [ "winreg", ] +[[package]] +name = "rstest" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.66", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.34" @@ -1399,6 +1527,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" version = "1.0.203" @@ -1730,7 +1864,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_edit 0.22.14", ] [[package]] @@ -1742,6 +1876,17 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.14" @@ -1752,7 +1897,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "winnow", + "winnow 0.6.13", ] [[package]] @@ -1831,9 +1976,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui-logger" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115f9ceabfa07b739dc00baf0180cf5060731a2de66aa7fb47e22dc442aa05ca" +checksum = "10cd1a0f217c2180e736bc9f3282fea4af182483532c6e719081b6b1c6d6be90" dependencies = [ "chrono", "fxhash", @@ -1845,13 +1990,25 @@ dependencies = [ [[package]] name = "tui-prompts" -version = "0.3.12" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b560717657e0e85ec29aad90ef66b78afaa94b0c7851d07508461971c88ee612" +dependencies = [ + "itertools 0.13.0", + "ratatui", + "ratatui-macros", + "rstest", +] + +[[package]] +name = "tui-textarea" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122bb001608c2f058fb3fc729614220a392eef4f2268c520a33b5ea8b422ce54" +checksum = "4a13589ef83273780b53a0e0be49282cb5d9cc10727b6c580e8f11366ccb460c" dependencies = [ "crossterm", - "itertools", "ratatui", + "unicode-width", ] [[package]] @@ -1877,6 +2034,7 @@ dependencies = [ "toml", "tui-logger", "tui-prompts", + "tui-textarea", ] [[package]] @@ -1918,7 +2076,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5fbabedabe362c618c714dbefda9927b5afc8e2a8102f47f081089a9019226" dependencies = [ - "itertools", + "itertools 0.12.1", "unicode-width", ] @@ -2236,6 +2394,15 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.6.13" diff --git a/Cargo.toml b/Cargo.toml index dcee811..987dc9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ keywords = ["tui", "atproto", "bluesky", "atrium"] exclude = ["/config"] [dependencies] -bsky-sdk = "0.1.5" +bsky-sdk = "0.1.6" chrono = { version = "0.4.38", default-features = false } clap = { version = "4.5.8", features = ["derive"] } color-eyre = "0.6.3" @@ -22,7 +22,7 @@ futures-util = "0.3.30" indexmap = "2.2.6" log = "0.4.22" open = "5.2.0" -ratatui = "0.26" +ratatui = "0.27" serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" textwrap = "0.16.1" @@ -33,8 +33,9 @@ tokio = { version = "1.38.0", features = [ "time", ] } toml = "0.8.14" -tui-logger = "0.11.1" -tui-prompts = "=0.3.12" +tui-logger = "0.11.2" +tui-prompts = "0.3.15" +tui-textarea = "0.5.1" [dev-dependencies] ipld-core = "0.4.0" diff --git a/config/tuisky.config.schema.json b/config/tuisky.config.schema.json index 305dfab..8508509 100644 --- a/config/tuisky.config.schema.json +++ b/config/tuisky.config.schema.json @@ -29,6 +29,7 @@ "NextFocus", "PrevFocus", "Help", + "NewPost", "Quit" ] } @@ -46,6 +47,7 @@ "NextInput", "PrevInput", "Enter", + "Escape", "Back", "Refresh" ] diff --git a/src/backend/types.rs b/src/backend/types.rs index d6c6c2e..575c01d 100644 --- a/src/backend/types.rs +++ b/src/backend/types.rs @@ -8,7 +8,7 @@ pub struct PinnedFeed { pub info: FeedSourceInfo, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum FeedSourceInfo { Feed(Box), List(Box), diff --git a/src/components/column.rs b/src/components/column.rs index 7c1e109..b3068d9 100644 --- a/src/components/column.rs +++ b/src/components/column.rs @@ -1,5 +1,6 @@ use super::views::feed::FeedViewComponent; use super::views::login::LoginComponent; +use super::views::new_post::NewPostViewComponent; use super::views::post::PostViewComponent; use super::views::root::RootComponent; use super::views::types::{Action as ViewAction, Transition, View}; @@ -81,7 +82,7 @@ impl ColumnComponent { format!(" id: {} ", self.id) } } - fn transition(&mut self, transition: &Transition) -> Result> { + pub(crate) fn transition(&mut self, transition: &Transition) -> Result> { match transition { Transition::Push(view) => { if let Some(current) = self.views.last_mut() { @@ -116,7 +117,12 @@ impl ColumnComponent { .as_ref() .ok_or_else(|| eyre::eyre!("watcher not initialized"))?; Ok(match view { + View::Login => Box::new(LoginComponent::new(self.view_tx.clone())), View::Root => Box::new(RootComponent::new(self.view_tx.clone(), watcher.clone())), + View::NewPost => Box::new(NewPostViewComponent::new( + self.view_tx.clone(), + watcher.agent.clone(), + )), View::Feed(info) => Box::new(FeedViewComponent::new( self.view_tx.clone(), watcher.clone(), @@ -129,6 +135,12 @@ impl ColumnComponent { watcher.clone(), post_view.clone(), reply.clone(), + self.session + .read() + .ok() + .as_ref() + .and_then(|s| s.as_ref()) + .cloned(), )) } }) @@ -154,6 +166,17 @@ impl Component for ColumnComponent { } fn update(&mut self, action: Action) -> Result> { match action { + Action::NewPost => { + if self.watcher.is_some() + && !self + .views + .last() + .map(|view| view.view() == View::NewPost) + .unwrap_or_default() + { + return self.transition(&Transition::Push(Box::new(View::NewPost))); + } + } Action::View((id, view_action)) if id == self.id => { if let ViewAction::Render = view_action { return Ok(Some(Action::Render)); diff --git a/src/components/main.rs b/src/components/main.rs index 23cc53d..7eacf2f 100644 --- a/src/components/main.rs +++ b/src/components/main.rs @@ -128,6 +128,11 @@ impl Component for MainComponent { ); return Ok(Some(Action::Render)); } + Action::NewPost => { + if let Some(selected) = self.state.selected { + return self.columns[selected].update(action); + } + } _ => { for column in self.columns.iter_mut() { if let Some(action) = column.update(action.clone())? { diff --git a/src/components/views.rs b/src/components/views.rs index 9fc2f9f..67e7472 100644 --- a/src/components/views.rs +++ b/src/components/views.rs @@ -1,16 +1,18 @@ pub mod feed; pub mod login; +pub mod new_post; pub mod post; pub mod root; pub mod types; mod utils; -use self::types::Action; +use self::types::{Action, View}; use color_eyre::Result; use crossterm::event::KeyEvent; use ratatui::{layout::Rect, Frame}; pub trait ViewComponent { + fn view(&self) -> View; fn activate(&mut self) -> Result<()> { Ok(()) } diff --git a/src/components/views/feed.rs b/src/components/views/feed.rs index 371412f..843c90b 100644 --- a/src/components/views/feed.rs +++ b/src/components/views/feed.rs @@ -139,6 +139,9 @@ impl FeedViewComponent { } impl ViewComponent for FeedViewComponent { + fn view(&self) -> View { + View::Feed(Box::new(self.feed_info.clone())) + } fn activate(&mut self) -> Result<()> { let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe()); let (quit_tx, mut quit_rx) = oneshot::channel(); diff --git a/src/components/views/login.rs b/src/components/views/login.rs index 022ea22..34e6656 100644 --- a/src/components/views/login.rs +++ b/src/components/views/login.rs @@ -1,4 +1,4 @@ -use super::types::Action; +use super::types::{Action, View}; use super::ViewComponent; use bsky_sdk::BskyAgent; use color_eyre::Result; @@ -74,11 +74,14 @@ impl LoginComponent { } impl ViewComponent for LoginComponent { + fn view(&self) -> View { + View::Login + } fn handle_key_events(&mut self, key: KeyEvent) -> Result> { if let Some(current) = self.current() { current.handle_key_event(key); - if let Err(err) = self.action_tx.send(Action::Render) { - log::error!("failed to send render event: {err}"); + if let Err(e) = self.action_tx.send(Action::Render) { + log::error!("failed to send render event: {e}"); } } Ok(None) diff --git a/src/components/views/new_post.rs b/src/components/views/new_post.rs new file mode 100644 index 0000000..5a44cb1 --- /dev/null +++ b/src/components/views/new_post.rs @@ -0,0 +1,258 @@ +use super::types::{Action, Transition, View}; +use super::ViewComponent; +use bsky_sdk::api::types::string::Datetime; +use bsky_sdk::api::types::string::Language; +use bsky_sdk::rich_text::RichText; +use bsky_sdk::BskyAgent; +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Padding}; +use ratatui::{layout::Rect, widgets::Paragraph, Frame}; +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; +use tui_textarea::TextArea; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Text(bool), + Langs, + Submit, +} + +impl Focus { + fn next(&self) -> Self { + match self { + Self::Text(_) => Self::Langs, + Self::Langs => Self::Submit, + Self::Submit => Self::Text(false), + } + } + fn prev(&self) -> Self { + match self { + Self::Text(_) => Self::Submit, + Self::Langs => Self::Text(false), + Self::Submit => Self::Langs, + } + } +} + +pub struct NewPostViewComponent { + action_tx: UnboundedSender, + agent: Arc, + textarea: TextArea<'static>, + langs: TextArea<'static>, + focus: Focus, + text_len: usize, +} + +impl NewPostViewComponent { + pub fn new(action_tx: UnboundedSender, agent: Arc) -> Self { + let mut textarea = TextArea::default(); + textarea.set_block(Block::bordered().title("Text")); + textarea.set_cursor_line_style(Style::default()); + let mut langs = TextArea::default(); + langs.set_block(Block::bordered().title("Langs").dim()); + langs.set_cursor_line_style(Style::default()); + langs.set_cursor_style(Style::default()); + Self { + action_tx, + agent, + textarea, + langs, + focus: Focus::Text(true), + text_len: 0, + } + } + fn current_textarea(&mut self) -> Option<&mut TextArea<'static>> { + match self.focus { + Focus::Text(b) => Some(&mut self.textarea).filter(|_| b), + Focus::Langs => Some(&mut self.langs), + Focus::Submit => None, + } + } + fn update_focus(&mut self, focus: Focus) { + let was_text = self.focus == Focus::Text(true); + if let Some(curr) = self.current_textarea() { + curr.set_cursor_style(Style::default()); + if let Some(block) = curr.block() { + if !was_text { + curr.set_block(block.clone().dim()); + } + } + } else if self.focus == Focus::Text(false) && focus != Focus::Text(true) { + if let Some(block) = self.textarea.block() { + self.textarea.set_block(block.clone().dim()); + } + } + self.focus = focus; + if let Some(curr) = self.current_textarea() { + curr.set_cursor_style(Style::default().reversed()); + if let Some(block) = curr.block() { + curr.set_block(block.clone().reset()); + } + } else if self.focus == Focus::Text(false) { + if let Some(block) = self.textarea.block() { + self.textarea.set_block(block.clone().reset()); + } + } + } +} + +impl ViewComponent for NewPostViewComponent { + fn view(&self) -> View { + View::NewPost + } + fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + let focus = self.focus; + if let Some(textarea) = self.current_textarea() { + if focus == Focus::Text(true) { + textarea.input(key); + self.text_len = + RichText::new(self.textarea.lines().join("\n"), None).grapheme_len(); + if let Some(block) = self.textarea.block() { + let mut block = block.clone(); + block = match self.text_len { + 0 => block.border_style(Color::Reset), + 1..=300 => block.border_style(Color::Green), + _ => block.border_style(Color::Red), + }; + self.textarea.set_block(block); + } + return Ok(if key.code == KeyCode::Esc { + None + } else { + Some(Action::Render) + }); + } else if matches!( + (key.code, key.modifiers), + (KeyCode::Enter, _) | (KeyCode::Char('m'), KeyModifiers::CONTROL) + ) { + return Ok(Some(Action::Enter)); + } else if textarea.input(key) { + if self.focus == Focus::Langs { + if let Some(block) = self.langs.block() { + let mut block = block.clone(); + if self + .langs + .lines() + .join("") + .split(',') + .map(str::trim) + .all(|s| s.parse::().is_ok()) + { + block = block.border_style(Color::Green); + } else { + block = block.border_style(Color::Red); + } + self.langs.set_block(block); + } + } + return Ok(Some(Action::Render)); + } + } + Ok(None) + } + fn update(&mut self, action: Action) -> Result> { + match action { + Action::NextItem => { + self.update_focus(self.focus.next()); + Ok(Some(Action::Render)) + } + Action::PrevItem => { + self.update_focus(self.focus.prev()); + Ok(Some(Action::Render)) + } + Action::Enter if self.focus == Focus::Text(false) => { + self.update_focus(Focus::Text(true)); + Ok(Some(Action::Render)) + } + Action::Enter if self.focus == Focus::Submit => { + let tx = self.action_tx.clone(); + let agent = self.agent.clone(); + let text = self.textarea.lines().join("\n"); + let langs = Some( + self.langs + .lines() + .join("") + .split(',') + .map(str::trim) + .filter_map(|s| s.parse::().ok()) + .collect::>(), + ) + .filter(|v| !v.is_empty()); + tokio::spawn(async move { + match agent + .create_record(bsky_sdk::api::app::bsky::feed::post::RecordData { + created_at: Datetime::now(), + embed: None, + entities: None, + facets: None, + labels: None, + langs, + reply: None, + tags: None, + text, + }) + .await + { + Ok(output) => { + log::info!("Post created: {output:?}"); + tx.send(Action::Transition(Transition::Pop)).ok(); + } + Err(e) => { + log::error!("failed to create post: {e}"); + } + } + }); + Ok(Some(Action::Render)) + } + Action::Escape if self.focus == Focus::Text(true) => { + self.update_focus(Focus::Text(false)); + Ok(Some(Action::Render)) + } + Action::Back => { + // TODO: confirm to discard the draft + Ok(Some(Action::Transition(Transition::Pop))) + } + Action::Transition(_) => Ok(Some(action)), + _ => Ok(None), + } + } + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { + let layout = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(8), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + let mut submit = Line::from("Post").centered().blue(); + if self.focus == Focus::Submit { + submit = submit.reversed(); + } + f.render_widget( + Paragraph::new("New post").bold().block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Color::Gray) + .padding(Padding::horizontal(1)), + ), + layout[0], + ); + f.render_widget( + Line::from(format!("{} ", 300 - self.text_len as isize)) + .right_aligned() + .gray(), + layout[1], + ); + f.render_widget(self.textarea.widget(), layout[2]); + f.render_widget(self.langs.widget(), layout[3]); + f.render_widget(submit, layout[4]); + Ok(()) + } +} diff --git a/src/components/views/post.rs b/src/components/views/post.rs index 64643b5..6fa6f0c 100644 --- a/src/components/views/post.rs +++ b/src/components/views/post.rs @@ -1,8 +1,8 @@ -use super::types::{Transition, View}; +use super::types::{Action, Data, Transition, View}; use super::utils::{profile_name, profile_name_as_str}; -use super::{types::Action, ViewComponent}; +use super::ViewComponent; use crate::backend::{Watch, Watcher}; -use crate::components::views::types::Data; +use bsky_sdk::api::agent::Session; use bsky_sdk::api::app::bsky::actor::defs::ProfileViewBasic; use bsky_sdk::api::app::bsky::embed::record::{self, ViewRecordRefs}; use bsky_sdk::api::app::bsky::embed::record_with_media::ViewMediaRefs; @@ -14,7 +14,7 @@ use bsky_sdk::api::app::bsky::feed::get_post_thread::OutputThreadRefs; use bsky_sdk::api::app::bsky::richtext::facet::MainFeaturesItem; use bsky_sdk::api::records::{KnownRecord, Record}; use bsky_sdk::api::types::string::Datetime; -use bsky_sdk::api::types::{Collection, Union}; +use bsky_sdk::api::types::Union; use bsky_sdk::{api, BskyAgent}; use chrono::Local; use color_eyre::Result; @@ -37,6 +37,7 @@ enum PostAction { Repost, Like, Unlike(String), + Delete, Open(String), ViewRecord(record::ViewRecord), } @@ -54,6 +55,7 @@ impl<'a> From<&'a PostAction> for ListItem<'a> { PostAction::Repost => Self::from("Repost").dim(), PostAction::Like => Self::from("Like"), PostAction::Unlike(_) => Self::from("Unlike"), + PostAction::Delete => Self::from("Delete").red(), PostAction::Open(uri) => Self::from(format!("Open {uri}")), PostAction::ViewRecord(view_record) => Self::from(Line::from(vec![ Span::from("Show "), @@ -75,6 +77,7 @@ pub struct PostViewComponent { agent: Arc, watcher: Box>>, quit: Option>, + session: Option, } impl PostViewComponent { @@ -83,8 +86,9 @@ impl PostViewComponent { watcher: Arc, post_view: PostView, reply: Option, + session: Option, ) -> Self { - let actions = Self::post_view_actions(&post_view); + let actions = Self::post_view_actions(&post_view, &session); let agent = watcher.agent.clone(); let watcher = Box::new(watcher.post_thread(post_view.uri.clone())); Self { @@ -97,9 +101,10 @@ impl PostViewComponent { agent, watcher, quit: None, + session, } } - fn post_view_actions(post_view: &PostView) -> Vec { + fn post_view_actions(post_view: &PostView, session: &Option) -> Vec { let mut liked = None; if let Some(viewer) = &post_view.viewer { liked = viewer.like.as_ref(); @@ -114,6 +119,9 @@ impl PostViewComponent { PostAction::Like }, ]; + if Some(&post_view.author.did) == session.as_ref().map(|s| &s.data.did) { + actions.push(PostAction::Delete); + } let mut links = IndexSet::new(); if let Record::Known(KnownRecord::AppBskyFeedPost(record)) = &post_view.record { if let Some(facets) = &record.facets { @@ -441,6 +449,9 @@ impl PostViewComponent { } impl ViewComponent for PostViewComponent { + fn view(&self) -> View { + View::Post(Box::new((self.post_view.clone(), self.reply.clone()))) + } fn activate(&mut self) -> Result<()> { let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe()); let (quit_tx, mut quit_rx) = oneshot::channel(); @@ -509,39 +520,16 @@ impl ViewComponent for PostViewComponent { } .into(), ); - let record = Record::Known(KnownRecord::AppBskyFeedLike(Box::new( - api::app::bsky::feed::like::RecordData { - created_at: Datetime::now(), - subject: api::com::atproto::repo::strong_ref::MainData { - cid: self.post_view.cid.clone(), - uri: self.post_view.uri.clone(), - } - .into(), + let record_data = api::app::bsky::feed::like::RecordData { + created_at: Datetime::now(), + subject: api::com::atproto::repo::strong_ref::MainData { + cid: self.post_view.cid.clone(), + uri: self.post_view.uri.clone(), } .into(), - ))); + }; tokio::spawn(async move { - let Some(session) = agent.get_session().await else { - return; - }; - match agent - .api - .com - .atproto - .repo - .create_record( - api::com::atproto::repo::create_record::InputData { - collection: api::app::bsky::feed::Like::nsid(), - record, - repo: session.data.did.into(), - rkey: None, - swap_commit: None, - validate: None, - } - .into(), - ) - .await - { + match agent.create_record(record_data).await { Ok(output) => { log::info!("created like record: {}", output.cid.as_ref()); viewer.like = Some(output.uri.clone()); @@ -559,31 +547,9 @@ impl ViewComponent for PostViewComponent { PostAction::Unlike(uri) => { let (agent, tx) = (self.agent.clone(), self.action_tx.clone()); let mut viewer = self.post_view.viewer.clone(); - let Some(rkey) = uri.split('/').last().map(String::from) else { - log::error!("failed to get rkey from uri: {uri}"); - return Ok(None); - }; + let at_uri = uri.clone(); tokio::spawn(async move { - let Some(session) = agent.get_session().await else { - return; - }; - match agent - .api - .com - .atproto - .repo - .delete_record( - api::com::atproto::repo::delete_record::InputData { - collection: api::app::bsky::feed::Like::nsid(), - repo: session.data.did.into(), - rkey, - swap_commit: None, - swap_record: None, - } - .into(), - ) - .await - { + match agent.delete_record(at_uri).await { Ok(_) => { log::info!("deleted like record"); if let Some(viewer) = viewer.as_mut() { @@ -600,6 +566,22 @@ impl ViewComponent for PostViewComponent { } }); } + PostAction::Delete => { + // TODO: confirmation dialog + let (agent, tx) = (self.agent.clone(), self.action_tx.clone()); + let at_uri = self.post_view.uri.clone(); + tokio::spawn(async move { + match agent.delete_record(at_uri).await { + Ok(_) => { + log::info!("deleted record"); + tx.send(Action::Transition(Transition::Pop)).ok(); + } + Err(e) => { + log::error!("failed to delete record: {e}"); + } + } + }); + } PostAction::Open(uri) => { if let Err(e) = open::that(uri) { log::error!("failed to open: {e}"); @@ -670,16 +652,19 @@ impl ViewComponent for PostViewComponent { } _ => return Ok(None), } - self.actions = Self::post_view_actions(&self.post_view); + self.actions = Self::post_view_actions(&self.post_view, &self.session); return Ok(Some(Action::Render)); } + Action::Transition(_) => { + return Ok(Some(action)); + } _ => {} } Ok(None) } fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { let widths = [Constraint::Length(11), Constraint::Percentage(100)]; - let width = Layout::horizontal(widths).split(area.inner(&Margin::new(1, 0)))[1].width; + let width = Layout::horizontal(widths).split(area.inner(Margin::new(1, 0)))[1].width; let mut rows = Vec::new(); if let Some(reply) = &self.reply { diff --git a/src/components/views/root.rs b/src/components/views/root.rs index f4f6b76..22e85dc 100644 --- a/src/components/views/root.rs +++ b/src/components/views/root.rs @@ -34,6 +34,9 @@ impl RootComponent { } impl ViewComponent for RootComponent { + fn view(&self) -> View { + View::Root + } fn activate(&mut self) -> Result<()> { let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe()); let (quit_tx, mut quit_rx) = oneshot::channel(); diff --git a/src/components/views/types.rs b/src/components/views/types.rs index 75ac667..293a9bf 100644 --- a/src/components/views/types.rs +++ b/src/components/views/types.rs @@ -13,6 +13,7 @@ pub enum Action { NextInput, PrevInput, Enter, + Escape, Back, Refresh, Login(Box), @@ -30,6 +31,7 @@ impl Debug for Action { Action::NextInput => write!(f, "NextInput"), Action::PrevInput => write!(f, "PrevInput"), Action::Enter => write!(f, "Enter"), + Action::Escape => write!(f, "Escape"), Action::Back => write!(f, "Back"), Action::Refresh => write!(f, "Refresh"), Action::Login(_) => write!(f, "Login"), @@ -55,9 +57,11 @@ pub enum Transition { Replace(Box), } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum View { + Login, Root, + NewPost, Feed(Box), Post(Box<(PostView, Option)>), } diff --git a/src/config.rs b/src/config.rs index d6019d9..6c0f9d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,6 +53,11 @@ impl Config { .column .entry(Key(KeyCode::Enter, KeyModifiers::NONE)) .or_insert(ColumnAction::Enter); + // column: Esc to Escape + self.keybindings + .column + .entry(Key(KeyCode::Esc, KeyModifiers::NONE)) + .or_insert(ColumnAction::Escape); // column: Backspace to Back self.keybindings .column @@ -152,6 +157,7 @@ impl<'de> Deserialize<'de> for Key { pub enum GlobalAction { NextFocus, PrevFocus, + NewPost, Help, Quit, } @@ -161,6 +167,7 @@ impl From<&GlobalAction> for AppAction { match action { GlobalAction::NextFocus => Self::NextFocus, GlobalAction::PrevFocus => Self::PrevFocus, + GlobalAction::NewPost => Self::NewPost, GlobalAction::Help => Self::Help, GlobalAction::Quit => Self::Quit, } @@ -174,6 +181,7 @@ pub enum ColumnAction { NextInput, PrevInput, Enter, + Escape, Back, Refresh, } @@ -186,6 +194,7 @@ impl From<&ColumnAction> for ViewAction { ColumnAction::NextInput => Self::NextInput, ColumnAction::PrevInput => Self::PrevInput, ColumnAction::Enter => Self::Enter, + ColumnAction::Escape => Self::Escape, ColumnAction::Back => Self::Back, ColumnAction::Refresh => Self::Refresh, } diff --git a/src/types.rs b/src/types.rs index fc98c81..40a14e4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -14,6 +14,7 @@ pub enum Action { Render, NextFocus, PrevFocus, + NewPost, View((IdType, ViewAction)), Login((IdType, Box)), } @@ -26,9 +27,10 @@ impl Debug for Action { Self::Quit => write!(f, "Quit"), Self::Tick(arg) => f.debug_tuple("Tick").field(arg).finish(), Self::Render => write!(f, "Render"), - Self::View(arg) => f.debug_tuple("View").field(arg).finish(), Self::NextFocus => write!(f, "NextFocus"), Self::PrevFocus => write!(f, "PrevFocus"), + Self::NewPost => write!(f, "NewPost"), + Self::View(arg) => f.debug_tuple("View").field(arg).finish(), Self::Login((arg, _)) => f.debug_tuple("Login").field(arg).finish(), } }