Something went wrong. Try again.
Hangar is your Bluesky home base—built natively for Linux. ✈️ hangar.blue
libadwaita gtk4 bluesky flatpak gnome atprotocol linux atproto appimage rust
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277// 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, Embed, Post, QuoteEmbed};use crate::ui::avatar_cache;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;
/// The actor in a bsky.app profile link, if that is what the link is./// Deeper paths are posts and lists, not the profile itself.fn bsky_profile_target(uri: &str) -> Option<String> { let rest = uri .strip_prefix("https://bsky.app/profile/") .or_else(|| uri.strip_prefix("http://bsky.app/profile/")) .or_else(|| uri.strip_prefix("https://www.bsky.app/profile/"))?; let actor = rest.split(['?', '#']).next().unwrap_or(""); if actor.is_empty() || actor.contains('/') { return None; } Some(actor.to_string())}
/// A message that is nothing but a GIF link plays as a GIF, not a URL./// The GIF pickers send them exactly like this.fn lone_gif_link(text: &str) -> Option<(String, crate::atproto::GifEmbed)> { let trimmed = text.trim(); if trimmed.split_whitespace().count() != 1 { return None; } let gif = crate::atproto::gif::detect(trimmed)?; Some((trimmed.to_string(), gif))}
/// 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}
/// Whether the desktop asks for 12 hour clocks.////// A deliberately set GNOME toggle wins. Reading the setting's value/// would answer the schema default (24h) even on a machine where nothing/// was ever toggled, so unset falls through to the locale instead.fn clock_uses_12h() -> bool { thread_local! { static TWELVE_HOUR: std::cell::OnceCell<bool> = const { std::cell::OnceCell::new() }; } TWELVE_HOUR.with(|cell| { *cell.get_or_init(|| { let toggled = gio::SettingsSchemaSource::default() .and_then(|source| source.lookup("org.gnome.desktop.interface", true)) .and_then(|_| { gio::Settings::new("org.gnome.desktop.interface").user_value("clock-format") }) .and_then(|value| value.str().map(str::to_string)); match toggled.as_deref() { Some("12h") => true, Some(_) => false, None => locale_uses_12h(), } }) })}
/// Whether the locale writes one in the afternoon without a 13.fn locale_uses_12h() -> bool { glib::DateTime::from_local(2000, 1, 1, 13, 0, 0.0) .ok() .and_then(|dt| dt.format("%X").ok()) .is_some_and(|formatted| !formatted.contains("13"))}
/// Short label text and full tooltip text for a message timestamp.fn format_message_time(sent_at: &str) -> (String, String) { use chrono::DateTime;
let Ok(time) = DateTime::parse_from_rfc3339(sent_at) else { return (String::new(), String::new()); }; format_local_time(time.with_timezone(&chrono::Local), clock_uses_12h())}
fn format_local_time(local: chrono::DateTime<chrono::Local>, use_12h: bool) -> (String, String) { let (time, date_time, full) = if use_12h { ("%-I:%M %p", "%b %-d, %-I:%M %p", "%B %-d, %Y at %-I:%M %p") } else { ("%H:%M", "%b %-d, %H:%M", "%B %-d, %Y at %H:%M") }; let short = if local.date_naive() == chrono::Local::now().date_naive() { local.format(time).to_string() } else { local.format(date_time).to_string() }; (short, local.format(full).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<Option<ChatMessage>>, }
#[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<imp::MessageObject>); }
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<ChatMessage> { 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 { /// Bubble plus the reaction strip under it; halign takes the /// message's side here. pub column: RefCell<Option<gtk4::Box>>, pub bubble: RefCell<Option<gtk4::Box>>, pub text_label: RefCell<Option<gtk4::Label>>, pub embed_slot: RefCell<Option<gtk4::Box>>, pub reactions_box: RefCell<Option<gtk4::Box>>, pub time_label: RefCell<Option<gtk4::Label>>, /// The bound message, read back by menu actions and chips. pub message: RefCell<Option<ChatMessage>>, pub my_did: RefCell<Option<String>>, pub context_menu: RefCell<Option<gtk4::Popover>>, pub emoji_chooser: RefCell<Option<gtk4::EmojiChooser>>, pub mention_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, pub post_callback: RefCell<Option<Box<dyn Fn(Post) + 'static>>>, /// Args: message id, emoji, true to add or false to remove. pub react_callback: RefCell<Option<Box<dyn Fn(String, String, bool) + 'static>>>, pub delete_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, }
#[glib::object_subclass] impl ObjectSubclass for MessageRow { const NAME: &'static str = "HangarMessageRow"; type Type = super::MessageRow; type ParentType = gtk4::Box;
fn class_init(klass: &mut Self::Class) { klass.set_accessible_role(gtk4::AccessibleRole::Group);
// Message shortcuts, live while the row itself has focus. // Menu and Shift+F10 are the context menu keys every GTK // app answers; Ctrl+C copies with no pointer in sight. klass.install_action("message.menu", None, |row, _, _| row.open_menu(None)); klass.install_action("message.copy", None, |row, _, _| row.copy_text()); let empty = gtk4::gdk::ModifierType::empty(); klass.add_binding_action(gtk4::gdk::Key::Menu, empty, "message.menu"); klass.add_binding_action( gtk4::gdk::Key::F10, gtk4::gdk::ModifierType::SHIFT_MASK, "message.menu", ); klass.add_binding_action( gtk4::gdk::Key::c, gtk4::gdk::ModifierType::CONTROL_MASK, "message.copy", ); } }
impl ObjectImpl for MessageRow { fn constructed(&self) { self.parent_constructed(); self.obj().setup_ui(); }
fn dispose(&self) { // Popovers are parented by hand and must be unparented the // same way. if let Some(menu) = self.context_menu.take() { menu.unparent(); } if let Some(chooser) = self.emoji_chooser.take() { chooser.unparent(); } } }
impl WidgetImpl for MessageRow {} impl BoxImpl for MessageRow {} }
glib::wrapper! { pub struct MessageRow(ObjectSubclass<imp::MessageRow>) @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); // A keyboard stop of its own; the message.* shortcuts act on it. self.set_focusable(true); self.set_margin_start(12); self.set_margin_end(12); self.set_margin_top(3); self.set_margin_bottom(3);
// Without hexpand the row gives the column its natural width // and the halign flip in bind never moves anything; every // message sat left. With it, halign places the column and the // bubble background still hugs the content. let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0); column.set_hexpand(true);
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 if let Some(actor) = bsky_profile_target(uri) { // A shared profile is a mention wearing a URL. if let Some(row) = row_weak.upgrade() && let Some(cb) = row.imp().mention_callback.borrow().as_ref() { cb(actor); } glib::Propagation::Stop } else { crate::ui::external::open_url(label, uri, "link"); glib::Propagation::Stop } }); bubble.append(&text_label);
// A shared post's card goes here; bind rebuilds it per message. let embed_slot = gtk4::Box::new(gtk4::Orientation::Vertical, 0); bubble.append(&embed_slot);
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);
column.append(&bubble);
// Reaction chips hang on the bubble's bottom edge, outside it; // bind rebuilds them per message. let reactions_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); reactions_box.add_css_class("msg-reactions"); reactions_box.set_visible(false); column.append(&reactions_box);
self.append(&column);
// React, copy, delete live behind a right click or long press // on the bubble. let menu = gtk4::Popover::new(); menu.set_parent(&bubble); menu.set_has_arrow(false); menu.add_css_class("menu"); let menu_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
let react_item = gtk4::Button::with_label("React"); react_item.add_css_class("flat"); let row_weak = self.downgrade(); let menu_weak = menu.downgrade(); react_item.connect_clicked(move |_| { if let Some(menu) = menu_weak.upgrade() { menu.popdown(); } if let Some(row) = row_weak.upgrade() && let Some(chooser) = row.imp().emoji_chooser.borrow().as_ref() { chooser.popup(); } }); menu_box.append(&react_item);
let copy_item = gtk4::Button::with_label("Copy Text"); copy_item.add_css_class("flat"); let row_weak = self.downgrade(); let menu_weak = menu.downgrade(); copy_item.connect_clicked(move |_| { if let Some(menu) = menu_weak.upgrade() { menu.popdown(); } if let Some(row) = row_weak.upgrade() { row.copy_text(); } }); menu_box.append(©_item);
let delete_item = gtk4::Button::with_label("Delete for Me"); delete_item.add_css_class("flat"); let row_weak = self.downgrade(); let menu_weak = menu.downgrade(); delete_item.connect_clicked(move |_| { if let Some(menu) = menu_weak.upgrade() { menu.popdown(); } if let Some(row) = row_weak.upgrade() { let id = row.imp().message.borrow().as_ref().map(|m| m.id.clone()); if let Some(id) = id && let Some(cb) = row.imp().delete_callback.borrow().as_ref() { cb(id); } } }); menu_box.append(&delete_item); menu.set_child(Some(&menu_box));
let chooser = gtk4::EmojiChooser::new(); chooser.set_parent(&bubble); let row_weak = self.downgrade(); chooser.connect_emoji_picked(move |_, emoji| { if let Some(row) = row_weak.upgrade() { let id = row.imp().message.borrow().as_ref().map(|m| m.id.clone()); if let Some(id) = id && let Some(cb) = row.imp().react_callback.borrow().as_ref() { cb(id, emoji.to_string(), true); } } });
let right_click = gtk4::GestureClick::new(); right_click.set_button(3); let row_weak = self.downgrade(); right_click.connect_released(move |gesture, _, x, y| { gesture.set_state(gtk4::EventSequenceState::Claimed); if let Some(row) = row_weak.upgrade() { row.open_menu(Some((x, y))); } }); bubble.add_controller(right_click);
// Double click hearts the message. Single clicks pass through // untouched so links in the text keep working. let double_click = gtk4::GestureClick::new(); double_click.set_button(1); let row_weak = self.downgrade(); double_click.connect_released(move |_, n_press, _, _| { if n_press == 2 && let Some(row) = row_weak.upgrade() { row.react_heart(); } }); bubble.add_controller(double_click);
let long_press = gtk4::GestureLongPress::new(); long_press.set_touch_only(true); let row_weak = self.downgrade(); long_press.connect_pressed(move |gesture, x, y| { gesture.set_state(gtk4::EventSequenceState::Claimed); if let Some(row) = row_weak.upgrade() { row.open_menu(Some((x, y))); } }); bubble.add_controller(long_press);
let imp = self.imp(); imp.column.replace(Some(column)); imp.bubble.replace(Some(bubble)); imp.text_label.replace(Some(text_label)); imp.embed_slot.replace(Some(embed_slot)); imp.reactions_box.replace(Some(reactions_box)); imp.time_label.replace(Some(time_label)); imp.context_menu.replace(Some(menu)); imp.emoji_chooser.replace(Some(chooser)); }
/// 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, my_did: Option<&str>) { let imp = self.imp(); imp.message.replace(Some(message.clone())); imp.my_did.replace(my_did.map(String::from));
if let Some(column) = imp.column.borrow().as_ref() { column.set_halign(if mine { gtk4::Align::End } else { gtk4::Align::Start }); } if let Some(bubble) = imp.bubble.borrow().as_ref() { if mine { bubble.add_css_class("msg-out"); bubble.remove_css_class("msg-in"); } else { bubble.add_css_class("msg-in"); bubble.remove_css_class("msg-out"); } let (_, full) = format_message_time(&message.sent_at); bubble.set_tooltip_text(if full.is_empty() { None } else { Some(&full) }); }
let gif = lone_gif_link(&message.text);
if let Some(label) = imp.text_label.borrow().as_ref() { crate::ui::rich_text::set_linkified(label, &message.text); // A post shared without a comment has no text of its own, // and a lone GIF link reads as its player below. label.set_visible(!message.text.is_empty() && gif.is_none()); }
if let Some(slot) = imp.embed_slot.borrow().as_ref() { while let Some(child) = slot.first_child() { slot.remove(&child); } if let Some(Embed::Quote(quote)) = &message.embed { slot.append(&self.shared_post_card(quote)); } else if let Some((link, gif)) = gif { slot.append(&Self::gif_player(link, gif)); } }
self.rebuild_reaction_chips(message, my_did, mine);
if let Some(label) = imp.time_label.borrow().as_ref() { let (short, _) = format_message_time(&message.sent_at); label.set_text(&short); } }
/// One chip per distinct emoji, counted, in first-seen order. Your /// own chip is marked and clicking it takes the reaction back; /// clicking someone else's joins in. fn rebuild_reaction_chips(&self, message: &ChatMessage, my_did: Option<&str>, mine: bool) { let imp = self.imp(); let Some(strip) = imp.reactions_box.borrow().clone() else { return; }; while let Some(child) = strip.first_child() { strip.remove(&child); } strip.set_visible(!message.reactions.is_empty()); // Hug the bubble's edge on the message's own side. strip.set_halign(if mine { gtk4::Align::End } else { gtk4::Align::Start });
let mut grouped: Vec<(String, u32, bool)> = Vec::new(); for reaction in &message.reactions { let mine = my_did == Some(reaction.sender_did.as_str()); match grouped.iter_mut().find(|(v, _, _)| v == &reaction.value) { Some((_, count, own)) => { *count += 1; *own |= mine; } None => grouped.push((reaction.value.clone(), 1, mine)), } }
for (value, count, own) in grouped { let chip = gtk4::Button::with_label(&format!("{value} {count}")); chip.add_css_class("reaction-chip"); if own { chip.add_css_class("reaction-chip-mine"); } chip.update_property(&[gtk4::accessible::Property::Label(&format!( "{count} reacted with {value}, {}", if own { "including you" } else { "react too" } ))]); let row_weak = self.downgrade(); let message_id = message.id.clone(); chip.connect_clicked(move |_| { if let Some(row) = row_weak.upgrade() && let Some(cb) = row.imp().react_callback.borrow().as_ref() { cb(message_id.clone(), value.clone(), !own); } }); strip.append(&chip); } }
/// Compact card for a post shared into the conversation: author, /// text, and a hint of its media. Clicking opens the thread, where /// the media plays for real. A Button, unlike the feed's quote /// card: nothing inside this one takes clicks of its own. fn shared_post_card(&self, quote: &QuoteEmbed) -> gtk4::Button { let card = gtk4::Button::new(); card.add_css_class("card"); card.add_css_class("msg-post-card");
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6); content.set_margin_start(10); content.set_margin_end(10); content.set_margin_top(8); content.set_margin_bottom(8);
let author_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); let avatar = adw::Avatar::new(20, None, true); let author_name = quote .author .display_name .as_deref() .unwrap_or("e.author.handle); avatar.set_text(Some(author_name)); if let Some(url) = "e.author.avatar { avatar_cache::load_avatar(avatar.clone(), url.clone()); } author_row.append(&avatar);
let name_label = gtk4::Label::new(Some(author_name)); name_label.add_css_class("heading"); name_label.add_css_class("caption"); name_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); author_row.append(&name_label);
let handle_label = gtk4::Label::new(Some(&format!("@{}", quote.author.handle))); handle_label.add_css_class("dim-label"); handle_label.add_css_class("caption"); handle_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); author_row.append(&handle_label); content.append(&author_row);
if !quote.text.is_empty() { let text_label = gtk4::Label::new(Some("e.text)); text_label.set_halign(gtk4::Align::Start); text_label.set_xalign(0.0); text_label.set_wrap(true); text_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); text_label.set_max_width_chars(45); text_label.add_css_class("caption"); content.append(&text_label); }
if let Some(nested) = "e.embed { content.append(&Self::media_hint(nested)); }
card.set_child(Some(&content)); card.update_property(&[gtk4::accessible::Property::Label(&format!( "Shared post by {author_name}" ))]);
// The card is the whole post as far as navigation cares. let post = Post { uri: quote.uri.clone(), cid: quote.cid.clone(), author: quote.author.clone(), text: quote.text.clone(), created_at: quote.indexed_at.clone(), indexed_at: quote.indexed_at.clone(), like_count: None, repost_count: None, reply_count: None, embed: quote.embed.as_deref().cloned(), viewer_like: None, viewer_repost: None, viewer_bookmarked: None, repost_reason: None, reply_context: None, }; let row_weak = self.downgrade(); card.connect_clicked(move |_| { if let Some(row) = row_weak.upgrade() && let Some(cb) = row.imp().post_callback.borrow().as_ref() { cb(post.clone()); } }); card }
/// Thumbnails for images, a labeled chip for everything else. The /// thread view the card opens does the real rendering. fn media_hint(embed: &Embed) -> gtk4::Widget { match embed { Embed::Images(images) => { let strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); for img in images.iter().take(4) { let frame = gtk4::Frame::new(None); frame.set_overflow(gtk4::Overflow::Hidden); frame.add_css_class("msg-card-thumb"); frame.set_size_request(72, 72); let picture = gtk4::Picture::new(); picture.set_can_shrink(true); picture.set_content_fit(gtk4::ContentFit::Cover); if !img.alt.is_empty() { picture.set_tooltip_text(Some(&img.alt)); } frame.set_child(Some(&picture)); avatar_cache::load_image_into_picture(picture, img.thumb.clone()); strip.append(&frame); } strip.upcast() } Embed::Video(video) => match &video.thumbnail { Some(thumb) => { let frame = gtk4::Frame::new(None); frame.set_overflow(gtk4::Overflow::Hidden); frame.add_css_class("msg-card-thumb"); frame.set_size_request(128, 72); frame.set_halign(gtk4::Align::Start); let picture = gtk4::Picture::new(); picture.set_can_shrink(true); picture.set_content_fit(gtk4::ContentFit::Cover); if let Some(alt) = video.alt.as_deref().filter(|a| !a.is_empty()) { picture.set_tooltip_text(Some(alt)); } frame.set_child(Some(&picture)); avatar_cache::load_image_into_picture(picture, thumb.clone()); frame.upcast() } None => Self::media_chip("video-x-generic-symbolic", "Video"), }, Embed::External(external) => { if crate::atproto::gif::detect(&external.uri).is_some() { Self::media_chip("image-x-generic-symbolic", "GIF") } else { let label = if external.title.is_empty() { &external.uri } else { &external.title }; Self::media_chip("emblem-symbolic-link", label) } } Embed::Quote(_) | Embed::QuoteWithMedia { .. } => { Self::media_chip("mail-forward-symbolic", "Quoted post") } } }
/// A play affordance for a GIF sent as a bare link. The pickers /// give no poster frame, so the badge and button carry it; play /// opens the same dialog player GIFs use in the feed. fn gif_player(link: String, gif: crate::atproto::GifEmbed) -> gtk4::Widget { let overlay = gtk4::Overlay::new();
let frame = gtk4::Frame::new(None); frame.set_overflow(gtk4::Overflow::Hidden); frame.add_css_class("msg-card-thumb"); // Sized from the link's own stated pixels, capped for a bubble. let (w, h) = gif.aspect_ratio; let width = 240.0_f32; let height = (width * h as f32 / w.max(1) as f32).clamp(100.0, 320.0); frame.set_size_request(width as i32, height as i32); overlay.set_child(Some(&frame));
let badge = gtk4::Label::new(Some("GIF")); badge.add_css_class("gif-badge"); badge.set_halign(gtk4::Align::Start); badge.set_valign(gtk4::Align::End); badge.set_margin_start(8); badge.set_margin_bottom(8); badge.set_can_target(false); overlay.add_overlay(&badge);
let play_btn = gtk4::Button::from_icon_name("media-playback-start-symbolic"); play_btn.add_css_class("circular"); play_btn.add_css_class("osd"); play_btn.set_halign(gtk4::Align::Center); play_btn.set_valign(gtk4::Align::Center); play_btn.set_tooltip_text(Some("Play GIF")); play_btn.update_property(&[gtk4::accessible::Property::Label("Play GIF")]); play_btn.connect_clicked(move |btn| { crate::ui::media_viewer::show_video( btn, crate::ui::video_player::VideoSource::from_gif(&gif, None, Some(link.clone())), ); }); overlay.add_overlay(&play_btn);
overlay.upcast() }
fn media_chip(icon: &str, text: &str) -> gtk4::Widget { let chip = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); chip.set_halign(gtk4::Align::Start); chip.append(>k4::Image::from_icon_name(icon)); let label = gtk4::Label::new(Some(text)); label.add_css_class("dim-label"); label.add_css_class("caption"); label.set_ellipsize(gtk4::pango::EllipsizeMode::End); label.set_max_width_chars(40); chip.append(&label); chip.upcast() }
/// Replace the handler run when a mention in the text is activated. pub fn set_mention_callback<F: Fn(String) + 'static>(&self, callback: F) { self.imp() .mention_callback .replace(Some(Box::new(callback))); }
/// Replace the handler run when a shared post's card is activated. pub fn set_post_callback<F: Fn(Post) + 'static>(&self, callback: F) { self.imp().post_callback.replace(Some(Box::new(callback))); }
/// Pop the message menu. A gesture says where the pointer was; the /// keyboard passes None and the menu falls back to pointing at the /// bubble it is parented to. fn open_menu(&self, at: Option<(f64, f64)>) { let Some(menu) = self.imp().context_menu.borrow().clone() else { return; }; // A popover needs a surface to open onto. Without this, an // action fired at a row that is not on screen takes GTK // through gdk_surface_new_popup with no parent and aborts. if !self.is_mapped() { return; } match at { Some((x, y)) => { let rect = gtk4::gdk::Rectangle::new(x as i32, y as i32, 1, 1); menu.set_pointing_to(Some(&rect)); } // Whatever a previous right click left would still be // pointed at otherwise. None => menu.set_pointing_to(None), } menu.popup(); }
/// Put the bound message's text on the clipboard. Copy Text and /// Ctrl+C both land here. fn copy_text(&self) { let text = self .imp() .message .borrow() .as_ref() .map(|message| message.text.clone()); if let Some(text) = text { self.clipboard().set_text(&text); } }
/// Heart the bound message, the double-click shortcut. Adding is /// idempotent server-side, so a second double-click is harmless. pub(super) fn react_heart(&self) { let id = self.imp().message.borrow().as_ref().map(|m| m.id.clone()); if let Some(id) = id && let Some(cb) = self.imp().react_callback.borrow().as_ref() { cb(id, "\u{2764}\u{FE0F}".to_string(), true); } }
/// Replace the reaction handler: message id, emoji, add or remove. pub fn set_react_callback<F: Fn(String, String, bool) + 'static>(&self, callback: F) { self.imp().react_callback.replace(Some(Box::new(callback))); }
/// Replace the handler run when Delete for Me is chosen. pub fn set_delete_callback<F: Fn(String) + 'static>(&self, callback: F) { self.imp().delete_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<Option<gio::ListStore>>, /// Ids already shown, so poll pages and echoes of our own sends /// merge instead of duplicating rows. pub known_ids: RefCell<HashSet<String>>, pub convo_id: RefCell<String>, pub my_did: RefCell<Option<String>>, pub title: RefCell<String>, pub overlay: RefCell<Option<gtk4::Overlay>>, pub spinner: RefCell<Option<gtk4::Spinner>>, pub empty_state: RefCell<Option<adw::StatusPage>>, pub error_state: RefCell<Option<adw::StatusPage>>, pub vadjustment: RefCell<Option<gtk4::Adjustment>>, pub entry: RefCell<Option<gtk4::Entry>>, pub send_button: RefCell<Option<gtk4::Button>>, /// Cursor into older history; None once the top is reached. pub cursor: RefCell<Option<String>>, /// A history fetch is out, first page or older. pub fetching: Cell<bool>, /// A background poll fetch is out. pub polling: Cell<bool>, pub sending: Cell<bool>, pub loaded_once: Cell<bool>, /// The view is at the bottom and should stay there as rows land. pub pinned: Cell<bool>, /// 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<Option<f64>>, /// Chained fetches left before the next user action re-arms it. pub backfill_budget: Cell<u8>, pub load_older_callback: RefCell<Option<Box<dyn Fn() + 'static>>>, pub retry_callback: RefCell<Option<Box<dyn Fn() + 'static>>>, pub send_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, pub mention_clicked_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, pub post_clicked_callback: RefCell<Option<Box<dyn Fn(Post) + 'static>>>, /// Args: message id, emoji, add or remove. pub react_callback: RefCell<Option<Box<dyn Fn(String, String, bool) + 'static>>>, pub delete_message_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, }
#[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<imp::MessagePage>) @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::<MessageObject>(); let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| { let row = MessageRow::new(); if let Some(list_item) = item.downcast_ref::<gtk4::ListItem>() { // The list's activation machinery claims first clicks, so a // double click never counted to two for the heart shortcut. list_item.set_activatable(false); list_item.set_selectable(false); 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::<gtk4::ListItem>() && let Some(object) = list_item.item().and_downcast::<MessageObject>() && let Some(message) = object.message() && let Some(row) = list_item.child().and_downcast::<MessageRow>() { let my_did = page.imp().my_did.borrow().clone(); let mine = my_did.as_deref() == Some(message.sender_did.as_str()); row.bind(&message, mine, my_did.as_deref()); 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 page_weak = page.downgrade(); row.set_post_callback(move |post| { let Some(page) = page_weak.upgrade() else { return; }; if let Some(cb) = page.imp().post_clicked_callback.borrow().as_ref() { cb(post); } }); let page_weak = page.downgrade(); row.set_react_callback(move |message_id, value, add| { let Some(page) = page_weak.upgrade() else { return; }; if let Some(cb) = page.imp().react_callback.borrow().as_ref() { cb(message_id, value, add); } }); let page_weak = page.downgrade(); row.set_delete_callback(move |message_id| { let Some(page) = page_weak.upgrade() else { return; }; if let Some(cb) = page.imp().delete_message_callback.borrow().as_ref() { cb(message_id); } }); } });
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.update_property(&[gtk4::accessible::Property::Label("Loading")]); 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<ChatMessage>) { 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<ChatMessage>) { let imp = self.imp(); let fresh: Vec<ChatMessage> = { 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<MessageObject> = 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<ChatMessage>) -> u32 { let imp = self.imp(); let (fresh, known_again): (Vec<ChatMessage>, Vec<ChatMessage>) = { let known = imp.known_ids.borrow(); newest_first .into_iter() .partition(|m| !known.contains(&m.id)) };
// Reactions land on messages already listed; carry them over. for message in known_again { self.update_message(message); }
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 }
/// Replace a listed message with the server's newer copy, if it is /// actually newer. Reactions are the only part that changes in place. pub fn update_message(&self, message: ChatMessage) { let Some(model) = self.imp().model.borrow().clone() else { return; }; for i in 0..model.n_items() { let Some(object) = model.item(i).and_downcast::<MessageObject>() else { continue; }; let Some(listed) = object.message() else { continue; }; if listed.id != message.id { continue; } if listed.reactions != message.reactions { model.splice(i, 1, &[MessageObject::new(message)]); } return; } }
/// Take a message out of the list after a delete-for-me went through. /// Its id stays known, so a straggling poll cannot bring it back. pub fn remove_message(&self, message_id: &str) { let Some(model) = self.imp().model.borrow().clone() else { return; }; for i in 0..model.n_items() { let matches = model .item(i) .and_downcast::<MessageObject>() .and_then(|o| o.message()) .is_some_and(|m| m.id == message_id); if matches { model.remove(i); break; } } self.refresh_visibility(); }
/// 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<String>) { self.imp().cursor.replace(cursor); }
/// The cursor for the next page of older history, if any. pub fn cursor(&self) -> Option<String> { 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<F: Fn() + 'static>(&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<F: Fn() + 'static>(&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<F: Fn(String) + 'static>(&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<F: Fn(String) + 'static>(&self, callback: F) { self.imp() .mention_clicked_callback .replace(Some(Box::new(callback))); }
/// Replace the handler run when a shared post's card is activated. pub fn set_post_clicked_callback<F: Fn(Post) + 'static>(&self, callback: F) { self.imp() .post_clicked_callback .replace(Some(Box::new(callback))); }
/// Replace the reaction handler: message id, emoji, add or remove. pub fn set_react_callback<F: Fn(String, String, bool) + 'static>(&self, callback: F) { self.imp().react_callback.replace(Some(Box::new(callback))); }
/// Replace the handler run when Delete for Me is chosen on a message. pub fn set_delete_message_callback<F: Fn(String) + 'static>(&self, callback: F) { self.imp() .delete_message_callback .replace(Some(Box::new(callback))); }}
#[cfg(test)]mod tests { use super::*; use crate::atproto::{ChatReaction, 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(), embed: None, reactions: vec![], } }
fn shared_post(text: &str) -> Embed { Embed::Quote(QuoteEmbed { uri: "at://did:plc:author/app.bsky.feed.post/shared".into(), cid: "cid".into(), author: Profile::minimal( "did:plc:author".into(), "author.bsky.social".into(), Some("Author".into()), None, ), text: text.into(), indexed_at: "2026-01-01T00:00:00Z".into(), embed: None, }) }
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<String> { let model = page.imp().model.borrow().clone().unwrap(); (0..model.n_items()) .map(|i| { model .item(i) .and_downcast::<MessageObject>() .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 column = row.first_child().expect("the column"); let bubble = column.first_child().expect("the bubble"); let label = bubble .first_child() .and_downcast::<gtk4::Label>() .expect("the text label");
for text in [ "<b>bold</b> & <script>alert(1)</script>", "&", "a & b < c > d", "&&&###@@@", ] { row.bind( &message("m1", "did:plc:them", text), false, Some("did:plc:me"), ); 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" ); }
/// Profile links route in-app like mentions; anything deeper in the /// path is a post or list and stays a plain link. A message that is /// just a GIF link is recognised for the player. #[test] fn profile_and_gif_links_are_recognized() { assert_eq!( bsky_profile_target("https://bsky.app/profile/them.bsky.social").as_deref(), Some("them.bsky.social") ); assert_eq!( bsky_profile_target("https://bsky.app/profile/did:plc:abc123").as_deref(), Some("did:plc:abc123") ); assert_eq!( bsky_profile_target("https://bsky.app/profile/them.bsky.social?tab=media").as_deref(), Some("them.bsky.social") ); assert!( bsky_profile_target("https://bsky.app/profile/them.bsky.social/post/3abc").is_none(), "post links stay links" ); assert!(bsky_profile_target("https://example.com/profile/x").is_none()); assert!(bsky_profile_target("https://bsky.app/profile/").is_none());
let klipy = "https://static.klipy.com/ii/abc/1/2/deadbeef.gif?hh=270&ww=480&mp4=deadbeef"; let (link, gif) = lone_gif_link(&format!(" {klipy} ")).expect("a lone GIF link"); assert_eq!(link, klipy); assert_eq!(gif.aspect_ratio, (480, 270)); assert!( lone_gif_link(&format!("look at this {klipy}")).is_none(), "words around the link keep it a link" ); assert!(lone_gif_link("https://example.com/a.gif").is_none()); }
/// The clock setting decides how message times read. #[test] fn times_follow_the_clock_format() { use chrono::TimeZone; let local = chrono::Local .with_ymd_and_hms(2026, 1, 1, 15, 7, 0) .single() .unwrap();
let (short, full) = format_local_time(local, false); assert_eq!(short, "Jan 1, 15:07"); assert_eq!(full, "January 1, 2026 at 15:07");
let (short, full) = format_local_time(local, true); assert_eq!(short, "Jan 1, 3:07 PM"); assert_eq!(full, "January 1, 2026 at 3:07 PM"); }
/// Reaction chips group by emoji, mark the viewer's own, and clicking /// toggles: yours comes off, anyone else's joins in. A recycled row /// drops them with the message. #[test] fn reaction_chips_group_and_toggle() { crate::ui::with_gtk(reaction_chips_group_and_toggle_body); }
fn reaction_chips_group_and_toggle_body() { let row = MessageRow::new(); let asked: Rc<RefCell<Vec<(String, String, bool)>>> = Rc::new(RefCell::new(vec![])); let sink = asked.clone(); row.set_react_callback(move |id, value, add| { sink.borrow_mut().push((id, value, add)); });
let mut reacted = message("m1", "did:plc:them", "hey"); reacted.reactions = vec![ ChatReaction { value: "\u{1F44D}".into(), sender_did: "did:plc:me".into(), }, ChatReaction { value: "\u{1F44D}".into(), sender_did: "did:plc:them".into(), }, ChatReaction { value: "\u{2764}".into(), sender_did: "did:plc:them".into(), }, ]; row.bind(&reacted, false, Some("did:plc:me"));
let strip = row.imp().reactions_box.borrow().clone().unwrap(); assert!(strip.is_visible()); assert_eq!( strip.halign(), gtk4::Align::Start, "their message wears its reactions on its own side" );
let mut chips = Vec::new(); let mut child = strip.first_child(); while let Some(c) = child { chips.push(c.clone().downcast::<gtk4::Button>().unwrap()); child = c.next_sibling(); } assert_eq!(chips.len(), 2, "one chip per distinct emoji"); assert_eq!(chips[0].label().as_deref(), Some("\u{1F44D} 2")); assert!(chips[0].has_css_class("reaction-chip-mine")); assert_eq!(chips[1].label().as_deref(), Some("\u{2764} 1")); assert!(!chips[1].has_css_class("reaction-chip-mine"));
chips[0].emit_clicked(); chips[1].emit_clicked(); assert_eq!( asked.borrow().as_slice(), [ ("m1".to_string(), "\u{1F44D}".to_string(), false), ("m1".to_string(), "\u{2764}".to_string(), true), ], "your own chip removes, another's adds" );
row.bind( &message("m2", "did:plc:them", "plain"), false, Some("did:plc:me"), ); assert!(!strip.is_visible()); assert!(strip.first_child().is_none());
// The double-click shortcut hearts whatever is bound now. row.react_heart(); assert_eq!( asked.borrow().last(), Some(&("m2".to_string(), "\u{2764}\u{FE0F}".to_string(), true)), "a double click asks to add the heart" ); }
/// The context menu serves the bound message: Delete for Me hands the /// current id over, not the one from a previous bind. #[test] fn the_menu_serves_the_current_message() { crate::ui::with_gtk(the_menu_serves_the_current_message_body); }
fn the_menu_serves_the_current_message_body() { let row = MessageRow::new(); let deleted: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(vec![])); let sink = deleted.clone(); row.set_delete_callback(move |id| { sink.borrow_mut().push(id); });
let menu = row.imp().context_menu.borrow().clone().unwrap(); let menu_box = menu.child().unwrap(); let mut items = Vec::new(); let mut child = menu_box.first_child(); while let Some(c) = child { items.push(c.clone().downcast::<gtk4::Button>().unwrap()); child = c.next_sibling(); } let labels: Vec<_> = items.iter().filter_map(|b| b.label()).collect(); assert_eq!(labels, ["React", "Copy Text", "Delete for Me"]);
row.bind(&message("m1", "did:plc:them", "first"), false, None); row.bind(&message("m2", "did:plc:them", "second"), false, None); items[2].emit_clicked(); assert_eq!( deleted.borrow().as_slice(), ["m2"], "the menu acts on the current occupant" ); }
/// A message has to be reachable with no pointer in the room: the row /// is a focus stop, it carries a role Orca can announce, and the /// actions its key bindings fire are installed. #[test] fn a_message_is_reachable_from_the_keyboard() { crate::ui::with_gtk(a_message_is_reachable_from_the_keyboard_body); }
fn a_message_is_reachable_from_the_keyboard_body() { let row = MessageRow::new(); assert!(row.is_focusable(), "the keyboard must be able to land here"); assert_eq!(row.accessible_role(), gtk4::AccessibleRole::Group);
row.bind( &message("m1", "did:plc:them", "copy me"), false, Some("did:plc:me"), ); assert!( row.activate_action("message.menu", None).is_ok(), "Menu and Shift+F10 need an action to fire" ); assert!( row.activate_action("message.copy", None).is_ok(), "Ctrl+C needs an action to fire" ); }
/// Poll pages carry reaction changes for messages already listed; /// merge_new applies them without duplicating rows. A removed message /// stays known, so a straggling poll cannot bring it back. #[test] fn merge_new_updates_reactions_and_removal_sticks() { crate::ui::with_gtk(merge_new_updates_reactions_and_removal_sticks_body); }
fn merge_new_updates_reactions_and_removal_sticks_body() { let page = a_page(); page.set_initial_messages(vec![ message("m2", "did:plc:them", "two"), message("m1", "did:plc:them", "one"), ]); assert_eq!(listed_ids(&page), ["m1", "m2"]);
let mut updated = message("m1", "did:plc:them", "one"); updated.reactions = vec![ChatReaction { value: "\u{1F44D}".into(), sender_did: "did:plc:them".into(), }]; let added = page.merge_new(vec![updated]); assert_eq!(added, 0, "a reaction change is not a new message"); assert_eq!(listed_ids(&page), ["m1", "m2"]); let model = page.imp().model.borrow().clone().unwrap(); let m1 = model .item(0) .and_downcast::<MessageObject>() .and_then(|o| o.message()) .unwrap(); assert_eq!( m1.reactions.len(), 1, "the listed copy carries the reaction" );
page.remove_message("m1"); assert_eq!(listed_ids(&page), ["m2"]); let added = page.merge_new(vec![message("m1", "did:plc:them", "one")]); assert_eq!(added, 0); assert_eq!( listed_ids(&page), ["m2"], "a deleted message must not come back on the next poll" ); }
/// 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 column = row.first_child().expect("the column"); let bubble = column.first_child().expect("the bubble");
// Without hexpand the column only ever gets its natural width and // halign has nothing to place it in; every message sat left. assert!( column.hexpands(), "halign cannot take a side unless the column takes the row" );
row.bind( &message("m1", "did:plc:me", "mine"), true, Some("did:plc:me"), ); assert_eq!(column.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, Some("did:plc:me"), ); assert_eq!(column.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" ); }
/// A shared post renders as a card that opens the post once, and a /// recycled row drops the card with the message that owned it. #[test] fn a_shared_post_card_opens_its_post_and_leaves_with_it() { crate::ui::with_gtk(a_shared_post_card_opens_its_post_and_leaves_with_it_body); }
fn a_shared_post_card_opens_its_post_and_leaves_with_it_body() { let row = MessageRow::new(); let opened: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(vec![])); let sink = opened.clone(); row.set_post_callback(move |post| { sink.borrow_mut().push(post.uri); });
let mut shared = message("m1", "did:plc:them", ""); shared.embed = Some(shared_post("the shared post")); row.bind(&shared, false, Some("did:plc:me"));
let column = row.first_child().expect("the column"); let bubble = column.first_child().expect("the bubble"); let text_label = bubble .first_child() .and_downcast::<gtk4::Label>() .expect("the text label"); assert!( !text_label.is_visible(), "a share without a comment shows no empty text line" );
let slot = text_label.next_sibling().expect("the embed slot"); let card = slot .first_child() .and_downcast::<gtk4::Button>() .expect("the shared post renders a card");
let mut texts = Vec::new(); collect_labels(&card.clone().upcast(), &mut texts); assert!(texts.iter().any(|t| t == "Author")); assert!(texts.iter().any(|t| t == "@author.bsky.social")); assert!(texts.iter().any(|t| t == "the shared post"));
card.emit_clicked(); assert_eq!( opened.borrow().as_slice(), ["at://did:plc:author/app.bsky.feed.post/shared"], "one click opens the post once" );
// Rebind to a plain message: text back, card gone. row.bind( &message("m2", "did:plc:them", "just words"), false, Some("did:plc:me"), ); assert!(text_label.is_visible()); assert!( slot.first_child().is_none(), "a recycled row must not keep the previous message's card" ); }
fn collect_labels(widget: >k4::Widget, out: &mut Vec<String>) { if let Some(label) = widget.downcast_ref::<gtk4::Label>() { out.push(label.text().to_string()); } let mut child = widget.first_child(); while let Some(c) = child { collect_labels(&c, out); child = c.next_sibling(); } }
/// 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<RefCell<Vec<String>>> = 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" ); }}