Skip to content

Commit 3b41ed5

Browse files
committed
Show image attachment
1 parent c8faf2b commit 3b41ed5

File tree

2 files changed

+100
-8
lines changed

2 files changed

+100
-8
lines changed

ntfy-daemon/src/models.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ pub struct Message {
3636
pub tags: Vec<String>,
3737
#[serde(skip_serializing_if = "Option::is_none")]
3838
pub priority: Option<i8>,
39-
#[serde(skip_serializing_if = "Vec::is_empty")]
39+
#[serde(skip_serializing_if = "Option::is_none")]
4040
#[serde(default)]
41-
pub attach: Vec<String>,
41+
pub attachment: Option<Attachment>,
4242
#[serde(skip_serializing_if = "Option::is_none")]
4343
pub icon: Option<String>,
4444
#[serde(skip_serializing_if = "Option::is_none")]
@@ -109,6 +109,28 @@ pub struct MinMessage {
109109
pub time: u64,
110110
}
111111

112+
#[derive(Clone, Debug, Serialize, Deserialize)]
113+
pub struct Attachment {
114+
pub name: String,
115+
pub url: url::Url,
116+
#[serde(rename = "type")]
117+
#[serde(skip_serializing_if = "Option::is_none")]
118+
pub atype: Option<String>,
119+
#[serde(skip_serializing_if = "Option::is_none")]
120+
pub size: Option<usize>,
121+
#[serde(skip_serializing_if = "Option::is_none")]
122+
pub expires: Option<usize>,
123+
}
124+
125+
impl Attachment {
126+
pub fn is_image(&self) -> bool {
127+
let Some(ext) = self.name.split('.').last() else {
128+
return false;
129+
};
130+
["jpeg", "jpg", "png", "webp", "gif"].contains(&ext)
131+
}
132+
}
133+
112134
#[derive(Clone, Debug)]
113135
pub struct Subscription {
114136
pub server: String,

src/widgets/message_row.rs

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
use std::io::Read;
2+
13
use adw::prelude::*;
24
use adw::subclass::prelude::*;
35
use chrono::NaiveDateTime;
4-
use gtk::glib;
6+
use gdk_pixbuf::Pixbuf;
7+
use gtk::gdk_pixbuf;
8+
use gtk::{gdk, gio, glib};
59
use ntfy_daemon::models;
10+
use tracing::error;
611

712
mod imp {
813
use super::*;
@@ -41,6 +46,7 @@ impl MessageRow {
4146
self.set_margin_end(8);
4247
self.set_column_spacing(8);
4348
self.set_row_spacing(8);
49+
let mut row = 0;
4450

4551
let time = gtk::Label::builder()
4652
.label(
@@ -51,7 +57,7 @@ impl MessageRow {
5157
.xalign(0.0)
5258
.build();
5359
time.add_css_class("caption");
54-
self.attach(&time, 0, 0, 1, 1);
60+
self.attach(&time, 0, row, 1, 1);
5561

5662
if let Some(p) = msg.priority {
5763
let text = format!(
@@ -76,6 +82,7 @@ impl MessageRow {
7682
priority.set_halign(gtk::Align::End);
7783
self.attach(&priority, 1, 0, 2, 1);
7884
}
85+
row += 1;
7986

8087
if let Some(title) = msg.display_title() {
8188
let label = gtk::Label::builder()
@@ -86,7 +93,8 @@ impl MessageRow {
8693
.selectable(true)
8794
.build();
8895
label.add_css_class("heading");
89-
self.attach(&label, 0, 1, 3, 1);
96+
self.attach(&label, 0, row, 3, 1);
97+
row += 1;
9098
}
9199

92100
if let Some(message) = msg.display_message() {
@@ -98,7 +106,15 @@ impl MessageRow {
98106
.selectable(true)
99107
.hexpand(true)
100108
.build();
101-
self.attach(&label, 0, 2, 3, 1);
109+
self.attach(&label, 0, row, 3, 1);
110+
row += 1;
111+
}
112+
113+
if let Some(attachment) = msg.attachment {
114+
if attachment.is_image() {
115+
self.attach(&self.build_image(attachment.url.to_string()), 0, row, 3, 1);
116+
row += 1;
117+
}
102118
}
103119

104120
if msg.actions.len() > 0 {
@@ -114,7 +130,8 @@ impl MessageRow {
114130
action_btns.append(&btn);
115131
}
116132

117-
self.attach(&action_btns, 0, 3, 3, 1);
133+
self.attach(&action_btns, 0, row, 3, 1);
134+
row += 1;
118135
}
119136
if msg.tags.len() > 0 {
120137
let mut tags_text = String::from("tags: ");
@@ -125,9 +142,62 @@ impl MessageRow {
125142
.wrap(true)
126143
.wrap_mode(gtk::pango::WrapMode::WordChar)
127144
.build();
128-
self.attach(&tags, 0, 4, 3, 1);
145+
self.attach(&tags, 0, row, 3, 1);
129146
}
130147
}
148+
fn build_image(&self, url: String) -> gtk::Picture {
149+
let (tx, rx) = glib::MainContext::channel(Default::default());
150+
gio::spawn_blocking(move || {
151+
let path = glib::user_cache_dir().join("com.ranfdev.Notify").join(&url);
152+
let bytes = if path.exists() {
153+
match std::fs::read(&path) {
154+
Ok(v) => v,
155+
Err(e) => {
156+
error!(error = %e, path = %path.display(), "reading image from disk");
157+
return glib::ControlFlow::Break;
158+
}
159+
}
160+
} else {
161+
let res = match ureq::get(&url).call() {
162+
Ok(res) => res,
163+
Err(e) => {
164+
error!(error = %e, "fetching image");
165+
return glib::ControlFlow::Break;
166+
}
167+
};
168+
let mut bytes = vec![];
169+
if let Err(e) = res
170+
.into_reader()
171+
.take(5 * 1_000_000) // 5 MB
172+
.read_to_end(&mut bytes)
173+
{
174+
error!(error = %e, "reading image data");
175+
return glib::ControlFlow::Break;
176+
}
177+
bytes
178+
};
179+
180+
tx.send(glib::Bytes::from_owned(bytes)).unwrap();
181+
glib::ControlFlow::Break
182+
});
183+
let picture = gtk::Picture::new();
184+
picture.set_can_shrink(true);
185+
picture.set_height_request(350);
186+
let picturec = picture.clone();
187+
rx.attach(Default::default(), move |b| {
188+
let stream = gio::MemoryInputStream::from_bytes(&b);
189+
let pixbuf = match Pixbuf::from_stream(&stream, gio::Cancellable::NONE) {
190+
Ok(res) => res,
191+
Err(e) => {
192+
error!(error = %e, "parsing image contents");
193+
return glib::ControlFlow::Break;
194+
}
195+
};
196+
picturec.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
197+
glib::ControlFlow::Break
198+
});
199+
picture
200+
}
131201
fn build_action_btn(&self, action: models::Action) -> gtk::Button {
132202
let btn = gtk::Button::new();
133203
match &action {

0 commit comments

Comments
 (0)