diff --git a/home/profiles/gui.nix b/home/profiles/gui.nix index 57c5af3..94fb665 100644 --- a/home/profiles/gui.nix +++ b/home/profiles/gui.nix @@ -89,8 +89,8 @@ # alternative GTK signal client; carries local feature patches under # patches/flare/ on top of upstream master (typing indicators, edited # messages, multi-select with delete-for-me, in-channel message search, - # the deleted-message placeholder, and the not-yet-merged init_channels - # cache-miss fix). + # the deleted-message placeholder, the image-viewer overlay, and the + # not-yet-merged init_channels cache-miss fix). (pkgs.flare-signal.overrideAttrs (old: { src = inputs.flare-upstream; cargoDeps = pkgs.rustPlatform.importCargoLock { @@ -104,7 +104,17 @@ ../../patches/flare/0004-feat-messages-In-channel-message-search.patch ../../patches/flare/0005-feat-messages-Show-This-message-was-deleted.-placeho.patch ../../patches/flare/0006-fix-backend-Refresh-cached-channels-on-init_channels.patch + ../../patches/flare/0007-feat-messages-Open-image-attachments-in-a-fullscreen.patch ]; + # The image-viewer demo patch (0007) references + # data/screenshots/dummy_apple.jpg, but git binary diffs are not + # supported by the standard `patch` tool nixpkgs uses for the + # patchPhase. Ship the JPEG alongside the patches and copy it into + # the source tree before the build picks it up. + postPatch = (old.postPatch or "") + '' + install -m 0644 ${./../../patches/flare/dummy_apple.jpg} \ + data/screenshots/dummy_apple.jpg + ''; })) # accounting diff --git a/patches/flare/0001-feat-typing-Implement-typing-indicators.patch b/patches/flare/0001-feat-typing-Implement-typing-indicators.patch index d423c13..88dd12c 100644 --- a/patches/flare/0001-feat-typing-Implement-typing-indicators.patch +++ b/patches/flare/0001-feat-typing-Implement-typing-indicators.patch @@ -1,7 +1,7 @@ From 7ab41203098dd868ee70249fcd78c8444438d80c Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:00:12 -0400 -Subject: [PATCH 1/6] feat(typing): Implement typing indicators +Subject: [PATCH 1/7] feat(typing): Implement typing indicators - Send TypingMessage Started/Stopped events as the user composes a message, including a periodic refresh and an idle-stop timer so the diff --git a/patches/flare/0002-feat-messages-Implement-edited-messages.patch b/patches/flare/0002-feat-messages-Implement-edited-messages.patch index ee84369..6f936f8 100644 --- a/patches/flare/0002-feat-messages-Implement-edited-messages.patch +++ b/patches/flare/0002-feat-messages-Implement-edited-messages.patch @@ -1,7 +1,7 @@ From f198aa5720bbc953fd72ebfffcec4f62f794fb19 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:33:06 -0400 -Subject: [PATCH 2/6] feat(messages): Implement edited messages +Subject: [PATCH 2/7] feat(messages): Implement edited messages - Receive incoming EditMessage (1-1 and sync) and replace the body, body_ranges, and attachments of the targeted message in place. The diff --git a/patches/flare/0003-feat-messages-Multi-select-messages-and-delete-for-m.patch b/patches/flare/0003-feat-messages-Multi-select-messages-and-delete-for-m.patch index efaccea..624b466 100644 --- a/patches/flare/0003-feat-messages-Multi-select-messages-and-delete-for-m.patch +++ b/patches/flare/0003-feat-messages-Multi-select-messages-and-delete-for-m.patch @@ -1,7 +1,7 @@ From e6db982a5be5ca392538259dfbb66d38ca0d158d Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:53:22 -0400 -Subject: [PATCH 3/6] feat(messages): Multi-select messages and delete for me +Subject: [PATCH 3/7] feat(messages): Multi-select messages and delete for me - Add a 'Select' action to the message context menu that puts the channel into selection mode and pre-selects the message. While in diff --git a/patches/flare/0004-feat-messages-In-channel-message-search.patch b/patches/flare/0004-feat-messages-In-channel-message-search.patch index 12d6584..77d1c0c 100644 --- a/patches/flare/0004-feat-messages-In-channel-message-search.patch +++ b/patches/flare/0004-feat-messages-In-channel-message-search.patch @@ -1,7 +1,7 @@ From 6272aee14e9ece866751b94b7ebd823f147f9531 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 29 Apr 2026 19:58:54 -0400 -Subject: [PATCH 4/6] feat(messages): In-channel message search +Subject: [PATCH 4/7] feat(messages): In-channel message search - Add a SearchBar above the message list that searches the currently-loaded timeline using a case-insensitive substring match diff --git a/patches/flare/0005-feat-messages-Show-This-message-was-deleted.-placeho.patch b/patches/flare/0005-feat-messages-Show-This-message-was-deleted.-placeho.patch index 7f7fc46..42a4265 100644 --- a/patches/flare/0005-feat-messages-Show-This-message-was-deleted.-placeho.patch +++ b/patches/flare/0005-feat-messages-Show-This-message-was-deleted.-placeho.patch @@ -1,7 +1,7 @@ From b8227bc71123c5ddb473a07884d33353e7078bf4 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Thu, 30 Apr 2026 04:25:07 -0400 -Subject: [PATCH 5/6] feat(messages): Show 'This message was deleted.' +Subject: [PATCH 5/7] feat(messages): Show 'This message was deleted.' placeholder Upstream hides the whole MessageItem when is-deleted is true via a diff --git a/patches/flare/0006-fix-backend-Refresh-cached-channels-on-init_channels.patch b/patches/flare/0006-fix-backend-Refresh-cached-channels-on-init_channels.patch index 5d97c8b..c64e80f 100644 --- a/patches/flare/0006-fix-backend-Refresh-cached-channels-on-init_channels.patch +++ b/patches/flare/0006-fix-backend-Refresh-cached-channels-on-init_channels.patch @@ -1,7 +1,7 @@ From 1546f95d8b6666a7f0e9575ddfa85bcb7ec53e3d Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Wed, 6 May 2026 14:38:07 -0400 -Subject: [PATCH 6/6] fix(backend): Refresh cached channels on init_channels +Subject: [PATCH 6/7] fix(backend): Refresh cached channels on init_channels re-runs init_channels populates the channel cache via or_insert_with, then diff --git a/patches/flare/0007-feat-messages-Open-image-attachments-in-a-fullscreen.patch b/patches/flare/0007-feat-messages-Open-image-attachments-in-a-fullscreen.patch new file mode 100644 index 0000000..9d30341 --- /dev/null +++ b/patches/flare/0007-feat-messages-Open-image-attachments-in-a-fullscreen.patch @@ -0,0 +1,759 @@ +From e13d8cbf15a2eea323397accb2183e2362dae106 Mon Sep 17 00:00:00 2001 +From: Simon Gardling +Date: Wed, 6 May 2026 18:41:15 -0400 +Subject: [PATCH 7/7] feat(messages): Open image attachments in a + fullscreen-capable viewer +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Clicking an image bubble currently does nothing — the existing +"pressed" gesture is right-click-only and routes to the context menu. +To actually look at an image at full resolution the user has to +right-click and choose "Open", which dispatches the file through the +xdg-open portal to the system image viewer. That is the wrong +affordance for a chat client; every other Signal frontend opens the +image inline on tap. + +Add an `ImageViewer` (Adw.Window subclass) that hosts the attachment's +texture in a `gtk::Picture` filling the window over a near-black +backdrop, with two `osd circular` buttons floating in the top-right +corner: a fullscreen toggle and a close button. Closing happens on +`Escape` or via the close button — clicks on the picture and on the +letterboxed area do nothing, leaving the gesture stack open for a +future pan/zoom implementation. + +Wire `AttachmentImage` to open the viewer on left-click. Bails out +silently if the texture has not loaded yet or if the row is not +currently parented in a `gtk::Window`. +--- + CHANGELOG.md | 1 + + data/resources/meson.build | 1 + + data/resources/resources.gresource.xml.in | 5 + + data/resources/style.css | 8 +- + .../ui/components/attachment_image.blp | 5 + + data/resources/ui/image_viewer.blp | 64 +++++ + data/resources/ui/window.blp | 224 +++++++++--------- + src/backend/dummy.rs | 36 ++- + src/gui/components/attachment_image.rs | 33 ++- + src/gui/image_viewer.rs | 120 ++++++++++ + src/gui/mod.rs | 1 + + src/gui/window.rs | 30 +++ + 13 files changed, 411 insertions(+), 117 deletions(-) + create mode 100644 data/resources/ui/image_viewer.blp + create mode 100644 src/gui/image_viewer.rs + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index e221f555..8ebca71c 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + - Display incoming edited messages with an `edited` indicator and edit your own sent messages from their context menu. + - Multi-select messages from their context menu and delete the selection locally with a single action. + - In-channel message search (Ctrl+Shift+F) over the loaded timeline with prev/next navigation and a match counter. ++- Click an image attachment to open it in a fullscreen-capable viewer with a close button, fullscreen toggle, and `Escape`-to-dismiss. + + ## [0.20.4] - 2026-04-22 + +diff --git a/data/resources/meson.build b/data/resources/meson.build +index 7fc00b7c..efd789b7 100644 +--- a/data/resources/meson.build ++++ b/data/resources/meson.build +@@ -11,6 +11,7 @@ blueprints = custom_target('blueprints', + 'ui/dialog_clear_messages.blp', + 'ui/dialog_unlink.blp', + 'ui/error_dialog.blp', ++ 'ui/image_viewer.blp', + 'ui/setup_window.blp', + 'ui/linked_devices_window.blp', + 'ui/device_info_item.blp', +diff --git a/data/resources/resources.gresource.xml.in b/data/resources/resources.gresource.xml.in +index 8604fe24..863564b3 100644 +--- a/data/resources/resources.gresource.xml.in ++++ b/data/resources/resources.gresource.xml.in +@@ -2,6 +2,10 @@ + + + ../icons/de.schmidhuberj.Flare.svg ++ ++ ++ ++ ../screenshots/dummy_apple.jpg + + ui/window.ui + ui/channel_list.ui +@@ -13,6 +17,7 @@ + ui/linked_devices_window.ui + ui/device_info_item.ui + ui/error_dialog.ui ++ ui/image_viewer.ui + ui/attachment.ui + ui/preferences_window.ui + ui/shortcuts.ui +diff --git a/data/resources/style.css b/data/resources/style.css +index b3a517f9..0c95a55a 100644 +--- a/data/resources/style.css ++++ b/data/resources/style.css +@@ -309,4 +309,10 @@ unread-indicator { + background: alpha(@accent_bg_color, 0.35); + outline: 2px solid alpha(@accent_color, 0.7); + outline-offset: -2px; +-} +\ No newline at end of file ++} ++ ++/* Image attachment viewer: a near-black backdrop so the picture appears to ++ float and the floating OSD close button has enough contrast. */ ++.image-viewer-backdrop { ++ background-color: rgb(15, 15, 15); ++} +diff --git a/data/resources/ui/components/attachment_image.blp b/data/resources/ui/components/attachment_image.blp +index ec303a72..bcbdabe1 100644 +--- a/data/resources/ui/components/attachment_image.blp ++++ b/data/resources/ui/components/attachment_image.blp +@@ -2,6 +2,11 @@ using Gtk 4.0; + + template $FlAttachmentImage: $FlAttachmentBase { + Picture picture { ++ GestureClick { ++ button: 1; ++ pressed => $on_clicked() swapped; ++ } ++ + styles [ + "photo", + ] +diff --git a/data/resources/ui/image_viewer.blp b/data/resources/ui/image_viewer.blp +new file mode 100644 +index 00000000..1b27cc09 +--- /dev/null ++++ b/data/resources/ui/image_viewer.blp +@@ -0,0 +1,64 @@ ++using Gtk 4.0; ++using Adw 1; ++ ++template $FlImageViewer: Adw.Bin { ++ hexpand: true; ++ vexpand: true; ++ focusable: true; ++ ++ EventControllerKey { ++ key-pressed => $on_key_pressed() swapped; ++ } ++ ++ // Box paints the dark backdrop. Overlay does not draw a background, so ++ // the CSS class lives on the Box. ++ Box { ++ hexpand: true; ++ vexpand: true; ++ ++ styles [ ++ "image-viewer-backdrop", ++ ] ++ ++ Overlay { ++ hexpand: true; ++ vexpand: true; ++ ++ // Any left-click in the viewer dismisses — on the image, on the ++ // letterboxed dark area, anywhere. The close button absorbs its own ++ // click before it reaches us via standard GTK click-bubbling so the ++ // button still works. ++ GestureClick { ++ button: 1; ++ pressed => $on_backdrop_pressed() swapped; ++ } ++ ++ Picture picture { ++ paintable: bind template.attachment as <$FlAttachmentObject>.image; ++ content-fit: contain; ++ can-shrink: true; ++ hexpand: true; ++ vexpand: true; ++ } ++ ++ [overlay] ++ Box { ++ halign: end; ++ valign: start; ++ margin-top: 12; ++ margin-end: 12; ++ ++ Button close_button { ++ icon-name: "window-close-symbolic"; ++ tooltip-text: _("Close"); ++ clicked => $on_close_clicked() swapped; ++ ++ styles [ ++ "osd", ++ "circular", ++ ] ++ } ++ } ++ } ++ } ++} +diff --git a/data/resources/ui/window.blp b/data/resources/ui/window.blp +index 82de6af3..3706121a 100644 +--- a/data/resources/ui/window.blp ++++ b/data/resources/ui/window.blp +@@ -52,130 +52,136 @@ template $FlWindow: Adw.ApplicationWindow { + Gtk.StackPage { + name: "main"; + +- child: Adw.NavigationSplitView split_view { +- min-sidebar-width: 300; +- max-sidebar-width: 400; +- sidebar-width-fraction: 0.25; +- notify::show-content => $handle_show_content() swapped; +- +- sidebar: Adw.NavigationPage { +- title: _("Channel list"); +- tag: "channel-list"; +- +- Adw.ToolbarView { +- [top] +- Adw.HeaderBar { +- title-widget: Adw.WindowTitle { +- title: "Flare"; +- }; ++ child: Gtk.Overlay image_viewer_overlay { ++ // Wraps the navigation split view so an image viewer pushed in via ++ // `Window::present_image_viewer` covers the entire window content ++ // (sidebar + chat) rather than being constrained to the split ++ // view's content pane like an Adw.Dialog would be. ++ Adw.NavigationSplitView split_view { ++ min-sidebar-width: 300; ++ max-sidebar-width: 400; ++ sidebar-width-fraction: 0.25; ++ notify::show-content => $handle_show_content() swapped; ++ ++ sidebar: Adw.NavigationPage { ++ title: _("Channel list"); ++ tag: "channel-list"; ++ ++ Adw.ToolbarView { ++ [top] ++ Adw.HeaderBar { ++ title-widget: Adw.WindowTitle { ++ title: "Flare"; ++ }; ++ ++ [start] ++ Gtk.Button { ++ accessibility { ++ label: C_("accessibility", "Add Conversation"); ++ } + +- [start] +- Gtk.Button { +- accessibility { +- label: C_("accessibility", "Add Conversation"); ++ tooltip-text: "Add Conversation"; ++ icon-name: "chat-message-new-symbolic"; ++ clicked => $handle_add_conversation_clicked() swapped; + } + +- tooltip-text: "Add Conversation"; +- icon-name: "chat-message-new-symbolic"; +- clicked => $handle_add_conversation_clicked() swapped; +- } ++ [end] ++ MenuButton { ++ accessibility { ++ label: C_("accessibility", "Menu"); ++ } + +- [end] +- MenuButton { +- accessibility { +- label: C_("accessibility", "Menu"); ++ tooltip-text: "Menu"; ++ menu-model: menubar; ++ primary: true; ++ icon-name: "open-menu-symbolic"; + } + +- tooltip-text: "Menu"; +- menu-model: menubar; +- primary: true; +- icon-name: "open-menu-symbolic"; +- } ++ [end] ++ ToggleButton { ++ accessibility { ++ label: C_("accessibility", "Search"); ++ } + +- [end] +- ToggleButton { +- accessibility { +- label: C_("accessibility", "Search"); ++ tooltip-text: "Search"; ++ icon-name: "system-search-symbolic"; ++ active: bind channel_list.search-enabled no-sync-create; ++ clicked => $handle_search_clicked() swapped; + } +- +- tooltip-text: "Search"; +- icon-name: "system-search-symbolic"; +- active: bind channel_list.search-enabled no-sync-create; +- clicked => $handle_search_clicked() swapped; + } +- } + +- content: $FlChannelList channel_list { +- notify::active-channel => $handle_go_forward() swapped; +- manager: bind template.manager; +- }; +- } +- }; +- +- content: Adw.NavigationPage { +- title: _("Chat"); +- tag: "chat"; +- +- Adw.ToolbarView { +- [top] +- Adw.HeaderBar { +- title-widget: Button { +- styles [ +- "flat", +- ] +- +- sensitive: bind $is_some(channel_list.active-channel) as ; +- visible: bind $is_some(channel_list.active-channel) as ; +- action-name: "win.channel-information"; +- +- child: Box { +- name: "room_title"; +- orientation: vertical; +- halign: center; +- valign: center; +- +- Label title_label { +- focusable: true; +- ellipsize: end; +- halign: center; +- wrap: false; +- single-line-mode: true; +- use-markup: false; +- width-chars: 5; +- label: bind channel_list.active-channel as <$FlChannel>.title; +- +- styles [ +- "title", +- ] +- } ++ content: $FlChannelList channel_list { ++ notify::active-channel => $handle_go_forward() swapped; ++ manager: bind template.manager; ++ }; ++ } ++ }; + +- Label subtitle_label { +- focusable: true; +- ellipsize: end; ++ content: Adw.NavigationPage { ++ title: _("Chat"); ++ tag: "chat"; ++ ++ Adw.ToolbarView { ++ [top] ++ Adw.HeaderBar { ++ title-widget: Button { ++ styles [ ++ "flat", ++ ] ++ ++ sensitive: bind $is_some(channel_list.active-channel) as ; ++ visible: bind $is_some(channel_list.active-channel) as ; ++ action-name: "win.channel-information"; ++ ++ child: Box { ++ name: "room_title"; ++ orientation: vertical; + halign: center; +- wrap: false; +- single-line-mode: true; +- use-markup: true; +- visible: bind channel_list.active-channel as <$FlChannel>.is-typing; +- label: bind channel_list.active-channel as <$FlChannel>.typing-label; +- tooltip-markup: bind channel_list.active-channel as <$FlChannel>.description; +- +- styles [ +- "subtitle-room", +- "accent", +- ] +- } ++ valign: center; ++ ++ Label title_label { ++ focusable: true; ++ ellipsize: end; ++ halign: center; ++ wrap: false; ++ single-line-mode: true; ++ use-markup: false; ++ width-chars: 5; ++ label: bind channel_list.active-channel as <$FlChannel>.title; ++ ++ styles [ ++ "title", ++ ] ++ } ++ ++ Label subtitle_label { ++ focusable: true; ++ ellipsize: end; ++ halign: center; ++ wrap: false; ++ single-line-mode: true; ++ use-markup: true; ++ visible: bind channel_list.active-channel as <$FlChannel>.is-typing; ++ label: bind channel_list.active-channel as <$FlChannel>.typing-label; ++ tooltip-markup: bind channel_list.active-channel as <$FlChannel>.description; ++ ++ styles [ ++ "subtitle-room", ++ "accent", ++ ] ++ } ++ }; + }; ++ } ++ ++ content: $FlChannelMessages channel_messages { ++ manager: bind template.manager; ++ active-channel: bind channel_list.active-channel; ++ has-channels: bind channel_list.has-channels; + }; + } +- +- content: $FlChannelMessages channel_messages { +- manager: bind template.manager; +- active-channel: bind channel_list.active-channel; +- has-channels: bind channel_list.has-channels; +- }; +- } +- }; ++ }; ++ } + }; + } + } +diff --git a/src/backend/dummy.rs b/src/backend/dummy.rs +index e6824e80..c1aae764 100644 +--- a/src/backend/dummy.rs ++++ b/src/backend/dummy.rs +@@ -382,18 +382,40 @@ impl super::Manager { + }, + )); + +- let msg_screenshot = msg!(self, "", 2, GROUP_ID, 19 + base_minute); +- let screenshot_file = gtk::gio::File::for_uri("resource:///icon.svg"); +- let attachment = crate::backend::Attachment::from_file(screenshot_file, self); +- msg_screenshot ++ // A short question-and-answer in the 1-on-1 chat with contact 2 ++ // ("Developer") demonstrating the click-to-open image viewer ++ // (`gui::image_viewer`). The contact sends a photo of an apple ++ // (bundled into the screenshot-mode build via ++ // `data/resources/resources.gresource.xml.in` as `dummy_apple.jpg`, ++ // public domain "Big red apple.jpg" by Paolo Neo via Wikimedia ++ // Commons) along with a ++ // text question, and the local user answers with the obvious ++ // one-word reply. ++ let msg_fruit_question = msg!( ++ self, ++ "what is the name of this fruit?", ++ 2, ++ 2, ++ 30 + base_minute ++ ); ++ // `Attachment::from_file` only loads images via filesystem paths; for ++ // `resource://` URIs `GFile::path()` returns None, so the image ++ // would never reach the Picture. Load the bytes directly out of the ++ // gresource bundle into a Texture instead, and build the attachment ++ // from that. ++ let fruit_pixbuf = gtk::gdk_pixbuf::Pixbuf::from_resource("/dummy_apple.jpg") ++ .expect("apple photo resource to load"); ++ let fruit_texture = gtk::gdk::Texture::for_pixbuf(&fruit_pixbuf); ++ let fruit_photo = crate::backend::Attachment::from_texture(fruit_texture, self); ++ msg_fruit_question + .clone() + .downcast::() + .unwrap() +- .add_attachment(attachment); ++ .add_attachment(fruit_photo); ++ let msg_fruit_answer = msg!(self, "apple", 0, 2, 32 + base_minute); + + vec![ + msg_replied, +- // msg_screenshot, + msg_reply, + msg!( + self, +@@ -405,6 +427,8 @@ impl super::Manager { + msg!(self, "Glad you like it.", 2, GROUP_ID, 25 + base_minute), + msg!(self, "YAY!", 0, GROUP_ID, 27 + base_minute), + msg!(self, "Greetings", 2, 2, base_minute - 100), ++ msg_fruit_question, ++ msg_fruit_answer, + msg!(self, "It's a trap!", 3, 3, 1 + base_minute), + msg!(self, "Hello, Mr. Anderson", 4, 4, 2 + base_minute), + ] +diff --git a/src/gui/components/attachment_image.rs b/src/gui/components/attachment_image.rs +index a91bf05b..4ddd29b6 100644 +--- a/src/gui/components/attachment_image.rs ++++ b/src/gui/components/attachment_image.rs +@@ -24,7 +24,10 @@ pub mod imp { + use glib::subclass::InitializingObject; + use gtk::{CompositeTemplate, Picture}; + +- use crate::gui::{attachment::Attachment, attachment::AttachmentImpl, utility::Utility}; ++ use crate::gui::{ ++ attachment::{Attachment, AttachmentImpl}, ++ utility::Utility, ++ }; + + #[derive(CompositeTemplate, Default)] + #[template(resource = "/ui/components/attachment_image.ui")] +@@ -41,6 +44,7 @@ pub mod imp { + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); ++ Self::bind_template_callbacks(klass); + Utility::bind_template_callbacks(klass); + } + +@@ -49,6 +53,33 @@ pub mod imp { + } + } + ++ #[gtk::template_callbacks] ++ impl AttachmentImage { ++ /// Open the image in a viewer presented inside the parent window. ++ /// Bails out silently if the attachment's texture hasn't loaded yet. ++ #[template_callback] ++ fn on_clicked(&self) { ++ let obj = self.obj(); ++ let base = obj.upcast_ref::(); ++ let attachment = base.attachment(); ++ if attachment.image().is_none() { ++ return; ++ } ++ // Walk up to the top-level `crate::gui::Window` and ask it to ++ // host the viewer in its window-level overlay so the viewer ++ // covers the entire window content (sidebar + chat) rather ++ // than being constrained to the navigation split view's ++ // content pane like an Adw.Dialog would be. ++ let Some(window) = obj ++ .root() ++ .and_then(|r| r.dynamic_cast::().ok()) ++ else { ++ return; ++ }; ++ window.present_image_viewer(&attachment); ++ } ++ } ++ + impl ObjectImpl for AttachmentImage { + fn constructed(&self) { + self.parent_constructed(); +diff --git a/src/gui/image_viewer.rs b/src/gui/image_viewer.rs +new file mode 100644 +index 00000000..b3e9d720 +--- /dev/null ++++ b/src/gui/image_viewer.rs +@@ -0,0 +1,120 @@ ++use crate::prelude::*; ++ ++use crate::backend::Attachment; ++ ++glib::wrapper! { ++ /// A widget that displays an image attachment over a dark backdrop. It is ++ /// designed to be inserted into a top-level [`gtk::Overlay`] in ++ /// [`crate::gui::Window`] (see [`crate::gui::Window::present_image_viewer`]) ++ /// so that it covers every other widget in the window — sidebar, chat ++ /// pane, header bar — rather than being constrained to the content area ++ /// of the surrounding navigation split view (which is what `Adw.Dialog` ++ /// would force). ++ /// ++ /// Emits `closed` when the user dismisses the viewer. Consumers should ++ /// remove the widget from the overlay in response. ++ pub struct ImageViewer(ObjectSubclass) ++ @extends adw::Bin, gtk::Widget, ++ @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; ++} ++ ++impl ImageViewer { ++ pub fn new(attachment: &Attachment) -> Self { ++ log::trace!("Initializing `ImageViewer`"); ++ Object::builder::() ++ .property("attachment", attachment) ++ .build() ++ } ++} ++ ++pub mod imp { ++ use crate::prelude::*; ++ ++ use glib::subclass::{InitializingObject, Signal}; ++ use gtk::{CompositeTemplate, Picture}; ++ ++ use crate::backend::Attachment; ++ ++ #[derive(CompositeTemplate, Default, glib::Properties)] ++ #[properties(wrapper_type = super::ImageViewer)] ++ #[template(resource = "/ui/image_viewer.ui")] ++ pub struct ImageViewer { ++ #[property(get, set, construct_only)] ++ attachment: RefCell>, ++ ++ #[template_child] ++ pub(super) picture: TemplateChild, ++ } ++ ++ #[gtk::template_callbacks] ++ impl ImageViewer { ++ /// Click anywhere in the viewer (image or backdrop) closes. The ++ /// close button stops its own click before it reaches us via the ++ /// usual GTK click-bubbling, so the button still works. ++ #[template_callback] ++ fn on_backdrop_pressed(&self) { ++ self.obj().emit_by_name::<()>("closed", &[]); ++ } ++ ++ #[template_callback] ++ fn on_close_clicked(&self) { ++ self.obj().emit_by_name::<()>("closed", &[]); ++ } ++ ++ /// Close on `Escape`. The viewer grabs focus on construction so the ++ /// key controller actually sees keystrokes; without that, focus ++ /// would still be on whatever widget the user clicked on (typically ++ /// the message row) and `Escape` would route there instead. ++ #[template_callback] ++ fn on_key_pressed( ++ &self, ++ keyval: gdk::Key, ++ _keycode: u32, ++ _state: gdk::ModifierType, ++ ) -> glib::Propagation { ++ if keyval == gdk::Key::Escape { ++ self.obj().emit_by_name::<()>("closed", &[]); ++ glib::Propagation::Stop ++ } else { ++ glib::Propagation::Proceed ++ } ++ } ++ } ++ ++ #[glib::object_subclass] ++ impl ObjectSubclass for ImageViewer { ++ const NAME: &'static str = "FlImageViewer"; ++ type Type = super::ImageViewer; ++ type ParentType = adw::Bin; ++ ++ fn class_init(klass: &mut Self::Class) { ++ Self::bind_template(klass); ++ Self::bind_template_callbacks(klass); ++ } ++ ++ fn instance_init(obj: &InitializingObject) { ++ obj.init_template(); ++ } ++ } ++ ++ #[glib::derived_properties] ++ impl ObjectImpl for ImageViewer { ++ fn signals() -> &'static [Signal] { ++ static SIGNALS: Lazy> = ++ Lazy::new(|| vec![Signal::builder("closed").build()]); ++ SIGNALS.as_ref() ++ } ++ ++ fn constructed(&self) { ++ self.parent_constructed(); ++ // Make the widget focusable so the key controller below is ++ // actually allowed to receive keystrokes; Adw.Bin defaults to ++ // not-focusable. ++ self.obj().set_focusable(true); ++ self.obj().set_can_focus(true); ++ } ++ } ++ ++ impl WidgetImpl for ImageViewer {} ++ impl BinImpl for ImageViewer {} ++} +diff --git a/src/gui/mod.rs b/src/gui/mod.rs +index 6b5cfd19..d2e997ee 100644 +--- a/src/gui/mod.rs ++++ b/src/gui/mod.rs +@@ -8,6 +8,7 @@ mod channel_messages; + mod components; + mod device_info_item; + mod error_dialog; ++mod image_viewer; + mod linked_devices_window; + mod message_item; + mod new_channel_dialog; +diff --git a/src/gui/window.rs b/src/gui/window.rs +index ce097ce1..bd9474e2 100644 +--- a/src/gui/window.rs ++++ b/src/gui/window.rs +@@ -104,6 +104,33 @@ impl Window { + pub(crate) fn settings(&self) -> gio::Settings { + self.imp().settings.clone() + } ++ ++ /// Show an image viewer overlay covering the whole window content area ++ /// for `attachment`. Removes itself from the overlay when the viewer ++ /// emits `closed` (on `Escape`, click anywhere inside the viewer, or ++ /// the close button). ++ pub fn present_image_viewer(&self, attachment: &crate::backend::Attachment) { ++ use crate::gui::image_viewer::ImageViewer; ++ let viewer = ImageViewer::new(attachment); ++ let overlay = self.imp().image_viewer_overlay.clone(); ++ overlay.add_overlay(&viewer); ++ viewer.grab_focus(); ++ viewer.connect_local( ++ "closed", ++ false, ++ clone!( ++ #[weak] ++ overlay, ++ #[weak] ++ viewer, ++ #[upgrade_or_default] ++ move |_| { ++ overlay.remove_overlay(&viewer); ++ None ++ } ++ ), ++ ); ++ } + } + + pub mod imp { +@@ -142,6 +169,8 @@ pub mod imp { + #[template_child] + split_view: TemplateChild, + #[template_child] ++ pub(super) image_viewer_overlay: TemplateChild, ++ #[template_child] + pub(super) channel_list: TemplateChild, + #[template_child] + subtitle_label: TemplateChild, +@@ -165,6 +194,7 @@ pub mod imp { + channel_list: Default::default(), + subtitle_label: Default::default(), + channel_messages: Default::default(), ++ image_viewer_overlay: Default::default(), + new_channel_dialog: Default::default(), + manager: Default::default(), + settings: Settings::new(BASE_ID), +-- +2.53.0 + diff --git a/patches/flare/dummy_apple.jpg b/patches/flare/dummy_apple.jpg new file mode 100644 index 0000000..da0c8e4 Binary files /dev/null and b/patches/flare/dummy_apple.jpg differ