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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849// SPDX-License-Identifier: MPL-2.0#![allow(clippy::type_complexity)]
use crate::atproto::{ComposeData, ImageAttachment, LinkCardData, Profile};use crate::state::AppSettings;use crate::ui::avatar_cache;use gtk4::gdk;use gtk4::glib;use gtk4::prelude::*;use gtk4::subclass::prelude::*;use libadwaita as adw;use libadwaita::prelude::*;use libadwaita::subclass::prelude::*;use regex::Regex;use std::sync::LazyLock;use unicode_segmentation::UnicodeSegmentation;
/// Maximum images per postconst MAX_IMAGES: usize = 4;
/// Largest edge the strip's preview textures decode to. The tiles are/// 80x80, so this still covers a 3x scale factor.const THUMBNAIL_DECODE_EDGE: i32 = 240;
/// Common languages for the language picker (code, English name, native name).const LANGUAGES: &[(&str, &str, &str)] = &[ ("en", "English", "English"), ("es", "Spanish", "Espa\u{f1}ol"), ("pt", "Portuguese", "Portugu\u{ea}s"), ("ja", "Japanese", "\u{65e5}\u{672c}\u{8a9e}"), ("ko", "Korean", "\u{d55c}\u{ad6d}\u{c5b4}"), ("fr", "French", "Fran\u{e7}ais"), ("de", "German", "Deutsch"), ("it", "Italian", "Italiano"), ("zh", "Chinese", "\u{4e2d}\u{6587}"), ( "ar", "Arabic", "\u{627}\u{644}\u{639}\u{631}\u{628}\u{64a}\u{629}", ), ("hi", "Hindi", "\u{939}\u{93f}\u{928}\u{94d}\u{926}\u{940}"), ( "ru", "Russian", "\u{420}\u{443}\u{441}\u{441}\u{43a}\u{438}\u{439}", ), ("nl", "Dutch", "Nederlands"), ("pl", "Polish", "Polski"), ("sv", "Swedish", "Svenska"), ("tr", "Turkish", "T\u{fc}rk\u{e7}e"), ( "uk", "Ukrainian", "\u{423}\u{43a}\u{440}\u{430}\u{457}\u{43d}\u{441}\u{44c}\u{43a}\u{430}", ), ("vi", "Vietnamese", "Ti\u{1ebf}ng Vi\u{1ec7}t"), ("th", "Thai", "\u{e44}\u{e17}\u{e22}"), ("id", "Indonesian", "Bahasa Indonesia"), ("ms", "Malay", "Bahasa Melayu"), ("fi", "Finnish", "Suomi"), ("da", "Danish", "Dansk"), ("no", "Norwegian", "Norsk"), ("cs", "Czech", "\u{10c}e\u{161}tina"), ( "el", "Greek", "\u{395}\u{3bb}\u{3bb}\u{3b7}\u{3bd}\u{3b9}\u{3ba}\u{3ac}", ), ("he", "Hebrew", "\u{5e2}\u{5d1}\u{5e8}\u{5d9}\u{5ea}"), ("ro", "Romanian", "Rom\u{e2}n\u{103}"), ("hu", "Hungarian", "Magyar"), ("ca", "Catalan", "Catal\u{e0}"), ("gl", "Galician", "Galego"), ("eu", "Basque", "Euskara"), ("tl", "Filipino", "Filipino"), ("bn", "Bengali", "\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be}"), ("fa", "Persian", "\u{641}\u{627}\u{631}\u{633}\u{6cc}"), ("ta", "Tamil", "\u{ba4}\u{bae}\u{bbf}\u{bb4}\u{bcd}"), ("ur", "Urdu", "\u{627}\u{631}\u{62f}\u{648}"), ("sk", "Slovak", "Sloven\u{10d}ina"), ( "bg", "Bulgarian", "\u{411}\u{44a}\u{43b}\u{433}\u{430}\u{440}\u{441}\u{43a}\u{438}", ), ("hr", "Croatian", "Hrvatski"), ( "sr", "Serbian", "\u{421}\u{440}\u{43f}\u{441}\u{43a}\u{438}", ), ("sl", "Slovenian", "Sloven\u{161}\u{10d}ina"), ("lt", "Lithuanian", "Lietuvi\u{173}"), ("lv", "Latvian", "Latvie\u{161}u"), ("et", "Estonian", "Eesti"),];
/// Get the display name for a language code.fn language_display_name(code: &str) -> String { LANGUAGES .iter() .find(|(c, _, _)| *c == code) .map(|(_, name, _)| name.to_string()) .unwrap_or_else(|| code.to_uppercase())}
/// Bluesky post character limit (grapheme clusters)const MAX_GRAPHEMES: i32 = 300;/// Show warning color when this many characters remainconst WARN_THRESHOLD: i32 = 20;
/// Tag names for rich text highlighting in the compose bufferconst TAG_MENTION: &str = "mention";const TAG_HASHTAG: &str = "hashtag";const TAG_URL: &str = "url";const TAG_EMOJI: &str = "emoji";
// Reuse the same regex patterns as facets.rs for consistent detectionstatic HL_URL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://[^\s<>\[\]\{}|\\^`\x00-\x1f\x7f]+").unwrap());
static HL_MENTION_RE: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"(?:^|[\s\(\[])(@(([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?))") .unwrap()});
static HL_HASHTAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:^|[\s\(\[])(#[a-zA-Z][a-zA-Z0-9_]*)").unwrap());
/// Check if a character is an emoji (or emoji component) that renders taller than text.fn is_emoji(ch: char) -> bool { let cp = ch as u32; matches!(cp, // Zero-width joiner (emoji sequences like 👨👩👧👦) 0x200D | // Misc symbols preceding emoji range 0x203C | 0x2049 | 0x2122 | 0x2139 | 0x2194..=0x2199 | 0x21A9..=0x21AA | // Miscellaneous Technical (⌚ ⏰ etc.) 0x2300..=0x23FF | 0x25AA..=0x25AB | 0x25B6 | 0x25C0 | 0x25FB..=0x25FE | // Miscellaneous Symbols & Dingbats (U+2600..U+27BF) 0x2600..=0x27BF | 0x2934..=0x2935 | 0x2B05..=0x2B07 | 0x2B1B..=0x2B1C | 0x2B50..=0x2B55 | 0x3030 | 0x303D | 0x3297 | 0x3299 | // Variation selectors (emoji presentation) 0xFE00..=0xFE0F | // Regional Indicator Symbols (flags) 0x1F1E0..=0x1F1FF | // All main emoji blocks (emoticons, transport, symbols, etc.) 0x1F300..=0x1F9FF | // Symbols and Pictographs Extended-A & B 0x1FA00..=0x1FAFF )}
/// An image being composed for attachment to a post.#[derive(Clone)]pub struct ComposeImage { pub data: Vec<u8>, pub mime_type: String, pub alt_text: String, /// The source's pixel size, which the embed's aspect ratio needs. /// The texture is a thumbnail, so it cannot answer this. pub width: u32, pub height: u32, /// Strip preview only, decoded small. See `decode_thumbnail`. pub texture: gdk::Texture,}
/// Decode `data` down to a strip thumbnail and report the source's/// pixel size alongside it.////// The bytes are what gets uploaded; the texture never draws larger than/// 80x80, and a 12 MP photo decoded at native size is ~49 MB of RGBA plus/// a GPU copy, times up to 40 attachments across a thread. The native/// size still has to reach the embed's aspectRatio, so it is read in/// size_prepared before the loader is told to shrink.////// set_size only ever scales down, the same technique as/// avatar_cache::decode_pixbuf; from_stream_at_scale would enlarge a/// small image to the cap.fn decode_thumbnail(data: &[u8]) -> Option<(gdk::Texture, u32, u32)> { let native = std::rc::Rc::new(std::cell::Cell::new((0i32, 0i32))); let loader = gdk::gdk_pixbuf::PixbufLoader::new(); let seen = std::rc::Rc::clone(&native); loader.connect_size_prepared(move |loader, width, height| { seen.set((width, height)); let largest = width.max(height); if largest > THUMBNAIL_DECODE_EDGE { let scale = f64::from(THUMBNAIL_DECODE_EDGE) / f64::from(largest); loader.set_size( ((f64::from(width) * scale).round() as i32).max(1), ((f64::from(height) * scale).round() as i32).max(1), ); } }); // Close whatever the write did: an open loader holds its buffers. let written = loader.write(data).is_ok(); let closed = loader.close().is_ok(); if !written || !closed { return None; } let pixbuf = loader.pixbuf()?; let (width, height) = native.get(); Some(( gdk::Texture::for_pixbuf(&pixbuf), width.max(1) as u32, height.max(1) as u32, ))}
/// Where a composed video stands. The post waits for Ready.#[derive(Clone, Copy, PartialEq, Eq, Debug)]pub enum VideoUploadState { Uploading, Processing, Ready, Failed,}
/// What a picked file turns into.enum MediaKind { Image, Video(&'static str),}
/// The video-related reasons a post cannot go yet.#[derive(Default)]struct VideoGate { pending: bool, failed: bool, alt_missing: bool,}
/// A video being composed. The upload starts the moment it attaches and/// the tile follows it to Ready or Failed. The token names it across the/// upload's lifetime; slots can shift while thread posts come and go.pub struct ComposeVideo { pub token: u64, pub mime_type: String, pub file_name: String, pub alt_text: String, pub total_bytes: u64, pub sent_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>, /// Set when the tile goes or the dialog closes. The upload thread /// checks it and drops the file bytes it is holding. pub cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>, pub state: VideoUploadState, pub blob: Option<serde_json::Value>, pub aspect_ratio: Option<(u32, u32)>, /// Tile widgets, created once so the progress timer always has the /// live ones no matter how often the strip rebuilds. pub progress_icon: crate::ui::progress_icon::ProgressIcon, pub status_label: gtk4::Label, /// The probe's MediaFile, kept paused on its first frame so the tile /// can show a real preview. None when GStreamer cannot read the file. pub preview: Option<gtk4::MediaFile>,}
/// A single post block in the thread composer (Post 2, 3, etc.)pub struct ThreadPostBlock { pub container: gtk4::Box, pub text_view: gtk4::TextView, pub image_strip: gtk4::Box, pub images: Vec<ComposeImage>, pub video: Option<ComposeVideo>, pub char_counter: gtk4::Label, /// Per-post content warning (each post can have its own CW) pub content_warning: Option<String>, /// "Add Content Warning..." button (visible when images attached) pub cw_button: gtk4::Button, /// "Remove All Images" button (visible when images attached) pub remove_all_button: gtk4::Button,}
/// Context for replying to a post#[derive(Clone)]pub struct ReplyContext { pub uri: String, pub cid: String, pub author_handle: String,}
/// Context for quoting a post#[derive(Clone)]pub struct QuoteContext { pub uri: String, pub cid: String, pub author_handle: String, pub text: String,}
mod imp { use super::*; use std::cell::{Cell, RefCell};
#[derive(Default)] pub struct ComposeDialog { pub text_view: RefCell<Option<gtk4::TextView>>, pub post_button: RefCell<Option<gtk4::Button>>, pub error_label: RefCell<Option<gtk4::Label>>, pub reply_context: RefCell<Option<ReplyContext>>, pub quote_context: RefCell<Option<QuoteContext>>, pub reply_label: RefCell<Option<gtk4::Label>>, pub quote_preview: RefCell<Option<gtk4::Box>>, pub post_callback: RefCell<Option<Box<dyn Fn(ComposeData) + 'static>>>, pub reply_callback: RefCell<Option<Box<dyn Fn(ComposeData, String, String) + 'static>>>, pub quote_callback: RefCell<Option<Box<dyn Fn(ComposeData, String, String) + 'static>>>, pub char_counter: RefCell<Option<gtk4::Label>>, // Image attachments pub image_strip: RefCell<Option<gtk4::Box>>, pub images: RefCell<Vec<ComposeImage>>, /// Read once when the dialog opens; posting waits for alt text on /// every image while set. pub require_alt_text: Cell<bool>, pub add_image_button: RefCell<Option<gtk4::Button>>, // Video attachment (main post; thread posts carry theirs in the block) pub video: RefCell<Option<ComposeVideo>>, pub video_token_counter: Cell<u64>, /// Hands (token, bytes, mime, sent-counter) to the app, which owns /// the network side and reports back through the video_* methods. pub video_upload_callback: RefCell< Option< Box< dyn Fn( u64, Vec<u8>, String, std::sync::Arc<std::sync::atomic::AtomicU64>, std::sync::Arc<std::sync::atomic::AtomicBool>, ) + 'static, >, >, >, /// MediaFiles probing freshly attached videos for their pixel size. pub video_probes: RefCell<Vec<gtk4::MediaFile>>, pub remove_all_images_button: RefCell<Option<gtk4::Button>>, // Language selection pub language_button: RefCell<Option<gtk4::Button>>, pub selected_language: RefCell<String>, // Content warning pub cw_button: RefCell<Option<gtk4::Button>>, pub content_warning: RefCell<Option<String>>, // Interaction settings. The button text is dynamic, e.g. "Everyone can reply". pub interaction_label: RefCell<Option<gtk4::Button>>, pub threadgate_config: RefCell<Option<crate::atproto::ThreadgateConfig>>, pub postgate_config: RefCell<Option<crate::atproto::PostgateConfig>>, // Link card preview pub link_preview_box: RefCell<Option<gtk4::Box>>, pub link_card_data: RefCell<Option<LinkCardData>>, /// The URL we've already fetched or are fetching (to avoid duplicate requests) pub link_preview_url: RefCell<Option<String>>, /// Set when the user dismissed the link card. No re-fetch until the text changes the URL. pub link_preview_dismissed: Cell<bool>, /// Callback to fetch link card metadata (called with URL string) pub link_preview_fetch_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, /// Counter for link preview debounce pub link_debounce_counter: Cell<u32>, // Thread composer /// Container holding all thread post blocks pub thread_container: RefCell<Option<gtk4::Box>>, /// Additional thread posts (Post 2 onward; Post 1 is the main text_view) pub thread_posts: RefCell<Vec<ThreadPostBlock>>, /// "Add to thread" button pub add_thread_button: RefCell<Option<gtk4::Button>>, /// Thread post callback (called with Vec<ComposeData>) pub thread_callback: RefCell<Option<Box<dyn Fn(Vec<ComposeData>) + 'static>>>, /// Which post is currently focused (0 = main, 1+ = thread posts) pub focused_post_index: Cell<usize>, // Mention autocomplete pub mention_popover: RefCell<Option<gtk4::Popover>>, pub mention_list: RefCell<Option<gtk4::ListBox>>, pub mention_results: RefCell<Vec<Profile>>, pub mention_search_callback: RefCell<Option<Box<dyn Fn(String) + 'static>>>, pub debounce_counter: Cell<u32>, /// Tracks the char offset of the '@' that triggered the current autocomplete pub mention_at_offset: Cell<i32>, /// Guard to prevent recursive buffer change notifications during highlighting pub highlighting: Cell<bool>, }
#[glib::object_subclass] impl ObjectSubclass for ComposeDialog { const NAME: &'static str = "HangarComposeDialog"; type Type = super::ComposeDialog; type ParentType = adw::Dialog; }
impl ObjectImpl for ComposeDialog { fn constructed(&self) { self.parent_constructed(); self.require_alt_text .set(crate::state::AppSettings::load().require_alt_text); // A dismissed composer must not leave an upload running with // the whole file still in memory. self.obj() .connect_closed(|dialog| dialog.cancel_all_uploads()); let obj = self.obj(); obj.setup_ui(); }
fn dispose(&self) { // Popover was manually parented to the TextView via set_parent(), // so we MUST unparent it before the widget tree is torn down. // Otherwise GTK spams "GtkPopover is not a child of GtkTextView" // warnings and the UI freezes. if let Some(popover) = self.mention_popover.borrow_mut().take() { popover.unparent(); } } }
impl WidgetImpl for ComposeDialog {} impl AdwDialogImpl for ComposeDialog {}}
glib::wrapper! { pub struct ComposeDialog(ObjectSubclass<imp::ComposeDialog>) @extends adw::Dialog, gtk4::Widget, @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget;}
impl ComposeDialog { pub fn new() -> Self { let dialog: Self = glib::Object::builder().build(); dialog.set_title("New Post"); dialog.set_content_width(480); dialog.set_content_height(360); dialog }
pub fn new_reply(context: ReplyContext) -> Self { let dialog: Self = glib::Object::builder().build(); dialog.set_title("Reply"); dialog.set_content_width(420); dialog.set_content_height(280); dialog.set_reply_context(context); dialog }
pub fn new_quote(context: QuoteContext) -> Self { let dialog: Self = glib::Object::builder().build(); dialog.set_title("Quote Post"); dialog.set_content_width(420); dialog.set_content_height(340); dialog.set_quote_context(context); dialog }
/// Generate a human-readable summary of the current threadgate configuration. fn interaction_summary_text(threadgate: &Option<crate::atproto::ThreadgateConfig>) -> String { use crate::atproto::ThreadgateRule; match threadgate { None => "Everyone can reply".to_string(), Some(tg) if tg.allow_rules.is_empty() => "Replies disabled".to_string(), Some(tg) => { let mut parts = Vec::new(); if tg.allow_rules.contains(&ThreadgateRule::FollowingRule) { parts.push("People you follow"); } if tg.allow_rules.contains(&ThreadgateRule::MentionRule) { parts.push("People you mention"); } if tg.allow_rules.contains(&ThreadgateRule::FollowersRule) { parts.push("Your followers"); } if parts.is_empty() { "Replies disabled".to_string() } else { format!("{} can reply", parts.join(", ")) } } } }
/// Update the interaction settings label to reflect current config. fn update_interaction_label(&self) { let imp = self.imp(); let tg = imp.threadgate_config.borrow().clone(); let text = Self::interaction_summary_text(&tg); if let Some(btn) = imp.interaction_label.borrow().as_ref() { btn.set_label(&text); btn.set_tooltip_text(Some(&format!("Interaction settings: {}", text))); btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Interaction settings: {}", text ))]); } }
fn set_reply_context(&self, context: ReplyContext) { let imp = self.imp(); if let Some(label) = imp.reply_label.borrow().as_ref() { label.set_text(&format!("Replying to @{}", context.author_handle)); label.set_visible(true); } imp.reply_context.replace(Some(context)); }
fn set_quote_context(&self, context: QuoteContext) { let imp = self.imp(); // Show quote preview card if let Some(preview) = imp.quote_preview.borrow().as_ref() { // Clear existing children while let Some(child) = preview.first_child() { preview.remove(&child); }
let header = gtk4::Label::new(Some(&format!("@{}", context.author_handle))); header.set_halign(gtk4::Align::Start); // A handle is a domain name and Pango does not break on the dots, // so without this the quote preview's minimum width is the whole // handle. Same measure as the mention picker above. header.set_ellipsize(gtk4::pango::EllipsizeMode::End); header.set_max_width_chars(22); header.add_css_class("dim-label"); header.add_css_class("caption"); preview.append(&header);
// Show truncated text let text = if context.text.len() > 100 { format!( "{}...", &context.text[..super::floor_char_boundary(&context.text, 100)] ) } else { context.text.clone() }; let text_label = gtk4::Label::new(Some(&text)); text_label.set_halign(gtk4::Align::Start); text_label.set_wrap(true); text_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); text_label.add_css_class("caption"); preview.append(&text_label);
preview.set_visible(true); } imp.quote_context.replace(Some(context)); }
fn setup_ui(&self) { // Header bar with Cancel (start), action buttons + Post (end) let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false);
let cancel_btn = gtk4::Button::with_label("Cancel"); cancel_btn.connect_clicked(glib::clone!( #[weak(rename_to = dialog)] self, move |_| { dialog.close(); } )); header.pack_start(&cancel_btn);
let post_btn = gtk4::Button::with_label("Post"); post_btn.add_css_class("suggested-action"); post_btn.set_sensitive(false); // Disabled until text is entered header.pack_end(&post_btn);
// Attach image button in header bar let add_image_btn = gtk4::Button::from_icon_name("image-x-generic-symbolic"); add_image_btn.add_css_class("flat"); add_image_btn.set_tooltip_text(Some("Attach image")); add_image_btn.update_property(&[gtk4::accessible::Property::Label("Attach image")]); header.pack_end(&add_image_btn);
let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header);
// ── Bottom bar: status labels (pinned, outside scroll) ── let status_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); status_row.add_css_class("compose-status-row"); status_row.set_margin_start(24); status_row.set_margin_end(24); status_row.set_margin_bottom(8); status_row.set_margin_top(4);
// Spacer pushes labels to the right let status_spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); status_spacer.set_hexpand(true); status_row.append(&status_spacer);
// Language button let default_lang = AppSettings::load() .default_post_language .unwrap_or_else(|| "en".to_string()); let lang_display = language_display_name(&default_lang); let lang_btn = gtk4::Button::with_label(&lang_display); lang_btn.add_css_class("flat"); lang_btn.add_css_class("caption"); lang_btn.set_tooltip_text(Some(&format!("Post language: {}", lang_display))); lang_btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Post language: {}", lang_display ))]); status_row.append(&lang_btn);
// Interaction settings button with dynamic text let settings = AppSettings::load(); let initial_interaction_text = Self::interaction_summary_text(&settings.default_threadgate); let interaction_btn = gtk4::Button::with_label(&initial_interaction_text); interaction_btn.add_css_class("flat"); interaction_btn.add_css_class("caption"); interaction_btn.set_tooltip_text(Some("Interaction settings")); interaction_btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Interaction settings: {}", initial_interaction_text ))]); status_row.append(&interaction_btn);
toolbar.add_bottom_bar(&status_row);
// ── Scrollable content area ── let content = gtk4::Box::new(gtk4::Orientation::Vertical, 8); content.set_margin_start(24); content.set_margin_end(24); content.set_margin_top(12); content.set_margin_bottom(8); content.set_vexpand(true);
let reply_label = gtk4::Label::new(None); reply_label.set_halign(gtk4::Align::Start); reply_label.add_css_class("dim-label"); reply_label.set_visible(false); content.append(&reply_label);
let text_view = gtk4::TextView::new(); text_view.set_wrap_mode(gtk4::WrapMode::WordChar); text_view.set_vexpand(true); text_view.set_left_margin(8); text_view.set_right_margin(8); text_view.set_top_margin(8); text_view.set_bottom_margin(8); text_view.set_accepts_tab(false); text_view.add_css_class("compose-text"); text_view.update_property(&[gtk4::accessible::Property::Label("Post content")]);
// Create text tags for rich text highlighting (mentions, hashtags, URLs). // Colors are applied once the widget is realized so we can read the theme accent. let buffer = text_view.buffer(); let tag_table = buffer.tag_table();
// Determine accent color for facet highlighting. // Use Adw.StyleManager to pick the right blue for the current color scheme, // with a reliable fallback to standard GNOME blue. let accent = { let style_manager = adw::StyleManager::default(); let is_dark = style_manager.is_dark(); if is_dark { // Adwaita dark accent blue gdk::RGBA::new(0.47, 0.68, 0.93, 1.0) // #78aeed } else { // Adwaita light accent blue gdk::RGBA::new(0.21, 0.52, 0.89, 1.0) // #3584e4 } };
// All facet tags use accent color with no extra weight, matching the // surrounding text. URLs additionally get an underline per WCAG 1.4.1. let mention_tag = gtk4::TextTag::new(Some(TAG_MENTION)); mention_tag.set_foreground_rgba(Some(&accent)); tag_table.add(&mention_tag);
let hashtag_tag = gtk4::TextTag::new(Some(TAG_HASHTAG)); hashtag_tag.set_foreground_rgba(Some(&accent)); tag_table.add(&hashtag_tag);
let url_tag = gtk4::TextTag::new(Some(TAG_URL)); url_tag.set_foreground_rgba(Some(&accent)); url_tag.set_underline(gtk4::pango::Underline::Single); tag_table.add(&url_tag);
// Emoji tag: fix vertical line overlap and horizontal overshoot. // line_height(1.4) stops emoji overlapping adjacent lines. // letter_spacing adds trailing space so the next character does not // render under the emoji's right edge. Base 6px (6144 Pango units), // scaled by the font size setting. Pango splits letter_spacing across // both sides of the glyph, so a larger value also inflates the left gap. let font_scale = AppSettings::load().font_size.scale_factor(); let emoji_spacing = (6144.0 * font_scale) as i32; let emoji_tag = gtk4::TextTag::new(Some(TAG_EMOJI)); emoji_tag.set_line_height(1.4); emoji_tag.set_letter_spacing(emoji_spacing); tag_table.add(&emoji_tag);
let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_vexpand(true); scrolled.set_min_content_height(120); scrolled.set_child(Some(&text_view)); content.append(&scrolled);
// --- Mention autocomplete popover --- let mention_list = gtk4::ListBox::new(); mention_list.set_selection_mode(gtk4::SelectionMode::Single); mention_list.add_css_class("mention-list"); mention_list.update_property(&[gtk4::accessible::Property::Label("Mention suggestions")]);
let mention_scroll = gtk4::ScrolledWindow::new(); mention_scroll.set_child(Some(&mention_list)); mention_scroll.set_max_content_height(250); mention_scroll.set_propagate_natural_height(true);
let mention_popover = gtk4::Popover::new(); mention_popover.set_child(Some(&mention_scroll)); mention_popover.set_parent(&text_view); mention_popover.set_has_arrow(true); mention_popover.set_autohide(false); mention_popover.add_css_class("mention-popover"); mention_popover.set_position(gtk4::PositionType::Bottom); mention_popover.set_size_request(320, -1);
// Connect row activation (click) to insert mention let dialog_weak = self.downgrade(); mention_list.connect_row_activated(move |_, row| { if let Some(dialog) = dialog_weak.upgrade() { dialog.insert_mention(row.index() as usize); } });
// --- Key controller for mention navigation --- let key_controller = gtk4::EventControllerKey::new(); let dialog_weak = self.downgrade(); key_controller.connect_key_pressed(move |_, keyval, _, _| { let Some(dialog) = dialog_weak.upgrade() else { return glib::Propagation::Proceed; }; let imp = dialog.imp();
// Only intercept keys when popover is visible let popover_visible = imp .mention_popover .borrow() .as_ref() .is_some_and(|p| p.is_visible());
if !popover_visible { return glib::Propagation::Proceed; }
match keyval { gdk::Key::Down => { dialog.move_mention_selection(1); glib::Propagation::Stop } gdk::Key::Up => { dialog.move_mention_selection(-1); glib::Propagation::Stop } gdk::Key::Tab | gdk::Key::ISO_Left_Tab => { // Tab cycles down, Shift+Tab cycles up if keyval == gdk::Key::ISO_Left_Tab { dialog.move_mention_selection(-1); } else { dialog.move_mention_selection(1); } glib::Propagation::Stop } gdk::Key::Return => { if let Some(list) = imp.mention_list.borrow().as_ref() && let Some(row) = list.selected_row() { dialog.insert_mention(row.index() as usize); return glib::Propagation::Stop; } glib::Propagation::Proceed } gdk::Key::Escape => { dialog.hide_mention_popover(); glib::Propagation::Stop } _ => glib::Propagation::Proceed, } }); text_view.add_controller(key_controller);
// --- Buffer change handler for @ detection --- let buffer = text_view.buffer(); let dialog_weak = self.downgrade(); buffer.connect_changed(move |buf| { let Some(dialog) = dialog_weak.upgrade() else { return; }; dialog.on_text_changed(buf); });
// --- Image attachment strip --- let image_strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); image_strip.add_css_class("compose-image-strip"); image_strip.set_visible(false); content.append(&image_strip);
// --- Link card preview --- let link_preview_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); link_preview_box.add_css_class("compose-link-card"); link_preview_box.set_visible(false); content.append(&link_preview_box);
// Character counter for the main post, right-aligned let char_counter = gtk4::Label::new(Some(&MAX_GRAPHEMES.to_string())); char_counter.set_halign(gtk4::Align::End); char_counter.add_css_class("dim-label"); char_counter.add_css_class("caption"); char_counter.add_css_class("char-counter"); char_counter.set_tooltip_text(Some("Characters remaining")); char_counter.update_property(&[gtk4::accessible::Property::Label( "300 characters remaining", )]); content.append(&char_counter);
// Main post action row: "Add Content Warning..." + "Remove All Images" // (shown below image strip when images are attached) let main_action_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); main_action_row.set_visible(false);
let cw_btn = gtk4::Button::with_label("Add Content Warning\u{2026}"); cw_btn.add_css_class("flat"); cw_btn.add_css_class("caption"); cw_btn.set_tooltip_text(Some("Set content warning for attached media")); cw_btn.update_property(&[gtk4::accessible::Property::Label( "Set content warning for attached media", )]); main_action_row.append(&cw_btn);
let remove_all_btn = gtk4::Button::with_label("Remove All Images"); remove_all_btn.add_css_class("flat"); remove_all_btn.add_css_class("caption"); remove_all_btn.add_css_class("destructive-action"); remove_all_btn.set_tooltip_text(Some("Remove all attached images")); main_action_row.append(&remove_all_btn);
content.append(&main_action_row);
// The add-image button targets the currently focused post let dialog_weak = self.downgrade(); add_image_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { let focused = dialog.imp().focused_post_index.get(); if focused == 0 { dialog.open_image_chooser(); } else { dialog.open_thread_image_chooser(focused - 1); } } });
// Wire up remove all images button for main post let dialog_weak = self.downgrade(); remove_all_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.remove_all_images(); } });
// The content warning button opens a dialog let dialog_weak = self.downgrade(); cw_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.show_content_warning_dialog(); } });
// Wire up language button let dialog_weak = self.downgrade(); lang_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.show_language_picker(); } });
// Wire up interaction settings button let dialog_weak = self.downgrade(); interaction_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.show_interaction_settings(); } });
// Track focus on the main text view (post index 0) let dialog_weak = self.downgrade(); let focus_controller = gtk4::EventControllerFocus::new(); focus_controller.connect_enter(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.imp().focused_post_index.set(0); } }); text_view.add_controller(focus_controller);
// --- Thread posts container --- let thread_container = gtk4::Box::new(gtk4::Orientation::Vertical, 0); thread_container.set_visible(false); content.append(&thread_container);
// "Add to thread" button let add_thread_btn = gtk4::Button::new(); let add_thread_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); add_thread_content.set_halign(gtk4::Align::Center); let add_icon = gtk4::Image::from_icon_name("list-add-symbolic"); add_thread_content.append(&add_icon); let add_label = gtk4::Label::new(Some("Add to thread")); add_thread_content.append(&add_label); add_thread_btn.set_child(Some(&add_thread_content)); add_thread_btn.add_css_class("flat"); add_thread_btn.set_tooltip_text(Some("Add another post to this thread")); add_thread_btn.update_property(&[gtk4::accessible::Property::Label( "Add another post to this thread", )]); content.append(&add_thread_btn);
let dialog_weak = self.downgrade(); add_thread_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.add_thread_post(); } });
// Quote preview card (shown when quoting) let quote_preview = gtk4::Box::new(gtk4::Orientation::Vertical, 4); quote_preview.add_css_class("quote-card"); quote_preview.set_visible(false); content.append("e_preview);
let error_label = gtk4::Label::new(None); error_label.set_halign(gtk4::Align::Start); error_label.add_css_class("dim-label"); error_label.add_css_class("error"); error_label.set_visible(false); content.append(&error_label);
// Wrap content in a ScrolledWindow so the dialog can handle // overflow when many thread posts are added. let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic); scrolled.set_propagate_natural_height(true); scrolled.set_vexpand(true); scrolled.set_child(Some(&content));
toolbar.set_content(Some(&scrolled));
self.set_child(Some(&toolbar));
let imp = self.imp(); imp.text_view.replace(Some(text_view)); imp.post_button.replace(Some(post_btn)); imp.error_label.replace(Some(error_label)); imp.reply_label.replace(Some(reply_label)); imp.quote_preview.replace(Some(quote_preview)); imp.mention_popover.replace(Some(mention_popover)); imp.mention_list.replace(Some(mention_list)); imp.char_counter.replace(Some(char_counter)); imp.image_strip.replace(Some(image_strip)); imp.add_image_button.replace(Some(add_image_btn)); imp.remove_all_images_button.replace(Some(remove_all_btn)); imp.link_preview_box.replace(Some(link_preview_box)); imp.language_button.replace(Some(lang_btn)); imp.selected_language.replace(default_lang); imp.cw_button.replace(Some(cw_btn)); imp.interaction_label.replace(Some(interaction_btn)); imp.thread_container.replace(Some(thread_container)); imp.add_thread_button.replace(Some(add_thread_btn)); // Load default threadgate/postgate from settings let settings = AppSettings::load(); imp.threadgate_config.replace(settings.default_threadgate); imp.postgate_config.replace(settings.default_postgate);
let dialog_weak = self.downgrade(); if let Some(btn) = imp.post_button.borrow().as_ref() { btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.emit_post(); } }); } }
/// Update the character counter label, Post button state, and styling. fn update_char_counter(&self, buffer: >k4::TextBuffer) { let imp = self.imp(); let text = { let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() };
let grapheme_count = text.graphemes(true).count() as i32; let remaining = MAX_GRAPHEMES - grapheme_count;
if let Some(counter) = imp.char_counter.borrow().as_ref() { counter.set_text(&remaining.to_string());
// Update accessible label let a11y = if remaining >= 0 { format!("{} characters remaining", remaining) } else { format!("{} characters over limit", -remaining) }; counter.update_property(&[gtk4::accessible::Property::Label(&a11y)]);
// Style: red when warning or over if remaining < 0 { counter.remove_css_class("dim-label"); counter.remove_css_class("char-counter-warn"); counter.add_css_class("char-counter-over"); } else if remaining <= WARN_THRESHOLD { counter.remove_css_class("dim-label"); counter.remove_css_class("char-counter-over"); counter.add_css_class("char-counter-warn"); } else { counter.remove_css_class("char-counter-warn"); counter.remove_css_class("char-counter-over"); counter.add_css_class("dim-label"); } }
// Disable Post button when over limit or empty (unless media is attached) let has_media = !imp.images.borrow().is_empty() || imp.video.borrow().is_some(); let gate = self.video_gate(); // The setting turns a missing description into a hard stop. let alt_ok = !imp.require_alt_text.get() || (imp .images .borrow() .iter() .all(|img| !img.alt_text.is_empty()) && !gate.alt_missing); if let Some(btn) = imp.post_button.borrow().as_ref() { btn.set_sensitive( (grapheme_count > 0 || has_media) && remaining >= 0 && alt_ok && !gate.pending && !gate.failed, ); btn.set_tooltip_text(Self::media_gate_tooltip(alt_ok, &gate)); } }
/// Video terms every post gate needs, across the main post and the /// whole thread. fn video_gate(&self) -> VideoGate { let imp = self.imp(); let mut gate = VideoGate::default(); let mut tally = |video: &ComposeVideo| { match video.state { VideoUploadState::Uploading | VideoUploadState::Processing => { gate.pending = true; } VideoUploadState::Failed => gate.failed = true, VideoUploadState::Ready => {} } if video.alt_text.is_empty() { gate.alt_missing = true; } }; if let Some(v) = imp.video.borrow().as_ref() { tally(v); } for block in imp.thread_posts.borrow().iter() { if let Some(v) = block.video.as_ref() { tally(v); } } gate }
/// Why the Post button is off, for its tooltip. Order matters: a /// failure beats waiting beats missing descriptions. fn media_gate_tooltip(alt_ok: bool, gate: &VideoGate) -> Option<&'static str> { if gate.failed { Some("Remove the failed video to post") } else if gate.pending { Some("The video is still uploading") } else if !alt_ok { Some("Describe your media before posting") } else { None } }
/// Apply syntax highlighting for mentions, hashtags, and URLs in the buffer. fn highlight_facets(&self, buffer: >k4::TextBuffer) { let text = { let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() };
// Remove all existing facet tags first let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.remove_tag_by_name(TAG_MENTION, &start, &end); buffer.remove_tag_by_name(TAG_HASHTAG, &start, &end); buffer.remove_tag_by_name(TAG_URL, &start, &end); buffer.remove_tag_by_name(TAG_EMOJI, &start, &end);
// Pre-compute byte→char offset mapping for the text. // char_offsets[byte_index] = char_index for each char boundary. let byte_to_char: Vec<i32> = { let mut map = vec![0i32; text.len() + 1]; let mut char_idx = 0i32; for (byte_idx, _) in text.char_indices() { map[byte_idx] = char_idx; char_idx += 1; } map[text.len()] = char_idx; // sentinel for end map };
// Apply URL tags for m in HL_URL_RE.find_iter(&text) { let url = m.as_str().trim_end_matches(['.', ',', ';', '!', '?']); let byte_end = m.start() + url.len(); let char_start = byte_to_char[m.start()]; let char_end = byte_to_char[byte_end]; let s = buffer.iter_at_offset(char_start); let e = buffer.iter_at_offset(char_end); buffer.apply_tag_by_name(TAG_URL, &s, &e); }
// Apply mention tags (capture group 1 = @handle) for caps in HL_MENTION_RE.captures_iter(&text) { if let Some(m) = caps.get(1) { let char_start = byte_to_char[m.start()]; let char_end = byte_to_char[m.end()]; let s = buffer.iter_at_offset(char_start); let e = buffer.iter_at_offset(char_end); buffer.apply_tag_by_name(TAG_MENTION, &s, &e); } }
// Apply hashtag tags (capture group 1 = #tag) for caps in HL_HASHTAG_RE.captures_iter(&text) { if let Some(m) = caps.get(1) { let char_start = byte_to_char[m.start()]; let char_end = byte_to_char[m.end()]; let s = buffer.iter_at_offset(char_start); let e = buffer.iter_at_offset(char_end); buffer.apply_tag_by_name(TAG_HASHTAG, &s, &e); } }
// Apply emoji tags for vertical line-height fix let chars: Vec<char> = text.chars().collect(); for (i, &ch) in chars.iter().enumerate() { if is_emoji(ch) { let s = buffer.iter_at_offset(i as i32); let e = buffer.iter_at_offset(i as i32 + 1); buffer.apply_tag_by_name(TAG_EMOJI, &s, &e); } } }
/// Detect @mention context from current cursor position and update char counter. fn on_text_changed(&self, buffer: >k4::TextBuffer) { let imp = self.imp();
// Guard against re-entrant calls from tag application if imp.highlighting.get() { return; }
// --- Update character counter + highlighting --- self.update_char_counter(buffer);
imp.highlighting.set(true); self.highlight_facets(buffer); imp.highlighting.set(false);
// --- Link card detection (debounced) --- self.check_for_link_card(buffer);
// Get cursor position let cursor_offset = buffer.cursor_position(); let cursor_iter = buffer.iter_at_offset(cursor_offset);
// Walk backwards from cursor to find @ with a word boundary before it let mut search_iter = cursor_iter; let mut found_at = false; let mut at_offset = 0i32;
while search_iter.backward_char() { let ch = search_iter.char(); if ch == '@' { // Check that @ is at start of text or preceded by whitespace/punctuation let mut before = search_iter; if !before.backward_char() || before.char().is_whitespace() || matches!(before.char(), '(' | '[' | '{') { found_at = true; at_offset = search_iter.offset(); } break; } // Stop if we hit whitespace (no @ in this word) if ch.is_whitespace() { break; } }
if !found_at { self.hide_mention_popover(); return; }
// Extract the partial query (text between @ and cursor, exclusive of @) let query_start = buffer.iter_at_offset(at_offset + 1); // skip the @ let query_text = buffer.text(&query_start, &cursor_iter, false).to_string();
// Need at least 1 character to search if query_text.is_empty() { self.hide_mention_popover(); return; }
// Store the @ position for later insertion imp.mention_at_offset.set(at_offset);
// Debounce: increment counter, fire search after delay if counter still matches let counter = imp.debounce_counter.get().wrapping_add(1); imp.debounce_counter.set(counter);
let dialog_weak = self.downgrade(); let query = query_text.clone(); glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { let Some(dialog) = dialog_weak.upgrade() else { return; }; // Only fire if no newer keystrokes have happened if dialog.imp().debounce_counter.get() != counter { return; } if let Some(cb) = dialog.imp().mention_search_callback.borrow().as_ref() { cb(query); } }); }
/// Populate the mention popover with search results and show it. pub fn set_mention_results(&self, profiles: Vec<Profile>) { let imp = self.imp();
if profiles.is_empty() { self.hide_mention_popover(); return; }
if let Some(list) = imp.mention_list.borrow().as_ref() { // Clear existing rows while let Some(child) = list.first_child() { list.remove(&child); }
for profile in &profiles { let row = self.build_mention_row(profile); list.append(&row); }
// Select first row if let Some(first) = list.row_at_index(0) { list.select_row(Some(&first)); } }
imp.mention_results.replace(profiles);
// Position the popover near the cursor in the text view self.position_mention_popover();
if let Some(popover) = imp.mention_popover.borrow().as_ref() && !popover.is_visible() { popover.popup(); } }
/// Position the mention popover at the current @ location in the text view. fn position_mention_popover(&self) { let imp = self.imp(); let tv = match imp.text_view.borrow().as_ref() { Some(tv) => tv.clone(), None => return, }; let popover = match imp.mention_popover.borrow().as_ref() { Some(p) => p.clone(), None => return, };
let buffer = tv.buffer(); let at_offset = imp.mention_at_offset.get(); let iter = buffer.iter_at_offset(at_offset);
// Get the location of the @ character in TextView coordinates let (strong, _weak) = tv.cursor_locations(Some(&iter));
// Convert buffer coords to widget coords let (wx, wy) = tv.buffer_to_window_coords(gtk4::TextWindowType::Widget, strong.x(), strong.y());
let rect = gdk::Rectangle::new(wx, wy + strong.height(), 1, 1); popover.set_pointing_to(Some(&rect)); }
/// Build a single mention autocomplete row showing avatar + name + handle + follow status. fn build_mention_row(&self, profile: &Profile) -> gtk4::ListBoxRow { let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); row_box.set_margin_start(6); row_box.set_margin_end(6); row_box.set_margin_top(6); row_box.set_margin_bottom(6);
let display_name = profile .display_name .clone() .unwrap_or_else(|| profile.handle.clone());
// Circular avatar. Initials show immediately; the real image loads async. let avatar = adw::Avatar::new(32, Some(&display_name), true); if let Some(url) = &profile.avatar { avatar_cache::load_avatar(avatar.clone(), url.clone()); } row_box.append(&avatar);
// Name + handle + follow status stacked vertically let info_box = gtk4::Box::new(gtk4::Orientation::Vertical, 1); info_box.set_hexpand(true); info_box.set_valign(gtk4::Align::Center);
let name_label = gtk4::Label::new(Some(&display_name)); name_label.set_halign(gtk4::Align::Start); name_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); name_label.set_max_width_chars(28); name_label.add_css_class("heading"); info_box.append(&name_label);
// Handle + follow status on one line let handle_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
let handle_label = gtk4::Label::new(Some(&format!("@{}", profile.handle))); handle_label.set_halign(gtk4::Align::Start); handle_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); handle_label.set_max_width_chars(22); handle_label.add_css_class("dim-label"); handle_label.add_css_class("caption"); handle_row.append(&handle_label);
// Follow status as a subtle indicator let follow_text = match (&profile.viewer_following, &profile.viewer_followed_by) { (Some(_), Some(_)) => Some("· Mutuals"), (Some(_), None) => Some("· Following"), (None, Some(_)) => Some("· Follows you"), _ => None, }; if let Some(text) = follow_text { let follow_label = gtk4::Label::new(Some(text)); follow_label.add_css_class("dim-label"); follow_label.add_css_class("caption"); handle_row.append(&follow_label); }
info_box.append(&handle_row); row_box.append(&info_box);
let row = gtk4::ListBoxRow::new(); row.set_child(Some(&row_box));
// Accessible label let a11y_label = format!("{}, @{}", display_name, profile.handle); row.update_property(&[gtk4::accessible::Property::Label(&a11y_label)]);
row }
/// Insert the selected mention handle into the text buffer. fn insert_mention(&self, index: usize) { let imp = self.imp(); let handle = { let results = imp.mention_results.borrow(); match results.get(index) { Some(p) => p.handle.clone(), None => return, } };
if let Some(tv) = imp.text_view.borrow().as_ref() { let buffer = tv.buffer(); let at_offset = imp.mention_at_offset.get(); let cursor_offset = buffer.cursor_position();
// Delete from @ through cursor (the partial query) let start = buffer.iter_at_offset(at_offset); let end = buffer.iter_at_offset(cursor_offset); buffer.delete(&mut start.clone(), &mut end.clone());
// Insert full @handle + trailing space let mention_text = format!("@{} ", handle); let mut insert_iter = buffer.iter_at_offset(at_offset); buffer.insert(&mut insert_iter, &mention_text); }
self.hide_mention_popover(); }
/// Move the mention list selection up or down, wrapping at boundaries. fn move_mention_selection(&self, delta: i32) { let imp = self.imp(); let result_count = imp.mention_results.borrow().len() as i32; if result_count == 0 { return; } if let Some(list) = imp.mention_list.borrow().as_ref() { let current = list.selected_row().map(|r| r.index()).unwrap_or(-1); // Wrap around: going past the end wraps to top, going before top wraps to bottom let next = (current + delta).rem_euclid(result_count); if let Some(row) = list.row_at_index(next) { list.select_row(Some(&row));
// Scroll the row into view after GTK has done its layout pass. // We schedule an idle callback so allocation coordinates are valid. let row_ref = row.clone(); glib::idle_add_local_once(move || { // Walk up the parent chain to find the ScrolledWindow. // GTK4 ScrolledWindow wraps children in a GtkViewport, // so the chain is: row → ListBox → Viewport → ScrolledWindow. let mut ancestor = row_ref.parent(); while let Some(widget) = ancestor { if let Some(sw) = widget.downcast_ref::<gtk4::ScrolledWindow>() { let adj = sw.vadjustment(); // Compute row position relative to the ListBox (the scrollable content) if let Some(list_parent) = row_ref.parent() { let row_y = row_ref .compute_point( &list_parent, >k4::graphene::Point::new(0.0, 0.0), ) .map_or(0.0, |p| p.y() as f64); let row_h = row_ref.height() as f64; let page_top = adj.value(); let page_bottom = page_top + adj.page_size();
if row_y + row_h > page_bottom { adj.set_value(row_y + row_h - adj.page_size()); } else if row_y < page_top { adj.set_value(row_y); } } break; } ancestor = widget.parent(); } }); } } }
fn hide_mention_popover(&self) { let imp = self.imp(); if let Some(popover) = imp.mention_popover.borrow().as_ref() { popover.popdown(); } imp.mention_results.borrow_mut().clear(); }
// ─── Image attachment methods ───
/// Open a file chooser dialog for selecting images. fn open_image_chooser(&self) { let image_count = self.imp().images.borrow().len(); if image_count >= MAX_IMAGES { return; }
if self.imp().video.borrow().is_some() { return; }
self.choose_image( Self::media_filter(), |dialog, path| match Self::media_kind_for_path(path) { MediaKind::Video(mime) => dialog.load_video_from_path(None, path, mime), MediaKind::Image => dialog.load_image_from_path(path), }, ); }
/// One filter for everything a post can carry: still images, videos, /// and GIFs, which upload through the video service and stay animated. fn media_filter() -> gtk4::FileFilter { let filter = gtk4::FileFilter::new(); filter.set_name(Some("Images and videos")); filter.add_mime_type("image/jpeg"); filter.add_mime_type("image/png"); filter.add_mime_type("image/webp"); filter.add_mime_type("image/gif"); filter.add_mime_type("video/mp4"); filter.add_mime_type("video/webm"); filter.add_mime_type("video/quicktime"); filter.add_mime_type("video/mpeg"); filter }
/// Which pipeline a picked file belongs to, by extension. GIFs count /// as video: uploaded as an image blob they would freeze to a still. fn media_kind_for_path(path: &std::path::Path) -> MediaKind { let ext = path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()); match ext.as_deref() { Some("mp4") | Some("m4v") => MediaKind::Video("video/mp4"), Some("webm") => MediaKind::Video("video/webm"), Some("mov") => MediaKind::Video("video/quicktime"), Some("mpeg") | Some("mpg") => MediaKind::Video("video/mpeg"), Some("gif") => MediaKind::Video("image/gif"), _ => MediaKind::Image, } }
/// Present the image picker and hand the chosen path to `on_chosen`. /// /// GtkFileDialog replaced GtkFileChooserNative in GTK 4.10. It reports the /// outcome through a callback rather than a response signal, and closing /// the picker without choosing arrives as DialogError::Dismissed, which is /// ordinary user behaviour rather than something worth logging. fn choose_image<F>(&self, filter: gtk4::FileFilter, on_chosen: F) where F: Fn(&ComposeDialog, &std::path::Path) + 'static, { let filters = gtk4::gio::ListStore::new::<gtk4::FileFilter>(); filters.append(&filter);
let file_dialog = gtk4::FileDialog::builder() .title("Select Image") .accept_label("Open") .filters(&filters) .default_filter(&filter) .modal(true) .build();
let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); let dialog_weak = self.downgrade();
file_dialog.open( window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| match result { Ok(file) => { if let (Some(path), Some(dialog)) = (file.path(), dialog_weak.upgrade()) { on_chosen(&dialog, &path); } } Err(e) if e.matches(gtk4::DialogError::Dismissed) => {} Err(e) => eprintln!("Image picker failed: {}", e), }, ); }
/// Load an image file and add it to the compose image strip. fn load_image_from_path(&self, path: &std::path::Path) { let imp = self.imp(); if imp.images.borrow().len() >= MAX_IMAGES { return; }
// Read file bytes let data = match std::fs::read(path) { Ok(d) => d, Err(e) => { eprintln!("Failed to read image: {}", e); return; } };
// Determine MIME type from extension let mime_type = match path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()) { Some(ext) if ext == "jpg" || ext == "jpeg" => "image/jpeg".to_string(), Some(ext) if ext == "png" => "image/png".to_string(), Some(ext) if ext == "gif" => "image/gif".to_string(), Some(ext) if ext == "webp" => "image/webp".to_string(), _ => "image/jpeg".to_string(), };
// The preview decodes small; `data` above is what gets uploaded. let Some((texture, width, height)) = decode_thumbnail(&data) else { eprintln!("Failed to decode image: {}", path.display()); return; };
let compose_image = ComposeImage { data, mime_type, alt_text: String::new(), width, height, texture, };
imp.images.borrow_mut().push(compose_image); self.rebuild_image_strip(); self.update_image_button_state(); // Images take precedence over link cards, so hide the link preview self.clear_link_preview();
// The descriptive-text prompt: with the setting on, attaching goes // straight to describing. if imp.require_alt_text.get() { let index = imp.images.borrow().len().saturating_sub(1); self.show_alt_text_dialog(index); }
// Re-evaluate post button state (images allow posting without text) if let Some(tv) = imp.text_view.borrow().as_ref() { self.update_char_counter(&tv.buffer()); } }
/// Rebuild the image strip thumbnails from current images list. fn rebuild_image_strip(&self) { let imp = self.imp(); let strip = match imp.image_strip.borrow().as_ref() { Some(s) => s.clone(), None => return, };
// Clear existing thumbnails while let Some(child) = strip.first_child() { strip.remove(&child); }
let images = imp.images.borrow(); let has_images = !images.is_empty();
for (i, img) in images.iter().enumerate() { let thumb_box = gtk4::Overlay::new(); thumb_box.set_size_request(80, 80); thumb_box.add_css_class("compose-thumbnail");
// Image. The role has to be set at construction, GTK will not // change it later, and this frame is what opens the description. let picture = gtk4::Picture::builder() .accessible_role(gtk4::AccessibleRole::Button) .build(); picture.set_paintable(Some(&img.texture)); picture.set_can_shrink(true); picture.set_size_request(80, 80); picture.add_css_class("compose-thumbnail-image"); thumb_box.set_child(Some(&picture));
// Remove button overlay (top-right) let remove_btn = gtk4::Button::from_icon_name("window-close-symbolic"); remove_btn.add_css_class("circular"); remove_btn.add_css_class("osd"); remove_btn.set_halign(gtk4::Align::End); remove_btn.set_valign(gtk4::Align::Start); remove_btn.set_margin_top(4); remove_btn.set_margin_end(4); remove_btn.set_tooltip_text(Some(&format!("Remove image {}", i + 1))); remove_btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Remove image {}", i + 1 ))]); let dialog_weak = self.downgrade(); let idx = i; remove_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.remove_image(idx); } }); thumb_box.add_overlay(&remove_btn);
// Accessible label for the thumbnail, on the frame that takes // focus as well as on the tile around it. let alt_desc = if img.alt_text.is_empty() { "No alt text".to_string() } else { format!("Alt text: {}", img.alt_text) }; let thumb_label = format!("Image {}. {}. Edit alt text.", i + 1, alt_desc); thumb_box.update_property(&[gtk4::accessible::Property::Label(&thumb_label)]); picture.update_property(&[gtk4::accessible::Property::Label(&thumb_label)]);
let described = !img.alt_text.is_empty(); let action = if described { format!("Edit alt text for image {}", i + 1) } else { format!("Add alt text to image {}", i + 1) }; let dialog_weak = self.downgrade(); let idx = i; Self::wire_alt_editing(&thumb_box, &picture, described, &action, move || { if let Some(dialog) = dialog_weak.upgrade() { dialog.show_alt_text_dialog(idx); } });
strip.append(&thumb_box); }
let has_video = imp.video.borrow().is_some(); if let Some(video) = imp.video.borrow().as_ref() { strip.append(&self.build_video_tile(video)); } strip.set_visible(has_images || has_video); }
/// Remove a specific image by index. fn remove_image(&self, index: usize) { let imp = self.imp(); { let mut images = imp.images.borrow_mut(); if index < images.len() { images.remove(index); } } self.rebuild_image_strip(); self.update_image_button_state();
// Re-evaluate post button state if let Some(tv) = imp.text_view.borrow().as_ref() { let buffer = tv.buffer(); self.update_char_counter(&buffer); // If all images removed, re-check for link cards if imp.images.borrow().is_empty() { self.check_for_link_card(&buffer); } } }
/// Remove all attached images. fn remove_all_images(&self) { self.imp().images.borrow_mut().clear(); self.rebuild_image_strip(); self.update_image_button_state();
// Re-evaluate post button state if let Some(tv) = self.imp().text_view.borrow().as_ref() { let buffer = tv.buffer(); self.update_char_counter(&buffer); // Re-check for link cards now that images are cleared self.check_for_link_card(&buffer); } }
/// Update the add image button and action row visibility/state for the main post. fn update_image_button_state(&self) { let imp = self.imp(); let count = imp.images.borrow().len(); let at_max = count >= MAX_IMAGES; let has_video = imp.video.borrow().is_some(); let has_images = count > 0 || has_video;
if let Some(btn) = imp.add_image_button.borrow().as_ref() { btn.set_sensitive(!at_max && !has_video); btn.set_tooltip_text(Some(if has_video { "One video per post" } else { "Attach media" })); btn.update_property(&[gtk4::accessible::Property::Label(&if has_video { "Attach media, a video is attached".to_string() } else { format!("Attach media, {} of {} images attached", count, MAX_IMAGES) })]); }
// Show/hide the action row (CW + Remove All) based on whether media is attached if let Some(btn) = imp.remove_all_images_button.borrow().as_ref() { btn.set_visible(count > 0); // Show/hide the parent action row if let Some(parent) = btn.parent() { parent.set_visible(has_images); } }
// Update the content warning button label from the current CW if let Some(btn) = imp.cw_button.borrow().as_ref() { btn.set_visible(has_images); if has_images { let cw = imp.content_warning.borrow(); if let Some(val) = cw.as_ref() { let display = match val.as_str() { "sexual" => "Suggestive", "nudity" => "Nudity", "porn" => "Pornography", "graphic-media" => "Graphic Media", _ => val.as_str(), }; btn.set_label(&format!("Content Warning: {}", display)); btn.set_tooltip_text(Some(&format!("Content warning: {}", display))); btn.add_css_class("accent"); } else { btn.set_label("Add Content Warning\u{2026}"); btn.set_tooltip_text(Some("Set content warning for attached media")); btn.remove_css_class("accent"); } } }
// Clear content warning if all images removed if !has_images { imp.content_warning.replace(None); } }
/// Wire a thumbnail up for describing. The frame itself takes focus and /// answers Enter, Keypad Enter and Space, and an ALT button sits on the /// overlay so the affordance has a name to tab to instead of hiding /// behind a click on the image. Pointer and keyboard run the same /// `describe` closure. fn wire_alt_editing<F>( overlay: >k4::Overlay, carrier: &impl IsA<gtk4::Widget>, described: bool, action_label: &str, describe: F, ) where F: Fn() + Clone + 'static, { let carrier = carrier.as_ref(); carrier.set_focusable(true); // Reuses the ring the quote card defines for clickable non-buttons. carrier.add_css_class("quote-card-button");
let click = gtk4::GestureClick::new(); let open = describe.clone(); click.connect_released(move |gesture, _, _, _| { gesture.set_state(gtk4::EventSequenceState::Claimed); open(); }); carrier.add_controller(click);
let key = gtk4::EventControllerKey::new(); let open = describe.clone(); key.connect_key_pressed(move |_, keyval, _, _| match keyval { gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::space | gdk::Key::KP_Space => { open(); glib::Propagation::Stop } _ => glib::Propagation::Proceed, }); carrier.add_controller(key);
// The badge doubles as the state: ALT once described, +ALT until then. let badge = gtk4::Button::with_label(if described { "ALT" } else { "+ALT" }); badge.add_css_class("compose-alt-badge"); badge.add_css_class("osd"); badge.add_css_class("flat"); badge.set_halign(gtk4::Align::End); badge.set_valign(gtk4::Align::End); badge.set_margin_bottom(4); badge.set_margin_end(4); badge.set_tooltip_text(Some(action_label)); badge.update_property(&[gtk4::accessible::Property::Label(action_label)]); badge.connect_clicked(move |_| describe()); overlay.add_overlay(&badge); }
/// Show a dialog for editing the alt text of an image. fn show_alt_text_dialog(&self, image_index: usize) { let imp = self.imp(); let current_alt = { let images = imp.images.borrow(); match images.get(image_index) { Some(img) => img.alt_text.clone(), None => return, } };
let alt_dialog = adw::Dialog::new(); alt_dialog.set_title("Add Descriptive Text"); alt_dialog.set_content_width(360);
let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false);
let done_btn = gtk4::Button::with_label("Done"); done_btn.add_css_class("suggested-action"); header.pack_end(&done_btn);
let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header);
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 12); content.set_margin_start(24); content.set_margin_end(24); content.set_margin_top(12); content.set_margin_bottom(24);
let desc_label = gtk4::Label::new(Some( "Help the blind and vision impaired to understand your posts by adding descriptive text to your images.", )); desc_label.set_wrap(true); // WordChar: no label sets the window's minimum width. desc_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); desc_label.set_halign(gtk4::Align::Start); desc_label.add_css_class("dim-label"); content.append(&desc_label);
let label = gtk4::Label::new(Some("Describe this image")); label.set_halign(gtk4::Align::Start); label.add_css_class("heading"); content.append(&label);
let text_view = gtk4::TextView::new(); text_view.set_wrap_mode(gtk4::WrapMode::WordChar); text_view.set_vexpand(true); text_view.set_left_margin(8); text_view.set_right_margin(8); text_view.set_top_margin(8); text_view.set_bottom_margin(8); text_view.add_css_class("card"); text_view.update_property(&[gtk4::accessible::Property::Label("Image description")]);
if !current_alt.is_empty() { text_view.buffer().set_text(¤t_alt); }
let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_vexpand(true); scrolled.set_child(Some(&text_view)); content.append(&scrolled);
toolbar.set_content(Some(&content)); alt_dialog.set_child(Some(&toolbar));
let dialog_weak = self.downgrade(); let alt_dialog_weak = alt_dialog.downgrade(); let text_view_for_done = text_view.clone(); done_btn.connect_clicked(move |_| { let buffer = text_view_for_done.buffer(); let start = buffer.start_iter(); let end = buffer.end_iter(); let alt_text = buffer .text(&start, &end, false) .to_string() .trim() .to_string();
if let Some(dialog) = dialog_weak.upgrade() { { let mut images = dialog.imp().images.borrow_mut(); if let Some(img) = images.get_mut(image_index) { img.alt_text = alt_text; } } dialog.rebuild_image_strip(); // A fresh description may be what the Post button waited on. if let Some(tv) = dialog.imp().text_view.borrow().as_ref() { dialog.update_char_counter(&tv.buffer()); } }
if let Some(alt_dlg) = alt_dialog_weak.upgrade() { alt_dlg.close(); } });
// Present the alt text dialog let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); if let Some(win) = window.as_ref() { alt_dialog.present(Some(win)); } }
// ─── Video attachment methods ───
/// The app installs the upload flow here; the dialog reports state /// back through the `video_*` methods, addressed by token. pub fn set_video_upload_callback< F: Fn( u64, Vec<u8>, String, std::sync::Arc<std::sync::atomic::AtomicU64>, std::sync::Arc<std::sync::atomic::AtomicBool>, ) + 'static, >( &self, callback: F, ) { self.imp() .video_upload_callback .replace(Some(Box::new(callback))); }
/// Read a picked video file and attach it to `slot` (None = main post). fn load_video_from_path( &self, slot: Option<usize>, path: &std::path::Path, mime: &'static str, ) { // Stat before reading. A phone's 4K capture is gigabytes, and // reading it to find that out is how the composer used to eat the // machine before the service ever saw a byte. let limit = crate::atproto::client::MAX_VIDEO_BYTES; match std::fs::metadata(path) { Ok(meta) if meta.len() > limit => { self.flash_compose_error(&format!( "That video is {} MB. The limit is {} MB.", meta.len() / (1024 * 1024), limit / (1024 * 1024) )); return; } Ok(_) => {} Err(e) => { self.flash_compose_error(&format!("Couldn't read the file: {e}")); return; } }
let data = match std::fs::read(path) { Ok(d) => d, Err(e) => { self.flash_compose_error(&format!("Couldn't read the file: {e}")); return; } }; let file_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("video") .to_string(); self.attach_video_bytes(slot, data, mime.to_string(), file_name); self.probe_video_aspect(path); }
/// Attach video bytes to a slot and start the upload. Split from the /// path reading so tests can feed bytes directly. pub(crate) fn attach_video_bytes( &self, slot: Option<usize>, data: Vec<u8>, mime_type: String, file_name: String, ) { let imp = self.imp();
// One video, and never beside images. let (occupied, has_images) = match slot { None => ( imp.video.borrow().is_some(), !imp.images.borrow().is_empty(), ), Some(i) => { let posts = imp.thread_posts.borrow(); match posts.get(i) { Some(b) => (b.video.is_some(), !b.images.is_empty()), None => return, } } }; if occupied { self.flash_compose_error("One video per post."); return; } if has_images { self.flash_compose_error("A post can hold images or a video, not both."); return; }
let token = imp.video_token_counter.get(); imp.video_token_counter.set(token + 1); let sent_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let status_label = gtk4::Label::new(Some("Uploading\u{2026}")); status_label.add_css_class("caption"); status_label.add_css_class("dim-label"); status_label.set_halign(gtk4::Align::Start);
let video = ComposeVideo { token, mime_type: mime_type.clone(), file_name, alt_text: String::new(), total_bytes: data.len() as u64, sent_bytes: sent_bytes.clone(), cancelled: cancelled.clone(), state: VideoUploadState::Uploading, blob: None, aspect_ratio: None, progress_icon: crate::ui::progress_icon::ProgressIcon::new(), status_label, preview: None, };
match slot { None => { imp.video.replace(Some(video)); self.rebuild_image_strip(); self.update_image_button_state(); // Video outranks a link card, same as images. self.clear_link_preview(); } Some(i) => { if let Some(block) = imp.thread_posts.borrow_mut().get_mut(i) { block.video = Some(video); } self.rebuild_thread_image_strip(i); } } self.refresh_post_gates();
// With the setting on, attaching goes straight to describing. if imp.require_alt_text.get() { self.show_video_alt_dialog(token); }
// Hand the bytes to the app and follow the counter while they go. if let Some(cb) = imp.video_upload_callback.borrow().as_ref() { cb(token, data, mime_type, sent_bytes, cancelled); } self.start_video_progress_timer(token); }
/// Tick the pie while bytes stream. Ends itself once the state moves /// past Uploading or the video is gone. fn start_video_progress_timer(&self, token: u64) { let dialog_weak = self.downgrade(); glib::timeout_add_local(std::time::Duration::from_millis(100), move || { let Some(dialog) = dialog_weak.upgrade() else { return glib::ControlFlow::Break; }; let mut keep = glib::ControlFlow::Break; dialog.with_video(token, |video| { if video.state == VideoUploadState::Uploading { let sent = video.sent_bytes.load(std::sync::atomic::Ordering::Relaxed); let fraction = sent as f64 / video.total_bytes.max(1) as f64; video.progress_icon.set_fraction(fraction); video .status_label .set_text(&format!("Uploading {}%", (fraction * 100.0) as u32)); keep = glib::ControlFlow::Continue; } }); keep }); }
/// Run `f` on the video with this token, wherever it sits. fn with_video(&self, token: u64, f: impl FnOnce(&mut ComposeVideo)) -> bool { let imp = self.imp(); { let mut video = imp.video.borrow_mut(); if let Some(v) = video.as_mut() && v.token == token { f(v); return true; } } let mut posts = imp.thread_posts.borrow_mut(); for block in posts.iter_mut() { if let Some(v) = block.video.as_mut() && v.token == token { f(v); return true; } } false }
/// The strip owning this token: None = main, Some(i) = thread post i. fn video_slot(&self, token: u64) -> Option<Option<usize>> { let imp = self.imp(); if imp .video .borrow() .as_ref() .is_some_and(|v| v.token == token) { return Some(None); } let posts = imp.thread_posts.borrow(); posts .iter() .position(|b| b.video.as_ref().is_some_and(|v| v.token == token)) .map(Some) }
/// The service took the bytes and is transcoding. pub fn video_processing(&self, token: u64, percent: Option<u8>) { self.with_video(token, |video| { video.state = VideoUploadState::Processing; match percent { Some(p) => { video.progress_icon.set_fraction(f64::from(p) / 100.0); video.status_label.set_text(&format!("Processing {p}%")); } None => { video.progress_icon.set_fraction(1.0); video.status_label.set_text("Processing\u{2026}"); } } }); self.refresh_post_gates(); }
/// The processed blob is in hand; the post can go. pub fn video_ready(&self, token: u64, blob: serde_json::Value) { let found = self.with_video(token, |video| { video.state = VideoUploadState::Ready; video.blob = Some(blob); }); if found && let Some(slot) = self.video_slot(token) { match slot { None => self.rebuild_image_strip(), Some(i) => self.rebuild_thread_image_strip(i), } } self.refresh_post_gates(); }
/// The upload or processing failed; the tile says so and the post /// stays blocked until the tile is removed. pub fn video_failed(&self, token: u64, message: &str) { let found = self.with_video(token, |video| { video.state = VideoUploadState::Failed; }); if found && let Some(slot) = self.video_slot(token) { match slot { None => self.rebuild_image_strip(), Some(i) => self.rebuild_thread_image_strip(i), } } self.flash_compose_error(&format!("Video upload failed: {message}")); self.refresh_post_gates(); }
/// Record the probed pixel size for the embed's aspect ratio. fn set_video_aspect(&self, token: u64, width: u32, height: u32) { self.with_video(token, |video| { video.aspect_ratio = Some((width, height)); }); }
/// Load the file into a muted MediaFile long enough to learn its /// pixel size. Videos GStreamer cannot read just skip the ratio. fn probe_video_aspect(&self, path: &std::path::Path) { let imp = self.imp(); let token = imp.video_token_counter.get().saturating_sub(1); let media = gtk4::MediaFile::for_filename(path); media.set_muted(true); let dialog_weak = self.downgrade(); media.connect_notify_local(Some("prerolled"), move |media, _| { let Some(dialog) = dialog_weak.upgrade() else { return; }; let width = gdk::prelude::PaintableExt::intrinsic_width(media); let height = gdk::prelude::PaintableExt::intrinsic_height(media); if width > 0 && height > 0 { dialog.set_video_aspect(token, width as u32, height as u32); } // Paused on its first frame, the probe doubles as the tile's // preview. media.pause(); let kept = dialog.with_video(token, |video| { video.preview = Some(media.clone()); }); if kept && let Some(slot) = dialog.video_slot(token) { match slot { None => dialog.rebuild_image_strip(), Some(i) => dialog.rebuild_thread_image_strip(i), } } dialog.drop_video_probe(media); }); let dialog_weak = self.downgrade(); media.connect_notify_local(Some("error"), move |media, _| { if let Some(dialog) = dialog_weak.upgrade() { dialog.drop_video_probe(media); } }); media.play(); imp.video_probes.borrow_mut().push(media); }
fn drop_video_probe(&self, media: >k4::MediaFile) { self.imp().video_probes.borrow_mut().retain(|m| m != media); }
/// Remove the video in `slot`, upload and all. A late result for its /// token finds nothing and falls away. fn remove_video(&self, slot: Option<usize>) { // Tell the upload to stop and let go of the file bytes. Without // this the thread ran to completion holding them, so attaching // and removing a few large videos stacked their buffers. self.cancel_video_in(slot); let imp = self.imp(); match slot { None => { imp.video.replace(None); self.rebuild_image_strip(); self.update_image_button_state(); if let Some(tv) = imp.text_view.borrow().as_ref() { self.check_for_link_card(&tv.buffer()); } } Some(i) => { if let Some(block) = imp.thread_posts.borrow_mut().get_mut(i) { block.video = None; } self.rebuild_thread_image_strip(i); } } self.refresh_post_gates(); }
/// Flag the upload in `slot` as abandoned, if there is one. fn cancel_video_in(&self, slot: Option<usize>) { let imp = self.imp(); let flag = match slot { None => imp.video.borrow().as_ref().map(|v| v.cancelled.clone()), Some(i) => imp .thread_posts .borrow() .get(i) .and_then(|b| b.video.as_ref().map(|v| v.cancelled.clone())), }; if let Some(flag) = flag { flag.store(true, std::sync::atomic::Ordering::Relaxed); } }
/// Every upload this dialog started is abandoned when it closes. pub fn cancel_all_uploads(&self) { self.cancel_video_in(None); let count = self.imp().thread_posts.borrow().len(); for i in 0..count { self.cancel_video_in(Some(i)); } }
/// Build the tile a strip shows for its video: the first frame when /// GStreamer could read the file, a filename card when it could not. fn build_video_tile(&self, video: &ComposeVideo) -> gtk4::Overlay { let tile = gtk4::Overlay::new(); tile.add_css_class("compose-thumbnail");
// The stored widgets move between rebuilds; unhook them first. for widget in [ video.progress_icon.clone().upcast::<gtk4::Widget>(), video.status_label.clone().upcast::<gtk4::Widget>(), ] { if let Some(parent) = widget.parent() && let Some(parent) = parent.downcast_ref::<gtk4::Box>() { parent.remove(&widget); } }
let status_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); status_row.append(&video.progress_icon); status_row.append(&video.status_label); match video.state { VideoUploadState::Ready => { video.progress_icon.set_visible(false); video.status_label.set_text("Ready"); video.status_label.remove_css_class("error"); video.status_label.add_css_class("dim-label"); } VideoUploadState::Failed => { video.progress_icon.set_visible(false); video.status_label.set_text("Upload failed"); video.status_label.remove_css_class("dim-label"); video.status_label.add_css_class("error"); } _ => video.progress_icon.set_visible(true), }
// The inner frame opens the description, so it announces itself as a // button; the role has to be set at construction. let inner: gtk4::Widget = if let Some(preview) = &video.preview { tile.set_size_request(142, 80); let picture = gtk4::Picture::builder() .accessible_role(gtk4::AccessibleRole::Button) .build(); picture.set_paintable(Some(preview)); picture.set_can_shrink(true); picture.set_content_fit(gtk4::ContentFit::Cover); picture.set_size_request(142, 80); picture.add_css_class("compose-thumbnail-image"); tile.set_child(Some(&picture));
// Progress floats over the frame and leaves once Ready. let chip = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); chip.add_css_class("osd"); chip.add_css_class("compose-video-status"); chip.set_halign(gtk4::Align::Start); chip.set_valign(gtk4::Align::End); chip.set_margin_start(4); chip.set_margin_bottom(4); chip.append(&status_row); chip.set_visible(video.state != VideoUploadState::Ready); tile.add_overlay(&chip); picture.upcast() } else { tile.set_size_request(200, 80); let card = gtk4::Box::builder() .orientation(gtk4::Orientation::Vertical) .spacing(4) .accessible_role(gtk4::AccessibleRole::Button) .build(); card.set_margin_top(10); card.set_margin_bottom(10); card.set_margin_start(10); card.set_margin_end(10); card.set_valign(gtk4::Align::Center);
let name_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); let icon_name = if video.mime_type == "image/gif" { "image-x-generic-symbolic" } else { "video-x-generic-symbolic" }; name_row.append(>k4::Image::from_icon_name(icon_name)); let name = gtk4::Label::new(Some(&video.file_name)); name.set_ellipsize(gtk4::pango::EllipsizeMode::Middle); name.set_max_width_chars(18); name.add_css_class("caption-heading"); name_row.append(&name); card.append(&name_row); card.append(&status_row); tile.set_child(Some(&card)); card.upcast() };
// Remove button, top-right like the image tiles. let remove_btn = gtk4::Button::from_icon_name("window-close-symbolic"); remove_btn.add_css_class("circular"); remove_btn.add_css_class("osd"); remove_btn.set_halign(gtk4::Align::End); remove_btn.set_valign(gtk4::Align::Start); remove_btn.set_margin_top(4); remove_btn.set_margin_end(4); remove_btn.set_tooltip_text(Some("Remove video")); remove_btn.update_property(&[gtk4::accessible::Property::Label("Remove video")]); let dialog_weak = self.downgrade(); let token = video.token; remove_btn.connect_clicked(move |_| { // The slot is found fresh; thread posts shuffle indices. if let Some(dialog) = dialog_weak.upgrade() && let Some(slot) = dialog.video_slot(token) { dialog.remove_video(slot); } }); tile.add_overlay(&remove_btn);
let alt_desc = if video.alt_text.is_empty() { "No alt text".to_string() } else { format!("Alt text: {}", video.alt_text) }; let tile_label = format!("Video {}. {}. Edit alt text.", video.file_name, alt_desc); tile.update_property(&[gtk4::accessible::Property::Label(&tile_label)]); inner.update_property(&[gtk4::accessible::Property::Label(&tile_label)]);
// Describing the video: badge, pointer and keyboard, all as on images. let described = !video.alt_text.is_empty(); let action = if described { "Edit alt text for the video" } else { "Add alt text to the video" }; let dialog_weak = self.downgrade(); let token = video.token; Self::wire_alt_editing(&tile, &inner, described, action, move || { if let Some(dialog) = dialog_weak.upgrade() { dialog.show_video_alt_dialog(token); } });
tile }
/// The describe dialog for a video, mirroring the image one. fn show_video_alt_dialog(&self, token: u64) { let mut current = None; self.with_video(token, |video| { current = Some(video.alt_text.clone()); }); let Some(current_alt) = current else { return; };
let alt_dialog = adw::Dialog::new(); alt_dialog.set_title("Add Descriptive Text"); alt_dialog.set_content_width(360);
let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false); let done_btn = gtk4::Button::with_label("Done"); done_btn.add_css_class("suggested-action"); header.pack_end(&done_btn);
let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header);
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 12); content.set_margin_start(24); content.set_margin_end(24); content.set_margin_top(12); content.set_margin_bottom(24);
let desc_label = gtk4::Label::new(Some( "Help the blind and vision impaired to understand your posts by adding descriptive text to your videos.", )); desc_label.set_wrap(true); desc_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); desc_label.set_halign(gtk4::Align::Start); desc_label.add_css_class("dim-label"); content.append(&desc_label);
let label = gtk4::Label::new(Some("Describe this video")); label.set_halign(gtk4::Align::Start); label.add_css_class("heading"); content.append(&label);
let text_view = gtk4::TextView::new(); text_view.set_wrap_mode(gtk4::WrapMode::WordChar); text_view.set_vexpand(true); text_view.set_left_margin(8); text_view.set_right_margin(8); text_view.set_top_margin(8); text_view.set_bottom_margin(8); text_view.add_css_class("card"); text_view.update_property(&[gtk4::accessible::Property::Label("Video description")]); if !current_alt.is_empty() { text_view.buffer().set_text(¤t_alt); }
let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_vexpand(true); scrolled.set_child(Some(&text_view)); content.append(&scrolled);
toolbar.set_content(Some(&content)); alt_dialog.set_child(Some(&toolbar));
let dialog_weak = self.downgrade(); let alt_dialog_weak = alt_dialog.downgrade(); let text_view_for_done = text_view.clone(); done_btn.connect_clicked(move |_| { let buffer = text_view_for_done.buffer(); let alt_text = buffer .text(&buffer.start_iter(), &buffer.end_iter(), false) .to_string() .trim() .to_string(); if let Some(dialog) = dialog_weak.upgrade() { dialog.with_video(token, |video| { video.alt_text = alt_text; }); if let Some(slot) = dialog.video_slot(token) { match slot { None => dialog.rebuild_image_strip(), Some(i) => dialog.rebuild_thread_image_strip(i), } } dialog.refresh_post_gates(); } if let Some(alt_dlg) = alt_dialog_weak.upgrade() { alt_dlg.close(); } });
let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); if let Some(win) = window.as_ref() { alt_dialog.present(Some(win)); } }
/// Re-run whichever post gate applies right now. fn refresh_post_gates(&self) { if self.imp().thread_posts.borrow().is_empty() { if let Some(tv) = self.imp().text_view.borrow().as_ref() { self.update_char_counter(&tv.buffer()); } } else { self.update_thread_post_button_state(); } }
/// Show a short-lived error line under the composer. fn flash_compose_error(&self, message: &str) { let Some(label) = self.imp().error_label.borrow().clone() else { return; }; label.set_text(message); label.set_visible(true); let label_weak = label.downgrade(); let shown = message.to_string(); glib::timeout_add_local_once(std::time::Duration::from_secs(6), move || { if let Some(label) = label_weak.upgrade() && label.text() == shown { label.set_visible(false); } }); }
// ─── Language picker ───
/// Show the language selection dialog. fn show_language_picker(&self) { let lang_dialog = adw::Dialog::new(); lang_dialog.set_title("Select Post Language"); lang_dialog.set_content_width(360); lang_dialog.set_content_height(420);
let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false);
let done_btn = gtk4::Button::with_label("Done"); done_btn.add_css_class("suggested-action"); header.pack_end(&done_btn);
let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header);
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 8); content.set_margin_start(16); content.set_margin_end(16); content.set_margin_top(8); content.set_margin_bottom(16);
let desc_label = gtk4::Label::new(Some( "Pick the appropriate language for your post so it can appear in community-created feeds that filter by language.", )); desc_label.set_wrap(true); desc_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); desc_label.set_xalign(0.0); desc_label.add_css_class("dim-label"); content.append(&desc_label);
let search = gtk4::SearchEntry::new(); search.set_placeholder_text(Some("Search Languages...")); search.set_hexpand(true); search.update_property(&[gtk4::accessible::Property::Label("Search languages")]); content.append(&search);
let list = gtk4::ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); list.add_css_class("boxed-list");
let current_lang = self.imp().selected_language.borrow().clone();
for (code, english_name, native_name) in LANGUAGES { let row = adw::ActionRow::new(); row.set_title(english_name); row.set_subtitle(native_name);
if *code == current_lang { let check = gtk4::Image::from_icon_name("object-select-symbolic"); check.set_valign(gtk4::Align::Center); row.add_suffix(&check); }
// Store code in widget name for retrieval on selection row.set_widget_name(code); list.append(&row); }
let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_vexpand(true); scrolled.set_child(Some(&list)); content.append(&scrolled);
toolbar.set_content(Some(&content)); lang_dialog.set_child(Some(&toolbar));
// Search filtering let list_ref = list.clone(); search.connect_search_changed(move |entry| { let query = entry.text().to_string().to_lowercase(); let mut idx = 0; while let Some(row) = list_ref.row_at_index(idx) { if query.is_empty() { row.set_visible(true); } else if let Some(action_row) = row .child() .and_then(|c| c.downcast::<adw::ActionRow>().ok()) { let title = action_row.title().to_string().to_lowercase(); let subtitle = action_row .subtitle() .map(|s| s.to_string().to_lowercase()) .unwrap_or_default(); row.set_visible(title.contains(&query) || subtitle.contains(&query)); } else { row.set_visible(true); } idx += 1; } });
// Row selection let dialog_weak = self.downgrade(); let lang_dialog_weak = lang_dialog.downgrade(); list.connect_row_activated(move |_, row| { if let Some(dialog) = dialog_weak.upgrade() { // Get the code from the child ActionRow's widget name if let Some(child) = row.child() { let code = child.widget_name().to_string(); if !code.is_empty() { dialog.set_language(&code); } } } if let Some(dlg) = lang_dialog_weak.upgrade() { dlg.close(); } });
// Done button closes without changing let lang_dialog_weak = lang_dialog.downgrade(); done_btn.connect_clicked(move |_| { if let Some(dlg) = lang_dialog_weak.upgrade() { dlg.close(); } });
let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); if let Some(win) = window.as_ref() { lang_dialog.present(Some(win)); } }
/// Set the selected post language. fn set_language(&self, code: &str) { let imp = self.imp(); imp.selected_language.replace(code.to_string()); let display = language_display_name(code); if let Some(btn) = imp.language_button.borrow().as_ref() { btn.set_label(&display); btn.set_tooltip_text(Some(&format!("Post language: {}", display))); btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Post language: {}", display ))]); } }
// ─── Content warning ───
/// Show the content warning dialog (matches Bluesky's native UI). fn show_content_warning_dialog(&self) { let imp = self.imp();
let cw_dialog = adw::Dialog::new(); cw_dialog.set_title("Content Warning"); cw_dialog.set_content_width(400);
let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false);
let done_btn = gtk4::Button::with_label("Done"); done_btn.add_css_class("suggested-action"); header.pack_end(&done_btn);
let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header);
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); content.set_margin_start(24); content.set_margin_end(24); content.set_margin_top(12); content.set_margin_bottom(24);
let group = adw::PreferencesGroup::new();
let current_cw = imp.content_warning.borrow().clone();
// Track the selection in a shared cell let selected = std::rc::Rc::new(std::cell::RefCell::new(current_cw.unwrap_or_default()));
// Options: None, Suggestive, Nudity, Pornography, Graphic Media let cw_options: &[(&str, &str, &str)] = &[ ( "", "None", "The images attached to this post are suitable for everyone", ), ( "sexual", "Suggestive", "The images attached to this post are meant for adults", ), ( "nudity", "Nudity", "The images attached to this post contain artistic or non-erotic nudity", ), ( "porn", "Pornography", "The images attached to this post contain sexual activity or erotic nudity", ), ( "graphic-media", "Graphic Media", "The images attached to this post contain disturbing content such as violence or gore", ), ];
// Use CheckButtons in a group so only one can be selected let mut first_check: Option<gtk4::CheckButton> = None;
for (value, label, description) in cw_options { let row = adw::ActionRow::new(); row.set_title(label); row.set_subtitle(description); row.set_activatable(true);
let check = gtk4::CheckButton::new(); let is_active = if value.is_empty() { selected.borrow().is_empty() } else { selected.borrow().as_str() == *value }; check.set_active(is_active);
// Group all checkbuttons together if let Some(ref first) = first_check { check.set_group(Some(first)); } else { first_check = Some(check.clone()); }
// Update the shared selection when toggled let selected_ref = selected.clone(); let val = value.to_string(); check.connect_toggled(move |cb| { if cb.is_active() { *selected_ref.borrow_mut() = val.clone(); } });
row.add_suffix(&check); row.set_activatable_widget(Some(&check));
group.add(&row); }
content.append(&group.clone().upcast::<gtk4::Widget>()); toolbar_view.set_content(Some(&content)); cw_dialog.set_child(Some(&toolbar_view));
// Done button: apply the selection let dialog_weak = self.downgrade(); let cw_dialog_weak = cw_dialog.downgrade(); let selected_for_done = selected; done_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { let val = selected_for_done.borrow().clone(); let imp = dialog.imp(); if val.is_empty() { imp.content_warning.replace(None); } else { imp.content_warning.replace(Some(val)); } dialog.update_image_button_state(); }
if let Some(dlg) = cw_dialog_weak.upgrade() { dlg.close(); } });
let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); if let Some(win) = window.as_ref() { cw_dialog.present(Some(win)); } }
// ─── Interaction settings ───
/// Show the interaction settings dialog (threadgate + postgate). fn show_interaction_settings(&self) { let imp = self.imp(); let int_dialog = adw::Dialog::new(); int_dialog.set_title("Interaction Settings"); int_dialog.set_content_width(400);
let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false);
let done_btn = gtk4::Button::with_label("Done"); done_btn.add_css_class("suggested-action"); header.pack_end(&done_btn);
let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header);
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 16); content.set_margin_start(24); content.set_margin_end(24); content.set_margin_top(12); content.set_margin_bottom(24);
// --- Threadgate section --- let tg_group = adw::PreferencesGroup::new();
// Get current state let current_tg = imp.threadgate_config.borrow().clone(); let replies_enabled = current_tg.is_none();
let reply_switch = adw::SwitchRow::new(); reply_switch.set_title("Allow replies to this thread"); reply_switch.set_active(replies_enabled); tg_group.add(&reply_switch);
content.append(&tg_group.clone().upcast::<gtk4::Widget>());
// Limit replies section let limit_group = adw::PreferencesGroup::new(); limit_group.set_title("Limit replies to"); limit_group.set_visible(!replies_enabled);
let following_check = gtk4::CheckButton::with_label("People you follow"); following_check.set_active(current_tg.as_ref().is_some_and(|tg| { tg.allow_rules .contains(&crate::atproto::ThreadgateRule::FollowingRule) })); limit_group.add(&following_check);
let mention_check = gtk4::CheckButton::with_label("People you mention"); mention_check.set_active(current_tg.as_ref().is_some_and(|tg| { tg.allow_rules .contains(&crate::atproto::ThreadgateRule::MentionRule) })); limit_group.add(&mention_check);
let followers_check = gtk4::CheckButton::with_label("Your followers"); followers_check.set_active(current_tg.as_ref().is_some_and(|tg| { tg.allow_rules .contains(&crate::atproto::ThreadgateRule::FollowersRule) })); limit_group.add(&followers_check);
content.append(&limit_group.clone().upcast::<gtk4::Widget>());
// Toggle limit section visibility let limit_group_ref = limit_group.clone(); reply_switch.connect_active_notify(move |switch| { limit_group_ref.set_visible(!switch.is_active()); });
// --- Postgate section --- let pg_group = adw::PreferencesGroup::new();
let current_pg = imp.postgate_config.borrow().clone(); let quoting_enabled = !current_pg.as_ref().is_some_and(|pg| pg.disable_quoting);
let quote_switch = adw::SwitchRow::new(); quote_switch.set_title("Allow people to quote your posts"); quote_switch.set_active(quoting_enabled); pg_group.add("e_switch);
content.append(&pg_group.clone().upcast::<gtk4::Widget>());
// Note about settings let note = gtk4::Label::new(Some( "These settings only apply to other people\u{2014}you can always reply to and quote your own posts.", )); note.set_wrap(true); note.set_wrap_mode(gtk4::pango::WrapMode::WordChar); note.set_xalign(0.0); note.add_css_class("dim-label"); note.add_css_class("caption"); content.append(¬e);
// Use as default button let default_btn = gtk4::Button::with_label("Use these settings by default"); default_btn.add_css_class("flat"); default_btn.set_halign(gtk4::Align::Center); content.append(&default_btn);
toolbar_view.set_content(Some(&content)); int_dialog.set_child(Some(&toolbar_view));
// Done button: apply settings, update label, and close let dialog_weak = self.downgrade(); let int_dialog_weak = int_dialog.downgrade(); let reply_switch_ref = reply_switch.clone(); let following_check_ref = following_check.clone(); let mention_check_ref = mention_check.clone(); let followers_check_ref = followers_check.clone(); let quote_switch_ref = quote_switch.clone(); done_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { let imp = dialog.imp();
// Build threadgate config if reply_switch_ref.is_active() { // Everyone can reply means no threadgate record imp.threadgate_config.replace(None); } else { let mut rules = Vec::new(); if following_check_ref.is_active() { rules.push(crate::atproto::ThreadgateRule::FollowingRule); } if mention_check_ref.is_active() { rules.push(crate::atproto::ThreadgateRule::MentionRule); } if followers_check_ref.is_active() { rules.push(crate::atproto::ThreadgateRule::FollowersRule); } imp.threadgate_config .replace(Some(crate::atproto::ThreadgateConfig { allow_rules: rules, })); }
// Build postgate config if quote_switch_ref.is_active() { imp.postgate_config.replace(None); } else { imp.postgate_config .replace(Some(crate::atproto::PostgateConfig { disable_quoting: true, })); }
// Update the interaction label dialog.update_interaction_label(); }
if let Some(dlg) = int_dialog_weak.upgrade() { dlg.close(); } });
// "Use as default" saves to settings let reply_switch_ref2 = reply_switch; let following_check_ref2 = following_check; let mention_check_ref2 = mention_check; let followers_check_ref2 = followers_check; let quote_switch_ref2 = quote_switch; default_btn.connect_clicked(move |_| { let mut settings = AppSettings::load();
if reply_switch_ref2.is_active() { settings.default_threadgate = None; } else { let mut rules = Vec::new(); if following_check_ref2.is_active() { rules.push(crate::atproto::ThreadgateRule::FollowingRule); } if mention_check_ref2.is_active() { rules.push(crate::atproto::ThreadgateRule::MentionRule); } if followers_check_ref2.is_active() { rules.push(crate::atproto::ThreadgateRule::FollowersRule); } settings.default_threadgate = Some(crate::atproto::ThreadgateConfig { allow_rules: rules }); }
if quote_switch_ref2.is_active() { settings.default_postgate = None; } else { settings.default_postgate = Some(crate::atproto::PostgateConfig { disable_quoting: true, }); }
let _ = settings.save(); });
let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); if let Some(win) = window.as_ref() { int_dialog.present(Some(win)); } }
// ─── Thread composer ───
/// Maximum posts in a thread const MAX_THREAD_POSTS: usize = 10;
/// Where a thread block sits right now, found by its container. /// Closures resolve through this at click time; a stored index goes /// stale the moment an earlier post is removed. fn thread_index_of(&self, container: >k4::Box) -> Option<usize> { self.imp() .thread_posts .borrow() .iter() .position(|b| &b.container == container) }
/// Add a new post to the thread. fn add_thread_post(&self) { let imp = self.imp(); let total = 1 + imp.thread_posts.borrow().len(); // 1 = main post if total >= Self::MAX_THREAD_POSTS { return; }
let container = match imp.thread_container.borrow().as_ref() { Some(c) => c.clone(), None => return, };
container.set_visible(true);
let post_index = imp.thread_posts.borrow().len(); // 0-indexed within thread_posts
// Separator with connecting line let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal); separator.add_css_class("thread-post-separator");
let block_box = gtk4::Box::new(gtk4::Orientation::Vertical, 8); block_box.set_margin_top(8); block_box.set_margin_bottom(4);
// Header row: "Post N" label + remove button let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); let post_label = gtk4::Label::new(Some(&format!("Post {}", post_index + 2))); post_label.add_css_class("dim-label"); post_label.add_css_class("caption"); post_label.set_halign(gtk4::Align::Start); post_label.set_hexpand(true); header.append(&post_label);
let remove_btn = gtk4::Button::from_icon_name("window-close-symbolic"); remove_btn.add_css_class("flat"); remove_btn.add_css_class("circular"); remove_btn.set_tooltip_text(Some(&format!("Remove post {}", post_index + 2))); remove_btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Remove post {}", post_index + 2 ))]); let dialog_weak = self.downgrade(); let block_for_remove = block_box.clone(); remove_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() && let Some(idx) = dialog.thread_index_of(&block_for_remove) { dialog.remove_thread_post(idx); } }); header.append(&remove_btn); block_box.append(&header);
// Text view for this post let text_view = gtk4::TextView::new(); text_view.set_wrap_mode(gtk4::WrapMode::WordChar); text_view.set_vexpand(false); text_view.set_left_margin(8); text_view.set_right_margin(8); text_view.set_top_margin(8); text_view.set_bottom_margin(8); text_view.add_css_class("compose-text"); text_view.update_property(&[gtk4::accessible::Property::Label(&format!( "Post {} content", post_index + 2 ))]);
let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_min_content_height(80); scrolled.set_child(Some(&text_view)); block_box.append(&scrolled);
// Image strip for this post let image_strip = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); image_strip.add_css_class("compose-image-strip"); image_strip.set_visible(false); block_box.append(&image_strip);
// Per-post action row: "Add Content Warning..." + "Remove All Images" let action_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); action_row.set_visible(false); // Hidden until images are attached
let cw_button = gtk4::Button::with_label("Add Content Warning\u{2026}"); cw_button.add_css_class("flat"); cw_button.add_css_class("caption"); cw_button.set_tooltip_text(Some("Set content warning for attached media")); cw_button.update_property(&[gtk4::accessible::Property::Label( "Set content warning for attached media", )]); action_row.append(&cw_button);
let remove_all_button = gtk4::Button::with_label("Remove All Images"); remove_all_button.add_css_class("flat"); remove_all_button.add_css_class("caption"); remove_all_button.add_css_class("destructive-action"); remove_all_button.set_tooltip_text(Some("Remove all attached images")); action_row.append(&remove_all_button);
block_box.append(&action_row);
// Wire up CW button for this thread post let dialog_weak = self.downgrade(); let block_for_cw = block_box.clone(); cw_button.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() && let Some(idx) = dialog.thread_index_of(&block_for_cw) { dialog.show_thread_content_warning_dialog(idx); } });
// Wire up remove all images button for this thread post let dialog_weak = self.downgrade(); let block_for_clear = block_box.clone(); remove_all_button.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() && let Some(idx) = dialog.thread_index_of(&block_for_clear) { dialog.remove_all_thread_images(idx); } });
// Character counter, right-aligned let char_counter = gtk4::Label::new(Some(&MAX_GRAPHEMES.to_string())); char_counter.set_halign(gtk4::Align::End); char_counter.add_css_class("dim-label"); char_counter.add_css_class("caption"); char_counter.add_css_class("char-counter"); block_box.append(&char_counter);
// Wire up text change handler for char counter let dialog_weak = self.downgrade(); let block_for_counter = block_box.clone(); text_view.buffer().connect_changed(move |buf| { let Some(dialog) = dialog_weak.upgrade() else { return; }; if let Some(idx) = dialog.thread_index_of(&block_for_counter) { dialog.update_thread_post_counter(idx, buf); } });
// Track focus on this thread post text view. 0 = main post, // 1+ = thread posts, resolved when focus lands. let dialog_weak = self.downgrade(); let block_for_focus = block_box.clone(); let focus_controller = gtk4::EventControllerFocus::new(); focus_controller.connect_enter(move |_| { if let Some(dialog) = dialog_weak.upgrade() && let Some(idx) = dialog.thread_index_of(&block_for_focus) { dialog.imp().focused_post_index.set(idx + 1); } }); text_view.add_controller(focus_controller);
// Append to container container.append(&separator); container.append(&block_box);
let block = ThreadPostBlock { container: block_box, text_view, image_strip, images: Vec::new(), video: None, char_counter, content_warning: None, cw_button, remove_all_button, };
imp.thread_posts.borrow_mut().push(block); self.update_post_button_label(); self.update_add_thread_button();
// Expand dialog height for the first 2 thread posts, then let scroll take over let thread_count = imp.thread_posts.borrow().len(); if thread_count <= 2 { // Each thread post adds roughly 120px of content let new_height = 360 + (thread_count as i32 * 120); self.set_content_height(new_height); } }
/// Remove a thread post by index. fn remove_thread_post(&self, index: usize) { let imp = self.imp(); let container = match imp.thread_container.borrow().as_ref() { Some(c) => c.clone(), None => return, };
// Remove UI elements { let posts = imp.thread_posts.borrow(); if let Some(block) = posts.get(index) { // Remove the block container and its preceding separator // We need to find the separator before this block let block_widget = block.container.clone(); if let Some(prev) = block_widget.prev_sibling() { container.remove(&prev); // separator } container.remove(&block_widget); } }
imp.thread_posts.borrow_mut().remove(index);
// The focused-post index shifts with the removal: the removed // post's focus falls back to the main post, later ones slide up. let focused = imp.focused_post_index.get(); if focused == index + 1 { imp.focused_post_index.set(0); } else if focused > index + 1 { imp.focused_post_index.set(focused - 1); }
// Re-number remaining posts self.renumber_thread_posts();
if imp.thread_posts.borrow().is_empty() { container.set_visible(false); }
self.update_post_button_label(); self.update_add_thread_button();
// Shrink dialog height when thread posts are removed let thread_count = imp.thread_posts.borrow().len(); if thread_count <= 2 { let new_height = 360 + (thread_count as i32 * 120); self.set_content_height(new_height); } }
/// Re-number thread post headers after removal. fn renumber_thread_posts(&self) { let imp = self.imp(); let posts = imp.thread_posts.borrow(); for (i, block) in posts.iter().enumerate() { // Find the header label (first child of container -> first child of header box) if let Some(header) = block.container.first_child() && let Some(label) = header.first_child() && let Some(label) = label.downcast_ref::<gtk4::Label>() { label.set_text(&format!("Post {}", i + 2)); } } }
/// Update the Post button label to reflect thread count. fn update_post_button_label(&self) { let imp = self.imp(); let thread_count = 1 + imp.thread_posts.borrow().len(); if let Some(btn) = imp.post_button.borrow().as_ref() { if thread_count > 1 { btn.set_label(&format!("Post Thread ({})", thread_count)); } else { btn.set_label("Post"); } } }
/// Update the "Add to thread" button state. fn update_add_thread_button(&self) { let imp = self.imp(); let total = 1 + imp.thread_posts.borrow().len(); if let Some(btn) = imp.add_thread_button.borrow().as_ref() { btn.set_sensitive(total < Self::MAX_THREAD_POSTS); } }
/// Update char counter for a thread post. fn update_thread_post_counter(&self, index: usize, buffer: >k4::TextBuffer) { let imp = self.imp(); let posts = imp.thread_posts.borrow(); let Some(block) = posts.get(index) else { return; };
let text = { let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() }; let grapheme_count = text.graphemes(true).count() as i32; let remaining = MAX_GRAPHEMES - grapheme_count;
block.char_counter.set_text(&remaining.to_string());
// Style if remaining < 0 { block.char_counter.remove_css_class("dim-label"); block.char_counter.remove_css_class("char-counter-warn"); block.char_counter.add_css_class("char-counter-over"); } else if remaining <= WARN_THRESHOLD { block.char_counter.remove_css_class("dim-label"); block.char_counter.remove_css_class("char-counter-over"); block.char_counter.add_css_class("char-counter-warn"); } else { block.char_counter.remove_css_class("char-counter-warn"); block.char_counter.remove_css_class("char-counter-over"); block.char_counter.add_css_class("dim-label"); }
// Update main post button state self.update_thread_post_button_state(); }
/// Recheck whether the Post button should be enabled based on all thread posts. fn update_thread_post_button_state(&self) { let imp = self.imp();
// Check main post let main_ok = imp .text_view .borrow() .as_ref() .map(|tv| { let buffer = tv.buffer(); let text = { let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() }; let count = text.graphemes(true).count() as i32; let has_media = !imp.images.borrow().is_empty() || imp.video.borrow().is_some(); (count > 0 || has_media) && count <= MAX_GRAPHEMES }) .unwrap_or(false);
// Check thread posts let thread_ok = imp.thread_posts.borrow().iter().all(|block| { let buffer = block.text_view.buffer(); let text = { let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() }; let count = text.graphemes(true).count() as i32; let has_media = !block.images.is_empty() || block.video.is_some(); (count > 0 || has_media) && count <= MAX_GRAPHEMES });
// The require-alt gate covers every post in the thread. let gate = self.video_gate(); let alt_ok = !imp.require_alt_text.get() || (imp .images .borrow() .iter() .all(|img| !img.alt_text.is_empty()) && imp .thread_posts .borrow() .iter() .all(|block| block.images.iter().all(|img| !img.alt_text.is_empty())) && !gate.alt_missing);
if let Some(btn) = imp.post_button.borrow().as_ref() { btn.set_sensitive(main_ok && thread_ok && alt_ok && !gate.pending && !gate.failed); btn.set_tooltip_text(Self::media_gate_tooltip(alt_ok, &gate)); } }
/// Open file chooser for a thread post's image attachment. fn open_thread_image_chooser(&self, post_index: usize) { let image_count = { let posts = self.imp().thread_posts.borrow(); posts .get(post_index) .map(|b| b.images.len()) .unwrap_or(MAX_IMAGES) }; if image_count >= MAX_IMAGES { return; }
let has_video = { let posts = self.imp().thread_posts.borrow(); posts .get(post_index) .map(|b| b.video.is_some()) .unwrap_or(true) }; if has_video { return; }
self.choose_image( Self::media_filter(), move |dialog, path| match Self::media_kind_for_path(path) { MediaKind::Video(mime) => dialog.load_video_from_path(Some(post_index), path, mime), MediaKind::Image => dialog.load_thread_image(post_index, path), }, ); }
/// Load an image for a thread post. fn load_thread_image(&self, post_index: usize, path: &std::path::Path) { let imp = self.imp(); { let posts = imp.thread_posts.borrow(); if posts .get(post_index) .map(|b| b.images.len()) .unwrap_or(MAX_IMAGES) >= MAX_IMAGES { return; } }
let data = match std::fs::read(path) { Ok(d) => d, Err(_) => return, };
let mime_type = match path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()) { Some(ext) if ext == "jpg" || ext == "jpeg" => "image/jpeg".to_string(), Some(ext) if ext == "png" => "image/png".to_string(), Some(ext) if ext == "gif" => "image/gif".to_string(), Some(ext) if ext == "webp" => "image/webp".to_string(), _ => "image/jpeg".to_string(), };
// Same as the main post: thumbnail for the strip, bytes for the // upload, source size for the aspect ratio. let Some((texture, width, height)) = decode_thumbnail(&data) else { return; };
let compose_image = ComposeImage { data, mime_type, alt_text: String::new(), width, height, texture, };
{ let mut posts = imp.thread_posts.borrow_mut(); if let Some(block) = posts.get_mut(post_index) { block.images.push(compose_image); } }
self.rebuild_thread_image_strip(post_index); self.update_thread_post_button_state();
// The descriptive-text prompt, same as the main post. if imp.require_alt_text.get() { let index = imp .thread_posts .borrow() .get(post_index) .map(|block| block.images.len().saturating_sub(1)); if let Some(index) = index { self.show_thread_alt_text_dialog(post_index, index); } } }
/// Rebuild image strip for a thread post. fn rebuild_thread_image_strip(&self, post_index: usize) { let imp = self.imp(); let posts = imp.thread_posts.borrow(); let Some(block) = posts.get(post_index) else { return; };
// Clear existing while let Some(child) = block.image_strip.first_child() { block.image_strip.remove(&child); }
let has_images = !block.images.is_empty();
for (i, img) in block.images.iter().enumerate() { let thumb_box = gtk4::Overlay::new(); thumb_box.set_size_request(80, 80); thumb_box.add_css_class("compose-thumbnail");
// As on the main post, the frame is what opens the description. let picture = gtk4::Picture::builder() .accessible_role(gtk4::AccessibleRole::Button) .build(); picture.set_paintable(Some(&img.texture)); picture.set_can_shrink(true); picture.set_size_request(80, 80); picture.add_css_class("compose-thumbnail-image"); thumb_box.set_child(Some(&picture));
// Remove button let remove_btn = gtk4::Button::from_icon_name("window-close-symbolic"); remove_btn.add_css_class("circular"); remove_btn.add_css_class("osd"); remove_btn.set_halign(gtk4::Align::End); remove_btn.set_valign(gtk4::Align::Start); remove_btn.set_margin_top(4); remove_btn.set_margin_end(4); remove_btn.set_tooltip_text(Some(&format!("Remove image {}", i + 1))); remove_btn.update_property(&[gtk4::accessible::Property::Label(&format!( "Remove image {}", i + 1 ))]); let dialog_weak = self.downgrade(); let block_for_remove = block.container.clone(); let ii = i; remove_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() && let Some(pi) = dialog.thread_index_of(&block_for_remove) { dialog.remove_thread_image(pi, ii); } }); thumb_box.add_overlay(&remove_btn);
// The thumbnails carried no label at all before this. let alt_desc = if img.alt_text.is_empty() { "No alt text".to_string() } else { format!("Alt text: {}", img.alt_text) }; let thumb_label = format!("Image {}. {}. Edit alt text.", i + 1, alt_desc); thumb_box.update_property(&[gtk4::accessible::Property::Label(&thumb_label)]); picture.update_property(&[gtk4::accessible::Property::Label(&thumb_label)]);
let described = !img.alt_text.is_empty(); let action = if described { format!("Edit alt text for image {}", i + 1) } else { format!("Add alt text to image {}", i + 1) }; let dialog_weak = self.downgrade(); let block_for_alt = block.container.clone(); let ii = i; Self::wire_alt_editing(&thumb_box, &picture, described, &action, move || { if let Some(dialog) = dialog_weak.upgrade() && let Some(pi) = dialog.thread_index_of(&block_for_alt) { dialog.show_thread_alt_text_dialog(pi, ii); } });
block.image_strip.append(&thumb_box); }
if let Some(video) = block.video.as_ref() { block.image_strip.append(&self.build_video_tile(video)); } let has_media = has_images || block.video.is_some(); block.image_strip.set_visible(has_media);
// Show/hide the per-post action buttons (CW + Remove All) block.cw_button.set_visible(has_media); block.remove_all_button.set_visible(has_images); // The action row parent is visible when either button is visible if let Some(parent) = block.cw_button.parent() { parent.set_visible(has_media); }
// Update CW button label based on current content warning if has_media { if let Some(ref cw) = block.content_warning { let display = match cw.as_str() { "sexual" => "Suggestive", "nudity" => "Nudity", "porn" => "Pornography", "graphic-media" => "Graphic Media", _ => cw.as_str(), }; block .cw_button .set_label(&format!("Content Warning: {}", display)); block.cw_button.add_css_class("accent"); } else { block.cw_button.set_label("Add Content Warning\u{2026}"); block.cw_button.remove_css_class("accent"); } }
// Clear CW once no media is left if !has_media { // Need to drop borrow first, then re-borrow mutably drop(posts); let mut posts = imp.thread_posts.borrow_mut(); if let Some(block) = posts.get_mut(post_index) { block.content_warning = None; } } }
/// Remove an image from a thread post. fn remove_thread_image(&self, post_index: usize, image_index: usize) { { let mut posts = self.imp().thread_posts.borrow_mut(); if let Some(block) = posts.get_mut(post_index) && image_index < block.images.len() { block.images.remove(image_index); } } self.rebuild_thread_image_strip(post_index); self.update_thread_post_button_state(); }
/// Remove all images from a thread post. fn remove_all_thread_images(&self, post_index: usize) { { let mut posts = self.imp().thread_posts.borrow_mut(); if let Some(block) = posts.get_mut(post_index) { block.images.clear(); block.content_warning = None; } } self.rebuild_thread_image_strip(post_index); self.update_thread_post_button_state(); }
/// Show content warning dialog for a specific thread post. fn show_thread_content_warning_dialog(&self, post_index: usize) { let current_cw = { let posts = self.imp().thread_posts.borrow(); posts .get(post_index) .and_then(|b| b.content_warning.clone()) .unwrap_or_default() };
let cw_dialog = adw::Dialog::new(); cw_dialog.set_title("Content Warning"); cw_dialog.set_content_width(400);
let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false);
let done_btn = gtk4::Button::with_label("Done"); done_btn.add_css_class("suggested-action"); header.pack_end(&done_btn);
let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header);
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); content.set_margin_start(24); content.set_margin_end(24); content.set_margin_top(12); content.set_margin_bottom(24);
let group = adw::PreferencesGroup::new();
let selected = std::rc::Rc::new(std::cell::RefCell::new(current_cw));
let cw_options: &[(&str, &str, &str)] = &[ ( "", "None", "The images attached to this post are suitable for everyone", ), ( "sexual", "Suggestive", "The images attached to this post are meant for adults", ), ( "nudity", "Nudity", "The images attached to this post contain artistic or non-erotic nudity", ), ( "porn", "Pornography", "The images attached to this post contain sexual activity or erotic nudity", ), ( "graphic-media", "Graphic Media", "The images attached to this post contain disturbing content such as violence or gore", ), ];
let mut first_check: Option<gtk4::CheckButton> = None;
for (value, label, description) in cw_options { let row = adw::ActionRow::new(); row.set_title(label); row.set_subtitle(description); row.set_activatable(true);
let check = gtk4::CheckButton::new(); let is_active = if value.is_empty() { selected.borrow().is_empty() } else { selected.borrow().as_str() == *value }; check.set_active(is_active);
if let Some(ref first) = first_check { check.set_group(Some(first)); } else { first_check = Some(check.clone()); }
let selected_ref = selected.clone(); let val = value.to_string(); check.connect_toggled(move |cb| { if cb.is_active() { *selected_ref.borrow_mut() = val.clone(); } });
row.add_suffix(&check); row.set_activatable_widget(Some(&check));
group.add(&row); }
content.append(&group.clone().upcast::<gtk4::Widget>()); toolbar_view.set_content(Some(&content)); cw_dialog.set_child(Some(&toolbar_view));
let dialog_weak = self.downgrade(); let cw_dialog_weak = cw_dialog.downgrade(); let selected_for_done = selected; let pi = post_index; done_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { let val = selected_for_done.borrow().clone(); let mut posts = dialog.imp().thread_posts.borrow_mut(); if let Some(block) = posts.get_mut(pi) { if val.is_empty() { block.content_warning = None; } else { block.content_warning = Some(val); } } drop(posts); dialog.rebuild_thread_image_strip(pi); }
if let Some(dlg) = cw_dialog_weak.upgrade() { dlg.close(); } });
let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); if let Some(win) = window.as_ref() { cw_dialog.present(Some(win)); } }
/// Show alt text dialog for a thread post image. fn show_thread_alt_text_dialog(&self, post_index: usize, image_index: usize) { let current_alt = { let posts = self.imp().thread_posts.borrow(); posts .get(post_index) .and_then(|b| b.images.get(image_index)) .map(|img| img.alt_text.clone()) .unwrap_or_default() };
let alt_dialog = adw::Dialog::new(); alt_dialog.set_title("Add Descriptive Text"); alt_dialog.set_content_width(360);
let header = adw::HeaderBar::new(); header.set_show_start_title_buttons(false); header.set_show_end_title_buttons(false);
let done_btn = gtk4::Button::with_label("Done"); done_btn.add_css_class("suggested-action"); header.pack_end(&done_btn);
let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header);
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 12); content.set_margin_start(24); content.set_margin_end(24); content.set_margin_top(12); content.set_margin_bottom(24);
let label = gtk4::Label::new(Some("Describe this image")); label.set_halign(gtk4::Align::Start); label.add_css_class("heading"); content.append(&label);
let text_view = gtk4::TextView::new(); text_view.set_wrap_mode(gtk4::WrapMode::WordChar); text_view.set_vexpand(true); text_view.set_left_margin(8); text_view.set_right_margin(8); text_view.set_top_margin(8); text_view.set_bottom_margin(8); text_view.add_css_class("card"); text_view.update_property(&[gtk4::accessible::Property::Label("Image description")]); if !current_alt.is_empty() { text_view.buffer().set_text(¤t_alt); }
let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_vexpand(true); scrolled.set_child(Some(&text_view)); content.append(&scrolled);
toolbar.set_content(Some(&content)); alt_dialog.set_child(Some(&toolbar));
let dialog_weak = self.downgrade(); let alt_dialog_weak = alt_dialog.downgrade(); let text_view_ref = text_view.clone(); done_btn.connect_clicked(move |_| { let buffer = text_view_ref.buffer(); let start = buffer.start_iter(); let end = buffer.end_iter(); let alt_text = buffer .text(&start, &end, false) .to_string() .trim() .to_string();
if let Some(dialog) = dialog_weak.upgrade() { { let mut posts = dialog.imp().thread_posts.borrow_mut(); if let Some(block) = posts.get_mut(post_index) && let Some(img) = block.images.get_mut(image_index) { img.alt_text = alt_text; } } dialog.rebuild_thread_image_strip(post_index); // A fresh description may be what the Post button waited on. dialog.update_thread_post_button_state(); }
if let Some(dlg) = alt_dialog_weak.upgrade() { dlg.close(); } });
let window = self.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); if let Some(win) = window.as_ref() { alt_dialog.present(Some(win)); } }
// ─── Link card preview ───
/// Check the compose text for URLs and trigger a link card fetch if appropriate. fn check_for_link_card(&self, buffer: >k4::TextBuffer) { let imp = self.imp();
// Don't show link cards when images are attached (images take precedence) if !imp.images.borrow().is_empty() { self.clear_link_preview(); return; }
let text = { let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() };
// Find the first URL in the text let first_url = HL_URL_RE.find(&text).map(|m| { m.as_str() .trim_end_matches(['.', ',', ';', '!', '?']) .to_string() });
let Some(url) = first_url else { // No URL in the text, so clear any existing preview self.clear_link_preview(); imp.link_preview_url.replace(None); imp.link_preview_dismissed.set(false); return; };
// Check if this URL is different from what we already fetched let current_url = imp.link_preview_url.borrow().clone(); if current_url.as_deref() == Some(&url) { // Same URL: keep the existing preview (or dismissed state) return; }
// New URL: reset the dismissed state imp.link_preview_dismissed.set(false); imp.link_preview_url.replace(Some(url.clone()));
// Debounce: wait 500ms before fetching let counter = imp.link_debounce_counter.get().wrapping_add(1); imp.link_debounce_counter.set(counter);
let dialog_weak = self.downgrade(); glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { let Some(dialog) = dialog_weak.upgrade() else { return; }; let imp = dialog.imp(); // Only fire if no newer text changes have happened if imp.link_debounce_counter.get() != counter { return; } // Don't fetch if dismissed if imp.link_preview_dismissed.get() { return; } if let Some(cb) = imp.link_preview_fetch_callback.borrow().as_ref() { cb(url); } }); }
/// Set the fetched link card data and rebuild the preview widget. pub fn set_link_card_data(&self, data: LinkCardData) { let imp = self.imp(); // Don't show if user dismissed or images are attached if imp.link_preview_dismissed.get() || !imp.images.borrow().is_empty() { return; } imp.link_card_data.replace(Some(data)); self.rebuild_link_preview(); }
/// Rebuild the link card preview widget from current link_card_data. fn rebuild_link_preview(&self) { let imp = self.imp(); let preview_box = match imp.link_preview_box.borrow().as_ref() { Some(b) => b.clone(), None => return, };
// Clear existing children while let Some(child) = preview_box.first_child() { preview_box.remove(&child); }
let data = match imp.link_card_data.borrow().as_ref() { Some(d) => d.clone(), None => { preview_box.set_visible(false); return; } };
// Thumbnail (if available) if let Some((ref thumb_bytes, _)) = data.thumb { let bytes = glib::Bytes::from(thumb_bytes); if let Ok(texture) = gdk::Texture::from_bytes(&bytes) { let picture = gtk4::Picture::new(); picture.set_paintable(Some(&texture)); picture.set_can_shrink(true); picture.set_size_request(72, 72); picture.add_css_class("compose-link-thumb"); preview_box.append(&picture); } }
// Text info column let info_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); info_box.set_hexpand(true); info_box.set_valign(gtk4::Align::Center);
if !data.title.is_empty() { let title_label = gtk4::Label::new(Some(&data.title)); title_label.set_halign(gtk4::Align::Start); title_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); title_label.set_max_width_chars(40); title_label.add_css_class("heading"); title_label.add_css_class("caption"); info_box.append(&title_label); }
if !data.description.is_empty() { let desc_label = gtk4::Label::new(Some(&data.description)); desc_label.set_halign(gtk4::Align::Start); desc_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); desc_label.set_lines(2); desc_label.set_max_width_chars(40); desc_label.set_wrap(true); desc_label.set_wrap_mode(gtk4::pango::WrapMode::WordChar); desc_label.add_css_class("dim-label"); desc_label.add_css_class("caption"); info_box.append(&desc_label); }
// Domain label if let Some(domain) = data .url .strip_prefix("https://") .or_else(|| data.url.strip_prefix("http://")) { let domain = domain.split('/').next().unwrap_or(&data.url); let domain_label = gtk4::Label::new(Some(domain)); domain_label.set_halign(gtk4::Align::Start); domain_label.set_ellipsize(gtk4::pango::EllipsizeMode::End); domain_label.add_css_class("dim-label"); domain_label.add_css_class("caption"); info_box.append(&domain_label); }
preview_box.append(&info_box);
// Remove button let remove_btn = gtk4::Button::from_icon_name("window-close-symbolic"); remove_btn.add_css_class("flat"); remove_btn.add_css_class("circular"); remove_btn.set_valign(gtk4::Align::Start); remove_btn.set_tooltip_text(Some("Remove link card")); remove_btn.update_property(&[gtk4::accessible::Property::Label("Remove link card")]); let dialog_weak = self.downgrade(); remove_btn.connect_clicked(move |_| { if let Some(dialog) = dialog_weak.upgrade() { dialog.dismiss_link_card(); } }); preview_box.append(&remove_btn);
// Accessible label let a11y = format!("Link preview: {}", data.title); preview_box.update_property(&[gtk4::accessible::Property::Label(&a11y)]);
preview_box.set_visible(true); }
/// The user dismissed the link card. Hide it and skip re-fetching the same URL. fn dismiss_link_card(&self) { let imp = self.imp(); imp.link_preview_dismissed.set(true); imp.link_card_data.replace(None); self.clear_link_preview(); }
/// Clear the link preview box without changing dismissed state. fn clear_link_preview(&self) { let imp = self.imp(); if let Some(preview_box) = imp.link_preview_box.borrow().as_ref() { while let Some(child) = preview_box.first_child() { preview_box.remove(&child); } preview_box.set_visible(false); } }
/// Build a `ComposeData` from the current dialog state. fn build_compose_data(&self) -> Option<ComposeData> { let imp = self.imp();
let text = imp .text_view .borrow() .as_ref() .map(|tv| { let buffer = tv.buffer(); let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() }) .unwrap_or_default() .trim() .to_string();
// Build image attachments let images: Vec<ImageAttachment> = imp .images .borrow() .iter() .map(|img| ImageAttachment { data: img.data.clone(), mime_type: img.mime_type.clone(), alt_text: img.alt_text.clone(), width: img.width, height: img.height, }) .collect();
// Allow posting with images even if text is empty if text.is_empty() && images.is_empty() { return None; }
// Language let lang = imp.selected_language.borrow().clone(); let langs = if lang.is_empty() { vec![] } else { vec![lang] };
// Content warning let content_warning = imp.content_warning.borrow().clone();
// Threadgate / postgate let threadgate = imp.threadgate_config.borrow().clone(); let postgate = imp.postgate_config.borrow().clone();
let video = Self::ready_video_attachment(imp.video.borrow().as_ref());
// Link card, only when no other media is attached (media wins) let link_card = if images.is_empty() && video.is_none() { imp.link_card_data.borrow().clone() } else { None };
Some(ComposeData { text, images, langs, content_warning, link_card, threadgate, postgate, video, }) }
/// The embeddable form of a slot's video, once it is Ready. fn ready_video_attachment( video: Option<&ComposeVideo>, ) -> Option<crate::atproto::VideoAttachment> { let video = video?; if video.state != VideoUploadState::Ready { return None; } Some(crate::atproto::VideoAttachment { blob: video.blob.clone()?, alt_text: video.alt_text.clone(), aspect_ratio: video.aspect_ratio, }) }
/// Build ComposeData for all thread posts (Post 2, 3, etc.) fn build_thread_data(&self) -> Vec<ComposeData> { let imp = self.imp(); let lang = imp.selected_language.borrow().clone(); let langs = if lang.is_empty() { vec![] } else { vec![lang] };
imp.thread_posts .borrow() .iter() .filter_map(|block| { let buffer = block.text_view.buffer(); let text = { let start = buffer.start_iter(); let end = buffer.end_iter(); buffer.text(&start, &end, false).to_string() } .trim() .to_string();
let images: Vec<ImageAttachment> = block .images .iter() .map(|img| ImageAttachment { data: img.data.clone(), mime_type: img.mime_type.clone(), alt_text: img.alt_text.clone(), width: img.width, height: img.height, }) .collect();
let video = Self::ready_video_attachment(block.video.as_ref());
if text.is_empty() && images.is_empty() && video.is_none() { return None; }
// Each thread post has its own content warning let content_warning = block.content_warning.clone();
Some(ComposeData { text, images, langs: langs.clone(), content_warning, link_card: None, threadgate: None, // only on root post postgate: None, // only on root post video, }) }) .collect() }
fn emit_post(&self) { self.hide_mention_popover();
let Some(data) = self.build_compose_data() else { return; };
let imp = self.imp(); let has_thread_posts = !imp.thread_posts.borrow().is_empty();
if has_thread_posts { // Thread mode: collect all posts and call thread callback let mut all_posts = vec![data]; all_posts.extend(self.build_thread_data());
// Thread callback handles reply context on the app side if let Some(cb) = imp.thread_callback.borrow().as_ref() { cb(all_posts); } } else if let Some(ctx) = imp.reply_context.borrow().as_ref() { if let Some(cb) = imp.reply_callback.borrow().as_ref() { cb(data, ctx.uri.clone(), ctx.cid.clone()); } } else if let Some(ctx) = imp.quote_context.borrow().as_ref() { if let Some(cb) = imp.quote_callback.borrow().as_ref() { cb(data, ctx.uri.clone(), ctx.cid.clone()); } } else if let Some(cb) = imp.post_callback.borrow().as_ref() { cb(data); } }
pub fn connect_post<F: Fn(ComposeData) + 'static>(&self, callback: F) { self.imp().post_callback.replace(Some(Box::new(callback))); }
pub fn connect_reply<F: Fn(ComposeData, String, String) + 'static>(&self, callback: F) { self.imp().reply_callback.replace(Some(Box::new(callback))); }
pub fn connect_quote<F: Fn(ComposeData, String, String) + 'static>(&self, callback: F) { self.imp().quote_callback.replace(Some(Box::new(callback))); }
/// Register a callback for mention typeahead search. /// Called with the partial handle text (without @) when the user types @query. pub fn connect_mention_search<F: Fn(String) + 'static>(&self, callback: F) { self.imp() .mention_search_callback .replace(Some(Box::new(callback))); }
/// Register a callback for link card metadata fetching. /// Called with the URL string when a URL is detected in the compose text. pub fn connect_link_preview_fetch<F: Fn(String) + 'static>(&self, callback: F) { self.imp() .link_preview_fetch_callback .replace(Some(Box::new(callback))); }
/// Register a callback for posting a thread (multiple posts). /// Called with Vec<ComposeData> where first element is the root post. pub fn connect_thread<F: Fn(Vec<ComposeData>) + 'static>(&self, callback: F) { self.imp().thread_callback.replace(Some(Box::new(callback))); }
/// Put the caret in the composer. Run after present; the idle lets the /// dialog map first so the grab has somewhere to land. pub fn focus_composer(&self) { let dialog = self.downgrade(); glib::idle_add_local_once(move || { if let Some(dialog) = dialog.upgrade() && let Some(text_view) = dialog.imp().text_view.borrow().as_ref() { text_view.grab_focus(); } }); }
pub fn set_loading(&self, loading: bool) { let imp = self.imp(); if let Some(btn) = imp.post_button.borrow().as_ref() { if loading { // Replace button content with a spinner + "Posting…" label let loading_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); loading_box.set_halign(gtk4::Align::Center); let spinner = gtk4::Spinner::new(); spinner.set_spinning(true); loading_box.append(&spinner); let label = gtk4::Label::new(Some("Posting\u{2026}")); loading_box.append(&label); btn.set_child(Some(&loading_box)); btn.set_sensitive(false); } else { // Restore normal label btn.set_child(None::<>k4::Widget>); self.update_post_button_label(); btn.set_sensitive(true); } }
// Disable/enable text editing during post if let Some(tv) = imp.text_view.borrow().as_ref() { tv.set_sensitive(!loading); } // Disable thread post text views for block in imp.thread_posts.borrow().iter() { block.text_view.set_sensitive(!loading); } // Disable add-to-thread button if let Some(btn) = imp.add_thread_button.borrow().as_ref() { btn.set_sensitive(!loading); } }
pub fn show_error(&self, message: &str) { if let Some(label) = self.imp().error_label.borrow().as_ref() { label.set_text(message); label.set_visible(true); } }
pub fn hide_error(&self) { if let Some(label) = self.imp().error_label.borrow().as_ref() { label.set_visible(false); } }}
#[cfg(test)]mod tests { use super::*;
fn a_compose_image(alt: &str) -> ComposeImage { let bytes = glib::Bytes::from_static(&[0u8; 4]); let texture = gdk::MemoryTexture::new(1, 1, gdk::MemoryFormat::R8g8b8a8, &bytes, 4); ComposeImage { data: vec![0; 4], mime_type: "image/png".into(), alt_text: alt.into(), width: 1, height: 1, texture: texture.upcast(), } }
/// A solid PNG of a given pixel size, on disk. The attach path takes /// a file, so the test hands it one. fn png_file(name: &str, width: i32, height: i32) -> std::path::PathBuf { let pixbuf = gdk::gdk_pixbuf::Pixbuf::new(gdk::gdk_pixbuf::Colorspace::Rgb, false, 8, width, height) .expect("allocate pixbuf"); pixbuf.fill(0x00_88_44_ff); let bytes = pixbuf.save_to_bufferv("png", &[]).expect("encode png"); let path = std::env::temp_dir().join(format!("hangar-compose-{}-{name}.png", std::process::id())); std::fs::write(&path, &bytes).expect("write the test image"); path }
/// An attachment keeps its file bytes for the upload and a thumbnail /// for the tile, never a texture at the camera's resolution. /// /// A 12 MP photo is ~49 MB decoded, and the composer allows 40 of /// them across a thread, all to draw 80x80 squares. #[test] fn an_attached_image_keeps_a_thumbnail_not_the_source() { crate::ui::with_gtk(an_attached_image_keeps_a_thumbnail_not_the_source_body); }
fn an_attached_image_keeps_a_thumbnail_not_the_source_body() { let big = png_file("big", 1600, 1200); let file_bytes = std::fs::read(&big).expect("read back the test image");
let dialog = ComposeDialog::new(); let imp = dialog.imp(); // The prompt would open a dialog this test has no window for. imp.require_alt_text.set(false); dialog.load_image_from_path(&big);
{ let images = imp.images.borrow(); let img = images.first().expect("the image attached"); assert!( img.texture.width() <= THUMBNAIL_DECODE_EDGE && img.texture.height() <= THUMBNAIL_DECODE_EDGE, "the strip retained a {}x{} texture for an 80x80 tile", img.texture.width(), img.texture.height() ); assert_eq!( (img.width, img.height), (1600, 1200), "the embed's aspect ratio still describes the source" ); assert_eq!(img.data, file_bytes, "the upload lost the full-size file"); }
// Small images are left alone: scaling up would blur a tile and // waste memory doing it. let small = png_file("small", 8, 8); dialog.load_image_from_path(&small); { let images = imp.images.borrow(); let img = images.get(1).expect("the second image attached"); assert_eq!(img.texture.width(), 8, "an 8px image was enlarged"); assert_eq!((img.width, img.height), (8, 8)); }
let _ = std::fs::remove_file(&big); let _ = std::fs::remove_file(&small); }
/// With the setting on, the Post button waits for every image to be /// described and comes back the moment the last one is. #[test] fn posting_waits_for_alt_text_when_required() { crate::ui::with_gtk(posting_waits_for_alt_text_when_required_body); }
fn posting_waits_for_alt_text_when_required_body() { let dialog = ComposeDialog::new(); let imp = dialog.imp(); imp.require_alt_text.set(true);
let buffer = imp.text_view.borrow().as_ref().unwrap().buffer(); buffer.set_text("words to post");
imp.images.borrow_mut().push(a_compose_image("")); dialog.update_char_counter(&buffer); let btn = imp.post_button.borrow().clone().unwrap(); assert!(!btn.is_sensitive(), "an undescribed image blocks posting");
imp.images.borrow_mut()[0].alt_text = "a described image".into(); dialog.update_char_counter(&buffer); assert!(btn.is_sensitive(), "a description frees the Post button");
// Setting off: the same undescribed image posts fine. imp.require_alt_text.set(false); imp.images.borrow_mut()[0].alt_text.clear(); dialog.update_char_counter(&buffer); assert!(btn.is_sensitive(), "the gate only exists when asked for"); }
/// Abandoning an upload releases the file bytes it was holding. /// /// The upload thread owns the only remaining copy once the composer /// hands it over, and it used to run to completion regardless. A user /// testing attachments could stack several files' worth of memory in /// threads nothing could reach. #[test] fn abandoning_an_upload_releases_its_buffer() { crate::ui::with_gtk(abandoning_an_upload_releases_its_buffer_body); }
fn abandoning_an_upload_releases_its_buffer_body() { use std::sync::atomic::Ordering;
let dialog = ComposeDialog::new(); let flags: std::rc::Rc< std::cell::RefCell<Vec<std::sync::Arc<std::sync::atomic::AtomicBool>>>, > = std::rc::Rc::default(); let sink = std::rc::Rc::clone(&flags); dialog.set_video_upload_callback(move |_, _, _, _, cancelled| { sink.borrow_mut().push(cancelled); });
dialog.attach_video_bytes(None, vec![7; 64], "video/mp4".into(), "clip.mp4".into()); let flag = flags.borrow()[0].clone(); assert!( !flag.load(Ordering::Relaxed), "a live upload is not cancelled" );
// Removing the tile is the common case: attach, change your mind. dialog.remove_video(None); assert!( flag.load(Ordering::Relaxed), "removing the video left its upload running with the file in memory" );
// Closing the dialog covers the rest. dialog.attach_video_bytes(None, vec![7; 64], "video/mp4".into(), "again.mp4".into()); let second = flags.borrow()[1].clone(); assert!(!second.load(Ordering::Relaxed)); dialog.cancel_all_uploads(); assert!( second.load(Ordering::Relaxed), "closing must abandon uploads" ); }
/// A video attaches, hands its bytes to the app, blocks the Post /// button until the service is done, and lands in the compose data. #[test] fn a_video_upload_gates_the_post_button() { crate::ui::with_gtk(a_video_upload_gates_the_post_button_body); }
fn a_video_upload_gates_the_post_button_body() { use std::cell::RefCell as StdRefCell; use std::rc::Rc;
let dialog = ComposeDialog::new(); let imp = dialog.imp(); let requests: Rc<StdRefCell<Vec<(u64, usize, String)>>> = Rc::default(); let sink = Rc::clone(&requests); dialog.set_video_upload_callback(move |token, data, mime, _sent, _cancel| { sink.borrow_mut().push((token, data.len(), mime)); });
let buffer = imp.text_view.borrow().as_ref().unwrap().buffer(); buffer.set_text("words to post"); let btn = imp.post_button.borrow().clone().unwrap();
dialog.attach_video_bytes(None, vec![7; 32], "video/mp4".into(), "clip.mp4".into()); assert_eq!( *requests.borrow(), vec![(0, 32, "video/mp4".to_string())], "the bytes go straight to the app" ); assert!( !btn.is_sensitive(), "an uploading video blocks posting even with text" ); assert_eq!( btn.tooltip_text().as_deref(), Some("The video is still uploading") );
dialog.video_processing(0, Some(40)); assert!(!btn.is_sensitive(), "processing still blocks");
// A second video has nowhere to go. dialog.attach_video_bytes(None, vec![7; 8], "video/mp4".into(), "again.mp4".into()); assert_eq!(requests.borrow().len(), 1, "one video per post");
dialog.video_ready(0, serde_json::json!({ "$type": "blob" })); assert!(btn.is_sensitive(), "a finished video frees the button"); let data = dialog.build_compose_data().expect("composable"); let video = data.video.expect("the video rides the compose data"); assert_eq!(video.blob["$type"], "blob");
// A failure blocks until the tile is removed. dialog.with_video(0, |v| v.state = VideoUploadState::Failed); dialog.refresh_post_gates(); assert!(!btn.is_sensitive()); assert_eq!( btn.tooltip_text().as_deref(), Some("Remove the failed video to post") ); dialog.remove_video(None); assert!(btn.is_sensitive(), "removing the failed video unblocks"); assert!( dialog.build_compose_data().unwrap().video.is_none(), "nothing left to embed" ); }
fn block_remove_button(block: &ThreadPostBlock) -> gtk4::Button { block .container .first_child() .and_then(|header| header.last_child()) .and_downcast::<gtk4::Button>() .expect("the block header ends with its remove button") }
/// Thread block actions act on the block they belong to, however the /// list has shuffled since they were built. Stored build-time indices /// used to panic or hit the wrong post after a removal. #[test] fn thread_actions_follow_the_block_not_its_birth_index() { crate::ui::with_gtk(thread_actions_follow_the_block_not_its_birth_index_body); }
fn thread_actions_follow_the_block_not_its_birth_index_body() { let dialog = ComposeDialog::new(); let imp = dialog.imp();
dialog.add_thread_post(); dialog.add_thread_post(); dialog.add_thread_post(); for (i, block) in imp.thread_posts.borrow().iter().enumerate() { block .text_view .buffer() .set_text(&format!("post {}", i + 2)); }
// Focus rides the shuffle: the third block keeps focus when an // earlier one goes. imp.focused_post_index.set(3); dialog.remove_thread_post(0); assert_eq!(imp.focused_post_index.get(), 2, "focus slid up");
// The last block's remove button was built at index 2 but the // block now sits at 1. The click must remove that block. let btn = block_remove_button(&imp.thread_posts.borrow()[1]); btn.emit_clicked(); let texts: Vec<String> = imp .thread_posts .borrow() .iter() .map(|b| { let buf = b.text_view.buffer(); buf.text(&buf.start_iter(), &buf.end_iter(), false).into() }) .collect(); assert_eq!( texts, vec!["post 3".to_string()], "the clicked block went, its neighbor stayed" );
// The survivor's counter still tracks its own text. let long_text = "words ".repeat(10); imp.thread_posts.borrow()[0] .text_view .buffer() .set_text(long_text.trim_end()); let expected = (MAX_GRAPHEMES - 59).to_string(); assert_eq!( imp.thread_posts.borrow()[0].char_counter.text(), expected, "the counter follows the block, not its birth index" );
// Removing the focused block parks focus on the main post. imp.focused_post_index.set(1); dialog.remove_thread_post(0); assert_eq!(imp.focused_post_index.get(), 0); assert!(imp.thread_posts.borrow().is_empty()); }
/// A thread post's video gates the same button, lands in that post's /// data, and honors the require-alt setting. #[test] fn a_thread_video_rides_its_own_post() { crate::ui::with_gtk(a_thread_video_rides_its_own_post_body); }
fn a_thread_video_rides_its_own_post_body() { let dialog = ComposeDialog::new(); let imp = dialog.imp(); dialog.set_video_upload_callback(|_, _, _, _, _| {});
let buffer = imp.text_view.borrow().as_ref().unwrap().buffer(); buffer.set_text("first post"); dialog.add_thread_post(); imp.thread_posts.borrow()[0] .text_view .buffer() .set_text("second post");
dialog.attach_video_bytes(Some(0), vec![1; 16], "image/gif".into(), "fun.gif".into()); let btn = imp.post_button.borrow().clone().unwrap(); dialog.update_thread_post_button_state(); assert!(!btn.is_sensitive(), "the thread waits for the upload");
dialog.video_ready(0, serde_json::json!({ "$type": "blob" })); assert!(btn.is_sensitive()); let posts = dialog.build_thread_data(); assert_eq!(posts.len(), 1); assert!( posts[0].video.is_some(), "the video belongs to its own post" );
// The require-alt gate covers thread videos too. imp.require_alt_text.set(true); dialog.update_thread_post_button_state(); assert!(!btn.is_sensitive(), "an undescribed video blocks posting"); assert_eq!( btn.tooltip_text().as_deref(), Some("Describe your media before posting") ); dialog.with_video(0, |v| v.alt_text = "a described video".into()); dialog.update_thread_post_button_state(); assert!(btn.is_sensitive(), "a description frees it"); }
/// The feature that exists for blind users cannot need a mouse: the /// thumbnail frame takes focus, announces itself as a button and answers /// keys, and an ALT button on the tile makes the affordance visible. #[test] fn a_thumbnail_hands_its_alt_text_editor_to_the_keyboard() { crate::ui::with_gtk(a_thumbnail_hands_its_alt_text_editor_to_the_keyboard_body); }
fn a_thumbnail_hands_its_alt_text_editor_to_the_keyboard_body() { let dialog = ComposeDialog::new(); let imp = dialog.imp(); imp.images.borrow_mut().push(a_compose_image("")); dialog.rebuild_image_strip();
let strip = imp .image_strip .borrow() .clone() .expect("compose builds its image strip up front"); let tile = strip.first_child().expect("the image built a thumbnail");
let mut frame = None; let mut alt_button = None; let mut child = tile.first_child(); while let Some(widget) = child { if widget.is::<gtk4::Picture>() { frame = Some(widget.clone()); } else if let Some(button) = widget.downcast_ref::<gtk4::Button>() && button.label().is_some_and(|text| text.contains("ALT")) { alt_button = Some(button.clone()); } child = widget.next_sibling(); }
let frame = frame.expect("the thumbnail shows the image"); assert!(frame.is_focusable(), "Tab has to reach the thumbnail"); assert_eq!( frame.accessible_role(), gtk4::AccessibleRole::Button, "Orca has to hear that the thumbnail does something" );
let controllers = frame.observe_controllers(); let names: Vec<String> = (0..controllers.n_items()) .filter_map(|i| controllers.item(i)) .map(|c| c.type_().name().to_string()) .collect(); assert!( names.iter().any(|n| n == "GtkEventControllerKey"), "Enter, Keypad Enter and Space open the description" ); assert!( names.iter().any(|n| n == "GtkGestureClick"), "the pointer still opens the description" );
let alt_button = alt_button.expect("the tile carries a visible ALT button"); assert_eq!( alt_button.tooltip_text().as_deref(), Some("Add alt text to image 1") ); }}