// 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 post const 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 remain const WARN_THRESHOLD: i32 = 20; /// Tag names for rich text highlighting in the compose buffer const 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 detection static HL_URL_RE: LazyLock = LazyLock::new(|| Regex::new(r"https?://[^\s<>\[\]\{}|\\^`\x00-\x1f\x7f]+").unwrap()); static HL_MENTION_RE: LazyLock = 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 = 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, 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, /// 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, pub state: VideoUploadState, pub blob: Option, 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, } /// 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, pub video: Option, pub char_counter: gtk4::Label, /// Per-post content warning (each post can have its own CW) pub content_warning: Option, /// "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>, pub post_button: RefCell>, pub error_label: RefCell>, pub reply_context: RefCell>, pub quote_context: RefCell>, pub reply_label: RefCell>, pub quote_preview: RefCell>, pub post_callback: RefCell>>, pub reply_callback: RefCell>>, pub quote_callback: RefCell>>, pub char_counter: RefCell>, // Image attachments pub image_strip: RefCell>, pub images: RefCell>, /// Read once when the dialog opens; posting waits for alt text on /// every image while set. pub require_alt_text: Cell, pub add_image_button: RefCell>, // Video attachment (main post; thread posts carry theirs in the block) pub video: RefCell>, pub video_token_counter: Cell, /// 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, String, std::sync::Arc, std::sync::Arc, ) + 'static, >, >, >, /// MediaFiles probing freshly attached videos for their pixel size. pub video_probes: RefCell>, pub remove_all_images_button: RefCell>, // Language selection pub language_button: RefCell>, pub selected_language: RefCell, // Content warning pub cw_button: RefCell>, pub content_warning: RefCell>, // Interaction settings. The button text is dynamic, e.g. "Everyone can reply". pub interaction_label: RefCell>, pub threadgate_config: RefCell>, pub postgate_config: RefCell>, // Link card preview pub link_preview_box: RefCell>, pub link_card_data: RefCell>, /// The URL we've already fetched or are fetching (to avoid duplicate requests) pub link_preview_url: RefCell>, /// Set when the user dismissed the link card. No re-fetch until the text changes the URL. pub link_preview_dismissed: Cell, /// Callback to fetch link card metadata (called with URL string) pub link_preview_fetch_callback: RefCell>>, /// Counter for link preview debounce pub link_debounce_counter: Cell, // Thread composer /// Container holding all thread post blocks pub thread_container: RefCell>, /// Additional thread posts (Post 2 onward; Post 1 is the main text_view) pub thread_posts: RefCell>, /// "Add to thread" button pub add_thread_button: RefCell>, /// Thread post callback (called with Vec) pub thread_callback: RefCell) + 'static>>>, /// Which post is currently focused (0 = main, 1+ = thread posts) pub focused_post_index: Cell, // Mention autocomplete pub mention_popover: RefCell>, pub mention_list: RefCell>, pub mention_results: RefCell>, pub mention_search_callback: RefCell>>, pub debounce_counter: Cell, /// Tracks the char offset of the '@' that triggered the current autocomplete pub mention_at_offset: Cell, /// Guard to prevent recursive buffer change notifications during highlighting pub highlighting: Cell, } #[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) @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) -> 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 = { 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 = 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) { 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::() { 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(&self, filter: gtk4::FileFilter, on_chosen: F) where F: Fn(&ComposeDialog, &std::path::Path) + 'static, { let filters = gtk4::gio::ListStore::new::(); 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::().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( overlay: >k4::Overlay, carrier: &impl IsA, 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::().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, String, std::sync::Arc, std::sync::Arc, ) + '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, 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, data: Vec, 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> { 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) { 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) { // 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) { 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::(), video.status_label.clone().upcast::(), ] { if let Some(parent) = widget.parent() && let Some(parent) = parent.downcast_ref::() { 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::().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::().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::().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 = 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::()); 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::().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::()); // 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::()); // 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::()); // 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::().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 { 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::() { 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 = 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::()); 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::().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::().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 { 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 = 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 { 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 { 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 = 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(&self, callback: F) { self.imp().post_callback.replace(Some(Box::new(callback))); } pub fn connect_reply(&self, callback: F) { self.imp().reply_callback.replace(Some(Box::new(callback))); } pub fn connect_quote(&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(&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(&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 where first element is the root post. pub fn connect_thread) + '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>>, > = 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>> = 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::() .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 = 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::() { frame = Some(widget.clone()); } else if let Some(button) = widget.downcast_ref::() && 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 = (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") ); } }