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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206// SPDX-License-Identifier: MPL-2.0#![allow(clippy::type_complexity)]#![allow(clippy::collapsible_if)]
use crate::atproto::{Embed, ImageEmbed, Post};use crate::ui::avatar_cache;use gtk4::gdk;use gtk4::glib;use gtk4::prelude::*;use gtk4::subclass::prelude::*;use libadwaita as adw;
use crate::atproto::Profile;
/// Post content column width at the timeline's 800px `AdwClamp`.////// An estimate: the real column is 495px in a 700px window, 633 at 820, ~684/// once the clamp binds, and the height has to be requested before any of that/// is known. Tuned narrow; [`PostRow::embed_height`] clamps the error.const CONTENT_WIDTH: f64 = 540.0;
/// Take off whatever the last `bind` left on a button's `clicked`.////// The buttons are built once in `setup_ui` and `connect_clicked` adds a/// handler rather than replacing one, so anything left on the button is a/// closure still holding the post it was built for. Unconditional, so a row/// that has stopped being a repost stops navigating to the last reposter.fn disarm_click(btn: >k4::Button, slot: &std::cell::RefCell<Option<glib::SignalHandlerId>>) { if let Some(id) = slot.take() { btn.disconnect(id); }}
/// Replace a button's `clicked` handler, keeping the id so the next bind can/// take this one off in turn. See [`disarm_click`].fn rearm_click<F: Fn(>k4::Button) + 'static>( btn: >k4::Button, slot: &std::cell::RefCell<Option<glib::SignalHandlerId>>, f: F,) { disarm_click(btn, slot); slot.replace(Some(btn.connect_clicked(f)));}
thread_local! { /// The signed-in user's DID. `bind` compares each post's author against /// it to decide whether the menu offers Delete. static CURRENT_USER_DID: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) }; /// What a Delete click does. The app installs one handler and every row /// dispatches to it, so the list factories need no per-bind wiring. static DELETE_POST_HANDLER: std::cell::RefCell<Option<Box<dyn Fn(Post)>>> = 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<Box<dyn Fn(Post, glib::WeakRef<PostRow>)>>, > = const { std::cell::RefCell::new(None) }; /// What Report Post does. The item existed unwired before this. static REPORT_POST_HANDLER: std::cell::RefCell<Option<Box<dyn Fn(Post)>>> = const { std::cell::RefCell::new(None) }; /// Feed-level moderation, acting on the post's author. static MUTE_ACCOUNT_HANDLER: std::cell::RefCell<Option<Box<dyn Fn(String)>>> = const { std::cell::RefCell::new(None) }; static BLOCK_ACCOUNT_HANDLER: std::cell::RefCell< Option<Box<dyn Fn(crate::atproto::Profile)>>, > = const { std::cell::RefCell::new(None) };}
/// Record whose posts are deletable. `None` on sign-out.pub fn set_current_user_did(did: Option<&str>) { CURRENT_USER_DID.with(|cell| { cell.replace(did.map(str::to_string)); });}
/// Install the app-level delete flow. Thread-local for the same reason the/// video director is: rows are built in half a dozen factories and this/// spares each one the wiring.pub fn set_delete_post_handler<F: Fn(Post) + 'static>(handler: F) { DELETE_POST_HANDLER.with(|cell| { cell.replace(Some(Box::new(handler))); });}
/// Install the app-level save/unsave flow. See [`set_delete_post_handler`].pub fn set_bookmark_post_handler<F: Fn(Post, glib::WeakRef<PostRow>) + 'static>(handler: F) { BOOKMARK_POST_HANDLER.with(|cell| { cell.replace(Some(Box::new(handler))); });}
/// Install the app-level report flow. See [`set_delete_post_handler`].pub fn set_report_post_handler<F: Fn(Post) + 'static>(handler: F) { REPORT_POST_HANDLER.with(|cell| { cell.replace(Some(Box::new(handler))); });}
/// Install the app-level mute flow for a post's author.pub fn set_mute_account_handler<F: Fn(String) + 'static>(handler: F) { MUTE_ACCOUNT_HANDLER.with(|cell| { cell.replace(Some(Box::new(handler))); });}
/// Install the app-level block flow for a post's author.pub fn set_block_account_handler<F: Fn(crate::atproto::Profile) + 'static>(handler: F) { BLOCK_ACCOUNT_HANDLER.with(|cell| { cell.replace(Some(Box::new(handler))); });}
mod imp { use super::*; use std::cell::{Cell, RefCell};
#[derive(Default)] pub struct PostRow { pub post: RefCell<Option<Post>>, /// Set on the post a thread view is already focused on. Suppresses the /// row-body click without touching post_clicked_callback, which /// embedded quote cards still need in order to navigate. pub is_focused_post: Cell<bool>, /// The row's position at bind time, so J and K can land on the /// neighbouring item of the list this row sits in. pub list_position: Cell<u32>, pub avatar: RefCell<Option<adw::Avatar>>, pub display_name_label: RefCell<Option<gtk4::Label>>, pub handle_label: RefCell<Option<gtk4::Label>>, pub timestamp_label: RefCell<Option<gtk4::Label>>, pub content_label: RefCell<Option<gtk4::Label>>, pub reply_count_label: RefCell<Option<gtk4::Label>>, pub repost_count_label: RefCell<Option<gtk4::Label>>, pub like_count_label: RefCell<Option<gtk4::Label>>, pub like_btn: RefCell<Option<gtk4::Button>>, pub repost_btn: RefCell<Option<gtk4::MenuButton>>, pub like_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub repost_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub quote_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub reply_handler_id: RefCell<Option<glib::SignalHandlerId>>, /// The five handlers [`super::PostRow::bind`] installs itself. Same /// reason as the four above: the buttons outlive every post the row /// shows, so the next `bind` needs the id to disconnect. pub repost_attribution_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub reply_indicator_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub view_post_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub copy_link_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub open_link_handler_id: RefCell<Option<glib::SignalHandlerId>>, pub reply_btn: RefCell<Option<gtk4::Button>>, pub repost_item: RefCell<Option<gtk4::Button>>, pub repost_item_label: RefCell<Option<gtk4::Label>>, pub quote_item: RefCell<Option<gtk4::Button>>, // Rich embeds and context pub repost_row: RefCell<Option<gtk4::Box>>, pub repost_avatar: RefCell<Option<adw::Avatar>>, pub repost_label: RefCell<Option<gtk4::Label>>, pub repost_attribution_btn: RefCell<Option<gtk4::Button>>, pub reply_indicator: RefCell<Option<gtk4::Button>>, pub reply_handle_label: RefCell<Option<gtk4::Label>>, pub reply_indicator_box: RefCell<Option<gtk4::Box>>, pub embed_container: RefCell<Option<gtk4::Box>>, pub verified_badge: RefCell<Option<gtk4::Image>>, pub post_menu_btn: RefCell<Option<gtk4::MenuButton>>, pub view_post_item: RefCell<Option<gtk4::Button>>, pub copy_link_item: RefCell<Option<gtk4::Button>>, pub open_link_item: RefCell<Option<gtk4::Button>>, pub bookmark_item: RefCell<Option<gtk4::Button>>, /// Reads "Save Post" or "Remove from Saved"; bind keeps it honest. pub bookmark_item_label: RefCell<Option<gtk4::Label>>, pub delete_item: RefCell<Option<gtk4::Button>>, /// Separator plus Delete button, shown and hidden as one block so no /// stray separator trails the menu on other people's posts. pub mute_item: RefCell<Option<gtk4::Button>>, pub block_item: RefCell<Option<gtk4::Button>>, pub delete_section: RefCell<Option<gtk4::Box>>, pub moderation_section: RefCell<Option<gtk4::Box>>, // Track current like/repost state (may differ from original post after user actions) pub is_liked: RefCell<bool>, pub is_reposted: RefCell<bool>, pub viewer_like_uri: RefCell<Option<String>>, pub viewer_repost_uri: RefCell<Option<String>>, // Store indexed_at for timestamp refresh pub indexed_at: RefCell<String>, // Navigation callbacks pub post_clicked_callback: RefCell<Option<Box<dyn Fn(Post) + 'static>>>, pub profile_clicked_callback: RefCell<Option<Box<dyn Fn(Profile) + 'static>>>, pub mention_clicked_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, // Main box for cursor control pub main_box: RefCell<Option<gtk4::Box>>, /// The row's video embed. Only strong ref in the process; the director /// holds `Weak`s. pub video_slot: RefCell<Option<std::rc::Rc<crate::ui::inline_video::VideoSlot>>>, /// Every image the current post is still fetching. Dropped at the top /// of the next [`super::PostRow::bind`], so a recycled row stops /// polling for an occupant that scrolled away. pub image_loads: RefCell<Vec<avatar_cache::ImageLoad>>, }
#[glib::object_subclass] impl ObjectSubclass for PostRow { const NAME: &'static str = "HangarPostRow"; type Type = super::PostRow; type ParentType = gtk4::Box;
fn class_init(klass: &mut Self::Class) { // Use Group role (available since GTK 4.0) with RoleDescription "post" // to provide screen readers with meaningful context. // Article role requires v4_14 feature flag. klass.set_accessible_role(gtk4::AccessibleRole::Group);
// Post shortcuts, live while the row itself has focus. Focus on // a button inside the row keeps that button's own keys. klass.install_action("post.like", None, |row, _, _| row.key_like()); klass.install_action("post.reply", None, |row, _, _| row.key_reply()); klass.install_action("post.repost", None, |row, _, _| row.key_repost_menu()); klass.install_action("post.open", None, |row, _, _| row.key_open()); klass.install_action("post.menu", None, |row, _, _| row.key_menu()); let empty = gtk4::gdk::ModifierType::empty(); klass.add_binding_action(gtk4::gdk::Key::l, empty, "post.like"); klass.add_binding_action(gtk4::gdk::Key::r, empty, "post.reply"); klass.add_binding_action(gtk4::gdk::Key::t, empty, "post.repost"); klass.add_binding_action(gtk4::gdk::Key::o, empty, "post.open"); klass.add_binding_action(gtk4::gdk::Key::m, empty, "post.menu"); klass.add_binding_action(gtk4::gdk::Key::Return, empty, "post.open"); klass.add_binding_action(gtk4::gdk::Key::KP_Enter, empty, "post.open"); klass.add_binding(gtk4::gdk::Key::j, empty, |row| { row.focus_neighbor(1); glib::Propagation::Stop }); klass.add_binding(gtk4::gdk::Key::k, empty, |row| { row.focus_neighbor(-1); glib::Propagation::Stop }); } }
impl ObjectImpl for PostRow { fn constructed(&self) { self.parent_constructed(); let obj = self.obj(); obj.setup_ui(); } }
impl WidgetImpl for PostRow {} impl BoxImpl for PostRow {}}
glib::wrapper! { pub struct PostRow(ObjectSubclass<imp::PostRow>) @extends gtk4::Box, gtk4::Widget, @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable;}
impl PostRow { pub fn new() -> Self { glib::Object::builder() .property("orientation", gtk4::Orientation::Vertical) .property("spacing", 8) .build() }
/// Build the row's widgets. /// /// Every closure attached to a descendant holds this row WEAKLY. A /// strong clone here is a reference cycle (row owns child owns closure /// owns row) that GObject cannot collect, and it pins the row's /// decoded textures too, since a texture on a live widget outlives /// cache eviction. Pooled rows hide it; unvirtualized pages like the /// thread view leak one subtree per post. fn setup_ui(&self) { self.set_margin_start(12); self.set_margin_end(16); self.set_margin_top(8); self.set_margin_bottom(8); self.add_css_class("post-row"); // A keyboard stop of its own; the post.* shortcuts act on it. self.set_focusable(true);
// Main horizontal layout: avatar on left, content on right // This entire area is clickable to open the thread let main_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); main_box.set_hexpand(true); main_box.set_cursor_from_name(Some("pointer"));
// Left column: Avatar (fixed width, aligned to top) let avatar_column = gtk4::Box::new(gtk4::Orientation::Vertical, 0); avatar_column.set_valign(gtk4::Align::Start);
// Wrap avatar in a button-like container for clickability let avatar_btn = gtk4::Button::new(); avatar_btn.add_css_class("flat"); avatar_btn.add_css_class("circular"); avatar_btn.add_css_class("avatar-button"); avatar_btn.set_cursor_from_name(Some("pointer")); avatar_btn.set_tooltip_text(Some("View profile")); avatar_btn.update_property(&[gtk4::accessible::Property::Label("View profile")]); let avatar = adw::Avatar::new(42, None, true); avatar_btn.set_child(Some(&avatar)); avatar_column.append(&avatar_btn);
// Connect avatar click to profile navigation let post_row = self.downgrade(); avatar_btn.connect_clicked(move |_| { let Some(post_row) = post_row.upgrade() else { return; }; let imp = post_row.imp(); if let Some(post) = imp.post.borrow().as_ref() { if let Some(cb) = imp.profile_clicked_callback.borrow().as_ref() { cb(post.author.clone()); } } });
main_box.append(&avatar_column);
// Right column: all content (repost attribution, header, text, embeds, actions) let content_column = gtk4::Box::new(gtk4::Orientation::Vertical, 4); content_column.set_hexpand(true);
// Repost attribution row above the header, shows "Reposted by X". // Clicking goes to the reposter's profile. let repost_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); repost_row.set_margin_bottom(2); repost_row.add_css_class("repost-attribution"); let repost_icon = gtk4::Image::from_icon_name("media-playlist-repeat-symbolic"); repost_icon.add_css_class("dim-label"); repost_icon.set_pixel_size(12); repost_row.append(&repost_icon);
// Clickable button containing avatar + name let repost_btn = gtk4::Button::new(); repost_btn.add_css_class("flat"); repost_btn.add_css_class("repost-btn"); repost_btn.set_cursor_from_name(Some("pointer"));
let repost_btn_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); let repost_avatar = adw::Avatar::new(16, None, true); repost_avatar.add_css_class("repost-avatar"); repost_btn_content.append(&repost_avatar); let repost_label = gtk4::Label::new(None); repost_label.add_css_class("dim-label"); repost_label.add_css_class("caption"); // A label with no wrap or ellipsize reports its full text width as its // minimum, and AdwClamp caps natural width only, so one long display // name widens the feed. Every wire-text label here is ellipsized. repost_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); repost_btn_content.append(&repost_label); repost_btn.set_child(Some(&repost_btn_content)); repost_row.append(&repost_btn);
repost_row.set_visible(false); content_column.append(&repost_row);
// Header row: display name + verified badge + handle + · + timestamp (all on one line) let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); header.set_hexpand(true);
let display_name = gtk4::Label::new(None); display_name.set_halign(gtk4::Align::Start); display_name.add_css_class("heading"); display_name.set_ellipsize(gtk4::pango::EllipsizeMode::End); header.append(&display_name);
// Verified badge (checkmark) let verified_badge = gtk4::Image::from_icon_name("object-select-symbolic"); verified_badge.add_css_class("verified-badge"); verified_badge.set_pixel_size(14); verified_badge.set_margin_start(4); verified_badge.set_visible(false); header.append(&verified_badge);
// Handle (with spacing) let handle = gtk4::Label::new(None); handle.set_halign(gtk4::Align::Start); handle.add_css_class("dim-label"); handle.set_ellipsize(gtk4::pango::EllipsizeMode::End); handle.set_margin_start(6); header.append(&handle);
// Separator dot let dot = gtk4::Label::new(Some("·")); dot.add_css_class("dim-label"); dot.set_margin_start(6); dot.set_margin_end(6); header.append(&dot);
// Timestamp let timestamp = gtk4::Label::new(None); timestamp.add_css_class("dim-label"); header.append(×tamp);
// Spacer to push timestamp/menu to the right if needed let header_spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); header_spacer.set_hexpand(true); header.append(&header_spacer);
content_column.append(&header);
// Reply indicator between header and content, shows "Replying to // @handle". Clickable. let reply_indicator_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); reply_indicator_box.set_margin_top(2); reply_indicator_box.set_margin_bottom(4); reply_indicator_box.set_valign(gtk4::Align::Center);
let reply_icon = gtk4::Image::from_icon_name("mail-reply-sender-symbolic"); reply_icon.add_css_class("dim-label"); reply_icon.set_pixel_size(12); reply_icon.set_margin_end(6); reply_indicator_box.append(&reply_icon);
let reply_text = gtk4::Label::new(Some("Replying to")); reply_text.add_css_class("dim-label"); reply_text.add_css_class("caption"); reply_text.set_valign(gtk4::Align::Center); reply_indicator_box.append(&reply_text);
// Clickable handle button (minimal padding, tight to text) let reply_indicator = gtk4::Button::new(); reply_indicator.add_css_class("flat"); reply_indicator.add_css_class("reply-handle-btn"); reply_indicator.set_cursor_from_name(Some("pointer")); reply_indicator.set_valign(gtk4::Align::Center); let reply_handle_label = gtk4::Label::new(None); reply_handle_label.add_css_class("caption"); reply_handle_label.add_css_class("link-label"); reply_handle_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); reply_indicator.set_child(Some(&reply_handle_label)); reply_indicator_box.append(&reply_indicator);
reply_indicator_box.set_visible(false); content_column.append(&reply_indicator_box);
// Content area let content_area = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
// Post content let content = gtk4::Label::new(None); content.set_halign(gtk4::Align::Fill); content.set_hexpand(true); content.set_wrap(true); content.set_wrap_mode(gtk4::pango::WrapMode::WordChar); content.set_natural_wrap_mode(gtk4::NaturalWrapMode::Word); content.set_selectable(false); // Disable selection to allow click-through content.set_xalign(0.0); content.set_use_markup(true); // Enable markup for clickable links
// Handle link clicks (URLs, @mentions, #hashtags) let post_row_for_links = self.downgrade(); content.connect_activate_link(move |label, uri| { let Some(post_row_for_links) = post_row_for_links.upgrade() else { return glib::Propagation::Proceed; }; if uri.starts_with("bsky-mention://") { // An @mention click navigates to the profile let handle = uri.strip_prefix("bsky-mention://").unwrap_or(""); let imp = post_row_for_links.imp(); if let Some(cb) = imp.mention_clicked_callback.borrow().as_ref() { cb(handle.to_string()); } glib::Propagation::Stop // We handled it } else if uri.starts_with("bsky-tag://") { // Hashtag click. No navigation yet. let _tag = uri.strip_prefix("bsky-tag://").unwrap_or(""); // TODO: Implement hashtag search navigation glib::Propagation::Stop } else { // A regular URL opens in the browser crate::ui::external::open_url(label, uri, "link"); glib::Propagation::Stop } }); content_area.append(&content);
// Unified embed container (images, videos, external cards, quotes) let embed_container = gtk4::Box::new(gtk4::Orientation::Vertical, 8); embed_container.set_margin_top(8); embed_container.add_css_class("post-embed"); embed_container.set_visible(false); content_area.append(&embed_container);
content_column.append(&content_area);
// Action bar let actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 16); actions.set_margin_top(8);
let (reply_btn, reply_count, reply_btn_ref) = Self::create_action_button("mail-reply-sender-symbolic"); reply_btn_ref.set_tooltip_text(Some("Reply")); reply_btn_ref.update_property(&[gtk4::accessible::Property::Label("Reply")]); actions.append(&reply_btn);
// Repost menu button with popover let ( repost_btn_box, repost_count, repost_menu_btn, repost_item, repost_item_label, quote_item, ) = Self::create_repost_menu_button(); repost_menu_btn.set_tooltip_text(Some("Repost")); repost_menu_btn.update_property(&[gtk4::accessible::Property::Label("Repost options")]); actions.append(&repost_btn_box);
let (like_btn, like_count, like_btn_ref) = Self::create_action_button("emote-love-symbolic"); like_btn_ref.set_tooltip_text(Some("Like")); like_btn_ref.update_property(&[gtk4::accessible::Property::Label("Like")]); actions.append(&like_btn);
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); spacer.set_hexpand(true); actions.append(&spacer);
// Post overflow menu button let ( menu_btn, view_post_item, copy_link_item, open_link_item, bookmark_item, bookmark_item_label, copy_text_item, mute_item, block_item, report_item, delete_item, delete_section, moderation_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);
// Copy Post Text: local, no app involvement. let row_weak = self.downgrade(); let copy_popover = menu_btn.popover(); copy_text_item.connect_clicked(move |_| { if let Some(p) = ©_popover { p.popdown(); } if let Some(row) = row_weak.upgrade() && let Some(post) = row.imp().post.borrow().as_ref() { row.clipboard().set_text(&post.text); } });
// Mute and Block act on the author; the profile page has the // stateful undo side. let row_weak = self.downgrade(); let mute_popover = menu_btn.popover(); mute_item.connect_clicked(move |_| { if let Some(p) = &mute_popover { p.popdown(); } let Some(row) = row_weak.upgrade() else { return; }; let did = row .imp() .post .borrow() .as_ref() .map(|p| p.author.did.clone()); if let Some(did) = did { MUTE_ACCOUNT_HANDLER.with(|cell| { if let Some(handler) = cell.borrow().as_ref() { handler(did); } }); } });
let row_weak = self.downgrade(); let block_popover = menu_btn.popover(); block_item.connect_clicked(move |_| { if let Some(p) = &block_popover { p.popdown(); } let Some(row) = row_weak.upgrade() else { return; }; let author = row.imp().post.borrow().as_ref().map(|p| p.author.clone()); if let Some(author) = author { BLOCK_ACCOUNT_HANDLER.with(|cell| { if let Some(handler) = cell.borrow().as_ref() { handler(author); } }); } });
// Report, wired once for the same reason as Delete below. let row_weak = self.downgrade(); let report_popover = menu_btn.popover(); report_item.connect_clicked(move |_| { if let Some(p) = &report_popover { p.popdown(); } let Some(row) = row_weak.upgrade() else { return; }; let post = row.imp().post.borrow().clone(); if let Some(post) = post { REPORT_POST_HANDLER.with(|cell| { if let Some(handler) = cell.borrow().as_ref() { handler(post); } }); } });
// Wired once, here. The handler reads the row's current post when // clicked, so rebinding has nothing to disarm. Weak for the usual // reason: the button is a descendant of the row. let row_weak = self.downgrade(); let delete_popover = menu_btn.popover(); delete_item.connect_clicked(move |_| { if let Some(p) = &delete_popover { p.popdown(); } let Some(row) = row_weak.upgrade() else { return; }; let post = row.imp().post.borrow().clone(); if let Some(post) = post { DELETE_POST_HANDLER.with(|cell| { if let Some(handler) = cell.borrow().as_ref() { handler(post); } }); } });
// 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);
// Add click gesture to main_box for opening thread let gesture = gtk4::GestureClick::new(); let post_row = self.downgrade(); gesture.connect_released(move |_, _, _, _| { let Some(post_row) = post_row.upgrade() else { return; }; let imp = post_row.imp(); // Already the subject of this page; navigating would push an // identical one. if imp.is_focused_post.get() { return; } if let Some(post) = imp.post.borrow().as_ref() { if let Some(cb) = imp.post_clicked_callback.borrow().as_ref() { cb(post.clone()); } } }); main_box.add_controller(gesture);
self.append(&main_box);
// Store references let imp = self.imp(); imp.avatar.replace(Some(avatar)); imp.display_name_label.replace(Some(display_name)); imp.handle_label.replace(Some(handle)); imp.timestamp_label.replace(Some(timestamp)); imp.content_label.replace(Some(content)); imp.reply_count_label.replace(Some(reply_count)); imp.repost_count_label.replace(Some(repost_count)); imp.like_count_label.replace(Some(like_count)); imp.like_btn.replace(Some(like_btn_ref)); imp.repost_btn.replace(Some(repost_menu_btn)); imp.repost_item.replace(Some(repost_item)); imp.repost_item_label.replace(Some(repost_item_label)); imp.quote_item.replace(Some(quote_item)); imp.reply_btn.replace(Some(reply_btn_ref)); imp.repost_row.replace(Some(repost_row)); imp.repost_avatar.replace(Some(repost_avatar)); imp.repost_label.replace(Some(repost_label)); imp.repost_attribution_btn.replace(Some(repost_btn)); imp.reply_indicator.replace(Some(reply_indicator)); imp.reply_handle_label.replace(Some(reply_handle_label)); imp.reply_indicator_box.replace(Some(reply_indicator_box)); imp.embed_container.replace(Some(embed_container)); imp.verified_badge.replace(Some(verified_badge)); imp.post_menu_btn.replace(Some(menu_btn)); 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.mute_item.replace(Some(mute_item)); imp.block_item.replace(Some(block_item)); imp.delete_item.replace(Some(delete_item)); imp.delete_section.replace(Some(delete_section)); imp.moderation_section.replace(Some(moderation_section)); imp.main_box.replace(Some(main_box)); }
fn create_action_button(icon_name: &str) -> (gtk4::Box, gtk4::Label, gtk4::Button) { let btn_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); btn_box.set_valign(gtk4::Align::Center);
let btn = gtk4::Button::from_icon_name(icon_name); btn.add_css_class("flat"); btn.add_css_class("circular"); btn_box.append(&btn);
let count_label = gtk4::Label::new(Some("")); count_label.add_css_class("dim-label"); count_label.add_css_class("caption"); btn_box.append(&count_label);
(btn_box, count_label, btn) }
/// Create a repost menu button with "Repost"/"Undo Repost" and "Quote" options fn create_repost_menu_button() -> ( gtk4::Box, gtk4::Label, gtk4::MenuButton, gtk4::Button, gtk4::Label, gtk4::Button, ) { let btn_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); btn_box.set_valign(gtk4::Align::Center);
// Create popover content let popover_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); popover_box.set_margin_top(8); popover_box.set_margin_bottom(8); popover_box.set_margin_start(8); popover_box.set_margin_end(8);
let repost_item = gtk4::Button::new(); let repost_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let repost_icon = gtk4::Image::from_icon_name("media-playlist-repeat-symbolic"); repost_content.append(&repost_icon); let repost_item_label = gtk4::Label::new(Some("Repost")); repost_content.append(&repost_item_label); repost_item.set_child(Some(&repost_content)); repost_item.add_css_class("flat"); popover_box.append(&repost_item);
let quote_item = gtk4::Button::new(); let quote_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let quote_icon = gtk4::Image::from_icon_name("mail-reply-sender-symbolic"); quote_content.append("e_icon); quote_content.append(>k4::Label::new(Some("Quote"))); quote_item.set_child(Some("e_content)); quote_item.add_css_class("flat"); popover_box.append("e_item);
let popover = gtk4::Popover::new(); popover.set_child(Some(&popover_box)); popover.add_css_class("menu"); popover.set_has_arrow(false);
let menu_btn = gtk4::MenuButton::new(); menu_btn.set_icon_name("media-playlist-repeat-symbolic"); menu_btn.add_css_class("flat"); menu_btn.add_css_class("circular"); menu_btn.set_popover(Some(&popover));
btn_box.append(&menu_btn);
let count_label = gtk4::Label::new(Some("")); count_label.add_css_class("dim-label"); count_label.add_css_class("caption"); btn_box.append(&count_label);
( btn_box, count_label, menu_btn, repost_item, repost_item_label, quote_item, ) }
/// Engagement counts for the screen reader label. Counts read out /// only when they say something; nobody needs three zeroes per post. fn counts_readout(post: &Post) -> String { let mut counts = Vec::new(); for (count, one, many) in [ (post.reply_count, "reply", "replies"), (post.repost_count, "repost", "reposts"), (post.like_count, "like", "likes"), ] { match count { Some(1) => counts.push(format!("1 {one}")), Some(n) if n > 1 => counts.push(format!("{n} {many}")), _ => {} } } if counts.is_empty() { String::new() } else { format!(". {}", counts.join(", ")) } }
/// L: the like button, optimistic path and all. fn key_like(&self) { if let Some(btn) = self.imp().like_btn.borrow().as_ref() { btn.emit_clicked(); } }
/// R: the reply button. fn key_reply(&self) { if let Some(btn) = self.imp().reply_btn.borrow().as_ref() { btn.emit_clicked(); } }
/// T: the repost menu, so quote stays one arrow key away. fn key_repost_menu(&self) { if let Some(btn) = self.imp().repost_btn.borrow().as_ref() { btn.popup(); } }
/// O: open the thread, same rules as clicking the row body. fn key_open(&self) { let imp = self.imp(); if imp.is_focused_post.get() { return; } if let Some(post) = imp.post.borrow().as_ref() { if let Some(cb) = imp.post_clicked_callback.borrow().as_ref() { cb(post.clone()); } } }
/// M: the overflow menu. fn key_menu(&self) { if let Some(btn) = self.imp().post_menu_btn.borrow().as_ref() { btn.popup(); } }
/// J and K. In a ListView, land focus on the neighbouring item; a /// thread page's plain box falls back to ordinary focus movement. fn focus_neighbor(&self, delta: i64) { let mut ancestor = self.parent(); while let Some(widget) = ancestor { if let Some(list) = widget.downcast_ref::<gtk4::ListView>() { let target = self.imp().list_position.get() as i64 + delta; let count = list.model().map(|m| m.n_items() as i64).unwrap_or(0); if (0..count).contains(&target) { list.scroll_to(target as u32, gtk4::ListScrollFlags::FOCUS, None); } return; } ancestor = widget.parent(); } self.emit_move_focus(if delta > 0 { gtk4::DirectionType::Down } else { gtk4::DirectionType::Up }); }
/// Remember where this row sits in its list; see `focus_neighbor`. pub fn set_list_position(&self, position: u32) { self.imp().list_position.set(position); }
/// Create a post overflow menu button with View Post, Save, Report, etc. /// Returns: (menu_btn, view_item, copy_link_item, open_link_item, /// 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::Button, gtk4::Button, gtk4::Button, gtk4::Button, gtk4::Box, gtk4::Box, ) { let popover_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); popover_box.set_margin_top(6); popover_box.set_margin_bottom(6); popover_box.set_margin_start(6); popover_box.set_margin_end(6);
// View Post and Replies let view_item = gtk4::Button::new(); let view_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); view_content.append(>k4::Image::from_icon_name("view-reveal-symbolic")); view_content.append(>k4::Label::new(Some("View Post and Replies"))); view_item.set_child(Some(&view_content)); view_item.add_css_class("flat"); popover_box.append(&view_item);
// Separator let sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); sep.set_margin_top(4); sep.set_margin_bottom(4); popover_box.append(&sep);
// Open Link to Post let open_link_item = gtk4::Button::new(); let open_link_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); open_link_content.append(>k4::Image::from_icon_name("web-browser-symbolic")); open_link_content.append(>k4::Label::new(Some("Open Link to Post"))); open_link_item.set_child(Some(&open_link_content)); open_link_item.add_css_class("flat"); popover_box.append(&open_link_item);
// Copy Link to Post let copy_link_item = gtk4::Button::new(); let copy_link_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); copy_link_content.append(>k4::Image::from_icon_name("edit-copy-symbolic")); copy_link_content.append(>k4::Label::new(Some("Copy Link to Post"))); copy_link_item.set_child(Some(©_link_content)); 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); sep2.set_margin_bottom(4); popover_box.append(&sep2);
// Copy Post Text let copy_text_item = gtk4::Button::new(); let copy_text_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); copy_text_content.append(>k4::Image::from_icon_name("edit-copy-symbolic")); copy_text_content.append(>k4::Label::new(Some("Copy Post Text"))); copy_text_item.set_child(Some(©_text_content)); copy_text_item.add_css_class("flat"); popover_box.append(©_text_item);
// Moderation, in a block of its own. Bind hides it on the // signed-in user's posts; you cannot mute yourself. let moderation_section = gtk4::Box::new(gtk4::Orientation::Vertical, 2); let sep_mod = gtk4::Separator::new(gtk4::Orientation::Horizontal); sep_mod.set_margin_top(4); sep_mod.set_margin_bottom(4); moderation_section.append(&sep_mod);
// Mute Account, on the post's author let mute_item = gtk4::Button::new(); let mute_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); mute_content.append(>k4::Image::from_icon_name("audio-volume-muted-symbolic")); mute_content.append(>k4::Label::new(Some("Mute Account"))); mute_item.set_child(Some(&mute_content)); mute_item.add_css_class("flat"); moderation_section.append(&mute_item);
// Block Account let block_item = gtk4::Button::new(); let block_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); block_content.append(>k4::Image::from_icon_name("action-unavailable-symbolic")); block_content.append(>k4::Label::new(Some("Block Account..."))); block_item.set_child(Some(&block_content)); block_item.add_css_class("flat"); block_item.add_css_class("destructive-action"); moderation_section.append(&block_item);
// Report Post let report_item = gtk4::Button::new(); let report_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); report_content.append(>k4::Image::from_icon_name("dialog-warning-symbolic")); report_content.append(>k4::Label::new(Some("Report Post..."))); report_item.set_child(Some(&report_content)); report_item.add_css_class("flat"); moderation_section.append(&report_item); popover_box.append(&moderation_section);
// Delete Post, in a block of its own. Hidden until bind sees the post // belongs to the signed-in user. let delete_section = gtk4::Box::new(gtk4::Orientation::Vertical, 2); let sep3 = gtk4::Separator::new(gtk4::Orientation::Horizontal); sep3.set_margin_top(4); sep3.set_margin_bottom(4); delete_section.append(&sep3);
let delete_item = gtk4::Button::new(); let delete_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); delete_content.append(>k4::Image::from_icon_name("user-trash-symbolic")); delete_content.append(>k4::Label::new(Some("Delete Post..."))); delete_item.set_child(Some(&delete_content)); delete_item.add_css_class("flat"); delete_item.add_css_class("destructive-action"); delete_section.append(&delete_item); delete_section.set_visible(false); popover_box.append(&delete_section);
// The menu grew past what a short window can show; it scrolls // inside its natural height instead of clipping. let menu_scroll = gtk4::ScrolledWindow::new(); menu_scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); menu_scroll.set_propagate_natural_height(true); menu_scroll.set_max_content_height(420); menu_scroll.set_child(Some(&popover_box));
let popover = gtk4::Popover::new(); popover.set_child(Some(&menu_scroll)); popover.add_css_class("menu"); popover.set_has_arrow(false);
let menu_btn = gtk4::MenuButton::new(); menu_btn.set_icon_name("view-more-symbolic"); menu_btn.add_css_class("flat"); menu_btn.add_css_class("circular"); menu_btn.set_popover(Some(&popover));
( menu_btn, view_item, copy_link_item, open_link_item, bookmark_item, bookmark_item_label, copy_text_item, mute_item, block_item, report_item, delete_item, delete_section, moderation_section, ) }
pub fn connect_like_clicked<F: Fn(&PostRow, bool, Option<String>) + 'static>(&self, f: F) { let imp = self.imp(); if let Some(id) = imp.like_handler_id.take() { if let Some(btn) = imp.like_btn.borrow().as_ref() { btn.disconnect(id); } } if let Some(btn) = imp.like_btn.borrow().as_ref() { // Toggle visual state optimistically when clicked let post_row = self.downgrade(); let id = btn.connect_clicked(move |_| { let Some(post_row) = post_row.upgrade() else { return; }; // Capture current state BEFORE toggling let was_liked = post_row.is_liked(); let like_uri = post_row.viewer_like_uri(); // Toggle visual state post_row.toggle_like_visual(); // Call callback with self, state info: (was_liked, like_uri_if_unliking) f(&post_row, was_liked, like_uri); }); imp.like_handler_id.replace(Some(id)); } }
/// Toggle the like button visual state (optimistic update) pub fn toggle_like_visual(&self) { let imp = self.imp(); let was_liked = *imp.is_liked.borrow();
if let Some(btn) = imp.like_btn.borrow().as_ref() { // The count comes off the post, never off the label: format_count // writes "" for zero and "1.2K" past a thousand, so reading it back // as a number failed on most of the feed. let count = imp .post .borrow() .as_ref() .and_then(|p| p.like_count) .unwrap_or(0); let new_count = if was_liked { count.saturating_sub(1) } else { count + 1 };
if was_liked { btn.remove_css_class("liked"); imp.is_liked.replace(false); // Clear the like URI since we're unliking imp.viewer_like_uri.replace(None); } else { btn.add_css_class("liked"); imp.is_liked.replace(true); // Note: we don't have the new like URI yet, but that's OK for visual state }
// Store the count as well as show it, or the next toggle counts // from the number the server last sent. if let Some(post) = imp.post.borrow_mut().as_mut() { post.like_count = Some(new_count); } if let Some(label) = imp.like_count_label.borrow().as_ref() { label.set_text(&Self::format_count(Some(new_count))); } // Unconditional: on a plain button this name is the only like // state an assistive technology can see. let like_label = if was_liked { format!("Like. {} likes", new_count) } else { format!("Unlike. {} likes", new_count) }; btn.update_property(&[gtk4::accessible::Property::Label(&like_label)]); } }
/// Connect callback for the "Repost" menu item pub fn connect_repost_clicked<F: Fn(&PostRow, bool, Option<String>) + 'static>(&self, f: F) { let imp = self.imp(); if let Some(id) = imp.repost_handler_id.take() { if let Some(btn) = imp.repost_item.borrow().as_ref() { btn.disconnect(id); } } if let Some(btn) = imp.repost_item.borrow().as_ref() { // Close popover when clicked let menu_btn = imp.repost_btn.borrow(); let popover = menu_btn.as_ref().and_then(|m| m.popover()); let post_row = self.downgrade(); let id = btn.connect_clicked(move |_| { let Some(post_row) = post_row.upgrade() else { return; }; if let Some(p) = &popover { p.popdown(); } // Capture current state BEFORE toggling let was_reposted = post_row.is_reposted(); let repost_uri = post_row.viewer_repost_uri(); // Toggle visual state post_row.toggle_repost_visual(); // Call callback with self, state info f(&post_row, was_reposted, repost_uri); }); imp.repost_handler_id.replace(Some(id)); } }
/// Toggle the repost button visual state (optimistic update) pub fn toggle_repost_visual(&self) { let imp = self.imp(); let was_reposted = *imp.is_reposted.borrow();
if let Some(btn) = imp.repost_btn.borrow().as_ref() { // Off the post, for the same reason as the like path above. let count = imp .post .borrow() .as_ref() .and_then(|p| p.repost_count) .unwrap_or(0); let new_count = if was_reposted { count.saturating_sub(1) } else { count + 1 };
if was_reposted { btn.remove_css_class("reposted"); imp.is_reposted.replace(false); imp.viewer_repost_uri.replace(None); // Update menu item label if let Some(label) = imp.repost_item_label.borrow().as_ref() { label.set_text("Repost"); } } else { btn.add_css_class("reposted"); imp.is_reposted.replace(true); if let Some(label) = imp.repost_item_label.borrow().as_ref() { label.set_text("Undo Repost"); } }
if let Some(post) = imp.post.borrow_mut().as_mut() { post.repost_count = Some(new_count); } if let Some(label) = imp.repost_count_label.borrow().as_ref() { label.set_text(&Self::format_count(Some(new_count))); } let repost_label = if was_reposted { format!("Repost. {} reposts", new_count) } else { format!("Undo repost. {} reposts", new_count) }; btn.update_property(&[gtk4::accessible::Property::Label(&repost_label)]); } }
/// Connect callback for the "Quote" menu item pub fn connect_quote_clicked<F: Fn() + 'static>(&self, f: F) { let imp = self.imp(); if let Some(id) = imp.quote_handler_id.take() { if let Some(btn) = imp.quote_item.borrow().as_ref() { btn.disconnect(id); } } if let Some(btn) = imp.quote_item.borrow().as_ref() { // Close popover when clicked let menu_btn = imp.repost_btn.borrow(); let popover = menu_btn.as_ref().and_then(|m| m.popover()); let id = btn.connect_clicked(move |_| { if let Some(p) = &popover { p.popdown(); } f(); }); imp.quote_handler_id.replace(Some(id)); } }
pub fn connect_reply_clicked<F: Fn() + 'static>(&self, f: F) { let imp = self.imp(); if let Some(id) = imp.reply_handler_id.take() { if let Some(btn) = imp.reply_btn.borrow().as_ref() { btn.disconnect(id); } } if let Some(btn) = imp.reply_btn.borrow().as_ref() { let id = btn.connect_clicked(move |_| f()); imp.reply_handler_id.replace(Some(id)); } }
/// Set callback for when the post content area is clicked (to open thread) pub fn set_post_clicked_callback<F: Fn(Post) + 'static>(&self, f: F) { self.imp().post_clicked_callback.replace(Some(Box::new(f))); }
/// Set callback for when the avatar/profile is clicked (to open profile) pub fn set_profile_clicked_callback<F: Fn(Profile) + 'static>(&self, f: F) { self.imp() .profile_clicked_callback .replace(Some(Box::new(f))); }
/// Set callback for when an @mention in post text is clicked (handle without @) pub fn set_mention_clicked_callback<F: Fn(String) + 'static>(&self, f: F) { self.imp() .mention_clicked_callback .replace(Some(Box::new(f))); }
/// Stop the row body from opening its own thread. /// /// Used for the main post in a thread view. This previously only cleared /// the pointer cursor, so the post still navigated on click and you could /// descend into the same thread indefinitely. The click callback stays /// because embedded quote cards depend on it. pub fn set_not_clickable(&self) { let imp = self.imp(); imp.is_focused_post.set(true); if let Some(main_box) = imp.main_box.borrow().as_ref() { main_box.set_cursor_from_name(None::<&str>); } }
/// Show `post` on this row, whatever it was showing before. /// /// The row is not necessarily new: a recycling `GtkListView` binds one /// pooled row to thousands of posts, so anything this only ever adds sticks /// for the life of the row. Two shapes of that here: a `connect_clicked` /// per bind, see [`disarm_click`], and state set from outside that nothing /// put back, like [`Self::set_not_clickable`]. pub fn bind(&self, post: &Post) { let imp = self.imp(); imp.post.replace(Some(post.clone()));
// Whatever the last post was still loading belongs to a row the user // has scrolled past. Dropping the handles stops those polls, so a // scroll burst leaves no 16ms timer per image behind, each pinning a // picture and decoding pixels nobody will see. drop(imp.image_loads.take());
// Clickable again until something says otherwise. `set_not_clickable` // runs after `bind` on a thread's focused post, and without this reset // the row that carried it would refuse to navigate ever after. imp.is_focused_post.set(false); if let Some(main_box) = imp.main_box.borrow().as_ref() { main_box.set_cursor_from_name(Some("pointer")); }
// Show or hide the repost attribution. Clicking goes to the reposter's profile. if let Some(repost_row) = imp.repost_row.borrow().as_ref() { // Outside the branch. A row recycled from a repost onto a post // nobody reposted must not keep the old reposter wired to a button // it is about to hide. if let Some(btn) = imp.repost_attribution_btn.borrow().as_ref() { disarm_click(btn, &imp.repost_attribution_handler_id); } if let Some(reason) = &post.repost_reason { let name = reason .by .display_name .as_deref() .unwrap_or(&reason.by.handle); // Update the repost label if let Some(label) = imp.repost_label.borrow().as_ref() { label.set_text(name); } // Load the reposter's avatar if let Some(repost_avatar) = imp.repost_avatar.borrow().as_ref() { repost_avatar.set_text(Some(name)); if let Some(avatar_url) = &reason.by.avatar { avatar_cache::load_avatar(repost_avatar.clone(), avatar_url.clone()); } } // Wire up click to navigate to reposter's profile if let Some(btn) = imp.repost_attribution_btn.borrow().as_ref() { // Weak. The button is a descendant of the row, so a strong // clone is the row holding itself alive through its own // widget tree and a pooled row is never dropped. let post_row = self.downgrade(); let reposter_profile = reason.by.clone(); rearm_click(btn, &imp.repost_attribution_handler_id, move |_| { let Some(post_row) = post_row.upgrade() else { return; }; let inner_imp = post_row.imp(); if let Some(cb) = inner_imp.profile_clicked_callback.borrow().as_ref() { cb(reposter_profile.clone()); } }); } repost_row.set_visible(true); } else { repost_row.set_visible(false); } }
// Show or hide the reply indicator. Clicking goes to the parent author's profile. if let Some(reply_indicator_box) = imp.reply_indicator_box.borrow().as_ref() { // As above. Off first, whether or not this post puts one back. if let Some(btn) = imp.reply_indicator.borrow().as_ref() { disarm_click(btn, &imp.reply_indicator_handler_id); } if let Some(context) = &post.reply_context { // Update the handle label if let Some(label) = imp.reply_handle_label.borrow().as_ref() { label.set_text(&format!("@{}", context.parent_author.handle)); } // Wire up click to navigate to parent author's profile if let Some(btn) = imp.reply_indicator.borrow().as_ref() { let post_row = self.downgrade(); let parent_profile = context.parent_author.clone(); rearm_click(btn, &imp.reply_indicator_handler_id, move |_| { let Some(post_row) = post_row.upgrade() else { return; }; let inner_imp = post_row.imp(); if let Some(cb) = inner_imp.profile_clicked_callback.borrow().as_ref() { cb(parent_profile.clone()); } }); } reply_indicator_box.set_visible(true); } else { reply_indicator_box.set_visible(false); } }
let display_name = post .author .display_name .as_deref() .unwrap_or(&post.author.handle);
if let Some(avatar) = imp.avatar.borrow().as_ref() { avatar.set_text(Some(display_name));
if let Some(avatar_url) = &post.author.avatar { avatar_cache::load_avatar(avatar.clone(), avatar_url.clone()); } }
if let Some(label) = imp.display_name_label.borrow().as_ref() { label.set_text(display_name); }
if let Some(label) = imp.handle_label.borrow().as_ref() { label.set_text(&format!("@{}", post.author.handle)); }
if let Some(label) = imp.timestamp_label.borrow().as_ref() { label.set_text(&Self::format_timestamp(&post.indexed_at)); } // Store indexed_at for timestamp refresh imp.indexed_at.replace(post.indexed_at.clone());
// Set content with rich text formatting (links, mentions, hashtags) if let Some(label) = imp.content_label.borrow().as_ref() { Self::set_post_markup(label, &post.text); }
// Render embed content if let Some(container) = imp.embed_container.borrow().as_ref() { // Before the widgets go: a recycled row's pipeline belongs to the // old post. self.release_video();
// Clear previous embeds while let Some(child) = container.first_child() { container.remove(&child); }
if let Some(embed) = &post.embed { self.render_embed(container, embed, true); container.set_visible(true); } else { container.set_visible(false); } }
if let Some(label) = imp.reply_count_label.borrow().as_ref() { label.set_text(&Self::format_count(post.reply_count)); }
if let Some(label) = imp.repost_count_label.borrow().as_ref() { label.set_text(&Self::format_count(post.repost_count)); }
if let Some(label) = imp.like_count_label.borrow().as_ref() { label.set_text(&Self::format_count(post.like_count)); }
// Track like/repost state for toggle operations imp.is_liked.replace(post.viewer_like.is_some()); imp.is_reposted.replace(post.viewer_repost.is_some()); imp.viewer_like_uri.replace(post.viewer_like.clone()); imp.viewer_repost_uri.replace(post.viewer_repost.clone());
// Update like button state if let Some(btn) = imp.like_btn.borrow().as_ref() { if post.viewer_like.is_some() { btn.add_css_class("liked"); let like_label = format!("Unlike. {} likes", post.like_count.unwrap_or(0)); btn.update_property(&[gtk4::accessible::Property::Label(&like_label)]); } else { btn.remove_css_class("liked"); let like_label = format!("Like. {} likes", post.like_count.unwrap_or(0)); btn.update_property(&[gtk4::accessible::Property::Label(&like_label)]); } }
// Update reply button accessible label with count if let Some(btn) = imp.reply_btn.borrow().as_ref() { let reply_label = format!("Reply. {} replies", post.reply_count.unwrap_or(0)); btn.update_property(&[gtk4::accessible::Property::Label(&reply_label)]); }
// Update repost button state and menu label if let Some(btn) = imp.repost_btn.borrow().as_ref() { if post.viewer_repost.is_some() { btn.add_css_class("reposted"); let repost_label = format!("Undo repost. {} reposts", post.repost_count.unwrap_or(0)); btn.update_property(&[gtk4::accessible::Property::Label(&repost_label)]); } else { btn.remove_css_class("reposted"); let repost_label = format!("Repost. {} reposts", post.repost_count.unwrap_or(0)); btn.update_property(&[gtk4::accessible::Property::Label(&repost_label)]); } } // Update repost menu item label if let Some(label) = imp.repost_item_label.borrow().as_ref() { if post.viewer_repost.is_some() { label.set_text("Undo Repost"); } else { label.set_text("Repost"); } }
// Set composite accessible label on the PostRow for screen readers let text_preview = if post.text.len() > 200 { &post.text[..super::floor_char_boundary(&post.text, 200)] } else { &post.text }; let article_label = format!( "Post by {} (@{}), {}: {}{}", display_name, post.author.handle, Self::format_timestamp(&post.indexed_at), text_preview, Self::counts_readout(post) ); self.update_property(&[ gtk4::accessible::Property::Label(&article_label), gtk4::accessible::Property::RoleDescription("post"), ]);
// Update avatar button accessible label with display name // (avatar_btn is inside avatar_column but we need the parent button) // We set it via the avatar's parent which is the button if let Some(avatar_widget) = imp.avatar.borrow().as_ref() { if let Some(parent_btn) = avatar_widget.parent() { let profile_label = format!("View profile of {}", display_name); parent_btn.update_property(&[gtk4::accessible::Property::Label(&profile_label)]); } }
// Wire up post menu actions let post_url = Self::get_post_url(&post.author.handle, &post.uri); let popover = imp .post_menu_btn .borrow() .as_ref() .and_then(|m| m.popover());
// Delete is offered only on the signed-in user's own posts, decided // fresh on every bind so a recycled row cannot keep the offer. let own = CURRENT_USER_DID .with(|cell| cell.borrow().as_deref() == Some(post.author.did.as_str())); if let Some(section) = imp.delete_section.borrow().as_ref() { section.set_visible(own); } // Moderation is the inverse: only other people's posts. if let Some(section) = imp.moderation_section.borrow().as_ref() { 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(); let popover_clone = popover.clone(); rearm_click(view_item, &imp.view_post_handler_id, move |_| { if let Some(p) = &popover_clone { p.popdown(); } let Some(post_row) = post_row.upgrade() else { return; }; let inner_imp = post_row.imp(); if let Some(post) = inner_imp.post.borrow().as_ref() { if let Some(cb) = inner_imp.post_clicked_callback.borrow().as_ref() { cb(post.clone()); } } }); }
// Copy Link action if let Some(copy_item) = imp.copy_link_item.borrow().as_ref() { // The URL is captured by value, so a stale handler copies a // different post's link and raises a toast for it. let url = post_url.clone(); let popover_clone = popover.clone(); rearm_click(copy_item, &imp.copy_link_handler_id, move |btn| { if let Some(p) = &popover_clone { p.popdown(); } crate::ui::external::copy_text(btn, &url, "Link to post"); }); }
// Open Link action if let Some(open_item) = imp.open_link_item.borrow().as_ref() { let url = post_url; let popover_clone = popover; rearm_click(open_item, &imp.open_link_handler_id, move |btn| { if let Some(p) = &popover_clone { p.popdown(); } crate::ui::external::open_url(btn, &url, "post"); }); } }
/// Generate a Bluesky web URL for a post fn get_post_url(handle: &str, uri: &str) -> String { // Extract the rkey from the AT URI (e.g., at://did:plc:xxx/app.bsky.feed.post/rkey) let rkey = uri.rsplit('/').next().unwrap_or(""); format!("https://bsky.app/profile/{}/post/{}", handle, rkey) }
/// The URI of the post this row is currently showing pub fn post_uri(&self) -> Option<String> { self.imp().post.borrow().as_ref().map(|p| p.uri.clone()) }
/// Check if the post is currently liked (tracks local state after user actions) pub fn is_liked(&self) -> bool { *self.imp().is_liked.borrow() }
/// Check if the post is currently reposted (tracks local state after user actions) pub fn is_reposted(&self) -> bool { *self.imp().is_reposted.borrow() }
/// Get the viewer's like URI (if liked) pub fn viewer_like_uri(&self) -> Option<String> { self.imp().viewer_like_uri.borrow().clone() }
/// Get the viewer's repost URI (if reposted) pub fn viewer_repost_uri(&self) -> Option<String> { self.imp().viewer_repost_uri.borrow().clone() }
/// Set the viewer's like URI (after a successful like operation) pub fn set_viewer_like_uri(&self, uri: Option<String>) { self.imp().viewer_like_uri.replace(uri); }
/// Set the viewer's repost URI (after a successful repost operation) pub fn set_viewer_repost_uri(&self, uri: Option<String>) { 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 /// old embed's widgets go. Public because a `SignalListItemFactory` has /// nothing but the row to call. pub fn release_video(&self) { // take() ends the borrow before the drop. Dropping the last `Rc` runs // `VideoSlot::drop`, which can re-enter this `RefCell`. let slot = self.imp().video_slot.borrow_mut().take(); drop(slot); }
/// Start an image load the row can call off. /// /// Every picture in an embed goes through here. The handle lives on the /// row, so the next `bind` cancels the load and dropping the row cancels /// it too; the fire and forget entry point cannot be stopped, and a scroll /// burst through an image feed leaves one poll per pending image. fn load_embed_image(&self, picture: >k4::Picture, url: &str) { let handle = avatar_cache::load_image_into_picture_tracked(picture.clone(), url.to_string(), |_| {}); self.imp().image_loads.borrow_mut().push(handle); }
/// Render embed content into the container /// /// `inline_allowed` is false inside a quote card: a quoted video keeps its /// thumbnail and opens the viewer. fn render_embed(&self, container: >k4::Box, embed: &Embed, inline_allowed: bool) { match embed { Embed::Images(images) => { self.render_images(container, images); } Embed::External(ext) => { self.render_external_card(container, ext); } Embed::Video(video) => { self.render_video(container, video, inline_allowed); } Embed::Quote(quote) => { self.render_quote(container, quote); } Embed::QuoteWithMedia { quote, media } => { // Render media first, then quote below self.render_embed(container, media, inline_allowed); self.render_quote(container, quote); } } }
/// Render images with smart layout based on count fn render_images(&self, container: >k4::Box, images: &[ImageEmbed]) { // Create a container for the image grid let grid_container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); grid_container.add_css_class("image-grid");
match images.len() { 0 => return, 1 => { // The height comes from the image's own aspect ratio instead // of a fixed 400px box. let img = &images[0];
let frame = gtk4::Frame::new(None); frame.set_hexpand(true); frame.set_overflow(gtk4::Overflow::Hidden); frame.add_css_class("post-embed-image"); // Clamped so a panorama is not a sliver and a tall portrait // does not take over the row. frame.set_size_request(-1, Self::embed_height(img.aspect_ratio, 180, 430));
let picture = gtk4::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); // Without can_shrink the minimum width is the image's full // width. The timeline has no horizontal scrollbar, so that // widens the whole window. picture.set_can_shrink(true); // Contain: a lone image has nothing to line up with. Cover // discarded 57% of a 2:3 portrait and 27% of a 16:9 landscape // at a 700px window. picture.set_content_fit(gtk4::ContentFit::Contain);
frame.set_child(Some(&picture)); self.load_embed_image(&picture, &img.thumb); let cell = Self::with_alt_badge(frame.upcast(), &picture, &img.alt); Self::attach_image_click(&cell, images, 0); grid_container.append(&cell); } 2 => { // Two images side by side, both square-ish let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); row.set_homogeneous(true);
for (i, img) in images.iter().enumerate() { let cell = self.create_image_cell(img, 240); Self::attach_image_click(&cell, images, i); row.append(&cell); } grid_container.append(&row); } 3 => { // One tall left, two stacked right let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); row.set_homogeneous(true);
// Left image, tall, visually spanning both rows let left = self.create_image_cell(&images[0], 300); Self::attach_image_click(&left, images, 0); row.append(&left);
// Right column with two stacked images. 148 * 2 + 4 spacing // matches the left cell exactly. let right_col = gtk4::Box::new(gtk4::Orientation::Vertical, 4); let top_right = self.create_image_cell(&images[1], 148); let bot_right = self.create_image_cell(&images[2], 148); Self::attach_image_click(&top_right, images, 1); Self::attach_image_click(&bot_right, images, 2); right_col.append(&top_right); right_col.append(&bot_right); row.append(&right_col);
grid_container.append(&row); } _ => { // 4+ images: 2x2 grid let top_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); top_row.set_homogeneous(true); let bot_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); bot_row.set_homogeneous(true);
for (i, img) in images.iter().take(4).enumerate() { let cell = self.create_image_cell(img, 180); Self::attach_image_click(&cell, images, i); if i < 2 { top_row.append(&cell); } else { bot_row.append(&cell); } }
grid_container.append(&top_row); grid_container.set_spacing(4); grid_container.append(&bot_row); } }
container.append(&grid_container); }
/// Create an image cell that fills a fixed height container /// Make an embedded image open the full-size viewer when clicked. /// /// The gesture claims the event so it does not also reach the row body, /// which would open the thread instead of the image. fn attach_image_click(widget: &impl IsA<gtk4::Widget>, images: &[ImageEmbed], index: usize) { let widget = widget.as_ref(); widget.set_cursor_from_name(Some("pointer"));
let gesture = gtk4::GestureClick::new(); let images = images.to_vec(); let widget_weak = widget.downgrade(); gesture.connect_released(move |gesture, _, _, _| { gesture.set_state(gtk4::EventSequenceState::Claimed); if let Some(widget) = widget_weak.upgrade() { crate::ui::media_viewer::show_images(&widget, images.clone(), index); } }); widget.add_controller(gesture); }
fn create_image_cell(&self, image: &crate::atproto::ImageEmbed, height: i32) -> gtk4::Widget { // Frame acts as the clipping container let frame = gtk4::Frame::new(None); frame.set_hexpand(true); frame.set_size_request(-1, height); frame.set_overflow(gtk4::Overflow::Hidden); frame.add_css_class("post-embed-image");
// Cover fills the cell. Fill ignored the aspect ratio and stretched a // 2:3 portrait in a 338x240 cell 2.4x too wide. let picture = gtk4::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); picture.set_can_shrink(true); picture.set_content_fit(gtk4::ContentFit::Cover);
frame.set_child(Some(&picture)); self.load_embed_image(&picture, &image.thumb);
Self::with_alt_badge(frame.upcast(), &picture, &image.alt) }
/// Described images say so: the text becomes the picture's accessible /// description and tooltip, and an ALT badge marks the corner. An /// undescribed image passes through untouched. fn with_alt_badge(cell: gtk4::Widget, picture: >k4::Picture, alt: &str) -> gtk4::Widget { if alt.is_empty() { return cell; } picture.set_alternative_text(Some(alt)); cell.set_tooltip_text(Some(alt));
let overlay = gtk4::Overlay::new(); overlay.set_child(Some(&cell)); let badge = gtk4::Label::new(Some("ALT")); badge.add_css_class("alt-badge"); badge.set_halign(gtk4::Align::End); badge.set_valign(gtk4::Align::End); badge.set_margin_end(6); badge.set_margin_bottom(6); badge.set_can_target(false); overlay.add_overlay(&badge); overlay.upcast() }
/// Render an external link card (clickable to open URL) fn render_external_card(&self, container: >k4::Box, ext: &crate::atproto::ExternalEmbed) { // Bluesky has no GIF embed type; GIFs arrive as external cards. See // `atproto::gif`. Anything unrecognised falls through as a link card. if let Some(gif) = crate::atproto::gif::detect(&ext.uri) { self.render_gif(container, ext, &gif); return; }
// Wrap card in a button for clickability let card_btn = gtk4::Button::new(); card_btn.add_css_class("flat"); card_btn.add_css_class("external-card-button"); card_btn.set_cursor_from_name(Some("pointer"));
// Vertical, matching the platform convention: thumbnail across the top, // then title, a two-line description and the domain. let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0); card.add_css_class("external-card"); // Lets the card's border-radius clip the thumbnail's top corners. card.set_overflow(gtk4::Overflow::Hidden);
// Optional thumbnail spanning the card width, height capped if let Some(thumb_url) = &ext.thumb { let thumb_frame = gtk4::Frame::new(None); thumb_frame.set_hexpand(true); thumb_frame.set_overflow(gtk4::Overflow::Hidden); thumb_frame.add_css_class("external-thumb-top"); // Height only. A width request here would become the minimum // width of the card and then the feed. thumb_frame.set_size_request(-1, 180);
let thumb = gtk4::Picture::new(); thumb.set_hexpand(true); thumb.set_vexpand(true); thumb.set_can_shrink(true); thumb.set_content_fit(gtk4::ContentFit::Cover); thumb_frame.set_child(Some(&thumb)); self.load_embed_image(&thumb, thumb_url); card.append(&thumb_frame); }
// Text content let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); text_box.set_hexpand(true); text_box.add_css_class("external-card-text");
let title = gtk4::Label::new(Some(&Self::collapse_whitespace(&ext.title))); title.set_halign(gtk4::Align::Start); title.set_xalign(0.0); title.add_css_class("external-title"); title.set_ellipsize(gtk4::pango::EllipsizeMode::End); title.set_wrap(true); title.set_wrap_mode(gtk4::pango::WrapMode::WordChar); title.set_lines(2); text_box.append(&title);
if !ext.description.is_empty() { // Strip any HTML tags that might be in the description from Open Graph metadata let clean_desc = Self::collapse_whitespace(&Self::strip_html_tags(&ext.description)); if !clean_desc.is_empty() { let desc = gtk4::Label::new(Some(&clean_desc)); desc.set_halign(gtk4::Align::Start); desc.set_xalign(0.0); desc.add_css_class("dim-label"); desc.add_css_class("caption"); desc.set_ellipsize(gtk4::pango::EllipsizeMode::End); desc.set_wrap(true); desc.set_wrap_mode(gtk4::pango::WrapMode::WordChar); desc.set_lines(2); text_box.append(&desc); } }
// Extract domain from URI and show with web browser icon if let Ok(url) = url::Url::parse(&ext.uri) { if let Some(domain) = url.host_str() { let domain_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); let link_icon = gtk4::Image::from_icon_name("web-browser-symbolic"); link_icon.add_css_class("dim-label"); link_icon.set_pixel_size(12); domain_row.append(&link_icon); let domain_label = gtk4::Label::new(Some(domain)); domain_label.set_halign(gtk4::Align::Start); domain_label.add_css_class("dim-label"); domain_label.add_css_class("caption"); // The card's title and description already ellipsize, so an // unconstrained domain is the whole card's minimum width. domain_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); domain_row.append(&domain_label); text_box.append(&domain_row); } }
card.append(&text_box); card_btn.set_child(Some(&card));
// Open link in browser when clicked let url = ext.uri.clone(); card_btn.connect_clicked(move |btn| { crate::ui::external::open_url(btn, &url, "link"); });
container.append(&card_btn); }
/// The alt text a GIF carries, with Bluesky's own prefix taken back off. /// /// The composer writes the picker's caption into `description` as /// `"ALT: <caption>"`, which is why an unrecognised GIF used to render as a /// link card whose subtitle read "ALT: shrug". fn gif_alt(ext: &crate::atproto::ExternalEmbed) -> Option<String> { let text = ext .description .strip_prefix("ALT: ") .unwrap_or(&ext.description) .trim(); let text = if text.is_empty() { ext.title.trim() } else { text }; (!text.is_empty()).then(|| format!("GIF: {text}")) }
/// Render an animated GIF that arrived as an external embed. /// /// Still frame + badge + play button. An inline pipeline here would mean /// one `playbin3` per visible GIF in a recycled `ListView`, a pipeline per /// row. Clicking opens the media viewer. fn render_gif( &self, container: >k4::Box, ext: &crate::atproto::ExternalEmbed, gif: &crate::atproto::GifEmbed, ) { let overlay = gtk4::Overlay::new(); overlay.add_css_class("video-embed"); overlay.set_hexpand(true);
// The GIF hosts state the exact pixel size in the link's query string, // so this never falls back to 16:9. Same clamp as a single image. let frame = gtk4::Frame::new(None); frame.set_hexpand(true); frame.set_overflow(gtk4::Overflow::Hidden); frame.add_css_class("post-embed-image"); frame.set_size_request(-1, Self::embed_height(Some(gif.aspect_ratio), 180, 430));
let thumb = gtk4::Picture::new(); thumb.set_hexpand(true); thumb.set_vexpand(true); // Without can_shrink the thumbnail's full width becomes the row's // minimum. thumb.set_can_shrink(true); thumb.set_content_fit(gtk4::ContentFit::Contain); if let Some(alt) = Self::gif_alt(ext) { thumb.set_alternative_text(Some(&alt)); } frame.set_child(Some(&thumb)); if let Some(thumb_url) = &ext.thumb { self.load_embed_image(&thumb, thumb_url); } overlay.set_child(Some(&frame));
// The GIF badge. 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")]);
let gif = gif.clone(); let alt = Self::gif_alt(ext); let link = ext.uri.clone(); let post_row = self.downgrade(); play_btn.connect_clicked(move |btn| { let Some(post_row) = post_row.upgrade() else { return; }; // The post on the web, else the GIF's own page. Never the // re-encoded MP4. let fallback = post_row .imp() .post .borrow() .as_ref() .map(|p| Self::get_post_url(&p.author.handle, &p.uri)) .or_else(|| Some(link.clone())); crate::ui::media_viewer::show_video( btn, crate::ui::video_player::VideoSource::from_gif(&gif, alt.clone(), fallback), ); }); overlay.add_overlay(&play_btn);
container.append(&overlay); }
/// Render a video embed: clipping frame at the video's aspect ratio, /// thumbnail, centre play button. /// /// `inline_allowed` decides whether the button arms a /// [`crate::ui::inline_video::VideoSlot`] or opens the media viewer. fn render_video( &self, container: >k4::Box, video: &crate::atproto::VideoEmbed, inline_allowed: bool, ) { let overlay = gtk4::Overlay::new(); overlay.add_css_class("video-embed"); overlay.set_hexpand(true);
// The height goes on a clipping frame. A Picture given more width // than its Contain-scaled image needs centres the result, and there is // no property to align it left. let frame = gtk4::Frame::new(None); frame.set_hexpand(true); frame.set_overflow(gtk4::Overflow::Hidden); frame.add_css_class("post-embed-image"); frame.set_size_request(-1, Self::embed_height(video.aspect_ratio, 200, 400));
let thumb = gtk4::Picture::new(); thumb.set_hexpand(true); thumb.set_vexpand(true); thumb.set_can_shrink(true); // Contain, same as a lone image. The frame's height already comes // from this video's aspect ratio, so it fills edge to edge. Grid cells // keep Cover; see `create_image_cell`. thumb.set_content_fit(gtk4::ContentFit::Contain); if let Some(alt) = video .alt .as_deref() .map(str::trim) .filter(|a| !a.is_empty()) { thumb.set_alternative_text(Some(alt)); }
frame.set_child(Some(&thumb)); if let Some(thumb_url) = &video.thumbnail { self.load_embed_image(&thumb, thumb_url); } overlay.set_child(Some(&frame));
// Play button overlay 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 video")); play_btn.update_property(&[gtk4::accessible::Property::Label("Play video")]);
// The post on the web, or no button. Never the playlist. let fallback = self .imp() .post .borrow() .as_ref() .map(|p| Self::get_post_url(&p.author.handle, &p.uri)); let source = crate::ui::video_player::VideoSource::from_embed(video, fallback);
overlay.add_overlay(&play_btn);
if inline_allowed { let slot = crate::ui::inline_video::VideoSlot::new( &overlay, &frame, &thumb, &play_btn, source, ); // The row owns the slot; the slot owns the pipeline. self.imp().video_slot.replace(Some(slot)); } else { play_btn.connect_clicked(move |btn| { crate::ui::media_viewer::show_video(btn, source.clone()); }); }
container.append(&overlay); }
/// Render a quote post card (clickable to open quoted post) fn render_quote(&self, container: >k4::Box, quote: &crate::atproto::QuoteEmbed) { // Deliberately a Box. GtkButton's click gesture runs in CAPTURE and // claims on release, and capture is walked ancestors-first, so a // button wrapping the card claimed the release before the play button, // images or link card inside it, and they navigated instead of // activating. A bubble-phase gesture on a Box is walked // descendants-first. // // The accessible role must be set at construction; GTK cannot change it // later. let card_btn = glib::Object::builder::<gtk4::Box>() .property("orientation", gtk4::Orientation::Vertical) .property("accessible-role", gtk4::AccessibleRole::Button) .build(); card_btn.add_css_class("quote-card-button"); card_btn.set_cursor_from_name(Some("pointer")); // A Box is not focusable or key-activatable the way the Button was. card_btn.set_focusable(true);
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8); card.add_css_class("quote-card");
// Author row with display name + handle + timestamp on same line 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(avatar_url) = "e.author.avatar { avatar_cache::load_avatar(avatar.clone(), avatar_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);
let dot = gtk4::Label::new(Some("·")); dot.add_css_class("dim-label"); dot.add_css_class("caption"); author_row.append(&dot);
let time_label = gtk4::Label::new(Some(&Self::format_timestamp("e.indexed_at))); time_label.add_css_class("dim-label"); time_label.add_css_class("caption"); author_row.append(&time_label);
card.append(&author_row);
// Quote text if !quote.text.is_empty() { let text_label = gtk4::Label::new(Some("e.text)); text_label.set_halign(gtk4::Align::Start); text_label.set_wrap(true); text_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); text_label.set_xalign(0.0); text_label.add_css_class("caption"); card.append(&text_label); }
// Nested embed (if any) if let Some(nested_embed) = "e.embed { let nested_container = gtk4::Box::new(gtk4::Orientation::Vertical, 8); self.render_embed(&nested_container, nested_embed, false); card.append(&nested_container); }
card_btn.append(&card); card_btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Quoted post by {author_name}" ))]);
// Navigate to quoted post in-app let quoted_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 imp = self.imp();
let gesture = gtk4::GestureClick::new(); // Claim on RELEASE, never on press: claiming on press would deny every // descendant. Without claiming at all, the row body's gesture fires too // and one click opens both the quoted post and the parent thread. gesture.connect_released(glib::clone!( #[weak] imp, #[strong] quoted_post, move |gesture, _, _, _| { gesture.set_state(gtk4::EventSequenceState::Claimed); if let Some(cb) = imp.post_clicked_callback.borrow().as_ref() { cb(quoted_post.clone()); } } )); card_btn.add_controller(gesture);
let key = gtk4::EventControllerKey::new(); key.connect_key_pressed(glib::clone!( #[weak] imp, #[strong] quoted_post, #[upgrade_or] glib::Propagation::Proceed, move |_, keyval, _, _| { match keyval { gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::space | gdk::Key::KP_Space => { if let Some(cb) = imp.post_clicked_callback.borrow().as_ref() { cb(quoted_post.clone()); } glib::Propagation::Stop } _ => glib::Propagation::Proceed, } } )); card_btn.add_controller(key);
container.append(&card_btn); }
fn format_count(count: Option<u32>) -> String { match count { Some(c) if c >= 1_000_000 => format!("{:.1}M", c as f64 / 1_000_000.0), Some(c) if c >= 1_000 => format!("{:.1}K", c as f64 / 1_000.0), Some(c) if c > 0 => c.to_string(), _ => String::new(), // Don't show "0", just leave empty } }
/// Strip HTML tags from text (for cleaning up Open Graph descriptions) fn strip_html_tags(text: &str) -> String { let tag_pattern = regex::Regex::new(r"<[^>]*>").unwrap(); tag_pattern.replace_all(text, "").to_string() }
/// Fold every run of whitespace, newlines included, into a single space. /// /// `gtk_label_set_lines` is a negative Pango layout height, so it clamps N /// lines per paragraph. A thirty-bullet App Store description got sixty /// lines out of set_lines(2), and that is the label's minimum height. fn collapse_whitespace(text: &str) -> String { text.split_whitespace().collect::<Vec<_>>().join(" ") }
/// Pixel height for `aspect_ratio`, clamped to `[min, max]`. /// /// Sized to fill [`CONTENT_WIDTH`] at the clamp width; the height cannot /// track the width without a tick callback or a custom layout manager. fn embed_height(aspect_ratio: Option<(u32, u32)>, min: i32, max: i32) -> i32 { let (w, h) = aspect_ratio .filter(|(w, h)| *w > 0 && *h > 0) .unwrap_or((16, 9)); ((CONTENT_WIDTH * f64::from(h) / f64::from(w)).round() as i32).clamp(min, max) }
/// Set post text on `label`, linkified, with a plain-text fallback. /// /// `gtk_label_set_markup` on markup it cannot parse emits a `Gtk-WARNING` /// and changes nothing, so a recycled row keeps the previous post's text. /// Clear, set markup, and if nothing arrived set it unformatted. /// /// Checked through the label itself. GtkLabel runs its own parser first /// and strips the `<a>` tags `pango::parse_markup` would reject. fn set_post_markup(label: >k4::Label, text: &str) { let markup = Self::format_post_text(text); label.set_text(""); label.set_markup(&markup); if label.text().is_empty() && !text.is_empty() { eprintln!( "hangar: post text could not be marked up; showing it unformatted.\n \ text: {text:?}\n markup: {markup:?}" ); label.set_text(text); } }
/// Format post text with clickable links, mentions and hashtags as Pango /// markup. The rules live in [`crate::ui::rich_text`], shared with /// profile bios; the `bsky-mention://` and `bsky-tag://` schemes are /// handled in `connect_activate_link`. fn format_post_text(text: &str) -> String { crate::ui::rich_text::linkify(text) }
fn format_timestamp(indexed_at: &str) -> String { if indexed_at.is_empty() { return String::new(); }
let Ok(post_time) = chrono::DateTime::parse_from_rfc3339(indexed_at) else { return String::new(); };
let now = chrono::Utc::now(); let duration = now.signed_duration_since(post_time);
if duration.num_seconds() < 60 { "now".to_string() } else if duration.num_minutes() < 60 { format!("{}m", duration.num_minutes()) } else if duration.num_hours() < 24 { format!("{}h", duration.num_hours()) } else if duration.num_days() < 7 { format!("{}d", duration.num_days()) } else { post_time.format("%b %d").to_string() } }
/// Refresh the timestamp display (for periodic updates) pub fn refresh_timestamp(&self) { let imp = self.imp(); let indexed_at = imp.indexed_at.borrow(); if !indexed_at.is_empty() { if let Some(label) = imp.timestamp_label.borrow().as_ref() { label.set_text(&Self::format_timestamp(&indexed_at)); } } }}
impl Default for PostRow { fn default() -> Self { Self::new() }}
#[cfg(test)]mod tests { use super::*; use crate::atproto::{ExternalEmbed, QuoteEmbed, VideoEmbed};
#[test] fn collapse_whitespace_folds_paragraphs_so_the_line_clamp_binds() { // set_lines clamps per paragraph, so a thirty-bullet App Store // description was allowed sixty lines. let bullets = (1..=30) .map(|i| format!("• Feature bullet {i}")) .collect::<Vec<_>>() .join("\n"); let collapsed = PostRow::collapse_whitespace(&bullets); assert!(!collapsed.contains('\n'), "no paragraph breaks survive"); assert!(collapsed.starts_with("• Feature bullet 1 • Feature bullet 2"));
// Every flavour of wire whitespace: \r\n, tabs, U+2028, runs of spaces. assert_eq!( PostRow::collapse_whitespace("a\r\nb\tc\u{2028}d e\n\n\nf"), "a b c d e f" ); assert_eq!(PostRow::collapse_whitespace(" \n\t "), ""); assert_eq!(PostRow::collapse_whitespace(""), ""); }
#[test] fn embed_height_tracks_the_aspect_ratio_and_clamps() { // Derived from CONTENT_WIDTH so tuning it doesn't break this test. let expect = |w: f64, h: f64| (CONTENT_WIDTH * h / w).round() as i32;
let sixteen_nine = expect(16.0, 9.0); assert_eq!(PostRow::embed_height(Some((16, 9)), 180, 430), sixteen_nine); assert_eq!( PostRow::embed_height(Some((3, 1)), 180, 430), expect(3.0, 1.0) ); // A tall portrait hits the cap rather than taking over the row. assert_eq!(PostRow::embed_height(Some((2, 3)), 180, 430), 430); // A very wide panorama hits the floor rather than becoming a sliver. assert_eq!(PostRow::embed_height(Some((10, 1)), 180, 430), 180); // Missing or nonsense ratios fall back to 16:9 instead of dividing by // zero. The API makes aspectRatio optional and does not promise > 0. assert_eq!(PostRow::embed_height(None, 180, 430), sixteen_nine); assert_eq!(PostRow::embed_height(Some((0, 9)), 180, 430), sixteen_nine); assert_eq!(PostRow::embed_height(Some((16, 0)), 180, 430), sixteen_nine); // Whatever the estimate, the clamp is what actually bounds the row. assert!((180..=430).contains(&sixteen_nine)); }
/// A Klipy GIF exactly as one arrives on the wire. const KLIPY_GIF: &str = "https://static.klipy.com/ii/4493325008d34b7bf8cd6813cd5c1619/76/03/cgMM2dB2oEoY.gif?hh=278&ww=498&mp4=UxPaHvFAwuHpj3&webm=MCEXFCTK5uN0J47r";
/// Whatever the post says, it has to end up on the label. /// /// Asserted through a real `GtkLabel`: it runs two parsers, its own for the /// `<a>` tags and then Pango's, and only the label answers for both. Every /// case below was a post that rendered blank. #[test] fn every_kind_of_post_text_ends_up_on_the_label() { crate::ui::with_gtk(every_kind_of_post_text_ends_up_on_the_label_body); }
fn every_kind_of_post_text_ends_up_on_the_label_body() { let label = gtk4::Label::new(None);
let cases = [ // The three characters that are markup. "Tom & Jerry", "5 < 6 > 4 && 7 > 3", "she said \"hi\" and it's fine", // The reported crash, near enough: a control character between an // emoji sequence and a hyphen. GLib escapes U+001F to ``, // and the hashtag pattern used to take `#x1f` straight out of the // middle of it, leaving an entity with no semicolon. "Finally picked up Caper: Europe by @keymastergames.bsky.social, \ excited to try it\u{1f}-just look at that art direction \ keymaster.fun/products/caper", // Every other character GLib escapes numerically. "bell\u{7} backspace\u{8} vtab\u{b} escape\u{1b} delete\u{7f}", // A mention pressed up against an ampersand, both directions. "AT&T @someone.bsky.social & #rust", "@a.bsky.social&#rust", "&@a.bsky.social", // Emoji, including a skin-tone modifier and a ZWJ sequence, which // are the multi-codepoint cases a byte-offset bug would split. "\u{1F44D}\u{1F3FD} and \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466} \u{FE0F}", // A query string is mostly ampersands, and it is inside an href. "https://example.com/s?a=1&b=2&=3 <tag> #done", "R&D at r&d.example.com", // Degenerate input. "", "#", "&", "<", "", "&", "&&&###@@@", ];
for text in cases { PostRow::set_post_markup(&label, text); assert_eq!( label.text().as_str(), text, "the label must show the post, not an empty string\n markup: {}", PostRow::format_post_text(text) ); // `use-markup` still on means the markup was accepted rather than // having gone down the unformatted fallback. assert!( label.uses_markup(), "{text:?} fell back to plain text; the markup was rejected\n markup: {}", PostRow::format_post_text(text) ); }
// If unparseable markup ever reaches the label, it should still // show the text instead of going blank. label.set_text("a previous post, from before this row was recycled"); label.set_markup("broken &markup <a href=\"x\">"); assert!( label.text().contains("previous post"), "GtkLabel leaves the old text in place on a parse failure, which is \ what set_post_markup exists to catch" ); }
#[test] fn links_mentions_and_hashtags_are_still_linkified() { // The escaping fix must not have quietly turned everything into plain // text: these are what makes the label worth marking up at all. let markup = PostRow::format_post_text( "hi @user.bsky.social see https://example.com and example.org too #rust", ); assert!( markup.contains(r#"<a href="bsky-mention://user.bsky.social">@user.bsky.social</a>"#), "{markup}" ); assert!( markup.contains(r#"<a href="https://example.com">https://example.com</a>"#), "{markup}" ); assert!( markup.contains(r#"<a href="https://example.org">example.org</a>"#), "{markup}" ); assert!( markup.contains(r#"<a href="bsky-tag://rust">#rust</a>"#), "{markup}" );
// A handle must come out as a mention even though `.social` is in // the bare-domain TLD list. let markup = PostRow::format_post_text("@someone.bsky.social"); assert!( markup.starts_with(r#"<a href="bsky-mention://"#), "{markup}" );
// An `&` inside a URL is escaped in the href as well as in the body, // and the two agree. let markup = PostRow::format_post_text("https://example.com/?a=1&b=2"); assert_eq!( markup, r#"<a href="https://example.com/?a=1&b=2">https://example.com/?a=1&b=2</a>"# ); }
fn profile() -> Profile { Profile { did: "did:plc:test".into(), handle: "someone.bsky.social".into(), display_name: Some("Someone".into()), avatar: None, banner: None, description: None, followers_count: None, following_count: None, posts_count: None, viewer_following: None, viewer_followed_by: None, viewer_muted: false, viewer_blocking: None, viewer_blocked_by: false, } }
/// A URL that fails to connect immediately, so nothing in the test waits on /// a network that may not be there. fn dead_url() -> String { "http://127.0.0.1:1/x.jpg".into() }
fn quote_with(embed: Option<Embed>) -> QuoteEmbed { QuoteEmbed { uri: "at://did:plc:test/app.bsky.feed.post/quoted".into(), cid: "cid".into(), author: profile(), text: "the quoted text".into(), indexed_at: "2026-01-01T00:00:00Z".into(), embed: embed.map(Box::new), } }
/// Every controller on `widget`, as (type name, propagation phase). fn controllers(widget: >k4::Widget) -> Vec<(String, gtk4::PropagationPhase)> { let list = widget.observe_controllers(); (0..list.n_items()) .filter_map(|i| list.item(i)) .filter_map(|obj| obj.downcast::<gtk4::EventController>().ok()) .map(|c| (c.type_().name().to_string(), c.propagation_phase())) .collect() }
fn walk(widget: >k4::Widget, depth: usize, out: &mut Vec<(usize, gtk4::Widget)>) { out.push((depth, widget.clone())); let mut child = widget.first_child(); while let Some(c) = child { walk(&c, depth + 1, out); child = c.next_sibling(); } }
/// Standalone media is shown whole at its own aspect ratio. #[test] fn standalone_media_is_shown_whole_and_a_gif_is_playable() { crate::ui::with_gtk(standalone_media_is_shown_whole_and_a_gif_is_playable_body); }
fn standalone_media_is_shown_whole_and_a_gif_is_playable_body() { let row = PostRow::new();
let pictures = |widgets: &[(usize, gtk4::Widget)]| -> Vec<gtk4::Picture> { widgets .iter() .filter_map(|(_, w)| w.downcast_ref::<gtk4::Picture>().cloned()) .collect() };
// ---- A GIF, which arrives as an external embed ---- // // Square, so its height is unmistakably not the 16:9 an embed with no // stated aspect ratio would fall back to. let square_gif = KLIPY_GIF.replace("hh=278&ww=498", "hh=500&ww=500"); let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_embed( &container, &Embed::External(ExternalEmbed { uri: square_gif, title: "Team".into(), description: "ALT: a shrug".into(), thumb: Some(dead_url()), }), true, ); let embed = container.first_child().expect("the GIF rendered something"); assert!( !embed.is::<gtk4::Button>() && embed.has_css_class("video-embed"), "a GIF is playable media, not the link card it arrives disguised as" );
let mut widgets = Vec::new(); walk(&embed, 0, &mut widgets); assert!( widgets.iter().any(|(_, w)| w .downcast_ref::<gtk4::Label>() .is_some_and(|l| l.text() == "GIF")), "a still frame needs something on it saying that it moves" ); assert!( widgets.iter().any(|(_, w)| w.is::<gtk4::Button>()), "and something to press to make it move" ); let frame = widgets .iter() .find_map(|(_, w)| w.downcast_ref::<gtk4::Frame>().cloned()) .expect("the clipping frame carries the height"); assert_eq!( frame.height_request(), PostRow::embed_height(Some((500, 500)), 180, 430), "the GIF hosts state the exact size, so nothing here guesses 16:9" ); for picture in pictures(&widgets) { assert_eq!(picture.content_fit(), gtk4::ContentFit::Contain); assert!( picture.can_shrink(), "a Picture that cannot shrink reports its image's width as the \ row minimum, and the timeline has no horizontal scrollbar to \ absorb that" ); }
// ---- An ordinary link is still an ordinary link card ---- let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_embed( &container, &Embed::External(ExternalEmbed { uri: "https://www.theguardian.com/world/2026/aug/01/a-story".into(), title: "A story".into(), description: "Something happened".into(), thumb: Some(dead_url()), }), true, ); assert!( container .first_child() .expect("the link card rendered") .is::<gtk4::Button>(), "only GIF hosts are diverted; everything else keeps its link card" );
// ---- A single video, which has nothing to line up with ---- let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_embed( &container, &Embed::Video(VideoEmbed { playlist: dead_url(), thumbnail: Some(dead_url()), alt: Some("a cat".into()), aspect_ratio: Some((9, 16)), }), true, ); let embed = container.first_child().expect("the video rendered"); let mut widgets = Vec::new(); walk(&embed, 0, &mut widgets); let frame = widgets .iter() .find_map(|(_, w)| w.downcast_ref::<gtk4::Frame>().cloned()) .expect("the video's clipping frame"); assert_eq!( frame.height_request(), PostRow::embed_height(Some((9, 16)), 200, 400) ); for picture in pictures(&widgets) { assert_eq!( picture.content_fit(), gtk4::ContentFit::Contain, "Cover crops to fill, and a lone video has no neighbour whose \ edges it has to match — cropping it is just losing the frame \ the author chose" ); assert_eq!(picture.alternative_text().as_deref(), Some("a cat")); }
// ---- Grid cells keep Cover, which is the point of the distinction ---- let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_embed( &container, &Embed::Images(vec![ ImageEmbed { thumb: dead_url(), fullsize: dead_url(), alt: String::new(), aspect_ratio: Some((2, 3)), }; 4 ]), true, ); let embed = container.first_child().expect("the grid rendered"); let mut widgets = Vec::new(); walk(&embed, 0, &mut widgets); let cells = pictures(&widgets); assert_eq!(cells.len(), 4); for picture in cells { assert_eq!( picture.content_fit(), gtk4::ContentFit::Cover, "in a grid the cells have to line up, so they crop" ); } }
fn post_with(embed: Option<Embed>, uri: &str) -> Post { Post { uri: uri.into(), cid: "cid".into(), author: profile(), text: "a post".into(), created_at: "2026-01-01T00:00:00Z".into(), indexed_at: "2026-01-01T00:00:00Z".into(), like_count: None, repost_count: None, reply_count: None, embed, viewer_like: None, viewer_repost: None, viewer_bookmarked: None, repost_reason: None, reply_context: None, } }
fn a_video(playlist: &str) -> Embed { Embed::Video(VideoEmbed { playlist: playlist.into(), thumbnail: Some(dead_url()), alt: Some("a cat".into()), aspect_ratio: Some((16, 9)), }) }
/// A recycled row must not carry the old post's pipeline. /// /// Two paths: the factory's `unbind`, and `bind` reusing a row. Checked /// against `live_pipelines`. #[test] fn a_recycled_row_gives_up_its_video() { crate::ui::with_gtk(a_recycled_row_gives_up_its_video_body); }
fn a_recycled_row_gives_up_its_video_body() { use crate::ui::inline_video::{Intent, VideoDirector, set_director};
let director = VideoDirector::new(&crate::state::AppSettings::default()); set_director(&director); assert_eq!(crate::media::live_pipelines(), 0);
let row = PostRow::new(); row.bind(&post_with( Some(a_video("https://127.0.0.1:1/hangar-row-1.mp4")), "at://did:plc:test/app.bsky.feed.post/one", ));
let slot = row .imp() .video_slot .borrow() .clone() .expect("a timeline video row owns a slot"); director.arm(&slot, Intent::Manual); assert_eq!( crate::media::live_pipelines(), 1, "pressing play should have built exactly one pipeline" ); drop(slot);
// Door one: the row is reused for somebody else's post. row.bind(&post_with(None, "at://did:plc:test/app.bsky.feed.post/two")); assert_eq!( crate::media::live_pipelines(), 0, "rebinding left the previous post's pipeline running" ); assert!( row.imp().video_slot.borrow().is_none(), "a post with no video left the previous post's slot in place" );
// Door two: the factory unbinds the row while it is still playing. row.bind(&post_with( Some(a_video("https://127.0.0.1:1/hangar-row-2.mp4")), "at://did:plc:test/app.bsky.feed.post/three", )); let slot = row .imp() .video_slot .borrow() .clone() .expect("the third post is a video too"); director.arm(&slot, Intent::Manual); assert_eq!(crate::media::live_pipelines(), 1); drop(slot);
row.release_video(); assert_eq!( crate::media::live_pipelines(), 0, "unbind left a pipeline bound to a row the list has taken back" ); // Idempotent, because unbind and the next bind both call it. row.release_video(); assert_eq!(crate::media::live_pipelines(), 0); }
/// Answers every request with a small PNG after a delay, so a load is /// still out when the row under test is bound again. fn slow_image_server() -> u16 { let pixbuf = gdk::gdk_pixbuf::Pixbuf::new(gdk::gdk_pixbuf::Colorspace::Rgb, false, 8, 16, 16) .expect("allocate pixbuf"); pixbuf.fill(0x00_88_44_ff); let png = pixbuf .save_to_bufferv("png", &[]) .expect("encode png") .to_vec();
let server = tiny_http::Server::http("127.0.0.1:0").expect("bind test server"); let port = server .server_addr() .to_ip() .expect("test server address") .port(); std::thread::spawn(move || { for request in server.incoming_requests() { let png = png.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(400)); let _ = request.respond(tiny_http::Response::from_data(png)); }); } }); port }
/// The single picture in a row's embed subtree. fn only_picture(row: &PostRow) -> gtk4::Picture { let container = embed_container_of(row); let mut widgets = Vec::new(); walk(container.upcast_ref(), 0, &mut widgets); let mut pictures = widgets .into_iter() .filter_map(|(_, w)| w.downcast::<gtk4::Picture>().ok()); let picture = pictures.next().expect("the image embed built a picture"); assert!(pictures.next().is_none(), "one image, one picture"); picture }
/// A recycled row stops loading the last post's images. /// /// A `GtkListView` binds one pooled row to post after post, and a load /// that nothing calls off keeps a 16ms poll and one of the eight download /// permits alive for a picture the user has already scrolled past, which /// is how the images actually on screen ended up last in the queue. /// /// The abandoned picture is held here on purpose. Otherwise it would be /// finalized with the old embed subtree and the poll would stop on its /// own, which measures the widget going away rather than the cancel. #[test] fn a_rebind_cancels_the_previous_posts_image_loads() { crate::ui::with_gtk(a_rebind_cancels_the_previous_posts_image_loads_body); }
fn a_rebind_cancels_the_previous_posts_image_loads_body() { let port = slow_image_server(); let url = format!("http://127.0.0.1:{port}/thumb.png?rebind"); let with_image = |uri: &str| { post_with( Some(Embed::Images(vec![ImageEmbed { thumb: url.clone(), fullsize: url.clone(), alt: String::new(), aspect_ratio: Some((1, 1)), }])), uri, ) };
let recycled = PostRow::new(); recycled.bind(&with_image("at://did:plc:test/app.bsky.feed.post/one")); let abandoned = only_picture(&recycled); assert_eq!( recycled.imp().image_loads.borrow().len(), 1, "the row kept no handle for the image it just started, so nothing \ can call the load off" );
// Same URL, so one fetch settles both rows and a server that never // answered cannot pass this test. let control = PostRow::new(); control.bind(&with_image("at://did:plc:test/app.bsky.feed.post/two")); let watched = only_picture(&control);
recycled.bind(&post_with( None, "at://did:plc:test/app.bsky.feed.post/three", )); assert!( recycled.imp().image_loads.borrow().is_empty(), "the rebind left the previous post's loads on the row" );
settle(1500); assert!( watched.paintable().is_some(), "the fetch never landed, so this test proved nothing" ); assert!( abandoned.paintable().is_none(), "the recycled row's poll was still running and painted the old \ post's image onto a widget the feed has taken back" ); }
/// A quoted video keeps its thumbnail and opens the viewer. #[test] fn a_quoted_video_does_not_play_inline() { crate::ui::with_gtk(a_quoted_video_does_not_play_inline_body); }
fn a_quoted_video_does_not_play_inline_body() { let row = PostRow::new(); let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_quote( &container, "e_with(Some(a_video("https://127.0.0.1:1/quoted.mp4"))), ); assert!( row.imp().video_slot.borrow().is_none(), "a quoted video claimed the row's one inline slot" );
// ...while the same video in the post's own embed does. let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_embed( &container, &a_video("https://127.0.0.1:1/standalone.mp4"), true, ); assert!( row.imp().video_slot.borrow().is_some(), "a standalone video should be playable in place" ); row.release_video(); }
/// A quote card must not out-rank the controls inside it. See /// `render_quote`: nothing enclosing the card's interior may run a /// capture-phase gesture. /// /// Video, images, the link card and a nested quote share one test because /// GTK may only be used from the thread that initialised it. #[test] fn quote_card_does_not_pre_empt_the_controls_inside_it() { // On the GTK thread, or skipped where there is no display: see // `crate::ui::with_gtk`. crate::ui::with_gtk(quote_card_does_not_pre_empt_the_controls_inside_it_body); }
fn quote_card_does_not_pre_empt_the_controls_inside_it_body() { let row = PostRow::new(); let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
// A quote carrying a video, so the card contains a real GtkButton (the // play button) that the old wrapper used to steal the click from. row.render_quote( &container, "e_with(Some(Embed::Video(VideoEmbed { playlist: dead_url(), thumbnail: Some(dead_url()), alt: None, aspect_ratio: Some((16, 9)), }))), );
let card = container.first_child().expect("quote card was rendered");
assert!( !card.is::<gtk4::Button>(), "the quote card wrapper must not be a GtkButton: its internal \ gesture is capture-phase and claims on release, which pre-empts \ every control inside the card" );
let card_controllers = controllers(&card); let clicks: Vec<_> = card_controllers .iter() .filter(|(name, _)| name == "GtkGestureClick") .collect(); assert_eq!(clicks.len(), 1, "exactly one click gesture on the card"); assert_eq!( clicks[0].1, gtk4::PropagationPhase::Bubble, "the card's gesture must bubble: bubble is walked descendants-first, \ so interactive children get first refusal" );
// The Button we replaced was focusable, key-activatable and announced as // a button. A plain Box is none of those unless we say so. assert!(card.is_focusable(), "keyboard can still reach the card"); assert_eq!(card.accessible_role(), gtk4::AccessibleRole::Button); assert!( card_controllers .iter() .any(|(name, _)| name == "GtkEventControllerKey"), "Enter/Space still activate the card" );
// Nothing between the card and its interior may capture, either. let mut widgets = Vec::new(); walk(&card, 0, &mut widgets); let play_depth = widgets .iter() .find(|(_, w)| w.is::<gtk4::Button>()) .map(|(d, _)| *d) .expect("the nested video's play button"); for (depth, widget) in &widgets { if *depth >= play_depth { continue; } for (name, phase) in controllers(widget) { assert_ne!( phase, gtk4::PropagationPhase::Capture, "{name} on an ancestor of the play button captures, which \ is exactly the bug: capture runs ancestors-first" ); } }
// The play button keeps its own capture-phase gesture, which is what // lets it out-rank the card's bubble gesture and activate on its own. let play = widgets .iter() .find(|(_, w)| w.is::<gtk4::Button>()) .map(|(_, w)| w.clone()) .expect("the nested video's play button"); assert!( controllers(&play) .iter() .any(|(name, phase)| name == "GtkGestureClick" && *phase == gtk4::PropagationPhase::Capture), "GtkButton's own gesture still captures and claims on release" );
// ---- Images and the external link card ---- // // Images: the frame carries a bubble GestureClick that claims, and it // sits deeper than the card, so the bubble walk reaches it first. let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_quote( &container, "e_with(Some(Embed::Images(vec![ ImageEmbed { thumb: dead_url(), fullsize: dead_url(), alt: String::new(), aspect_ratio: Some((4, 3)), }; 4 ]))), ); let card = container.first_child().expect("quote card"); let mut widgets = Vec::new(); walk(&card, 0, &mut widgets); let cells: Vec<_> = widgets .iter() .filter(|(_, w)| { w.is::<gtk4::Frame>() && controllers(w).iter().any(|(name, phase)| { name == "GtkGestureClick" && *phase == gtk4::PropagationPhase::Bubble }) }) .collect(); assert_eq!( cells.len(), 4, "every image cell is independently clickable" ); for (depth, _) in &cells { assert!( *depth > 0, "cells are descendants of the card, so bubble first" ); }
// External link card: still a GtkButton, so it captures and claims on // release, out-ranking the card's bubble gesture. let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_quote( &container, "e_with(Some(Embed::External(ExternalEmbed { uri: "https://apps.apple.com/app/id1".into(), title: "A title\nwith a newline".into(), description: "line one\nline two\nline three".into(), thumb: Some(dead_url()), }))), ); let card = container.first_child().expect("quote card"); let mut widgets = Vec::new(); walk(&card, 0, &mut widgets); let link_btn = widgets .iter() .find(|(_, w)| w.is::<gtk4::Button>()) .map(|(_, w)| w.clone()) .expect("the external link card button"); assert!( controllers(&link_btn) .iter() .any(|(name, phase)| name == "GtkGestureClick" && *phase == gtk4::PropagationPhase::Capture), "the link card captures and claims, so it opens the link rather \ than letting the quote card navigate" );
// And its wire text reached the labels as a single paragraph, so the // two-line clamp actually clamps. for (_, widget) in &widgets { if let Some(label) = widget.downcast_ref::<gtk4::Label>() { assert!( !label.text().contains('\n'), "label text {:?} still has a paragraph break, so set_lines \ will not bind", label.text() ); } }
// ---- A nested quote (a quote of a quote) navigates to the INNER post. let container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); row.render_quote( &container, "e_with(Some(Embed::Quote(quote_with(None)))), );
let outer = container.first_child().expect("outer quote card"); let mut widgets = Vec::new(); walk(&outer, 0, &mut widgets); let cards: Vec<_> = widgets .iter() .filter(|(_, w)| w.has_css_class("quote-card-button")) .collect(); assert_eq!(cards.len(), 2, "outer card plus the nested one"); // Both bubble, so the deeper one is reached first and claims. for (_, widget) in &cards { assert!( controllers(widget) .iter() .any(|(name, phase)| name == "GtkGestureClick" && *phase == gtk4::PropagationPhase::Bubble) ); } assert!(cards[1].0 > cards[0].0, "the nested card is the deeper one"); }
// --------------------------------------------------------------------- // Rebinding a row that is on screen // ---------------------------------------------------------------------
/// Pump the main context for `ms`, so GTK has frames in which to lay /// anything out. fn settle(ms: u64) { let ctx = glib::MainContext::default(); let deadline = std::time::Instant::now() + std::time::Duration::from_millis(ms); while std::time::Instant::now() < deadline { while ctx.iteration(false) {} std::thread::sleep(std::time::Duration::from_millis(2)); } }
/// The timeline's shape around a column of rows: no horizontal valve, an /// `AdwClamp`, and a box that leaves its children their natural height. /// /// Presented, and checked for having mapped. An unallocated widget answers /// every geometry question with a zero or a stale number. fn presented_column() -> (adw::Window, gtk4::ScrolledWindow, gtk4::Box) { use libadwaita::prelude::AdwWindowExt;
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0); column.set_valign(gtk4::Align::Start);
let clamp = adw::Clamp::new(); clamp.set_maximum_size(800); clamp.set_tightening_threshold(600); clamp.set_child(Some(&column));
let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_vexpand(true); scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); scrolled.set_child(Some(&clamp));
let window = adw::Window::new(); window.set_default_size(700, 900); window.set_content(Some(&scrolled)); window.present(); settle(250); (window, scrolled, column) }
/// Give up if the session will not put a window on screen. fn window_is_usable(window: &adw::Window, scrolled: >k4::ScrolledWindow) -> bool { if window.is_mapped() && scrolled.width() > 400 { return true; } eprintln!( "skipping: the window never mapped (mapped {}, viewport {}px wide), so \ there is no geometry to measure", window.is_mapped(), scrolled.width() ); false }
fn embed_container_of(row: &PostRow) -> gtk4::Box { row.imp() .embed_container .borrow() .clone() .expect("every row builds its embed container in setup_ui") }
/// The embed subtree as (depth, type, width, height), container included. /// /// Compared against a freshly built row instead of numbers written down /// here, so retuning `embed_height` or restyling a card does not break it. fn subtree_geometry(container: >k4::Box) -> Vec<(usize, String, i32, i32)> { let mut widgets = Vec::new(); walk(container.upcast_ref(), 0, &mut widgets); widgets .into_iter() .map(|(depth, w)| (depth, w.type_().name().to_string(), w.width(), w.height())) .collect() }
/// What GTK paints at nine points across the embed band, as /// `Type@depth-below-the-container`. /// /// Hit-tested rather than read off allocations. A container can have the /// right size and still never allocate its child; the blank band this /// guards against picks only the container itself. /// /// `pick` resolves downwards from the container, so where the row is /// scrolled to does not matter. fn band_hits(container: >k4::Box) -> Vec<String> { let width = f64::from(container.width()); let height = f64::from(container.height()); let root: gtk4::Widget = container.clone().upcast(); let mut hits = Vec::new(); for fy in [0.25_f64, 0.5, 0.75] { for fx in [0.25_f64, 0.5, 0.75] { let hit = container.pick(width * fx, height * fy, gtk4::PickFlags::DEFAULT); hits.push(match hit { None => "NOTHING".to_string(), Some(hit) if hit == root => "THE EMPTY CONTAINER".to_string(), Some(hit) => { // How far beneath the container GTK had to go. One // level is a filler box; real embeds are deeper. let mut depth = 0; let mut cursor = Some(hit.clone()); while let Some(widget) = cursor { if widget == root { break; } depth += 1; cursor = widget.parent(); } format!("{}@{depth}", hit.type_().name()) } }); } } hits }
fn image(aspect_ratio: (u32, u32)) -> ImageEmbed { ImageEmbed { thumb: dead_url(), fullsize: dead_url(), alt: "alt text".into(), aspect_ratio: Some(aspect_ratio), } }
/// Every kind of embed a row can be recycled onto, plus the post with no /// embed, which is the transition that empties the container. fn every_embed_kind() -> Vec<(&'static str, Option<Embed>)> { vec![ ("one image", Some(Embed::Images(vec![image((16, 9))]))), ("two images", Some(Embed::Images(vec![image((1, 1)); 2]))), ("three images", Some(Embed::Images(vec![image((4, 3)); 3]))), ("four images", Some(Embed::Images(vec![image((2, 3)); 4]))), ( "video", Some(Embed::Video(VideoEmbed { playlist: "https://127.0.0.1:1/rebind.mp4".into(), thumbnail: Some(dead_url()), alt: Some("a cat".into()), aspect_ratio: Some((16, 9)), })), ), ( "gif", Some(Embed::External(ExternalEmbed { uri: KLIPY_GIF.into(), title: "Team".into(), description: "ALT: a shrug".into(), thumb: Some(dead_url()), })), ), ( "link card", Some(Embed::External(ExternalEmbed { uri: "https://www.example.com/world/2026/aug/01/a-story".into(), title: "A story about something".into(), description: "Something happened, and here is a sentence about it".into(), thumb: Some(dead_url()), })), ), ("quote", Some(Embed::Quote(quote_with(None)))), ( "quote with media", Some(Embed::QuoteWithMedia { quote: quote_with(None), media: Box::new(Embed::Images(vec![image((16, 9))])), }), ), ("no embed", None), ] }
/// A row that is bound again shows the new post's embed, every time. /// /// The property the virtualization migration rests on. A `GtkListView` /// binds one pooled row to post after post, so anything `bind` gets wrong /// the second time is a blank band in the feed. /// /// Each case is measured against a row built seconds earlier and bound to /// the same post, in the same window, at the same width. Subtree geometry /// and nine hit tests must match, and none of the nine may come back as the /// container itself, which is what a subtree allocated 0x0 looks like. #[test] fn a_rebound_row_renders_every_embed_kind_where_you_can_hit_it() { crate::ui::with_gtk(a_rebound_row_renders_every_embed_kind_where_you_can_hit_it_body); }
fn a_rebound_row_renders_every_embed_kind_where_you_can_hit_it_body() { /// Three, so the cycle is walked twice and two of the binds are /// rebinds of a row already showing something. const PASSES: usize = 3;
let (window, scrolled, column) = presented_column(); if !window_is_usable(&window, &scrolled) { return; }
let recycled = PostRow::new(); column.append(&recycled); let recycled_container = embed_container_of(&recycled);
for pass in 0..PASSES { for (kind, embed) in every_embed_kind() { let uri = format!("at://did:plc:test/app.bsky.feed.post/{kind}-{pass}");
// The control. Never held anything else, same post, same // column, same frame. let fresh = PostRow::new(); column.append(&fresh); fresh.bind(&post_with(embed.clone(), &uri)); let fresh_container = embed_container_of(&fresh);
recycled.bind(&post_with(embed.clone(), &uri)); settle(120);
let at = format!("pass {pass}, {kind}");
assert_eq!( recycled_container.is_visible(), fresh_container.is_visible(), "{at}: the recycled row's embed container is not even shown the \ way a new row's is" );
if embed.is_none() { assert!( !recycled_container.is_visible() && recycled_container.first_child().is_none(), "{at}: a post with no embed must leave the container empty \ and hidden, not showing the last post's media" ); column.remove(&fresh); continue; }
assert!( recycled_container.width() > 0 && recycled_container.height() > 0, "{at}: the embed band itself was never allocated ({}x{})", recycled_container.width(), recycled_container.height() );
// A zero-size widget is what a child its parent never // allocated looks like. let mut widgets = Vec::new(); walk(recycled_container.upcast_ref(), 0, &mut widgets); let unallocated: Vec<String> = widgets .iter() .skip(1) .filter(|(_, w)| w.is_visible() && (w.width() == 0 || w.height() == 0)) .map(|(depth, w)| format!("{}{}", " ".repeat(*depth), w.type_().name())) .collect(); assert!( unallocated.is_empty(), "{at}: {} widget(s) in the new embed subtree are visible but \ allocated 0x0, so the band is blank:\n{}", unallocated.len(), unallocated.join("\n") );
// A blank band picks the container and nothing else. Anything // real is at least two levels down. let hits = band_hits(&recycled_container); assert!( !hits .iter() .any(|h| h == "THE EMPTY CONTAINER" || h == "NOTHING"), "{at}: hit-testing the band found no embed beneath the \ container at some of the nine sampled points: {hits:?}" ); let deepest = hits .iter() .filter_map(|h| h.rsplit_once('@')) .filter_map(|(_, d)| d.parse::<usize>().ok()) .max() .unwrap_or(0); assert!( deepest >= 2, "{at}: the band picks only filler one level down, not a \ frame, picture, card or play button: {hits:?}" );
// And it matches what a new row shows. assert_eq!( hits, band_hits(&fresh_container), "{at}: the recycled row paints something different from a \ row bound to this post for the first time" ); assert_eq!( subtree_geometry(&recycled_container), subtree_geometry(&fresh_container), "{at}: the recycled row's embed subtree is not laid out the \ way a new row's is" );
column.remove(&fresh); } } }
/// One click on a recycled row does one thing. /// /// The buttons `bind` wires are built once in `setup_ui` and outlive every /// post the row shows. Without a disconnect a pooled row accumulates one /// live closure per post it has been bound to, each holding that post, so /// one click opens every reposter the row has shown, pushes a thread page /// per bind, and copies a link per bind. /// /// Counted through the row's navigation callbacks, where a user would see /// it, instead of by counting handlers. #[test] fn rebinding_a_row_does_not_stack_up_the_last_posts_click_handlers() { crate::ui::with_gtk(rebinding_a_row_does_not_stack_up_the_last_posts_click_handlers_body); }
fn rebinding_a_row_does_not_stack_up_the_last_posts_click_handlers_body() { use std::cell::RefCell as StdRefCell; use std::rc::Rc;
/// Enough that an off-by-one in the disconnect is not a pass. const BINDS: usize = 4;
let row = PostRow::new();
let profiles: Rc<StdRefCell<Vec<String>>> = Rc::default(); let sink = Rc::clone(&profiles); row.set_profile_clicked_callback(move |p| sink.borrow_mut().push(p.handle.clone()));
let posts: Rc<StdRefCell<Vec<String>>> = Rc::default(); let sink = Rc::clone(&posts); row.set_post_clicked_callback(move |p| sink.borrow_mut().push(p.uri.clone()));
let someone = |n: usize| Profile { handle: format!("someone{n}.bsky.social"), ..profile() };
for n in 0..BINDS { let mut post = post_with(None, &format!("at://did:plc:test/app.bsky.feed.post/{n}")); post.repost_reason = Some(crate::atproto::RepostReason { by: someone(n), indexed_at: "2026-01-01T00:00:00Z".into(), }); post.reply_context = Some(crate::atproto::ReplyContext { parent_author: someone(100 + n), root_author: someone(100 + n), }); row.bind(&post); } let last = BINDS - 1;
let imp = row.imp(); let click = |btn: &StdRefCell<Option<gtk4::Button>>| { btn.borrow() .as_ref() .expect("built in setup_ui") .emit_clicked(); };
click(&imp.repost_attribution_btn); assert_eq!( *profiles.borrow(), vec![someone(last).handle], "one click on the repost attribution of a row bound {BINDS} times \ must open one profile, and it must be this post's reposter" ); profiles.borrow_mut().clear();
click(&imp.reply_indicator); assert_eq!( *profiles.borrow(), vec![someone(100 + last).handle], "one click on the reply indicator must open one profile, and it must \ be this post's parent author" ); profiles.borrow_mut().clear();
click(&imp.view_post_item); assert_eq!( *posts.borrow(), vec![format!("at://did:plc:test/app.bsky.feed.post/{last}")], "\"View Post and Replies\" must push one thread page, not one per \ post the row has ever been recycled onto" ); posts.borrow_mut().clear();
// "Copy Link to Post" captures the URL by value, so a stale handler // writes a different post's link and raises a toast for it. Counted off // the clipboard's change notification. // // Compared against a control write: how many `changed` one `set_text` // produces is the display's business, and on X11 it is two. let clipboard = gtk4::gdk::Display::default() .expect("a display, since with_gtk started GTK") .clipboard(); let notifications = Rc::new(std::cell::Cell::new(0usize)); let counter = Rc::clone(¬ifications); clipboard.connect_changed(move |_| counter.set(counter.get() + 1));
let control = gtk4::Button::new(); control.connect_clicked(|btn| { crate::ui::external::copy_text(btn, "one write", "Control"); }); control.emit_clicked(); settle(80); let per_write = notifications.get(); assert!( per_write >= 1, "the clipboard reports nothing at all, so this measures nothing" );
notifications.set(0); click(&imp.copy_link_item); settle(80); assert_eq!( notifications.get(), per_write, "one click on \"Copy Link to Post\" must write the clipboard once, \ not once per post the row has ever been recycled onto" );
// "Open Link to Post" is wired the same way but must not be clicked // here: it hands the URL to the user's browser, one per bind under the // bug. Checked at the bookkeeping instead. The handler id is the whole // mechanism by which the next bind takes the last one off. assert!( imp.open_link_handler_id.borrow().is_some(), "\"Open Link to Post\" is connected but its handler is not being \ kept, so nothing will ever disconnect it and a recycled row will \ open one browser tab per post it has shown" );
// Recycled off a repost onto an ordinary post: the old reposter must // not stay wired to the button being hidden. row.bind(&post_with( None, "at://did:plc:test/app.bsky.feed.post/plain", )); click(&imp.repost_attribution_btn); click(&imp.reply_indicator); assert!( profiles.borrow().is_empty(), "a post that is neither a repost nor a reply has no profile to \ navigate to, but the row still went to {:?}", profiles.borrow() ); }
/// `set_not_clickable` applies to one post, and `bind` has to undo it. /// /// It runs after `bind` on a thread's focused post. Nothing used to put it /// back, so once a thread page recycles rows the row that carried the /// focused post would refuse to navigate ever after. #[test] fn rebinding_a_row_makes_it_an_ordinary_clickable_post_again() { crate::ui::with_gtk(rebinding_a_row_makes_it_an_ordinary_clickable_post_again_body); }
fn rebinding_a_row_makes_it_an_ordinary_clickable_post_again_body() { let row = PostRow::new(); row.bind(&post_with( None, "at://did:plc:test/app.bsky.feed.post/focused", )); row.set_not_clickable();
let main_box = row .imp() .main_box .borrow() .clone() .expect("built in setup_ui"); assert!(row.imp().is_focused_post.get()); assert!( main_box.cursor().is_none(), "the focused post does not advertise itself as clickable" );
row.bind(&post_with( None, "at://did:plc:test/app.bsky.feed.post/next", )); assert!( !row.imp().is_focused_post.get(), "a recycled row is an ordinary post again, and an ordinary post \ opens its thread" ); assert_eq!( main_box.cursor().and_then(|c| c.name()).as_deref(), Some("pointer"), "and it says so under the pointer" ); }
/// Delete shows itself only on the signed-in user's own posts, and one /// click asks for one delete however often the row has been recycled. #[test] fn delete_is_offered_only_on_own_posts_and_fires_once() { crate::ui::with_gtk(delete_is_offered_only_on_own_posts_and_fires_once_body); }
fn delete_is_offered_only_on_own_posts_and_fires_once_body() { use std::cell::RefCell as StdRefCell; use std::rc::Rc;
let deleted: Rc<StdRefCell<Vec<String>>> = Rc::default(); let sink = Rc::clone(&deleted); super::set_delete_post_handler(move |post| sink.borrow_mut().push(post.uri));
let row = PostRow::new(); let section = row .imp() .delete_section .borrow() .clone() .expect("built in setup_ui");
let mine = || { let mut post = post_with(None, "at://did:plc:me/app.bsky.feed.post/mine"); post.author = Profile { did: "did:plc:me".into(), ..profile() }; post }; let theirs = post_with(None, "at://did:plc:test/app.bsky.feed.post/theirs");
// Signed out, nothing is deletable. super::set_current_user_did(None); row.bind(&mine()); assert!(!section.get_visible(), "no signed-in user, no Delete");
super::set_current_user_did(Some("did:plc:me")); row.bind(&mine()); assert!(section.get_visible(), "an own post offers Delete");
row.bind(&theirs); assert!( !section.get_visible(), "recycled onto someone else's post, the offer has to go away" );
// Bound many times over, one click still asks for one delete, and of // the post the row shows now. for _ in 0..4 { row.bind(&mine()); row.bind(&theirs); } row.bind(&mine()); assert!(section.get_visible()); row.imp() .delete_item .borrow() .as_ref() .expect("built in setup_ui") .emit_clicked(); assert_eq!( *deleted.borrow(), vec!["at://did:plc:me/app.bsky.feed.post/mine".to_string()], "one click on Delete must request exactly one deletion" );
// Other tests bind posts authored by did:plc:test; leave nothing set. super::set_current_user_did(None); }
/// A row must die when its owner lets go. /// /// Every closure on a descendant widget holds the row weakly. A strong /// capture makes row -> child -> closure -> row, which GObject cannot /// collect, so the row, its whole widget subtree and its decoded /// textures live until the process exits. Pooled lists hide it because /// the pool is bounded; the thread page appends one row per post and /// never reuses them, so a browsing session leaked a subtree per post /// it ever showed. #[test] fn a_dropped_row_is_actually_freed() { crate::ui::with_gtk(a_dropped_row_is_actually_freed_body); }
fn a_dropped_row_is_actually_freed_body() { // Bound and wired the way a real page builds one, so every // closure that could capture the row has been attached. let row = PostRow::new(); row.bind(&post_with( None, "at://did:plc:test/app.bsky.feed.post/leak", )); let weak = row.downgrade(); assert!(weak.upgrade().is_some(), "the row is alive while held");
drop(row); assert!( weak.upgrade().is_none(), "the row outlived its last owner: a closure on one of its own \ children is holding it strongly" );
// The unvirtualized case the leak actually bit: a page that builds // a row per post and is then dropped whole. let mut weaks = Vec::new(); { let page = gtk4::Box::new(gtk4::Orientation::Vertical, 0); for i in 0..20 { let row = PostRow::new(); row.bind(&post_with( None, &format!("at://did:plc:test/app.bsky.feed.post/t{i}"), )); weaks.push(row.downgrade()); page.append(&row); } assert!(weaks.iter().all(|w| w.upgrade().is_some())); while let Some(child) = page.first_child() { page.remove(&child); } } let survivors = weaks.iter().filter(|w| w.upgrade().is_some()).count(); assert_eq!(survivors, 0, "{survivors} of 20 thread rows leaked"); }
/// Feed-level moderation: the section hides on own posts, and Mute and /// Block hand the app the author of the post the row shows now. #[test] fn moderation_targets_the_bound_author_and_skips_own_posts() { crate::ui::with_gtk(moderation_targets_the_bound_author_and_skips_own_posts_body); }
fn moderation_targets_the_bound_author_and_skips_own_posts_body() { use std::cell::RefCell as StdRefCell; use std::rc::Rc;
let muted: Rc<StdRefCell<Vec<String>>> = Rc::default(); let sink = Rc::clone(&muted); super::set_mute_account_handler(move |did| sink.borrow_mut().push(did)); let blocked: Rc<StdRefCell<Vec<String>>> = Rc::default(); let sink = Rc::clone(&blocked); super::set_block_account_handler(move |profile| sink.borrow_mut().push(profile.did));
let row = PostRow::new(); let section = row .imp() .moderation_section .borrow() .clone() .expect("built in setup_ui");
let theirs = post_with(None, "at://did:plc:test/app.bsky.feed.post/theirs"); row.bind(&theirs); assert!( section.get_visible(), "someone else's post offers moderation" );
super::set_current_user_did(Some("did:plc:test")); row.bind(&theirs); assert!(!section.get_visible(), "you cannot mute yourself"); super::set_current_user_did(None);
// Recycled across posts, a click still targets the current author. let other = { let mut post = post_with(None, "at://did:plc:other/app.bsky.feed.post/x"); post.author = Profile { did: "did:plc:other".into(), ..profile() }; post }; row.bind(&theirs); row.bind(&other); row.imp() .mute_item .borrow() .as_ref() .expect("built in setup_ui") .emit_clicked(); row.imp() .block_item .borrow() .as_ref() .expect("built in setup_ui") .emit_clicked(); assert_eq!(*muted.borrow(), vec!["did:plc:other".to_string()]); assert_eq!(*blocked.borrow(), vec!["did:plc:other".to_string()]); }
/// 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<StdRefCell<Vec<(String, Option<bool>)>>> = 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" ); }
/// The screen reader readout counts only what is there: no zeroes, /// singulars for one, and nothing at all on an untouched post. #[test] fn the_readout_counts_only_what_is_there() { let quiet = post_with(None, "at://did:plc:test/app.bsky.feed.post/quiet"); assert_eq!(PostRow::counts_readout(&quiet), "");
let mut zeroes = quiet.clone(); zeroes.reply_count = Some(0); zeroes.like_count = Some(0); assert_eq!(PostRow::counts_readout(&zeroes), "", "zeroes stay quiet");
let mut busy = quiet.clone(); busy.reply_count = Some(1); busy.repost_count = Some(2); busy.like_count = Some(30); assert_eq!( PostRow::counts_readout(&busy), ". 1 reply, 2 reposts, 30 likes" ); }
/// The post shortcuts drive the row's own controls: L rides the like /// button's full path and O opens the thread. The row is a keyboard /// stop so the bindings have somewhere to live. #[test] fn the_post_shortcuts_drive_the_row() { crate::ui::with_gtk(the_post_shortcuts_drive_the_row_body); }
fn the_post_shortcuts_drive_the_row_body() { use std::cell::RefCell as StdRefCell; use std::rc::Rc;
let row = PostRow::new(); assert!(row.is_focusable(), "the shortcuts need a focused row");
row.bind(&post_with( None, "at://did:plc:test/app.bsky.feed.post/keys", ));
let liked: Rc<StdRefCell<u32>> = Rc::default(); let sink = Rc::clone(&liked); row.connect_like_clicked(move |_, _, _| { *sink.borrow_mut() += 1; }); assert!(row.activate_action("post.like", None).is_ok()); assert_eq!(*liked.borrow(), 1, "L is the like button");
let opened: Rc<StdRefCell<Vec<String>>> = Rc::default(); let sink = Rc::clone(&opened); row.set_post_clicked_callback(move |post| { sink.borrow_mut().push(post.uri); }); assert!(row.activate_action("post.open", None).is_ok()); assert_eq!( opened.borrow().as_slice(), ["at://did:plc:test/app.bsky.feed.post/keys"], "O opens the thread" );
// The thread page's own subject stays put, same as a body click. row.imp().is_focused_post.set(true); assert!(row.activate_action("post.open", None).is_ok()); assert_eq!(opened.borrow().len(), 1, "the focused post does not reopen"); }
/// The accessible name on `widget`, the string a screen reader announces. /// /// gtk4-rs binds no getter for an accessible property, so this goes /// through GTK's own test helper, which hands back the real value only /// when it differs from the one passed in. Nothing equals the sentinel, /// so what comes back is always the real value. fn accessible_label(widget: &impl IsA<gtk4::Accessible>) -> String { use gtk4::glib::translate::ToGlibPtr;
let sentinel = std::ffi::CString::new("\u{1}no label\u{1}").unwrap(); unsafe { let raw = gtk4::ffi::gtk_test_accessible_check_property( widget.as_ref().to_glib_none().0, gtk4::ffi::GTK_ACCESSIBLE_PROPERTY_LABEL, sentinel.as_ptr(), ); if raw.is_null() { return String::new(); } let label = std::ffi::CStr::from_ptr(raw).to_string_lossy().into_owned(); gtk4::glib::ffi::g_free(raw.cast()); label } }
/// Neither button carries a pressed state, so its name is the whole of /// what an assistive technology knows about the like, and it has to follow /// every toggle. Both counts here used to be recovered by parsing the /// rendered label: blank at zero, "1.2K" past a thousand, neither of which /// is a number, so the button went on offering the action it had just done. #[test] fn the_engagement_buttons_announce_their_new_count() { crate::ui::with_gtk(the_engagement_buttons_announce_their_new_count_body); }
fn the_engagement_buttons_announce_their_new_count_body() { let row = PostRow::new(); let imp = row.imp(); let like_btn = imp.like_btn.borrow().clone().expect("built in setup_ui"); let repost_btn = imp.repost_btn.borrow().clone().expect("built in setup_ui"); let like_count = imp .like_count_label .borrow() .clone() .expect("built in setup_ui"); let repost_count = imp .repost_count_label .borrow() .clone() .expect("built in setup_ui");
// Zero, which is most of any feed: the visible count is blank. let mut post = post_with(None, "at://did:plc:test/app.bsky.feed.post/counts"); post.like_count = Some(0); post.repost_count = Some(0); row.bind(&post); assert_eq!(accessible_label(&like_btn), "Like. 0 likes");
row.toggle_like_visual(); assert_eq!(accessible_label(&like_btn), "Unlike. 1 likes"); assert_eq!(like_count.text(), "1"); row.toggle_like_visual(); assert_eq!(accessible_label(&like_btn), "Like. 0 likes"); assert_eq!(like_count.text(), "", "a zero count shows nothing");
row.toggle_repost_visual(); assert_eq!(accessible_label(&repost_btn), "Undo repost. 1 reposts"); assert_eq!(repost_count.text(), "1");
// Past a thousand the label reads "1.2K", the other string that never // parsed, and the one that left the name at the server's number. post.like_count = Some(1200); post.repost_count = Some(1200); row.bind(&post); assert_eq!(accessible_label(&like_btn), "Like. 1200 likes");
row.toggle_like_visual(); assert_eq!(accessible_label(&like_btn), "Unlike. 1201 likes"); row.toggle_like_visual(); assert_eq!(accessible_label(&like_btn), "Like. 1200 likes");
row.toggle_repost_visual(); assert_eq!(accessible_label(&repost_btn), "Undo repost. 1201 reposts");
// Over a thousand and back has to land on the number it started from. // Reading "1.0K" back left the row a like high until the next bind. post.like_count = Some(999); row.bind(&post); row.toggle_like_visual(); assert_eq!(like_count.text(), "1.0K"); row.toggle_like_visual(); assert_eq!(like_count.text(), "999", "unliking has to undo the like"); assert_eq!(accessible_label(&like_btn), "Like. 999 likes"); }}