diff --git a/src/app.rs b/src/app.rs index ca5adcc..fc2ea72 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,9 @@ use std::thread; use tokio::sync::Semaphore; use crate::atproto::client::{ClientError, UnreadCounts}; -use crate::atproto::{Conversation, HangarClient, Notification, Post, Profile, SavedFeed, Session}; +use crate::atproto::{ + ChatMessage, Conversation, HangarClient, Notification, Post, Profile, SavedFeed, Session, +}; use crate::cache::{CacheDb, FeedCache, FeedState, PostCache, ProfileCache}; use crate::config; use crate::runtime; @@ -23,12 +25,15 @@ use crate::ui::avatar_cache; use crate::ui::post_row::PostRow; use crate::ui::{ ComposeDialog, FollowListKind, FollowListPage, FollowListPush, HangarWindow, LoginDialog, - NavItem, QuoteContext, ReplyContext, + MessagePage, MessagePush, NavItem, QuoteContext, ReplyContext, }; /// Limit concurrent API requests to prevent overwhelming the server during rapid scrolling static API_SEMAPHORE: Lazy> = Lazy::new(|| Arc::new(Semaphore::new(4))); +/// Seconds between polls for new messages while a conversation is open. +const MESSAGE_POLL_SECS: u32 = 5; + /// Which turn of events owns some shared state. /// /// Every reset bumps it. A fetch carries the token it started under and @@ -103,6 +108,12 @@ mod imp { /// Likes state pub likes_cursor: RefCell>, pub likes_loading_more: RefCell, + /// Saved posts state. Saves happen from any list at any time, so the + /// section refetches on every open; the generation keeps a page-two + /// append from an earlier visit off the fresh list. + pub bookmarks_cursor: RefCell>, + pub bookmarks_loading_more: RefCell, + pub(crate) bookmarks_generation: Generation, /// Search state pub search_query: RefCell>, pub search_cursor: RefCell>, @@ -222,6 +233,12 @@ mod imp { app_clone.confirm_delete_post(post); }); + // Save/unsave, dispatched the same way. + let app_clone = app.clone(); + crate::ui::post_row::set_bookmark_post_handler(move |post, row_weak| { + app_clone.toggle_bookmark(post, row_weak); + }); + let app_clone = app.clone(); window.set_compose_callback(move || { app_clone.open_compose_dialog(); @@ -292,6 +309,11 @@ mod imp { app_clone.fetch_likes_more(); }); + let app_clone = app.clone(); + window.set_bookmarks_load_more_callback(move || { + app_clone.fetch_bookmarks_more(); + }); + let app_clone = app.clone(); window.set_search_load_more_callback(move || { app_clone.fetch_search_more(); @@ -2248,6 +2270,9 @@ impl HangarApplication { NavItem::Likes => { self.open_likes_view(); } + NavItem::Bookmarks => { + self.open_bookmarks_view(); + } NavItem::Search => { self.open_search_view(); } @@ -2592,14 +2617,348 @@ impl HangarApplication { }); } - /// Open a conversation view (placeholder for now) + /// Open a conversation in the message view, pushed onto the chat stack. fn open_conversation_view(&self, conversation: Conversation) { - // TODO: Implement conversation detail view with messages - eprintln!( - "Opening conversation: {} with {} members", - conversation.id, - conversation.members.len() - ); + let pushed = { + let window = self.imp().window.borrow(); + let Some(window) = window.as_ref() else { + return; + }; + window.push_message_page(&conversation) + }; + let Some(pushed) = pushed else { + return; + }; + + let page = match pushed { + MessagePush::Pushed(page) => { + let app = self.clone(); + let page_weak = page.downgrade(); + page.set_load_older_callback(move || { + let Some(page) = page_weak.upgrade() else { + return; + }; + app.fetch_older_messages(&page); + }); + + // Retry redoes the first load; only a page with nothing + // listed ever shows the button. + let app = self.clone(); + let page_weak = page.downgrade(); + page.set_retry_callback(move || { + let Some(page) = page_weak.upgrade() else { + return; + }; + app.fetch_first_messages(&page); + }); + + let app = self.clone(); + let page_weak = page.downgrade(); + page.set_send_callback(move |text| { + let Some(page) = page_weak.upgrade() else { + return; + }; + app.send_conversation_message(&page, text); + }); + + self.start_message_poll(&page); + page + } + MessagePush::PoppedBack(page) => { + // A healthy page keeps what it has, and its poll is still + // running. One whose first load failed gets another try. + if !page.needs_reload() { + return; + } + page + } + }; + + self.fetch_first_messages(&page); + } + + /// Fetch the newest page of a conversation's history. + /// + /// The page is its own generation token: results apply through a weak + /// ref, so a popped page drops whatever was still in flight for it. + fn fetch_first_messages(&self, page: &MessagePage) { + if page.is_fetching() { + return; + } + page.set_fetching(true); + let convo_id = page.convo_id(); + + let (tx, rx) = + std::sync::mpsc::channel::, Option), String>>(); + let client = self.client(); + let semaphore = API_SEMAPHORE.clone(); + + thread::spawn(move || { + let result = runtime::block_on(async { + let _permit = semaphore.acquire().await; + client.get_messages(&convo_id, None).await + }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + let page_weak = page.downgrade(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(Ok((messages, cursor))) => { + if let Some(page) = page_weak.upgrade() { + page.set_cursor(cursor); + page.set_initial_messages(messages); + page.set_fetching(false); + page.backfill_if_short(); + // The conversation is on screen now, so the server + // can honestly call it read. + app.mark_conversation_read(page.convo_id()); + } + glib::ControlFlow::Break + } + Ok(Err(e)) => { + eprintln!("Failed to fetch messages: {}", e); + if let Some(page) = page_weak.upgrade() { + page.set_fetching(false); + page.show_load_failed(); + } + app.report_session_expiry(); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + eprintln!("Failed to fetch messages: connection lost"); + if let Some(page) = page_weak.upgrade() { + page.set_fetching(false); + page.show_load_failed(); + } + glib::ControlFlow::Break + } + } + }); + } + + /// Fetch the page of history older than what is shown. + fn fetch_older_messages(&self, page: &MessagePage) { + if page.is_fetching() { + return; + } + let Some(cursor) = page.cursor() else { + return; + }; + page.set_fetching(true); + let convo_id = page.convo_id(); + + let (tx, rx) = + std::sync::mpsc::channel::, Option), String>>(); + let client = self.client(); + let semaphore = API_SEMAPHORE.clone(); + + thread::spawn(move || { + let result = runtime::block_on(async { + let _permit = semaphore.acquire().await; + client.get_messages(&convo_id, Some(&cursor)).await + }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + let page_weak = page.downgrade(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(Ok((messages, cursor))) => { + if let Some(page) = page_weak.upgrade() { + page.set_cursor(cursor); + page.prepend_older(messages); + page.set_fetching(false); + // Filtered pages can leave the viewport unfilled + // with a cursor still stored. + page.backfill_if_short(); + } + glib::ControlFlow::Break + } + Ok(Err(e)) => { + eprintln!("Failed to fetch older messages: {}", e); + if let Some(page) = page_weak.upgrade() { + page.set_fetching(false); + } + // Rows are on screen, so the status page is out; say it + // out loud instead. + if let Some(window) = app.imp().window.borrow().as_ref() { + window.show_toast("Couldn't load older messages"); + } + app.report_session_expiry(); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + if let Some(page) = page_weak.upgrade() { + page.set_fetching(false); + } + glib::ControlFlow::Break + } + } + }); + } + + /// Send the composer's draft. On failure the draft stays in the entry + /// and the toast says to try again. + fn send_conversation_message(&self, page: &MessagePage, text: String) { + if page.is_sending() { + return; + } + page.set_sending(true); + let convo_id = page.convo_id(); + + let (tx, rx) = std::sync::mpsc::channel::>(); + let client = self.client(); + + thread::spawn(move || { + let result = + runtime::block_on(async { client.send_chat_message(&convo_id, &text).await }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + let page_weak = page.downgrade(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(Ok(message)) => { + if let Some(page) = page_weak.upgrade() { + page.sent_ok(message); + } + glib::ControlFlow::Break + } + Ok(Err(e)) => { + eprintln!("Failed to send message: {}", e); + if let Some(page) = page_weak.upgrade() { + page.send_failed(); + } + if let Some(window) = app.imp().window.borrow().as_ref() { + window.show_toast("The message didn't send. Try again."); + } + app.report_session_expiry(); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + if let Some(page) = page_weak.upgrade() { + page.send_failed(); + } + if let Some(window) = app.imp().window.borrow().as_ref() { + window.show_toast("The message didn't send. Try again."); + } + glib::ControlFlow::Break + } + } + }); + } + + /// Poll for new messages while the conversation page is up. + /// + /// The timer holds a weak ref and dies with the page. Ticks skip while + /// the page is covered or off screen, while any fetch of its own is + /// already out, and for the rest of a dead session. + fn start_message_poll(&self, page: &MessagePage) { + let app = self.clone(); + let page_weak = page.downgrade(); + glib::timeout_add_seconds_local(MESSAGE_POLL_SECS, move || { + let Some(page) = page_weak.upgrade() else { + return glib::ControlFlow::Break; + }; + let busy = page.is_fetching() || page.is_polling() || !page.loaded_once(); + if message_poll_allowed(page.is_mapped(), busy, *app.imp().session_dead.borrow()) { + app.poll_conversation(&page); + } + glib::ControlFlow::Continue + }); + } + + /// One poll round: fetch the newest page and fold in whatever is new. + fn poll_conversation(&self, page: &MessagePage) { + page.set_polling(true); + let convo_id = page.convo_id(); + + let (tx, rx) = + std::sync::mpsc::channel::, Option), String>>(); + let client = self.client(); + + thread::spawn(move || { + let result = runtime::block_on(async { client.get_messages(&convo_id, None).await }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + let page_weak = page.downgrade(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(Ok((messages, _cursor))) => { + if let Some(page) = page_weak.upgrade() { + page.set_polling(false); + let added = page.merge_new(messages); + // Only a page still on screen can honestly claim + // the user saw what just arrived. + if added > 0 && page.is_mapped() { + app.mark_conversation_read(page.convo_id()); + } + } + glib::ControlFlow::Break + } + Ok(Err(e)) => { + // Transient failures self-heal on the next tick; an + // expiry parks the poll through the flag it raises. + eprintln!("Failed to poll messages: {}", e); + if let Some(page) = page_weak.upgrade() { + page.set_polling(false); + } + app.report_session_expiry(); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + if let Some(page) = page_weak.upgrade() { + page.set_polling(false); + } + glib::ControlFlow::Break + } + } + }); + } + + /// Tell the server the conversation is read, then let the UI follow: + /// the row in the chat list drops its badge and the sidebar re-tallies. + /// On failure both stay lit, which is the truth; the next successful + /// open clears them. + fn mark_conversation_read(&self, convo_id: String) { + let (tx, rx) = std::sync::mpsc::channel::>(); + let client = self.client(); + let id = convo_id.clone(); + + thread::spawn(move || { + let result = runtime::block_on(async { client.mark_convo_read(&id).await }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(Ok(())) => { + if let Some(window) = app.imp().window.borrow().as_ref() { + window.set_conversation_read(&convo_id); + } + app.check_unread_counts(); + glib::ControlFlow::Break + } + Ok(Err(e)) => { + eprintln!("Failed to mark conversation read: {}", e); + app.report_session_expiry(); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => glib::ControlFlow::Break, + } + }); } /// Open the own profile view @@ -2834,6 +3193,195 @@ impl HangarApplication { }); } + /// Open the Saved view. Posts get saved and unsaved from every list, and + /// from other clients, so this refetches on every open rather than + /// trusting a list loaded earlier. + fn open_bookmarks_view(&self) { + if let Some(window) = self.imp().window.borrow().as_ref() { + window.show_bookmarks_page(); + } + self.fetch_bookmarks(); + } + + /// Fetch the first page of saved posts, replacing the list + fn fetch_bookmarks(&self) { + // Take ownership of the list: anything still in flight from an + // earlier visit belongs to the page this replaces. + self.imp().bookmarks_generation.bump(); + let token = self.imp().bookmarks_generation.token(); + self.imp().bookmarks_loading_more.replace(false); + + let (tx, rx) = std::sync::mpsc::channel::, Option), String>>(); + let client = self.client(); + + thread::spawn(move || { + let result = runtime::block_on(async { client.get_bookmarks(None).await }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(result) => { + if !app.imp().bookmarks_generation.is_current(token) { + return glib::ControlFlow::Break; + } + match result { + Ok((posts, next_cursor)) => { + app.imp().bookmarks_cursor.replace(next_cursor); + if let Some(window) = app.imp().window.borrow().as_ref() { + window.set_bookmarks(posts); + } + } + Err(e) => { + eprintln!("Failed to fetch saved posts: {}", e); + app.report_session_expiry(); + if let Some(window) = app.imp().window.borrow().as_ref() { + window.show_toast("Couldn't load saved posts"); + } + } + } + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + eprintln!("Failed to fetch saved posts: connection lost"); + if let Some(window) = app.imp().window.borrow().as_ref() { + window.show_toast("Couldn't load saved posts"); + } + glib::ControlFlow::Break + } + } + }); + } + + /// Fetch more saved posts for infinite scroll + fn fetch_bookmarks_more(&self) { + if *self.imp().bookmarks_loading_more.borrow() { + return; + } + let cursor = match self.imp().bookmarks_cursor.borrow().as_ref() { + Some(c) => c.clone(), + None => return, + }; + self.imp().bookmarks_loading_more.replace(true); + let token = self.imp().bookmarks_generation.token(); + + if let Some(window) = self.imp().window.borrow().as_ref() { + window.set_bookmarks_loading(true); + } + + let (tx, rx) = std::sync::mpsc::channel::, Option), String>>(); + let client = self.client(); + + thread::spawn(move || { + let result = runtime::block_on(async { client.get_bookmarks(Some(&cursor)).await }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(result) => { + app.imp().bookmarks_loading_more.replace(false); + if let Some(window) = app.imp().window.borrow().as_ref() { + window.set_bookmarks_loading(false); + } + if !app.imp().bookmarks_generation.is_current(token) { + return glib::ControlFlow::Break; + } + match result { + Ok((posts, next_cursor)) => { + app.imp().bookmarks_cursor.replace(next_cursor); + if !posts.is_empty() { + if let Some(window) = app.imp().window.borrow().as_ref() { + window.append_bookmarks(posts); + } + } + } + Err(e) => { + eprintln!("Failed to fetch more saved posts: {}", e); + app.report_session_expiry(); + if let Some(window) = app.imp().window.borrow().as_ref() { + window.show_toast("Couldn't load more saved posts"); + } + } + } + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + app.imp().bookmarks_loading_more.replace(false); + if let Some(window) = app.imp().window.borrow().as_ref() { + window.set_bookmarks_loading(false); + } + glib::ControlFlow::Break + } + } + }); + } + + /// Save or unsave a post, whichever the menu offered + fn toggle_bookmark(&self, post: Post, row_weak: glib::WeakRef) { + let was_saved = post.viewer_bookmarked == Some(true); + let (tx, rx) = std::sync::mpsc::channel::>(); + let client = self.client(); + let uri = post.uri.clone(); + let cid = post.cid.clone(); + + thread::spawn(move || { + let result = runtime::block_on(async { + if was_saved { + client.delete_bookmark(&uri).await + } else { + client.create_bookmark(&uri, &cid).await + } + }); + let _ = tx.send(result.map_err(|e| e.to_string())); + }); + + let app = self.clone(); + let uri = post.uri; + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + match rx.try_recv() { + Ok(Ok(())) => { + // Flip the row, but only if it is still showing this post; + // a pooled row may have been rebound while we waited. + if let Some(row) = row_weak.upgrade() { + if row.post_uri().as_deref() == Some(uri.as_str()) { + row.set_viewer_bookmarked(!was_saved); + } + } + if let Some(window) = app.imp().window.borrow().as_ref() { + if was_saved { + // Unsaving from the Saved page takes the row out + // now; from any other list this is a no-op. + window.remove_bookmark(&uri); + window.show_toast("Removed from Saved"); + } else { + window.show_toast("Post saved"); + } + } + glib::ControlFlow::Break + } + Ok(Err(e)) => { + eprintln!("Failed to update bookmark: {}", e); + app.report_session_expiry(); + if let Some(window) = app.imp().window.borrow().as_ref() { + window.show_toast(if was_saved { + "Couldn't remove from Saved" + } else { + "Couldn't save post" + }); + } + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => glib::ControlFlow::Break, + } + }); + } + /// Open the search view fn open_search_view(&self) { // Switch to search page @@ -3111,9 +3659,18 @@ fn unread_poll_allowed(signed_in: bool, in_flight: bool, expired: bool) -> bool signed_in && !in_flight && !expired } +/// Whether a message-poll tick may fetch: the conversation page is on +/// screen, none of its fetches are already out and its first load landed, +/// and the session has not expired. The timer itself only dies with the +/// page; this is what quiets it in between. +fn message_poll_allowed(mapped: bool, busy: bool, expired: bool) -> bool { + mapped && !busy && !expired +} + #[cfg(test)] mod tests { use super::Generation; + use super::message_poll_allowed; use super::unread_poll_allowed; /// The badge poll must stay quiet when signed out, must not stack @@ -3139,6 +3696,31 @@ mod tests { assert!(!unread_poll_allowed(false, false, true)); } + /// The message poll must stay quiet while its page is off screen, + /// while any of the page's fetches are already out, and for the rest + /// of a dead session. Only the page being dropped stops the timer; + /// this gate is what holds each tick. + #[test] + fn the_message_poll_waits_for_its_page_and_for_itself() { + assert!(message_poll_allowed(true, false, false)); + assert!( + !message_poll_allowed(false, false, false), + "a covered or hidden page is not being read" + ); + assert!( + !message_poll_allowed(true, true, false), + "one fetch per page at a time" + ); + assert!( + !message_poll_allowed(true, false, true), + "an expired session parks the poll" + ); + assert!(!message_poll_allowed(false, true, false)); + assert!(!message_poll_allowed(false, false, true)); + assert!(!message_poll_allowed(true, true, true)); + assert!(!message_poll_allowed(false, true, true)); + } + /// A late page from the old query must not splice into the new list. #[test] fn a_new_search_strands_fetches_from_the_old_one() { diff --git a/src/atproto/client.rs b/src/atproto/client.rs index 645020a..5cfbb46 100644 --- a/src/atproto/client.rs +++ b/src/atproto/client.rs @@ -467,13 +467,19 @@ impl HangarClient { // Extract reply context (who this is replying to) let reply_context = self.extract_reply_context(&feed_view.data.reply); - // Extract viewer state (like/repost URIs) - let (viewer_like, viewer_repost) = post_view + // Extract viewer state (like/repost URIs, bookmark flag) + let (viewer_like, viewer_repost, viewer_bookmarked) = post_view .data .viewer .as_ref() - .map(|v| (v.data.like.clone(), v.data.repost.clone())) - .unwrap_or((None, None)); + .map(|v| { + ( + v.data.like.clone(), + v.data.repost.clone(), + v.data.bookmarked, + ) + }) + .unwrap_or((None, None, None)); Post { uri: post_view.data.uri, @@ -493,6 +499,7 @@ impl HangarClient { embed, viewer_like, viewer_repost, + viewer_bookmarked, repost_reason, reply_context, } @@ -1884,12 +1891,18 @@ impl HangarClient { let (text, created_at) = self.extract_post_record(&post_view.data.record); let embed = self.extract_embed(&post_view.data.embed); - let (viewer_like, viewer_repost) = post_view + let (viewer_like, viewer_repost, viewer_bookmarked) = post_view .data .viewer .as_ref() - .map(|v| (v.data.like.clone(), v.data.repost.clone())) - .unwrap_or((None, None)); + .map(|v| { + ( + v.data.like.clone(), + v.data.repost.clone(), + v.data.bookmarked, + ) + }) + .unwrap_or((None, None, None)); Post { uri: post_view.data.uri.clone(), @@ -1909,6 +1922,7 @@ impl HangarClient { embed, viewer_like, viewer_repost, + viewer_bookmarked, repost_reason: None, reply_context: None, } @@ -2108,6 +2122,7 @@ impl HangarClient { embed: None, viewer_like: None, viewer_repost: None, + viewer_bookmarked: None, repost_reason: None, reply_context: None, }) @@ -2244,9 +2259,7 @@ impl HangarClient { /// Mark a conversation read up to its latest message. /// - /// For the conversation view once it exists; the chat badge follows the - /// server's word on the next poll. - #[allow(dead_code)] + /// The chat badge follows the server's word on the next poll. #[allow(clippy::await_holding_lock)] pub async fn mark_convo_read(&self, convo_id: &str) -> Result<(), ClientError> { use atrium_api::agent::bluesky::{AtprotoServiceType, BSKY_CHAT_DID}; @@ -2315,12 +2328,7 @@ impl HangarClient { .into_iter() .filter_map(|msg| match msg { Union::Refs(OutputMessagesItem::ChatBskyConvoDefsMessageView(view)) => { - Some(ChatMessage { - id: view.data.id.clone(), - text: view.data.text.clone(), - sender_did: view.data.sender.data.did.to_string(), - sent_at: view.data.sent_at.as_str().to_string(), - }) + Some(Self::chat_message_from_view(&view)) } // Skip deleted messages Union::Refs(OutputMessagesItem::ChatBskyConvoDefsDeletedMessageView(_)) => None, @@ -2355,12 +2363,9 @@ impl HangarClient { .collect(); let last_message = convo.data.last_message.as_ref().and_then(|msg| match msg { - Union::Refs(ConvoViewLastMessageRefs::MessageView(view)) => Some(ChatMessage { - id: view.data.id.clone(), - text: view.data.text.clone(), - sender_did: view.data.sender.data.did.to_string(), - sent_at: view.data.sent_at.as_str().to_string(), - }), + Union::Refs(ConvoViewLastMessageRefs::MessageView(view)) => { + Some(Self::chat_message_from_view(view)) + } _ => None, }); @@ -2373,6 +2378,60 @@ impl HangarClient { } } + /// Convert an atrium MessageView to our ChatMessage type + fn chat_message_from_view( + view: &atrium_api::chat::bsky::convo::defs::MessageView, + ) -> ChatMessage { + ChatMessage { + id: view.data.id.clone(), + text: view.data.text.clone(), + sender_did: view.data.sender.data.did.to_string(), + sent_at: view.data.sent_at.as_str().to_string(), + } + } + + /// Send a plain text message in a conversation. + /// + /// The server answers with the message it stored, so the open view can + /// append the real thing rather than a local copy. + #[allow(clippy::await_holding_lock)] + pub async fn send_chat_message( + &self, + convo_id: &str, + text: &str, + ) -> Result { + use atrium_api::agent::bluesky::{AtprotoServiceType, BSKY_CHAT_DID}; + + with_agent!(self, agent => { + + let chat_did = BSKY_CHAT_DID + .parse() + .map_err(|e| ClientError::Network(format!("invalid chat DID: {e}")))?; + let chat_api = agent.api_with_proxy(chat_did, AtprotoServiceType::BskyChat); + + let message = atrium_api::chat::bsky::convo::defs::MessageInputData { + embed: None, + facets: None, + text: text.to_string(), + }; + + let input = atrium_api::chat::bsky::convo::send_message::InputData { + convo_id: convo_id.to_string(), + message: message.into(), + }; + + let output = chat_api + .chat + .bsky + .convo + .send_message(input.into()) + .await + .map_err(|e| self.xrpc_error(e))?; + + Ok(Self::chat_message_from_view(&output)) + }) + } + /// Search posts by query string #[allow(clippy::await_holding_lock)] pub async fn search_posts( @@ -2598,6 +2657,106 @@ impl HangarClient { Ok((profiles, output.data.cursor)) }) } + + /// Save a post to the signed-in user's bookmarks. Bookmarks are private + /// server-side state, not repo records, so there is nothing to delete by + /// URI later; the post URI itself is the key. + #[allow(clippy::await_holding_lock)] + pub async fn create_bookmark(&self, uri: &str, cid: &str) -> Result<(), ClientError> { + with_agent!(self, agent => { + + let input = atrium_api::app::bsky::bookmark::create_bookmark::InputData { + cid: cid + .parse() + .map_err(|e| ClientError::InvalidResponse(format!("invalid cid: {e}")))?, + uri: uri.to_string(), + }; + + agent + .api + .app + .bsky + .bookmark + .create_bookmark(input.into()) + .await + .map_err(|e| self.xrpc_error(e))?; + + Ok(()) + }) + } + + /// Take a post out of the signed-in user's bookmarks + #[allow(clippy::await_holding_lock)] + pub async fn delete_bookmark(&self, uri: &str) -> Result<(), ClientError> { + with_agent!(self, agent => { + + let input = atrium_api::app::bsky::bookmark::delete_bookmark::InputData { + uri: uri.to_string(), + }; + + agent + .api + .app + .bsky + .bookmark + .delete_bookmark(input.into()) + .await + .map_err(|e| self.xrpc_error(e))?; + + Ok(()) + }) + } + + /// Fetch one page of the signed-in user's saved posts + #[allow(clippy::await_holding_lock)] + pub async fn get_bookmarks( + &self, + cursor: Option<&str>, + ) -> Result<(Vec, Option), ClientError> { + with_agent!(self, agent => { + + let params = atrium_api::app::bsky::bookmark::get_bookmarks::ParametersData { + cursor: cursor.map(String::from), + limit: None, + }; + + let output = agent + .api + .app + .bsky + .bookmark + .get_bookmarks(params.into()) + .await + .map_err(|e| self.xrpc_error(e))?; + + let posts: Vec = output + .data + .bookmarks + .iter() + .filter_map(|bookmark| self.convert_bookmark_view(bookmark)) + .collect(); + + Ok((posts, output.data.cursor)) + }) + } + + /// The post behind a bookmark, if there still is one. A bookmark can + /// outlive its post or point at an author who has since blocked the + /// viewer; those items carry no post to show and are skipped. + fn convert_bookmark_view( + &self, + bookmark: &atrium_api::app::bsky::bookmark::defs::BookmarkView, + ) -> Option { + use atrium_api::app::bsky::bookmark::defs::BookmarkViewItemRefs; + use atrium_api::types::Union; + + match &bookmark.data.item { + Union::Refs(BookmarkViewItemRefs::AppBskyFeedDefsPostView(view)) => { + Some(self.convert_post_view(view)) + } + _ => None, + } + } } impl Default for HangarClient { @@ -2784,6 +2943,101 @@ mod tests { }]) } + fn bookmark_view( + item: serde_json::Value, + ) -> atrium_api::app::bsky::bookmark::defs::BookmarkView { + serde_json::from_value(serde_json::json!({ + "subject": { + "uri": "at://did:plc:test/app.bsky.feed.post/saved", + "cid": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" + }, + "item": item + })) + .expect("a bookmark view deserializes") + } + + /// A saved post comes through whole; a bookmark whose post is gone or + /// whose author blocks the viewer has nothing to show and is skipped. + #[test] + fn a_bookmark_yields_its_post_and_a_dead_one_yields_nothing() { + let client = HangarClient::new(); + + let live = bookmark_view(serde_json::json!({ + "$type": "app.bsky.feed.defs#postView", + "uri": "at://did:plc:test/app.bsky.feed.post/saved", + "cid": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", + "author": { "did": "did:plc:test", "handle": "someone.bsky.social" }, + "record": { + "$type": "app.bsky.feed.post", + "text": "worth keeping", + "createdAt": "2026-01-01T00:00:00Z" + }, + "indexedAt": "2026-01-01T00:00:00Z", + "viewer": { "bookmarked": true } + })); + let post = client + .convert_bookmark_view(&live) + .expect("a live post comes through"); + assert_eq!(post.uri, "at://did:plc:test/app.bsky.feed.post/saved"); + assert_eq!(post.text, "worth keeping"); + assert_eq!( + post.viewer_bookmarked, + Some(true), + "the menu decides Save vs Remove from this flag" + ); + + let gone = bookmark_view(serde_json::json!({ + "$type": "app.bsky.feed.defs#notFoundPost", + "uri": "at://did:plc:test/app.bsky.feed.post/gone", + "notFound": true + })); + assert!(client.convert_bookmark_view(&gone).is_none()); + + let blocked = bookmark_view(serde_json::json!({ + "$type": "app.bsky.feed.defs#blockedPost", + "uri": "at://did:plc:test/app.bsky.feed.post/blocked", + "blocked": true, + "author": { "did": "did:plc:test" } + })); + assert!(client.convert_bookmark_view(&blocked).is_none()); + } + + /// Cached posts were serialized before `viewer_bookmarked` existed; a row + /// missing the key must still come back rather than voiding the cache. + #[test] + fn a_post_serialized_before_bookmarks_still_deserializes() { + let old = serde_json::json!({ + "uri": "at://did:plc:test/app.bsky.feed.post/old", + "cid": "cid", + "author": { + "did": "did:plc:test", + "handle": "someone.bsky.social", + "display_name": null, + "avatar": null, + "banner": null, + "description": null, + "followers_count": null, + "following_count": null, + "posts_count": null, + "viewer_following": null, + "viewer_followed_by": null + }, + "text": "from the old cache", + "created_at": "2026-01-01T00:00:00Z", + "indexed_at": "2026-01-01T00:00:00Z", + "like_count": null, + "repost_count": null, + "reply_count": null, + "embed": null, + "viewer_like": null, + "viewer_repost": null, + "repost_reason": null, + "reply_context": null + }); + let post: Post = serde_json::from_value(old).expect("an old cached post still loads"); + assert_eq!(post.viewer_bookmarked, None); + } + /// A GIF attached to a quote is an `external` view, which was the one /// variant of this union with no arm: it fell into `_ => None` and the post /// rendered with a blank body. @@ -2962,4 +3216,32 @@ mod tests { assert_eq!(counts.mentions, 0); assert_eq!(counts.activity, 0); } + + /// The wire-to-app mapping for one chat message: id, text, sender, + /// and timestamp come through untouched. + #[test] + fn chat_messages_map_straight_off_the_wire() { + use atrium_api::chat::bsky::convo::defs::{MessageViewData, MessageViewSenderData}; + + let view: atrium_api::chat::bsky::convo::defs::MessageView = MessageViewData { + embed: None, + facets: None, + id: "3kmsgid".into(), + reactions: None, + rev: "22".into(), + sender: MessageViewSenderData { + did: "did:plc:sender".parse().unwrap(), + } + .into(), + sent_at: "2026-01-01T00:00:00Z".parse().unwrap(), + text: "hi there & ".into(), + } + .into(); + + let message = HangarClient::chat_message_from_view(&view); + assert_eq!(message.id, "3kmsgid"); + assert_eq!(message.text, "hi there & "); + assert_eq!(message.sender_did, "did:plc:sender"); + assert_eq!(message.sent_at, "2026-01-01T00:00:00Z"); + } } diff --git a/src/atproto/mod.rs b/src/atproto/mod.rs index 84ae094..fb73ed5 100644 --- a/src/atproto/mod.rs +++ b/src/atproto/mod.rs @@ -8,7 +8,7 @@ mod types; pub use client::{HangarClient, ReplyRef}; pub use gif::GifEmbed; pub use types::{ - ComposeData, Conversation, Embed, ExternalEmbed, ImageAttachment, ImageEmbed, LinkCardData, - Notification, Post, PostgateConfig, Profile, QuoteEmbed, ReplyContext, RepostReason, SavedFeed, - Session, ThreadgateConfig, ThreadgateRule, VideoEmbed, + ChatMessage, ComposeData, Conversation, Embed, ExternalEmbed, ImageAttachment, ImageEmbed, + LinkCardData, Notification, Post, PostgateConfig, Profile, QuoteEmbed, ReplyContext, + RepostReason, SavedFeed, Session, ThreadgateConfig, ThreadgateRule, VideoEmbed, }; diff --git a/src/atproto/types.rs b/src/atproto/types.rs index d2977e6..c1ac3b8 100644 --- a/src/atproto/types.rs +++ b/src/atproto/types.rs @@ -174,6 +174,10 @@ pub struct Post { pub viewer_like: Option, /// URI of the viewer's repost record, if they reposted this post pub viewer_repost: Option, + /// Whether the viewer saved this post. Defaulted so cached posts written + /// before the field existed still deserialize. + #[serde(default)] + pub viewer_bookmarked: Option, /// Repost attribution if this appeared in feed via repost pub repost_reason: Option, /// Reply context if this post is a reply diff --git a/src/cache/feeds.rs b/src/cache/feeds.rs index bdb793b..15ffe77 100644 --- a/src/cache/feeds.rs +++ b/src/cache/feeds.rs @@ -244,6 +244,8 @@ impl<'a> FeedCache<'a> { embed, viewer_like: row.get(12)?, viewer_repost: row.get(13)?, + // Not a cached column; the menu falls back to offering Save. + viewer_bookmarked: None, repost_reason, reply_context, }) diff --git a/src/cache/posts.rs b/src/cache/posts.rs index d5008cd..095e007 100644 --- a/src/cache/posts.rs +++ b/src/cache/posts.rs @@ -296,6 +296,8 @@ impl<'a> PostCache<'a> { embed, viewer_like: row.get(12)?, viewer_repost: row.get(13)?, + // Not a cached column; the menu falls back to offering Save. + viewer_bookmarked: None, repost_reason, reply_context, }) diff --git a/src/ui/inline_video.rs b/src/ui/inline_video.rs index 4553fd1..ed29302 100644 --- a/src/ui/inline_video.rs +++ b/src/ui/inline_video.rs @@ -1062,6 +1062,7 @@ mod tests { })), viewer_like: None, viewer_repost: None, + viewer_bookmarked: None, repost_reason: None, reply_context: None, } diff --git a/src/ui/message_page.rs b/src/ui/message_page.rs new file mode 100644 index 0000000..9683b4d --- /dev/null +++ b/src/ui/message_page.rs @@ -0,0 +1,1208 @@ +// SPDX-License-Identifier: MPL-2.0 +#![allow(clippy::type_complexity)] + +//! One open conversation: history above, composer below. +//! +//! Pushed onto the chat section's stack. The page owns its cursor, its +//! in-flight flags, and the set of message ids it has shown, so a popped +//! page takes its state with it, same as the follow list pages. Fetch +//! results reach the page through weak refs; a page that has been popped +//! simply never sees them. +//! +//! History arrives newest first from the server and reads oldest first on +//! screen, so pages are reversed on the way in. Older pages splice in at +//! the top and the scroll position is nudged to keep the same messages in +//! view. + +use crate::atproto::{ChatMessage, Conversation}; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use std::collections::HashSet; +use unicode_segmentation::UnicodeSegmentation; + +/// Message limits from the chat lexicon. +const MAX_MESSAGE_GRAPHEMES: usize = 1000; +const MAX_MESSAGE_BYTES: usize = 10000; + +/// How many fetches one user action may chain when pages come back short. +/// +/// `get_messages` drops deleted messages, so a page can be short or empty +/// with a cursor still stored. The cap keeps a pathological server from +/// looping us forever. +const BACKFILL_CAP: u8 = 5; + +/// Within this many pixels of the top counts as asking for older history. +const TOP_THRESHOLD: f64 = 200.0; + +/// Within this many pixels of the bottom still counts as reading along, +/// so new messages keep the view pinned there. +const BOTTOM_SLACK: f64 = 40.0; + +/// Whether the composer's draft may go on the wire. +fn message_sendable(text: &str) -> bool { + !text.trim().is_empty() + && text.len() <= MAX_MESSAGE_BYTES + && text.graphemes(true).count() <= MAX_MESSAGE_GRAPHEMES +} + +/// Short label text and full tooltip text for a message timestamp. +fn format_message_time(sent_at: &str) -> (String, String) { + use chrono::{DateTime, Local}; + + let Ok(time) = DateTime::parse_from_rfc3339(sent_at) else { + return (String::new(), String::new()); + }; + let local = time.with_timezone(&Local); + let short = if local.date_naive() == Local::now().date_naive() { + local.format("%H:%M").to_string() + } else { + local.format("%b %-d, %H:%M").to_string() + }; + (short, local.format("%B %-d, %Y at %H:%M").to_string()) +} + +/// What `HangarWindow::push_message_page` did with the request. +pub enum MessagePush { + /// A new page went onto the stack and needs wiring and a first fetch. + Pushed(MessagePage), + /// An already-open copy was popped back to. Its callbacks and poll are + /// live; it only needs a fetch if its first load never completed. + PoppedBack(MessagePage), +} + +mod message_object { + use super::*; + + mod imp { + use super::*; + use std::cell::RefCell; + + #[derive(Default)] + pub struct MessageObject { + pub message: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageObject { + const NAME: &'static str = "HangarMessageObject"; + type Type = super::MessageObject; + type ParentType = glib::Object; + } + + impl ObjectImpl for MessageObject {} + } + + glib::wrapper! { + pub struct MessageObject(ObjectSubclass); + } + + impl MessageObject { + pub fn new(message: ChatMessage) -> Self { + let obj: Self = glib::Object::builder().build(); + obj.imp().message.replace(Some(message)); + obj + } + + pub fn message(&self) -> Option { + self.imp().message.borrow().clone() + } + } +} + +use message_object::MessageObject; + +mod message_row { + use super::*; + + mod imp { + use super::*; + use std::cell::RefCell; + + #[derive(Default)] + pub struct MessageRow { + pub bubble: RefCell>, + pub text_label: RefCell>, + pub time_label: RefCell>, + pub mention_callback: RefCell>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageRow { + const NAME: &'static str = "HangarMessageRow"; + type Type = super::MessageRow; + type ParentType = gtk4::Box; + } + + impl ObjectImpl for MessageRow { + fn constructed(&self) { + self.parent_constructed(); + self.obj().setup_ui(); + } + } + + impl WidgetImpl for MessageRow {} + impl BoxImpl for MessageRow {} + } + + glib::wrapper! { + pub struct MessageRow(ObjectSubclass) + @extends gtk4::Box, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable; + } + + impl MessageRow { + pub fn new() -> Self { + glib::Object::builder() + .property("orientation", gtk4::Orientation::Horizontal) + .property("spacing", 0) + .build() + } + + fn setup_ui(&self) { + self.set_hexpand(true); + self.set_margin_start(12); + self.set_margin_end(12); + self.set_margin_top(3); + self.set_margin_bottom(3); + + let bubble = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + bubble.add_css_class("msg-bubble"); + + // Wire text. Escaped and linkified by `set_linkified`; wrapped + // WordChar so an unbroken run cannot widen the page. + let text_label = gtk4::Label::new(None); + text_label.set_wrap(true); + text_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); + text_label.set_natural_wrap_mode(gtk4::NaturalWrapMode::Word); + text_label.set_max_width_chars(55); + text_label.set_xalign(0.0); + text_label.set_halign(gtk4::Align::Start); + text_label.set_use_markup(true); + + // Links route like post text: mentions in-app, hashtags styled + // only, anything else to the browser. + let row_weak = self.downgrade(); + text_label.connect_activate_link(move |label, uri| { + if let Some(handle) = uri.strip_prefix("bsky-mention://") { + if let Some(row) = row_weak.upgrade() + && let Some(cb) = row.imp().mention_callback.borrow().as_ref() + { + cb(handle.to_string()); + } + glib::Propagation::Stop + } else if uri.starts_with("bsky-tag://") { + glib::Propagation::Stop + } else { + crate::ui::external::open_url(label, uri, "link"); + glib::Propagation::Stop + } + }); + bubble.append(&text_label); + + let time_label = gtk4::Label::new(None); + time_label.add_css_class("dim-label"); + time_label.add_css_class("caption"); + time_label.set_halign(gtk4::Align::End); + bubble.append(&time_label); + + self.append(&bubble); + + let imp = self.imp(); + imp.bubble.replace(Some(bubble)); + imp.text_label.replace(Some(text_label)); + imp.time_label.replace(Some(time_label)); + } + + /// Fill the row for one message. Recycled rows pass through here + /// again, so every setter overwrites what the last message left. + pub fn bind(&self, message: &ChatMessage, mine: bool) { + let imp = self.imp(); + + if let Some(bubble) = imp.bubble.borrow().as_ref() { + if mine { + bubble.add_css_class("msg-out"); + bubble.remove_css_class("msg-in"); + bubble.set_halign(gtk4::Align::End); + } else { + bubble.add_css_class("msg-in"); + bubble.remove_css_class("msg-out"); + bubble.set_halign(gtk4::Align::Start); + } + let (_, full) = format_message_time(&message.sent_at); + bubble.set_tooltip_text(if full.is_empty() { None } else { Some(&full) }); + } + + if let Some(label) = imp.text_label.borrow().as_ref() { + crate::ui::rich_text::set_linkified(label, &message.text); + } + + if let Some(label) = imp.time_label.borrow().as_ref() { + let (short, _) = format_message_time(&message.sent_at); + label.set_text(&short); + } + } + + /// Replace the handler run when a mention in the text is activated. + pub fn set_mention_callback(&self, callback: F) { + self.imp() + .mention_callback + .replace(Some(Box::new(callback))); + } + } + + impl Default for MessageRow { + fn default() -> Self { + Self::new() + } + } +} + +use message_row::MessageRow; + +mod imp { + use super::*; + use std::cell::{Cell, RefCell}; + + #[derive(Default)] + pub struct MessagePage { + pub model: RefCell>, + /// Ids already shown, so poll pages and echoes of our own sends + /// merge instead of duplicating rows. + pub known_ids: RefCell>, + pub convo_id: RefCell, + pub my_did: RefCell>, + pub title: RefCell, + pub overlay: RefCell>, + pub spinner: RefCell>, + pub empty_state: RefCell>, + pub error_state: RefCell>, + pub vadjustment: RefCell>, + pub entry: RefCell>, + pub send_button: RefCell>, + /// Cursor into older history; None once the top is reached. + pub cursor: RefCell>, + /// A history fetch is out, first page or older. + pub fetching: Cell, + /// A background poll fetch is out. + pub polling: Cell, + pub sending: Cell, + pub loaded_once: Cell, + /// The view is at the bottom and should stay there as rows land. + pub pinned: Cell, + /// Upper bound recorded just before a prepend, so the scroll can + /// keep the same messages in view once the rows land. + pub anchor_upper: Cell>, + /// Chained fetches left before the next user action re-arms it. + pub backfill_budget: Cell, + pub load_older_callback: RefCell>>, + pub retry_callback: RefCell>>, + pub send_callback: RefCell>>, + pub mention_clicked_callback: RefCell>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessagePage { + const NAME: &'static str = "HangarMessagePage"; + type Type = super::MessagePage; + type ParentType = gtk4::Box; + } + + impl ObjectImpl for MessagePage {} + impl WidgetImpl for MessagePage {} + impl BoxImpl for MessagePage {} +} + +glib::wrapper! { + pub struct MessagePage(ObjectSubclass) + @extends gtk4::Box, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable; +} + +impl MessagePage { + pub fn new(conversation: &Conversation, my_did: Option<&str>) -> Self { + let page: Self = glib::Object::builder() + .property("orientation", gtk4::Orientation::Vertical) + .property("spacing", 0) + .build(); + + let imp = page.imp(); + imp.convo_id.replace(conversation.id.clone()); + imp.my_did.replace(my_did.map(String::from)); + + // The person across the table, ourselves filtered out. Group chats + // fall back to whoever is listed first. + let other = conversation + .members + .iter() + .find(|m| my_did.is_none_or(|did| m.did != did)) + .or_else(|| conversation.members.first()); + let title = other + .map(|m| m.display_name.clone().unwrap_or_else(|| m.handle.clone())) + .unwrap_or_else(|| "Conversation".to_string()); + imp.title.replace(title); + + // The handle is wire text and the status page takes markup. + let empty_description = match other { + Some(m) => format!("Say hello to @{}.", glib::markup_escape_text(&m.handle)), + None => "Say hello.".to_string(), + }; + + page.setup_ui(&empty_description); + // Opening the page is the first user action. + imp.backfill_budget.set(BACKFILL_CAP); + page + } + + fn setup_ui(&self, empty_description: &str) { + self.set_vexpand(true); + + let overlay = gtk4::Overlay::new(); + overlay.set_vexpand(true); + + let model = gio::ListStore::new::(); + let factory = gtk4::SignalListItemFactory::new(); + + factory.connect_setup(|_, item| { + let row = MessageRow::new(); + if let Some(list_item) = item.downcast_ref::() { + list_item.set_child(Some(&row)); + } + }); + + let page_weak = self.downgrade(); + factory.connect_bind(move |_, item| { + let Some(page) = page_weak.upgrade() else { + return; + }; + if let Some(list_item) = item.downcast_ref::() + && let Some(object) = list_item.item().and_downcast::() + && let Some(message) = object.message() + && let Some(row) = list_item.child().and_downcast::() + { + let mine = + page.imp().my_did.borrow().as_deref() == Some(message.sender_did.as_str()); + row.bind(&message, mine); + let page_weak = page.downgrade(); + row.set_mention_callback(move |handle| { + let Some(page) = page_weak.upgrade() else { + return; + }; + if let Some(cb) = page.imp().mention_clicked_callback.borrow().as_ref() { + cb(handle); + } + }); + } + }); + + let selection = gtk4::NoSelection::new(Some(model.clone())); + let list_view = gtk4::ListView::new(Some(selection), Some(factory)); + list_view.add_css_class("background"); + + // The virtualized chain every feed uses: scroller over clamp + // scrollable over list view, nothing between. See `build_timeline`. + let clamp = adw::ClampScrollable::new(); + clamp.set_maximum_size(800); + clamp.set_tightening_threshold(600); + clamp.set_child(Some(&list_view)); + + let scrolled = gtk4::ScrolledWindow::new(); + scrolled.set_vexpand(true); + scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + scrolled.set_child(Some(&clamp)); + overlay.set_child(Some(&scrolled)); + + // Older history loads at the top, so the spinner sits there. + let spinner = gtk4::Spinner::new(); + spinner.set_visible(false); + spinner.set_halign(gtk4::Align::Center); + spinner.set_valign(gtk4::Align::Start); + spinner.set_margin_top(16); + overlay.add_overlay(&spinner); + + // Shown once a first load comes back with nothing to show. + let empty_state = adw::StatusPage::new(); + empty_state.set_icon_name(Some("chat-message-new-symbolic")); + empty_state.set_title("No messages yet"); + empty_state.set_description(Some(empty_description)); + empty_state.set_vexpand(true); + empty_state.set_visible(false); + + // Shown when a fetch fails with nothing listed yet. + let error_state = adw::StatusPage::new(); + error_state.set_icon_name(Some("dialog-warning-symbolic")); + error_state.set_title("Couldn't Load This Conversation"); + error_state.set_description(Some("Check your connection and try again.")); + error_state.set_vexpand(true); + error_state.set_visible(false); + + let retry_button = gtk4::Button::with_label("Try Again"); + retry_button.add_css_class("pill"); + retry_button.add_css_class("suggested-action"); + retry_button.set_halign(gtk4::Align::Center); + let page_weak = self.downgrade(); + retry_button.connect_clicked(move |_| { + let Some(page) = page_weak.upgrade() else { + return; + }; + // A retry is a user action. + page.imp().backfill_budget.set(BACKFILL_CAP); + if let Some(cb) = page.imp().retry_callback.borrow().as_ref() { + cb(); + } + }); + error_state.set_child(Some(&retry_button)); + + // The composer. Enter and the button both send. + let composer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + composer.set_margin_start(12); + composer.set_margin_end(12); + composer.set_margin_top(8); + composer.set_margin_bottom(8); + + let entry = gtk4::Entry::new(); + entry.set_hexpand(true); + entry.set_placeholder_text(Some("Write a message")); + let page_weak = self.downgrade(); + entry.connect_activate(move |_| { + if let Some(page) = page_weak.upgrade() { + page.try_send(); + } + }); + let page_weak = self.downgrade(); + entry.connect_changed(move |_| { + if let Some(page) = page_weak.upgrade() { + page.update_send_state(); + } + }); + composer.append(&entry); + + let send_button = gtk4::Button::with_label("Send"); + send_button.add_css_class("suggested-action"); + send_button.set_sensitive(false); + let page_weak = self.downgrade(); + send_button.connect_clicked(move |_| { + if let Some(page) = page_weak.upgrade() { + page.try_send(); + } + }); + composer.append(&send_button); + + // Same width as the list above it. + let composer_clamp = adw::Clamp::new(); + composer_clamp.set_maximum_size(800); + composer_clamp.set_tightening_threshold(600); + composer_clamp.set_child(Some(&composer)); + + self.append(&overlay); + self.append(&empty_state); + self.append(&error_state); + self.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + self.append(&composer_clamp); + + // Wired once here. Nearing the top asks for older history; the + // pinned flag tracks whether the reader is at the bottom. + let adj = scrolled.vadjustment(); + let page_weak = self.downgrade(); + adj.connect_value_changed(move |adj| { + let Some(page) = page_weak.upgrade() else { + return; + }; + let imp = page.imp(); + imp.pinned + .set(adj.value() >= adj.upper() - adj.page_size() - BOTTOM_SLACK); + if adj.value() < TOP_THRESHOLD + && imp.loaded_once.get() + && !imp.fetching.get() + && imp.cursor.borrow().is_some() + { + // A real scroll re-arms the chained-fetch budget. + imp.backfill_budget.set(BACKFILL_CAP); + if let Some(cb) = imp.load_older_callback.borrow().as_ref() { + cb(); + } + } + }); + + // Runs when the content size changes. A prepend keeps the same + // messages in view; anything else keeps the bottom pinned while + // the reader is there. + let page_weak = self.downgrade(); + adj.connect_changed(move |adj| { + let Some(page) = page_weak.upgrade() else { + return; + }; + let imp = page.imp(); + if let Some(old_upper) = imp.anchor_upper.take() { + let delta = adj.upper() - old_upper; + if delta > 0.0 { + adj.set_value(adj.value() + delta); + } + return; + } + if imp.pinned.get() { + adj.set_value(adj.upper() - adj.page_size()); + } + }); + + let imp = self.imp(); + imp.pinned.set(true); + imp.model.replace(Some(model)); + imp.overlay.replace(Some(overlay)); + imp.spinner.replace(Some(spinner)); + imp.empty_state.replace(Some(empty_state)); + imp.error_state.replace(Some(error_state)); + imp.vadjustment.replace(Some(adj)); + imp.entry.replace(Some(entry)); + imp.send_button.replace(Some(send_button)); + } + + pub fn convo_id(&self) -> String { + self.imp().convo_id.borrow().clone() + } + + /// The other member's name, for the page header. + pub fn conversation_title(&self) -> String { + self.imp().title.borrow().clone() + } + + /// Replace the whole list with the newest page of history, as the + /// server sent it: newest first. + pub fn set_initial_messages(&self, newest_first: Vec) { + let imp = self.imp(); + imp.known_ids.borrow_mut().clear(); + let mut messages = newest_first; + messages.reverse(); + if let Some(model) = imp.model.borrow().as_ref() { + model.remove_all(); + for message in messages { + imp.known_ids.borrow_mut().insert(message.id.clone()); + model.append(&MessageObject::new(message)); + } + } + imp.loaded_once.set(true); + imp.pinned.set(true); + self.refresh_visibility(); + } + + /// Splice a page of older history in at the top, newest first as the + /// server sent it. Overlap with what is shown is dropped by id. + pub fn prepend_older(&self, newest_first: Vec) { + let imp = self.imp(); + let fresh: Vec = { + let known = imp.known_ids.borrow(); + newest_first + .into_iter() + .filter(|m| !known.contains(&m.id)) + .collect() + }; + if fresh.is_empty() { + return; + } + // Keep the reader's place: record the extent now, and the changed + // handler shifts the scroll by however much the prepend grew it. + if let Some(adj) = imp.vadjustment.borrow().as_ref() { + imp.anchor_upper.set(Some(adj.upper())); + } + let mut fresh = fresh; + fresh.reverse(); + let objects: Vec = fresh + .into_iter() + .map(|m| { + imp.known_ids.borrow_mut().insert(m.id.clone()); + MessageObject::new(m) + }) + .collect(); + if let Some(model) = imp.model.borrow().as_ref() { + model.splice(0, 0, &objects); + } + self.refresh_visibility(); + } + + /// Fold a freshly polled page into the list and say how many messages + /// were actually new. Zero new messages means nothing to mark read. + pub fn merge_new(&self, newest_first: Vec) -> u32 { + let imp = self.imp(); + let fresh: Vec = { + let known = imp.known_ids.borrow(); + newest_first + .into_iter() + .filter(|m| !known.contains(&m.id)) + .collect() + }; + if fresh.is_empty() { + return 0; + } + let mut fresh = fresh; + fresh.reverse(); + let added = fresh.len() as u32; + if let Some(model) = imp.model.borrow().as_ref() { + for message in fresh { + imp.known_ids.borrow_mut().insert(message.id.clone()); + model.append(&MessageObject::new(message)); + } + } + self.refresh_visibility(); + added + } + + /// The send went through: clear the draft and show the message the + /// server stored. + pub fn sent_ok(&self, message: ChatMessage) { + let imp = self.imp(); + imp.sending.set(false); + if let Some(entry) = imp.entry.borrow().as_ref() { + entry.set_text(""); + } + imp.pinned.set(true); + self.append_new(message); + self.update_send_state(); + } + + /// The send failed. The draft stays where it is; the caller says why. + pub fn send_failed(&self) { + self.set_sending(false); + } + + /// Append one message unless a poll already brought it in. + fn append_new(&self, message: ChatMessage) { + let imp = self.imp(); + if !imp.known_ids.borrow_mut().insert(message.id.clone()) { + return; + } + if let Some(model) = imp.model.borrow().as_ref() { + model.append(&MessageObject::new(message)); + } + self.refresh_visibility(); + } + + /// Decide between the list and the empty state. Only a spent cursor + /// proves a conversation is empty; a page can be all deleted messages + /// with more history behind it. + fn refresh_visibility(&self) { + let imp = self.imp(); + let empty = imp.model.borrow().as_ref().is_none_or(|m| m.n_items() == 0) + && imp.cursor.borrow().is_none(); + if let Some(overlay) = imp.overlay.borrow().as_ref() { + overlay.set_visible(!empty); + } + if let Some(empty_state) = imp.empty_state.borrow().as_ref() { + empty_state.set_visible(empty && imp.loaded_once.get()); + } + if let Some(error_state) = imp.error_state.borrow().as_ref() { + error_state.set_visible(false); + } + } + + /// Mark a history fetch in flight and show or hide the spinner with + /// it. Starting one also stands the error state down. + pub fn set_fetching(&self, fetching: bool) { + let imp = self.imp(); + imp.fetching.set(fetching); + if fetching + && let Some(error_state) = imp.error_state.borrow().as_ref() + && error_state.is_visible() + { + error_state.set_visible(false); + if let Some(overlay) = imp.overlay.borrow().as_ref() { + overlay.set_visible(true); + } + } + if let Some(spinner) = imp.spinner.borrow().as_ref() { + spinner.set_visible(fetching); + spinner.set_spinning(fetching); + } + } + + pub fn is_fetching(&self) -> bool { + self.imp().fetching.get() + } + + pub fn set_polling(&self, polling: bool) { + self.imp().polling.set(polling); + } + + pub fn is_polling(&self) -> bool { + self.imp().polling.get() + } + + pub fn set_sending(&self, sending: bool) { + self.imp().sending.set(sending); + self.update_send_state(); + } + + pub fn is_sending(&self) -> bool { + self.imp().sending.get() + } + + pub fn loaded_once(&self) -> bool { + self.imp().loaded_once.get() + } + + pub fn set_cursor(&self, cursor: Option) { + self.imp().cursor.replace(cursor); + } + + /// The cursor for the next page of older history, if any. + pub fn cursor(&self) -> Option { + self.imp().cursor.borrow().clone() + } + + /// Swap in the error state after a failed fetch, unless rows are + /// shown. With rows up the list stays; the caller toasts instead. + pub fn show_load_failed(&self) { + let imp = self.imp(); + let listed = imp.model.borrow().as_ref().is_some_and(|m| m.n_items() > 0); + if listed { + return; + } + if let Some(overlay) = imp.overlay.borrow().as_ref() { + overlay.set_visible(false); + } + if let Some(empty_state) = imp.empty_state.borrow().as_ref() { + empty_state.set_visible(false); + } + if let Some(error_state) = imp.error_state.borrow().as_ref() { + error_state.set_visible(true); + } + } + + /// True when no fetch ever completed and none is under way, as after + /// a failed first load. Reopening such a page fetches again. + pub fn needs_reload(&self) -> bool { + !self.imp().loaded_once.get() && !self.imp().fetching.get() + } + + /// Chain another history fetch when there is a cursor and room to + /// spare. `get_messages` filters deleted messages, so a short first + /// page can leave the viewport unfilled with history still behind it. + pub fn backfill_if_short(&self) { + let imp = self.imp(); + if imp.fetching.get() || imp.cursor.borrow().is_none() { + return; + } + if self.content_overflows() { + return; + } + if imp.backfill_budget.get() == 0 { + if imp.model.borrow().as_ref().is_none_or(|m| m.n_items() == 0) { + if let Some(overlay) = imp.overlay.borrow().as_ref() { + overlay.set_visible(false); + } + if let Some(empty_state) = imp.empty_state.borrow().as_ref() { + empty_state.set_visible(true); + } + } + return; + } + imp.backfill_budget.set(imp.backfill_budget.get() - 1); + if let Some(cb) = imp.load_older_callback.borrow().as_ref() { + cb(); + } + } + + /// Whether the listed rows already extend past the viewport. + fn content_overflows(&self) -> bool { + self.imp() + .vadjustment + .borrow() + .as_ref() + .is_some_and(|adj| adj.upper() > adj.page_size() + 1.0) + } + + /// Hand the composer's draft to the send callback, if it may go. + fn try_send(&self) { + let imp = self.imp(); + if imp.sending.get() { + return; + } + let Some(entry) = imp.entry.borrow().clone() else { + return; + }; + let text = entry.text().to_string(); + if !message_sendable(&text) { + return; + } + if let Some(cb) = imp.send_callback.borrow().as_ref() { + cb(text); + } + } + + /// The send button follows the draft: something to say, within the + /// limits, and no send already out. + fn update_send_state(&self) { + let imp = self.imp(); + let Some(entry) = imp.entry.borrow().clone() else { + return; + }; + let text = entry.text(); + let sendable = message_sendable(&text); + if !sendable && !text.trim().is_empty() { + entry.add_css_class("error"); + } else { + entry.remove_css_class("error"); + } + if let Some(button) = imp.send_button.borrow().as_ref() { + button.set_sensitive(sendable && !imp.sending.get()); + } + } + + /// Replace the handler run when the list nears its top. + pub fn set_load_older_callback(&self, callback: F) { + self.imp() + .load_older_callback + .replace(Some(Box::new(callback))); + } + + /// Replace the handler run by the error state's retry button. + pub fn set_retry_callback(&self, callback: F) { + self.imp().retry_callback.replace(Some(Box::new(callback))); + } + + /// Replace the handler given the composer's draft to send. + pub fn set_send_callback(&self, callback: F) { + self.imp().send_callback.replace(Some(Box::new(callback))); + } + + /// Replace the handler run when a mention in a message is activated. + pub fn set_mention_clicked_callback(&self, callback: F) { + self.imp() + .mention_clicked_callback + .replace(Some(Box::new(callback))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atproto::Profile; + use std::cell::RefCell; + use std::rc::Rc; + + fn message(id: &str, sender: &str, text: &str) -> ChatMessage { + ChatMessage { + id: id.into(), + text: text.into(), + sender_did: sender.into(), + sent_at: "2026-01-01T12:00:00Z".into(), + } + } + + fn conversation() -> Conversation { + Conversation { + id: "convo1".into(), + members: vec![ + Profile::minimal("did:plc:me".into(), "me.bsky.social".into(), None, None), + Profile::minimal( + "did:plc:them".into(), + "them.bsky.social".into(), + Some("Them".into()), + None, + ), + ], + last_message: None, + unread_count: 0, + muted: false, + } + } + + fn a_page() -> MessagePage { + MessagePage::new(&conversation(), Some("did:plc:me")) + } + + fn listed_ids(page: &MessagePage) -> Vec { + let model = page.imp().model.borrow().clone().unwrap(); + (0..model.n_items()) + .map(|i| { + model + .item(i) + .and_downcast::() + .and_then(|o| o.message()) + .map(|m| m.id) + .unwrap() + }) + .collect() + } + + /// The composer refuses empty drafts and drafts past the lexicon's + /// grapheme and byte limits. + #[test] + fn drafts_are_bounded_by_the_lexicon_limits() { + assert!(message_sendable("hello")); + assert!(!message_sendable("")); + assert!(!message_sendable(" \n "), "whitespace says nothing"); + assert!(message_sendable(&"a".repeat(1000))); + assert!(!message_sendable(&"a".repeat(1001)), "grapheme limit"); + // 500 family emoji: well under the grapheme limit, far over the + // byte limit. + let heavy = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}".repeat(500); + assert!(heavy.graphemes(true).count() <= 1000); + assert!(!message_sendable(&heavy), "byte limit"); + } + + /// Message text is wire text: whatever hostile markup it carries must + /// come out on screen as written, wrapped so it cannot widen the page. + #[test] + fn hostile_message_text_renders_as_written() { + crate::ui::with_gtk(hostile_message_text_renders_as_written_body); + } + + fn hostile_message_text_renders_as_written_body() { + let row = MessageRow::new(); + let bubble = row.first_child().expect("the bubble"); + let label = bubble + .first_child() + .and_downcast::() + .expect("the text label"); + + for text in [ + "bold & ", + "&", + "a & b < c > d", + "&&&###@@@", + ] { + row.bind(&message("m1", "did:plc:them", text), false); + assert_eq!( + label.text().as_str(), + text, + "the label must show the message as written" + ); + } + + assert!(label.wraps()); + assert_eq!( + label.wrap_mode(), + gtk4::pango::WrapMode::WordChar, + "an unbroken run must wrap rather than widen the page" + ); + } + + /// Own messages sit right in an outgoing bubble, everyone else's sit + /// left, and a recycled row swaps sides cleanly. + #[test] + fn messages_take_their_sides_and_rebind_cleanly() { + crate::ui::with_gtk(messages_take_their_sides_and_rebind_cleanly_body); + } + + fn messages_take_their_sides_and_rebind_cleanly_body() { + let row = MessageRow::new(); + let bubble = row.first_child().expect("the bubble"); + + row.bind(&message("m1", "did:plc:me", "mine"), true); + assert_eq!(bubble.halign(), gtk4::Align::End); + assert!(bubble.has_css_class("msg-out")); + assert!(!bubble.has_css_class("msg-in")); + + row.bind(&message("m2", "did:plc:them", "theirs"), false); + assert_eq!(bubble.halign(), gtk4::Align::Start); + assert!(bubble.has_css_class("msg-in")); + assert!( + !bubble.has_css_class("msg-out"), + "a recycled row must shed the other side's styling" + ); + } + + /// The wire hands pages newest first; the screen reads oldest first. + /// Older pages go in at the top, and overlap dedupes by id. + #[test] + fn history_displays_oldest_first_and_older_pages_go_on_top() { + crate::ui::with_gtk(history_displays_oldest_first_and_older_pages_go_on_top_body); + } + + fn history_displays_oldest_first_and_older_pages_go_on_top_body() { + let page = a_page(); + page.set_cursor(Some("older".into())); + page.set_initial_messages(vec![ + message("c", "did:plc:them", "third"), + message("b", "did:plc:me", "second"), + message("a", "did:plc:them", "first"), + ]); + assert_eq!(listed_ids(&page), vec!["a", "b", "c"]); + + // The older page overlaps at "a", as cursor pages can. + page.set_cursor(None); + page.prepend_older(vec![ + message("a", "did:plc:them", "first"), + message("z", "did:plc:me", "zeroth"), + message("y", "did:plc:them", "minus one"), + ]); + assert_eq!(listed_ids(&page), vec!["y", "z", "a", "b", "c"]); + } + + /// A polled page folds in by id: only unseen messages append, in + /// order, and the count tells the caller whether there is anything to + /// mark read. An unchanged page merges to zero. + #[test] + fn polls_merge_only_unseen_messages() { + crate::ui::with_gtk(polls_merge_only_unseen_messages_body); + } + + fn polls_merge_only_unseen_messages_body() { + let page = a_page(); + page.set_initial_messages(vec![ + message("b", "did:plc:me", "second"), + message("a", "did:plc:them", "first"), + ]); + + let added = page.merge_new(vec![ + message("d", "did:plc:them", "fourth"), + message("c", "did:plc:them", "third"), + message("b", "did:plc:me", "second"), + message("a", "did:plc:them", "first"), + ]); + assert_eq!(added, 2); + assert_eq!(listed_ids(&page), vec!["a", "b", "c", "d"]); + + let added = page.merge_new(vec![ + message("d", "did:plc:them", "fourth"), + message("c", "did:plc:them", "third"), + ]); + assert_eq!(added, 0, "an unchanged page leaves nothing to mark read"); + assert_eq!(listed_ids(&page), vec!["a", "b", "c", "d"]); + } + + /// The empty state waits for the first load, and the first sent + /// message stands it down again. + #[test] + fn the_empty_state_waits_for_the_first_load_and_yields_to_a_send() { + crate::ui::with_gtk(the_empty_state_waits_for_the_first_load_and_yields_to_a_send_body); + } + + fn the_empty_state_waits_for_the_first_load_and_yields_to_a_send_body() { + let page = a_page(); + let imp = page.imp(); + + assert!(!imp.empty_state.borrow().as_ref().unwrap().is_visible()); + + page.set_fetching(true); + assert!(imp.spinner.borrow().as_ref().unwrap().is_visible()); + page.set_initial_messages(vec![]); + page.set_fetching(false); + + assert!(imp.empty_state.borrow().as_ref().unwrap().is_visible()); + assert!(!imp.overlay.borrow().as_ref().unwrap().is_visible()); + assert!( + imp.empty_state + .borrow() + .as_ref() + .unwrap() + .description() + .unwrap() + .contains("them.bsky.social"), + "the empty state names the other member" + ); + + // The first message in a fresh conversation flips it to a list. + imp.entry.borrow().as_ref().unwrap().set_text("hello"); + page.sent_ok(message("s1", "did:plc:me", "hello")); + assert!(!imp.empty_state.borrow().as_ref().unwrap().is_visible()); + assert!(imp.overlay.borrow().as_ref().unwrap().is_visible()); + assert_eq!(listed_ids(&page), vec!["s1"]); + assert_eq!( + imp.entry.borrow().as_ref().unwrap().text().as_str(), + "", + "a delivered draft clears" + ); + } + + /// One send at a time, and a failed send keeps the draft for another + /// try instead of eating it. + #[test] + fn sending_gates_the_composer_and_a_failure_keeps_the_draft() { + crate::ui::with_gtk(sending_gates_the_composer_and_a_failure_keeps_the_draft_body); + } + + fn sending_gates_the_composer_and_a_failure_keeps_the_draft_body() { + let page = a_page(); + let imp = page.imp(); + let entry = imp.entry.borrow().clone().unwrap(); + let button = imp.send_button.borrow().clone().unwrap(); + + let sent: Rc>> = Rc::new(RefCell::new(vec![])); + let sink = sent.clone(); + page.set_send_callback(move |text| { + sink.borrow_mut().push(text); + }); + + assert!(!button.is_sensitive(), "nothing to send yet"); + page.try_send(); + assert!(sent.borrow().is_empty()); + + entry.set_text("hi there"); + assert!(button.is_sensitive()); + page.try_send(); + assert_eq!(*sent.borrow(), vec!["hi there".to_string()]); + + // In flight: the button drops and repeat sends are refused. + page.set_sending(true); + assert!(!button.is_sensitive()); + page.try_send(); + assert_eq!(sent.borrow().len(), 1, "one send at a time"); + + // Failure: the draft survives and the composer re-arms. + page.send_failed(); + assert_eq!(entry.text().as_str(), "hi there"); + assert!(button.is_sensitive()); + + // Overlong drafts are refused before they go anywhere. + entry.set_text(&"a".repeat(1001)); + assert!(!button.is_sensitive()); + page.try_send(); + assert_eq!(sent.borrow().len(), 1); + assert!(entry.has_css_class("error"), "the draft is flagged"); + entry.set_text("short again"); + assert!(!entry.has_css_class("error")); + assert!(button.is_sensitive()); + } + + /// An older page that is all deleted messages chains the next fetch, + /// and the budget keeps a hostile server from looping forever. + #[test] + fn short_history_pages_chain_fetches_up_to_the_budget() { + crate::ui::with_gtk(short_history_pages_chain_fetches_up_to_the_budget_body); + } + + fn short_history_pages_chain_fetches_up_to_the_budget_body() { + use std::cell::Cell; + + let page = a_page(); + let fetches = Rc::new(Cell::new(0u32)); + let counter = fetches.clone(); + let page_weak = page.downgrade(); + page.set_load_older_callback(move || { + counter.set(counter.get() + 1); + let Some(page) = page_weak.upgrade() else { + return; + }; + page.set_cursor(Some(format!("page-{}", counter.get() + 2))); + page.prepend_older(vec![]); + page.backfill_if_short(); + }); + + page.set_cursor(Some("page-2".into())); + page.set_initial_messages(vec![]); + assert!( + !page + .imp() + .empty_state + .borrow() + .as_ref() + .unwrap() + .is_visible(), + "a stored cursor means there may be history behind the page" + ); + page.backfill_if_short(); + + assert_eq!(fetches.get(), u32::from(super::BACKFILL_CAP)); + assert!( + page.imp() + .empty_state + .borrow() + .as_ref() + .unwrap() + .is_visible(), + "with the budget spent and nothing listed, the page reads empty" + ); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0d3ba13..9e1eb8d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,6 +8,7 @@ mod follow_list_page; pub mod inline_video; mod login_dialog; pub mod media_viewer; +mod message_page; pub mod post_row; #[cfg(test)] mod rebind_audit; @@ -19,6 +20,7 @@ mod window; pub use compose_dialog::{ComposeDialog, QuoteContext, ReplyContext}; pub use follow_list_page::{FollowListKind, FollowListPage}; pub use login_dialog::LoginDialog; +pub use message_page::{MessagePage, MessagePush}; pub use sidebar::NavItem; pub use window::{FollowListPush, HangarWindow}; diff --git a/src/ui/post_row.rs b/src/ui/post_row.rs index 650bc4c..cef606a 100644 --- a/src/ui/post_row.rs +++ b/src/ui/post_row.rs @@ -51,6 +51,11 @@ thread_local! { /// dispatches to it, so the list factories need no per-bind wiring. static DELETE_POST_HANDLER: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// What a Save/Remove click does. Same shape as the delete handler; the + /// row comes along weakly so the app can flip its label on success. + static BOOKMARK_POST_HANDLER: std::cell::RefCell< + Option)>>, + > = const { std::cell::RefCell::new(None) }; } /// Record whose posts are deletable. `None` on sign-out. @@ -69,6 +74,13 @@ pub fn set_delete_post_handler(handler: F) { }); } +/// Install the app-level save/unsave flow. See [`set_delete_post_handler`]. +pub fn set_bookmark_post_handler) + 'static>(handler: F) { + BOOKMARK_POST_HANDLER.with(|cell| { + cell.replace(Some(Box::new(handler))); + }); +} + mod imp { use super::*; use std::cell::{Cell, RefCell}; @@ -120,6 +132,9 @@ mod imp { pub view_post_item: RefCell>, pub copy_link_item: RefCell>, pub open_link_item: RefCell>, + pub bookmark_item: RefCell>, + /// Reads "Save Post" or "Remove from Saved"; bind keeps it honest. + pub bookmark_item_label: RefCell>, pub delete_item: RefCell>, /// Separator plus Delete button, shown and hidden as one block so no /// stray separator trails the menu on other people's posts. @@ -423,8 +438,16 @@ impl PostRow { actions.append(&spacer); // Post overflow menu button - let (menu_btn, view_post_item, copy_link_item, open_link_item, delete_item, delete_section) = - Self::create_post_menu_button(); + let ( + menu_btn, + view_post_item, + copy_link_item, + open_link_item, + bookmark_item, + bookmark_item_label, + delete_item, + delete_section, + ) = Self::create_post_menu_button(); menu_btn.set_tooltip_text(Some("More options")); menu_btn.update_property(&[gtk4::accessible::Property::Label("More options")]); actions.append(&menu_btn); @@ -451,6 +474,26 @@ impl PostRow { } }); + // Save/Remove, wired once for the same reason as Delete. + let row_weak = self.downgrade(); + let bookmark_popover = menu_btn.popover(); + bookmark_item.connect_clicked(move |_| { + if let Some(p) = &bookmark_popover { + p.popdown(); + } + let Some(row) = row_weak.upgrade() else { + return; + }; + let post = row.imp().post.borrow().clone(); + if let Some(post) = post { + BOOKMARK_POST_HANDLER.with(|cell| { + if let Some(handler) = cell.borrow().as_ref() { + handler(post, row.downgrade()); + } + }); + } + }); + content_column.append(&actions); main_box.append(&content_column); @@ -504,6 +547,8 @@ impl PostRow { imp.view_post_item.replace(Some(view_post_item)); imp.copy_link_item.replace(Some(copy_link_item)); imp.open_link_item.replace(Some(open_link_item)); + imp.bookmark_item.replace(Some(bookmark_item)); + imp.bookmark_item_label.replace(Some(bookmark_item_label)); imp.delete_item.replace(Some(delete_item)); imp.delete_section.replace(Some(delete_section)); imp.main_box.replace(Some(main_box)); @@ -592,15 +637,17 @@ impl PostRow { ) } - /// Create a post overflow menu button with View Post, Bookmark, Report, etc. + /// Create a post overflow menu button with View Post, Save, Report, etc. /// Returns: (menu_btn, view_item, copy_link_item, open_link_item, - /// delete_item, delete_section) + /// bookmark_item, bookmark_item_label, delete_item, delete_section) fn create_post_menu_button() -> ( gtk4::MenuButton, gtk4::Button, gtk4::Button, gtk4::Button, gtk4::Button, + gtk4::Label, + gtk4::Button, gtk4::Box, ) { let popover_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); @@ -642,6 +689,17 @@ impl PostRow { copy_link_item.add_css_class("flat"); popover_box.append(©_link_item); + // Save Post / Remove from Saved. One item; bind swaps the label to + // match the post it is showing. + let bookmark_item = gtk4::Button::new(); + let bookmark_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + bookmark_content.append(>k4::Image::from_icon_name("bookmark-new-symbolic")); + let bookmark_item_label = gtk4::Label::new(Some("Save Post")); + bookmark_content.append(&bookmark_item_label); + bookmark_item.set_child(Some(&bookmark_content)); + bookmark_item.add_css_class("flat"); + popover_box.append(&bookmark_item); + // Separator let sep2 = gtk4::Separator::new(gtk4::Orientation::Horizontal); sep2.set_margin_top(4); @@ -692,6 +750,8 @@ impl PostRow { view_item, copy_link_item, open_link_item, + bookmark_item, + bookmark_item_label, delete_item, delete_section, ) @@ -1158,6 +1218,16 @@ impl PostRow { section.set_visible(own); } + // The Save/Remove label follows the post, fresh on every bind so a + // recycled row cannot offer to save a post the user already saved. + if let Some(label) = imp.bookmark_item_label.borrow().as_ref() { + label.set_text(if post.viewer_bookmarked == Some(true) { + "Remove from Saved" + } else { + "Save Post" + }); + } + // The View Post action reuses post_clicked_callback if let Some(view_item) = imp.view_post_item.borrow().as_ref() { let post_row = self.downgrade(); @@ -1247,6 +1317,22 @@ impl PostRow { self.imp().viewer_repost_uri.replace(uri); } + /// Record a save or unsave that just succeeded. Updates the stored post, + /// so the next click toggles the other way, and the menu label with it. + pub fn set_viewer_bookmarked(&self, saved: bool) { + let imp = self.imp(); + if let Some(post) = imp.post.borrow_mut().as_mut() { + post.viewer_bookmarked = Some(saved); + } + if let Some(label) = imp.bookmark_item_label.borrow().as_ref() { + label.set_text(if saved { + "Remove from Saved" + } else { + "Save Post" + }); + } + } + /// Give up any video this row is playing. Idempotent. /// /// Called from every list factory's `unbind`, and from `bind` before the @@ -1814,6 +1900,7 @@ impl PostRow { embed: quote.embed.as_deref().cloned(), viewer_like: None, viewer_repost: None, + viewer_bookmarked: None, repost_reason: None, reply_context: None, }; @@ -2359,6 +2446,7 @@ mod tests { embed, viewer_like: None, viewer_repost: None, + viewer_bookmarked: None, repost_reason: None, reply_context: None, } @@ -3257,4 +3345,81 @@ mod tests { // Other tests bind posts authored by did:plc:test; leave nothing set. super::set_current_user_did(None); } + + /// The menu's Save entry follows the post: bind decides the label fresh + /// each time, one click asks for one toggle of the post the row shows + /// now, and a confirmed save flips the label without a rebind. + #[test] + fn save_follows_the_bound_post_and_fires_once() { + crate::ui::with_gtk(save_follows_the_bound_post_and_fires_once_body); + } + + fn save_follows_the_bound_post_and_fires_once_body() { + use std::cell::RefCell as StdRefCell; + use std::rc::Rc; + + let toggled: Rc)>>> = Rc::default(); + let sink = Rc::clone(&toggled); + super::set_bookmark_post_handler(move |post, _row| { + sink.borrow_mut().push((post.uri, post.viewer_bookmarked)); + }); + + let row = PostRow::new(); + let label = row + .imp() + .bookmark_item_label + .borrow() + .clone() + .expect("built in setup_ui"); + + let saved = || { + let mut post = post_with(None, "at://did:plc:test/app.bsky.feed.post/saved"); + post.viewer_bookmarked = Some(true); + post + }; + let unsaved = post_with(None, "at://did:plc:test/app.bsky.feed.post/unsaved"); + + row.bind(&saved()); + assert_eq!(label.text(), "Remove from Saved"); + + // Recycled onto an unsaved post, the offer has to flip back. + row.bind(&unsaved); + assert_eq!(label.text(), "Save Post"); + + // Bound many times over, one click still asks for one toggle, of the + // post the row shows now, carrying the saved state the server call + // has to invert. + for _ in 0..4 { + row.bind(&saved()); + row.bind(&unsaved); + } + row.imp() + .bookmark_item + .borrow() + .as_ref() + .expect("built in setup_ui") + .emit_clicked(); + assert_eq!( + *toggled.borrow(), + vec![( + "at://did:plc:test/app.bsky.feed.post/unsaved".to_string(), + None + )], + "one click on Save must request exactly one toggle" + ); + + // The app confirms the save: the label flips in place and the stored + // post flips with it, so the next click asks to remove. + row.set_viewer_bookmarked(true); + assert_eq!(label.text(), "Remove from Saved"); + assert_eq!( + row.imp() + .post + .borrow() + .as_ref() + .and_then(|p| p.viewer_bookmarked), + Some(true), + "the stored post must flip too, or the next click saves again" + ); + } } diff --git a/src/ui/rebind_audit.rs b/src/ui/rebind_audit.rs index 2d223d2..bdb2980 100644 --- a/src/ui/rebind_audit.rs +++ b/src/ui/rebind_audit.rs @@ -133,6 +133,7 @@ fn audit_post(i: u32) -> Post { embed: embed_for(k), viewer_like: None, viewer_repost: None, + viewer_bookmarked: None, // Reposts and replies, so the header furniture changes shape between // binds as well as the embed band. // diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 3a38890..719610a 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -16,6 +16,7 @@ pub enum NavItem { Chat, Profile, Likes, + Bookmarks, Search, } @@ -28,6 +29,7 @@ impl NavItem { Self::Chat => "chat-message-new-symbolic", Self::Profile => "avatar-default-symbolic", Self::Likes => "emote-love-symbolic", + Self::Bookmarks => "user-bookmarks-symbolic", Self::Search => "system-search-symbolic", } } @@ -40,6 +42,7 @@ impl NavItem { Self::Chat => "Chat", Self::Profile => "Profile", Self::Likes => "Likes", + Self::Bookmarks => "Saved", Self::Search => "Search", } } @@ -52,6 +55,7 @@ impl NavItem { Self::Chat, Self::Profile, Self::Likes, + Self::Bookmarks, Self::Search, ] } diff --git a/src/ui/style.css b/src/ui/style.css index 18b449a..f4d0b81 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -406,6 +406,25 @@ popover.menu > contents { padding: 4px 0; } +/* Chat message bubbles */ +.msg-bubble { + padding: 8px 12px; + border-radius: 12px; +} + +.msg-in { + background-color: alpha(@card_bg_color, 0.8); +} + +.msg-out { + background-color: alpha(@accent_bg_color, 0.25); +} + +/* Links in messages are underlined, same reasoning as post content */ +.msg-bubble label a { + text-decoration: underline; +} + /* Settings sidebar navigation */ .settings-sidebar { background-color: @window_bg_color; diff --git a/src/ui/window.rs b/src/ui/window.rs index 6b31b1e..f1e5536 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -6,6 +6,7 @@ use super::actor_row::{ActorObject, ActorRow}; use super::follow_list_page::{FollowListKind, FollowListPage}; +use super::message_page::{MessagePage, MessagePush}; use super::post_row::PostRow; use super::sidebar::Sidebar; use crate::atproto::{Conversation, Notification, Post, SavedFeed}; @@ -231,6 +232,14 @@ mod imp { pub likes_load_more_callback: RefCell>>, pub likes_scrolled_window: RefCell>, pub likes_spinner: RefCell>, + // Saved posts page state + pub bookmarks_nav_view: RefCell>, + pub bookmarks_model: RefCell>, + pub bookmarks_load_more_callback: RefCell>>, + pub bookmarks_scrolled_window: RefCell>, + pub bookmarks_spinner: RefCell>, + pub bookmarks_overlay: RefCell>, + pub bookmarks_empty_state: RefCell>, // Search page state pub search_nav_view: RefCell>, pub search_model: RefCell>, @@ -390,6 +399,12 @@ impl HangarWindow { likes_nav_view.add(&likes_page); main_stack.add_named(&likes_nav_view, Some("likes")); + // Saved posts section: NavigationView for thread/profile drill-down + let bookmarks_nav_view = adw::NavigationView::new(); + let bookmarks_page = self.build_bookmarks_page(); + bookmarks_nav_view.add(&bookmarks_page); + main_stack.add_named(&bookmarks_nav_view, Some("bookmarks")); + // Search section: NavigationView for search results let search_nav_view = adw::NavigationView::new(); let search_page = self.build_search_page(); @@ -432,6 +447,7 @@ impl HangarWindow { imp.chat_nav_view.replace(Some(chat_nav_view)); imp.profile_nav_view.replace(Some(profile_nav_view)); imp.likes_nav_view.replace(Some(likes_nav_view)); + imp.bookmarks_nav_view.replace(Some(bookmarks_nav_view)); imp.search_nav_view.replace(Some(search_nav_view)); // Add keyboard shortcuts @@ -1429,6 +1445,7 @@ impl HangarWindow { "chat" => "chat", "profile" => "own-profile", "likes" => "likes", + "bookmarks" => "bookmarks", "search" => "search", _ => return, }; @@ -1447,6 +1464,7 @@ impl HangarWindow { "chat" => self.imp().chat_nav_view.borrow().clone(), "profile" => self.imp().profile_nav_view.borrow().clone(), "likes" => self.imp().likes_nav_view.borrow().clone(), + "bookmarks" => self.imp().bookmarks_nav_view.borrow().clone(), "search" => self.imp().search_nav_view.borrow().clone(), _ => self.imp().home_nav_view.borrow().clone(), } @@ -1461,6 +1479,7 @@ impl HangarWindow { "chat" => Some("chat"), "profile" => Some("own-profile"), "likes" => Some("likes"), + "bookmarks" => Some("bookmarks"), "search" => Some("search"), // Settings has no NavigationView of its own, and `nav_view_named` // falls back to Home's, so a tag here would unwind Home instead. @@ -2510,6 +2529,91 @@ impl HangarWindow { .replace(Some(Box::new(callback))); } + /// Push a conversation's message view onto the chat stack. + /// + /// A conversation already in the stack is popped back to instead of + /// stacked twice, so a double click cannot open two copies. + pub fn push_message_page(&self, conversation: &Conversation) -> Option { + let nav_view = self.imp().chat_nav_view.borrow().clone()?; + + let tag = format!("convo:{}", conversation.id); + if let Some(existing) = nav_view.find_page(&tag) { + nav_view.pop_to_tag(&tag); + // The message page is the content box's last child; see the + // push below. + let page = existing + .child() + .and_then(|content| content.last_child()) + .and_downcast::()?; + return Some(MessagePush::PoppedBack(page)); + } + + let my_did = self.imp().current_user_did.borrow().clone(); + let page = MessagePage::new(conversation, my_did.as_deref()); + + // Mentions in messages route like mentions everywhere else. + let win = self.downgrade(); + page.set_mention_clicked_callback(move |handle| { + let Some(win) = win.upgrade() else { + return; + }; + if let Some(cb) = win.imp().mention_clicked_callback.borrow().as_ref() { + cb(handle); + } + }); + + let content_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + content_box.set_hexpand(true); + + let header = adw::HeaderBar::new(); + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + + let title_text = page.conversation_title(); + let title = gtk4::Label::new(Some(&title_text)); + title.add_css_class("title"); + // A bare label's minimum width is its full text; a long display + // name would push the window past the screen. + title.set_ellipsize(gtk4::pango::EllipsizeMode::End); + header.set_title_widget(Some(&title)); + + let window_controls = gtk4::WindowControls::new(gtk4::PackType::End); + header.pack_end(&window_controls); + + content_box.append(&header); + content_box.append(&page); + + let nav_page = adw::NavigationPage::new(&content_box, &title_text); + nav_page.set_tag(Some(&tag)); + nav_view.push(&nav_page); + + Some(MessagePush::Pushed(page)) + } + + /// Drop the unread badge on one conversation row after a read went + /// through on the server. + pub fn set_conversation_read(&self, convo_id: &str) { + let model = self.imp().chat_model.borrow().clone(); + let Some(model) = model else { + return; + }; + for i in 0..model.n_items() { + let Some(object) = model.item(i).and_downcast::() else { + continue; + }; + let Some(mut convo) = object.conversation() else { + continue; + }; + if convo.id != convo_id || convo.unread_count == 0 { + continue; + } + convo.unread_count = 0; + // Replacing the item is what makes the bound row repaint. + model.splice(i, 1, &[ConversationObject::new(convo)]); + break; + } + } + /// Set callback for nav item changes pub fn set_nav_changed_callback( &self, @@ -3414,6 +3518,263 @@ impl HangarWindow { .replace(Some(Box::new(callback))); } + // ======== Saved Posts Page ======== + + /// Build the saved posts page + fn build_bookmarks_page(&self) -> adw::NavigationPage { + let content_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + content_box.set_hexpand(true); + + let header = adw::HeaderBar::new(); + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + + let title = gtk4::Label::new(Some("Saved")); + title.add_css_class("title"); + header.set_title_widget(Some(&title)); + + // Add window controls (close, minimize, maximize) to the end + let window_controls = gtk4::WindowControls::new(gtk4::PackType::End); + header.pack_end(&window_controls); + + content_box.append(&header); + content_box.append(&self.build_bookmarks_list()); + + let page = adw::NavigationPage::new(&content_box, "Saved"); + page.set_tag(Some("bookmarks")); + page + } + + /// Build the saved posts list widget + fn build_bookmarks_list(&self) -> gtk4::Box { + let bookmarks_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + bookmarks_box.set_vexpand(true); + + let overlay = gtk4::Overlay::new(); + overlay.set_vexpand(true); + + let model = gio::ListStore::new::(); + let factory = gtk4::SignalListItemFactory::new(); + + factory.connect_setup(|_, item| { + let post_row = PostRow::new(); + if let Some(list_item) = item.downcast_ref::() { + list_item.set_child(Some(&post_row)); + } + }); + Self::release_video_on_unbind(&factory); + + let win = self.downgrade(); + factory.connect_bind(move |_, item| { + let Some(win) = win.upgrade() else { + return; + }; + if let Some(list_item) = item.downcast_ref::() + && let Some(post_object) = list_item.item().and_downcast::() + && let Some(post) = post_object.post() + && let Some(post_row) = list_item.child().and_downcast::() + { + post_row.bind(&post); + let post_for_like = post.clone(); + let w = win.downgrade(); + post_row.connect_like_clicked(move |row, was_liked, like_uri| { + let Some(w) = w.upgrade() else { + return; + }; + let mut post_with_state = post_for_like.clone(); + post_with_state.viewer_like = if was_liked { like_uri } else { None }; + let row_weak = row.downgrade(); + w.imp() + .like_callback + .borrow() + .as_ref() + .map(|cb| cb(post_with_state, row_weak)); + }); + let post_for_repost = post.clone(); + let w = win.downgrade(); + post_row.connect_repost_clicked(move |row, was_reposted, repost_uri| { + let Some(w) = w.upgrade() else { + return; + }; + let mut post_with_state = post_for_repost.clone(); + post_with_state.viewer_repost = if was_reposted { repost_uri } else { None }; + let row_weak = row.downgrade(); + w.imp() + .repost_callback + .borrow() + .as_ref() + .map(|cb| cb(post_with_state, row_weak)); + }); + let post_for_quote = post.clone(); + let w = win.downgrade(); + post_row.connect_quote_clicked(move || { + let Some(w) = w.upgrade() else { + return; + }; + w.imp() + .quote_callback + .borrow() + .as_ref() + .map(|cb| cb(post_for_quote.clone())); + }); + let post_clone = post.clone(); + let w = win.downgrade(); + post_row.connect_reply_clicked(move || { + let Some(w) = w.upgrade() else { + return; + }; + w.imp() + .reply_callback + .borrow() + .as_ref() + .map(|cb| cb(post_clone.clone())); + }); + // Navigation callbacks for saved posts + let w = win.downgrade(); + post_row.set_post_clicked_callback(move |p| { + let Some(w) = w.upgrade() else { + return; + }; + w.imp() + .post_clicked_callback + .borrow() + .as_ref() + .map(|cb| cb(p)); + }); + } + }); + + let selection = gtk4::NoSelection::new(Some(model.clone())); + let list_view = gtk4::ListView::new(Some(selection), Some(factory)); + list_view.add_css_class("background"); + + // Content width, and the scrollability the list needs to virtualize. + // See `build_timeline`. The list has to be this widget's direct child + // and this widget the scroller's; a box or overlay between them breaks + // scrolling. + let clamp = adw::ClampScrollable::new(); + clamp.set_maximum_size(800); + clamp.set_tightening_threshold(600); + clamp.set_child(Some(&list_view)); + + let scrolled = gtk4::ScrolledWindow::new(); + scrolled.set_vexpand(true); + scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); + scrolled.set_child(Some(&clamp)); + overlay.set_child(Some(&scrolled)); + + // Loading spinner + let spinner = gtk4::Spinner::new(); + spinner.set_visible(false); + spinner.set_halign(gtk4::Align::Center); + spinner.set_valign(gtk4::Align::End); + spinner.set_margin_bottom(16); + overlay.add_overlay(&spinner); + + // Empty state (shown instead of the overlay when nothing is saved) + let empty_state = adw::StatusPage::new(); + empty_state.set_icon_name(Some("user-bookmarks-symbolic")); + empty_state.set_title("Nothing saved yet"); + empty_state.set_description(Some("Save a post from its menu and it will show up here.")); + empty_state.set_vexpand(true); + empty_state.set_visible(false); + + bookmarks_box.append(&overlay); + bookmarks_box.append(&empty_state); + + // Store references + let imp = self.imp(); + imp.bookmarks_model.replace(Some(model)); + imp.bookmarks_scrolled_window + .replace(Some(scrolled.clone())); + imp.bookmarks_spinner.replace(Some(spinner)); + imp.bookmarks_overlay.replace(Some(overlay)); + imp.bookmarks_empty_state.replace(Some(empty_state)); + + // Infinite scroll + let adj = scrolled.vadjustment(); + let win = self.downgrade(); + adj.connect_value_changed(move |adj| { + let Some(win) = win.upgrade() else { + return; + }; + let value = adj.value(); + let upper = adj.upper(); + let page_size = adj.page_size(); + if value >= upper - page_size - 200.0 { + if let Some(cb) = win.imp().bookmarks_load_more_callback.borrow().as_ref() { + cb(); + } + } + }); + + bookmarks_box + } + + /// Show the saved posts page (top-level navigation, instant switch) + pub fn show_bookmarks_page(&self) { + self.switch_to_page("bookmarks"); + } + + /// Set saved posts in the list, swapping in the empty state when there + /// are none + pub fn set_bookmarks(&self, posts: Vec) { + let empty = posts.is_empty(); + if let Some(model) = self.imp().bookmarks_model.borrow().as_ref() { + model.remove_all(); + for post in posts { + model.append(&PostObject::new(post)); + } + } + if let Some(overlay) = self.imp().bookmarks_overlay.borrow().as_ref() { + overlay.set_visible(!empty); + } + if let Some(empty_state) = self.imp().bookmarks_empty_state.borrow().as_ref() { + empty_state.set_visible(empty); + } + } + + /// Append more saved posts to the list + pub fn append_bookmarks(&self, posts: Vec) { + if let Some(model) = self.imp().bookmarks_model.borrow().as_ref() { + for post in posts { + model.append(&PostObject::new(post)); + } + } + } + + /// Take an unsaved post out of the Saved list right away, if the list is + /// showing it. Bringing back the empty state when the last one goes. + pub fn remove_bookmark(&self, uri: &str) { + let Some(model) = self.imp().bookmarks_model.borrow().clone() else { + return; + }; + Self::remove_post_from_store(&model, uri); + if model.n_items() == 0 { + if let Some(overlay) = self.imp().bookmarks_overlay.borrow().as_ref() { + overlay.set_visible(false); + } + if let Some(empty_state) = self.imp().bookmarks_empty_state.borrow().as_ref() { + empty_state.set_visible(true); + } + } + } + + /// Set loading state for the saved posts page + pub fn set_bookmarks_loading(&self, loading: bool) { + if let Some(spinner) = self.imp().bookmarks_spinner.borrow().as_ref() { + spinner.set_visible(loading); + spinner.set_spinning(loading); + } + } + + /// Set callback for loading more saved posts + pub fn set_bookmarks_load_more_callback(&self, callback: F) { + self.imp() + .bookmarks_load_more_callback + .replace(Some(Box::new(callback))); + } + // ======== Search Page ======== fn build_search_page(&self) -> adw::NavigationPage { @@ -3882,7 +4243,14 @@ impl HangarWindow { fn pop_thread_pages_for(&self, uri: &str) { let tag = format!("thread:{uri}"); for section in [ - "home", "mentions", "activity", "chat", "profile", "likes", "search", + "home", + "mentions", + "activity", + "chat", + "profile", + "likes", + "bookmarks", + "search", ] { let Some(nav_view) = self.nav_view_named(section) else { continue; @@ -4590,6 +4958,7 @@ impl HangarWindow { "chat" => Some(crate::ui::sidebar::NavItem::Chat), "profile" => Some(crate::ui::sidebar::NavItem::Profile), "likes" => Some(crate::ui::sidebar::NavItem::Likes), + "bookmarks" => Some(crate::ui::sidebar::NavItem::Bookmarks), "search" => Some(crate::ui::sidebar::NavItem::Search), _ => Some(crate::ui::sidebar::NavItem::Home), }; @@ -5648,6 +6017,7 @@ mod tests { embed: None, viewer_like: None, viewer_repost: None, + viewer_bookmarked: None, repost_reason: None, reply_context: None, }), @@ -5675,6 +6045,7 @@ mod tests { embed: None, viewer_like: None, viewer_repost: None, + viewer_bookmarked: None, repost_reason: None, reply_context: None, } @@ -6009,9 +6380,9 @@ mod tests { /// /// The `AdwClamp` to `AdwClampScrollable` swaps only count at the /// call sites, and a clamp built inside a test says nothing about what - /// `window.rs` uses. So this walks the trees the app constructs: seven + /// `window.rs` uses. So this walks the trees the app constructs: eight /// feeds off the window's own fields, plus the other-user profile page, - /// which is built per profile. Reverting any one of the eight turns this + /// which is built per profile. Reverting any one of the nine turns this /// red on that page's name. /// /// The pages that keep a plain `AdwClamp` are covered by @@ -6032,6 +6403,7 @@ mod tests { ("activity", &imp.activity_scrolled_window), ("chat", &imp.chat_scrolled_window), ("likes", &imp.likes_scrolled_window), + ("bookmarks", &imp.bookmarks_scrolled_window), ("search", &imp.search_scrolled_window), ("search people", &imp.search_people_scrolled_window), ] { @@ -6060,8 +6432,8 @@ mod tests { assert_eq!( feeds.len(), - 8, - "this test is meant to cover all eight virtualized feeds" + 9, + "this test is meant to cover all nine virtualized feeds" ); for (page, scrolled) in &feeds { @@ -6223,6 +6595,61 @@ mod tests { window.destroy(); } + /// The Saved page swaps between its list and its empty state as posts + /// come and go, including when unsaving empties the list in place. + #[test] + fn the_saved_page_swaps_between_list_and_empty_state() { + crate::ui::with_gtk(the_saved_page_swaps_between_list_and_empty_state_body); + } + + fn the_saved_page_swaps_between_list_and_empty_state_body() { + let window: HangarWindow = glib::Object::builder().build(); + let imp = window.imp(); + + let empty_state = imp + .bookmarks_empty_state + .borrow() + .clone() + .expect("the Saved page built an empty state"); + let overlay = imp + .bookmarks_overlay + .borrow() + .clone() + .expect("the Saved page built its list overlay"); + let model = imp + .bookmarks_model + .borrow() + .clone() + .expect("the Saved page built a model"); + + // Nothing saved: the empty state speaks, the list stands down. + window.set_bookmarks(vec![]); + assert!(empty_state.get_visible()); + assert!(!overlay.get_visible()); + + window.set_bookmarks(vec![a_post("first"), a_post("second")]); + window.append_bookmarks(vec![a_post("third")]); + assert_eq!(model.n_items(), 3); + assert!(!empty_state.get_visible()); + assert!(overlay.get_visible()); + + // Unsaving takes rows out one at a time; the last one leaving brings + // the empty state back. + window.remove_bookmark(&a_post("third").uri); + window.remove_bookmark(&a_post("first").uri); + assert_eq!(model.n_items(), 1); + assert!( + !empty_state.get_visible(), + "one post still saved, the list stays" + ); + window.remove_bookmark(&a_post("second").uri); + assert_eq!(model.n_items(), 0); + assert!(empty_state.get_visible()); + assert!(!overlay.get_visible()); + + window.destroy(); + } + /// Deleting a post has to take it out of everything showing it. /// /// The stored models, a pushed profile page whose model nothing else @@ -6240,6 +6667,7 @@ mod tests { window.set_posts(vec![a_post("doomed"), a_post("kept")]); window.set_likes(vec![a_post("doomed")]); + window.set_bookmarks(vec![a_post("doomed"), a_post("kept")]); window.set_search_results(vec![a_post("kept"), a_post("doomed")]); window.set_profile_posts(vec![a_post("doomed"), a_post("kept")]); @@ -6272,6 +6700,7 @@ mod tests { for (page, cell) in [ ("timeline", &imp.timeline_model), ("likes", &imp.likes_model), + ("bookmarks", &imp.bookmarks_model), ("search", &imp.search_model), ("own profile", &imp.profile_page_model), ] {