Skip to content

Commit 3536748

Browse files
tmmampagent
andauthored
feat(zbugs-rune): avatars, and images in issues and comments (#153)
As the web app: the creator's avatar on the issue, each commenter's on their comment, and the user's in the sidebar, and the images in issue and comment Markdown. images.rs fetches each URL once on a thread (ureq over rustls, whose crates the app already builds), Rune's ImageCache decodes it, and AppSpec::poll rebuilds the view when one finishes decoding, since Markdown reads its pictures only as it builds. GitHub avatars are asked for at 64 pixels, as the web app asks. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c85e-fd3c-72ba-98dd-f4767a00e73b Co-authored-by: Amp <amp@ampcode.com>
1 parent 3efab0a commit 3536748

6 files changed

Lines changed: 196 additions & 19 deletions

File tree

‎apps/zbugs-rune/Cargo.lock‎

Lines changed: 43 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎apps/zbugs-rune/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ tokio = { version = "1", default-features = false, features = [
2525
"sync",
2626
"time",
2727
] }
28+
# Fetches avatars and the images in issues and comments, over rustls.
29+
ureq = { version = "3", default-features = false, features = ["rustls"] }
2830
# The login token goes in the Keychain, the Credential Manager, or the
2931
# Secret Service (over zbus, so Linux needs no libdbus).
3032
keyring = "4"

‎apps/zbugs-rune/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ both), and Windows is next.
2121
reactions, its assignee, visibility, notifications, and labels, and sheets
2222
to create, edit, and delete an issue, edit and delete comments, create
2323
a label, log in, and set the servers.
24+
- `src/images.rs` fetches avatars and the images in issues and comments
25+
(`ureq`, over rustls) for Rune's image cache to decode, as the web app
26+
shows them.
2427
- `src/login.rs` logs in through GitHub in the browser: the API server
2528
redirects back to a listener the app opens on 127.0.0.1 for that one
2629
login (a loopback redirect, RFC 8252 §7.3).

‎apps/zbugs-rune/src/images.rs‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//! Pictures from the web: avatars, and images in issues and comments. Each
2+
//! URL is fetched once, on a thread of its own, and decoded by Rune's image
3+
//! cache, which wakes the app when it's ready.
4+
5+
use std::cell::RefCell;
6+
use std::collections::HashMap;
7+
use std::rc::Rc;
8+
9+
use rune::{Image, ImageCache, Waker};
10+
11+
use crate::App;
12+
13+
/// Larger responses aren't images zbugs shows.
14+
const MAX_BYTES: u64 = 20 << 20;
15+
16+
#[derive(Clone)]
17+
pub struct Images(Rc<RefCell<State>>);
18+
19+
struct State {
20+
ui: rune::AppHandle<App>,
21+
cache: Option<ImageCache>,
22+
/// `None` while fetching, or when the fetch failed.
23+
images: HashMap<String, Option<Image>>,
24+
/// Those still decoding: the view shows them once they're done.
25+
decoding: Vec<Image>,
26+
}
27+
28+
impl Images {
29+
pub fn new(ui: rune::AppHandle<App>) -> Images {
30+
Images(Rc::new(RefCell::new(State {
31+
ui,
32+
cache: None,
33+
images: HashMap::new(),
34+
decoding: Vec::new(),
35+
})))
36+
}
37+
38+
/// Rune's waker, from `AppSpec::setup`: there are no pictures before it.
39+
pub fn start(&self, waker: Waker) {
40+
self.0.borrow_mut().cache = Some(ImageCache::new(waker));
41+
}
42+
43+
/// The picture at `url`, once it has arrived; the first ask fetches it.
44+
pub fn get(&self, url: &str) -> Option<Image> {
45+
let mut state = self.0.borrow_mut();
46+
if let Some(image) = state.images.get(url) {
47+
return image.clone();
48+
}
49+
if state.cache.is_none() || !(url.starts_with("https://") || url.starts_with("http://")) {
50+
return None;
51+
}
52+
state.images.insert(url.to_owned(), None);
53+
let (ui, url) = (state.ui.clone(), url.to_owned());
54+
std::thread::spawn(move || {
55+
let bytes = fetch(&url);
56+
ui.update(move |s, _| {
57+
if let Some(bytes) = bytes {
58+
s.images.arrived(url, bytes);
59+
}
60+
});
61+
});
62+
None
63+
}
64+
65+
fn arrived(&self, url: String, bytes: Vec<u8>) {
66+
let mut state = self.0.borrow_mut();
67+
let image = state.cache.as_ref().map(|c| c.load(bytes));
68+
state.decoding.extend(image.clone());
69+
state.images.insert(url, image);
70+
}
71+
72+
/// From `AppSpec::poll`, which the cache's waker runs: whether a picture
73+
/// finished decoding, so the view shows it. Markdown reads a picture
74+
/// only as it builds, and nothing else changes then.
75+
pub fn poll(&self) -> bool {
76+
let mut state = self.0.borrow_mut();
77+
let before = state.decoding.len();
78+
state.decoding.retain(|i| !i.is_ready());
79+
state.decoding.len() != before
80+
}
81+
}
82+
83+
fn fetch(url: &str) -> Option<Vec<u8>> {
84+
let mut response = ureq::get(url).call().ok()?;
85+
response
86+
.body_mut()
87+
.with_config()
88+
.limit(MAX_BYTES)
89+
.read_to_vec()
90+
.ok()
91+
}
92+
93+
/// A GitHub avatar at the size zbugs shows it, as the web app asks for.
94+
pub fn avatar_url(url: &str) -> String {
95+
if url.starts_with("https://avatars.githubusercontent.com/") && !url.contains("size=") {
96+
let sep = if url.contains('?') { '&' } else { '?' };
97+
format!("{url}{sep}size=64")
98+
} else {
99+
url.to_owned()
100+
}
101+
}

‎apps/zbugs-rune/src/main.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! it; their handlers change it and ask the sync thread for other rows or
66
//! to run a mutator.
77
8+
mod images;
89
mod login;
910
mod settings;
1011
mod sync;
@@ -129,6 +130,8 @@ pub enum Sheet {
129130

130131
pub struct App {
131132
ui: rune::AppHandle<App>,
133+
/// Avatars, and images in issues and comments.
134+
pub images: images::Images,
132135
sync: SyncThread<App, Context>,
133136
/// The settings in use, and those saved (without the environment's).
134137
settings: Settings,
@@ -241,6 +244,7 @@ impl App {
241244
|s, state| s.connection = state,
242245
);
243246
let mut app = App {
247+
images: images::Images::new(ui.clone()),
244248
ui,
245249
sync,
246250
auth: None,
@@ -1107,6 +1111,8 @@ fn main() {
11071111
let spec = AppSpec {
11081112
min_size: Some((900.0, 520.0)),
11091113
on_key: Some(on_key),
1114+
setup: Some(|s, waker| s.images.start(waker)),
1115+
poll: Some(|s, _, _| s.images.poll()),
11101116
..AppSpec::new("Zero Bugs", (1180.0, 760.0), placeholder, view::view)
11111117
};
11121118
let app = rune::App::new(spec);

‎apps/zbugs-rune/src/view.rs‎

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use zbugs_model::schema::{Comment, Issue, IssueVisibility, Label, User};
1414
use zero_client::client::ConnectionState;
1515
use zero_client::ivm::ResultType;
1616

17+
use crate::images;
1718
use crate::{App, Confirm, NOTIFICATIONS, Picker, REACTIONS, Sheet, Status, VISIBILITY};
1819

1920
/// The rows' estimated height, for the virtual list.
@@ -47,6 +48,22 @@ pub fn view(s: &App, cx: &mut Cx<App>) -> El {
4748
rune_kit::root(body, layer)
4849
}
4950

51+
/// Markdown with its images, as the web app shows them.
52+
fn markdown(s: &App, body: &str) -> Markdown {
53+
let images = s.images.clone();
54+
Markdown::new(body.to_owned()).images(move |src| images.get(src))
55+
}
56+
57+
/// The user's avatar, a circle `size` across; blank until it arrives.
58+
fn avatar(s: &App, user: Option<&User>, size: f32, k: &Kit) -> El {
59+
let circle = el().w(size).h(size).radius(size / 2.0).a11y_hidden();
60+
let image = user.and_then(|u| s.images.get(&images::avatar_url(&u.avatar)));
61+
match image {
62+
Some(image) => circle.pixels(image.pixels()),
63+
None => circle.fill(k.c.layer_03),
64+
}
65+
}
66+
5067
fn label_text(k: &Kit, t: impl Into<String>) -> El {
5168
text(t).font(k.ty.small_medium).color(k.c.text_faint)
5269
}
@@ -169,15 +186,21 @@ fn sidebar(s: &App, k: &Kit, layer: &mut Vec<El>) -> El {
169186
Some(auth) => col()
170187
.gap(6.0)
171188
.child(
172-
text(format!(
173-
"{} ({})",
174-
s.me.as_ref()
175-
.map_or(auth.sub.as_str(), |u| u.login.as_str()),
176-
auth.role.as_str()
177-
))
178-
.font(k.ty.small)
179-
.color(k.c.text_muted)
180-
.truncate(),
189+
row()
190+
.gap(8.0)
191+
.center_y()
192+
.child(avatar(s, s.me.as_ref(), 22.0, k))
193+
.child(
194+
text(format!(
195+
"{} ({})",
196+
s.me.as_ref()
197+
.map_or(auth.sub.as_str(), |u| u.login.as_str()),
198+
auth.role.as_str()
199+
))
200+
.font(k.ty.small)
201+
.color(k.c.text_muted)
202+
.truncate(),
203+
),
181204
)
182205
.child(
183206
Button::new("Log Out")
@@ -626,9 +649,12 @@ fn detail(s: &App, k: &Kit, layer: &mut Vec<El>) -> El {
626649
);
627650
if let Some(u) = &issue.creator {
628651
status = status.child(
629-
text(format!("by {}", u.login))
630-
.font(k.ty.small)
631-
.color(k.c.text_muted),
652+
row()
653+
.gap(6.0)
654+
.center_y()
655+
.child(text("by").font(k.ty.small).color(k.c.text_muted))
656+
.child(avatar(s, Some(u), 18.0, k))
657+
.child(text(u.login.clone()).font(k.ty.small).color(k.c.text_muted)),
632658
);
633659
}
634660
let editable = s.can_edit(&issue.creator_id);
@@ -738,11 +764,7 @@ fn detail(s: &App, k: &Kit, layer: &mut Vec<El>) -> El {
738764
column = column.child(r);
739765
}
740766
if !issue.description.is_empty() {
741-
column = column.child(
742-
Markdown::new(issue.description.clone())
743-
.build(k)
744-
.key("description"),
745-
);
767+
column = column.child(markdown(s, &issue.description).build(k).key("description"));
746768
}
747769
column = column
748770
.child(reactions(s, &issue.id, k, layer))
@@ -784,6 +806,7 @@ fn comment(s: &App, c: &Comment, k: &Kit, layer: &mut Vec<El>) -> El {
784806
.w_full()
785807
.gap(8.0)
786808
.center_y()
809+
.child(avatar(s, c.creator.as_deref(), 20.0, k))
787810
.child(
788811
text(c.creator.as_ref().map_or("…", |u| u.login.as_str()))
789812
.font(k.ty.small_medium)
@@ -821,7 +844,7 @@ fn comment(s: &App, c: &Comment, k: &Kit, layer: &mut Vec<El>) -> El {
821844
.child(header);
822845
if !editing {
823846
return frame
824-
.child(Markdown::new(c.body.clone()).build(k))
847+
.child(markdown(s, &c.body).build(k))
825848
.child(reactions(s, &c.id, k, layer));
826849
}
827850
frame

0 commit comments

Comments
 (0)