Live arrivals on the sky da8f7826a645
New activity joins its cluster without a reload (docs/backgrounds.md §5 "Live"). GET /sky/arrivals?scene=mine|hub&since=MS answers what happened since, over the same repositories as the sky it feeds: the viewer's member repositories for mine (nothing signed out), the public ones for hub, never a private repository in the hub's sky; private, no-store. The page polls it every 30 s while shown; the engine adds the marks as the newest and draws one ring in the lit colour, widening and fading over 1.2 s (none when still). Tested like the sky's privacy: Andy, Bob and signed out. Moving backdrops now stop 2 minutes after the last input, as §6 says: every scene counted its drift as busy, so none ever stopped. Lighting, links and rings keep a frame going while they change; an arrival wakes it. closes #172
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 00:17 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
da8f7826a645425ddb43d21ea15d154a00d29f50afe0f58684d1546f6ccb1d0c- Depends on
- 0a3b244b748f 17ca6cddc534 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 2f14c5f39e81 5501b1c657b2 6246d1e51f4f 6907a989e82a d23464c0ac60 d7d182467382 df5c69cbd047 eb56c8bc4d0e
- Depended on by
- 8a48dd830178 a7aa76ac52d7 ffb67a81188c
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 42 atoms, 0 tree operations, 14 files
Browse the tree at this change
modified assets/backdrop/backdrop.js +10 −0
| 1 | 1 | /** |
|
| 2 | + | * New activity, as the page polled it (§5, "Live"): `{events: [[repo |
|
| 3 | + | * name, kind, at_ms, n]]}`. Each joins its cluster with an arrival ring; |
|
| 4 | + | * a scene other than the sky ignores it. |
|
| 5 | + | * @param {any} data |
|
| 6 | + | */ |
|
| 7 | + | export function arrive(data) { |
|
| 8 | + | wasm.arrive(data); |
|
| 9 | + | } |
|
| 10 | + | ||
| 11 | + | /** |
|
| 2 | 12 | * Start a backdrop on `canvas` with the page's `data`: the scene it names |
|
| 3 | 13 | * (docs/backgrounds.md §2), the dashboard's sky by default. `still` |
|
| 4 | 14 | * is the viewer's choice of a still backdrop; reduced motion asks for |
|
| … | 643 unchanged lines |
replaced assets/backdrop/backdrop_bg.wasm +0 −0
No line changes.
modified crates/backdrop/src/adrift.rs +1 −1
| … | 95 unchanged lines |
||
| 96 | 96 | None |
|
| 97 | 97 | } |
|
| 98 | 98 | fn busy(&self) -> bool { |
|
| 99 | - | !self.field.still || self.field.easing() |
|
| 99 | + | self.field.easing() |
|
| 100 | 100 | } |
|
| 101 | 101 | fn still(&self) -> bool { |
|
| 102 | 102 | self.field.still |
|
| … | 30 unchanged lines |
modified crates/backdrop/src/lib.rs +30 −0
| … | 50 unchanged lines |
||
| 51 | 51 | ||
| 52 | 52 | type Frame = Rc<RefCell<Option<Closure<dyn FnMut(f64)>>>>; |
|
| 53 | 53 | ||
| 54 | + | thread_local! { |
|
| 55 | + | /// The running backdrop, for `arrive`. |
|
| 56 | + | static RUNNING: RefCell<Option<(Rc<RefCell<Engine>>, Frame)>> = const { RefCell::new(None) }; |
|
| 57 | + | } |
|
| 58 | + | ||
| 54 | 59 | impl Engine { |
|
| 55 | 60 | fn now(&self) -> f64 { |
|
| 56 | 61 | self.window.performance().map(|p| p.now()).unwrap_or(0.0) |
|
| … | 141 unchanged lines |
||
| 198 | 203 | Some(ripples::Node { name: n.get(0).as_string()?, tone: field::Tone::from_name(&n.get(1).as_string().unwrap_or_default()) }) |
|
| 199 | 204 | }) |
|
| 200 | 205 | .collect() |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | /// New activity, as the page polled it (§5, "Live"): `{events: [[repo |
|
| 209 | + | /// name, kind, at_ms, n]]}`. Each joins its cluster with an arrival ring; |
|
| 210 | + | /// a scene other than the sky ignores it. |
|
| 211 | + | #[wasm_bindgen] |
|
| 212 | + | pub fn arrive(data: JsValue) { |
|
| 213 | + | let Some((engine, frame)) = RUNNING.with(|r| r.borrow().clone()) else { |
|
| 214 | + | return; |
|
| 215 | + | }; |
|
| 216 | + | let mut any = false; |
|
| 217 | + | // Oldest first, so the newest ends up newest. |
|
| 218 | + | for v in js_sys::Array::from(&get(&data, "events")).iter().rev() { |
|
| 219 | + | let e = js_sys::Array::from(&v); |
|
| 220 | + | let (Some(repo), Some(kind)) = (e.get(0).as_string(), e.get(1).as_f64()) else { |
|
| 221 | + | continue; |
|
| 222 | + | }; |
|
| 223 | + | let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32; |
|
| 224 | + | engine.borrow_mut().scene.arrive(&repo, Kind::from_code(kind as u32), n); |
|
| 225 | + | any = true; |
|
| 226 | + | } |
|
| 227 | + | if any { |
|
| 228 | + | wake(&engine, &frame); |
|
| 229 | + | } |
|
| 201 | 230 | } |
|
| 202 | 231 | ||
| 203 | 232 | /// The scene the page's data names (`scene`), the sky when it names none. |
|
| … | 145 unchanged lines |
||
| 349 | 378 | shown.forget(); |
|
| 350 | 379 | } |
|
| 351 | 380 | ||
| 381 | + | RUNNING.with(|r| *r.borrow_mut() = Some((engine.clone(), frame.clone()))); |
|
| 352 | 382 | wake(&engine, &frame); |
|
| 353 | 383 | Ok(()) |
|
| 354 | 384 | } |
modified crates/backdrop/src/mark.rs +1 −1
| … | 114 unchanged lines |
||
| 115 | 115 | None |
|
| 116 | 116 | } |
|
| 117 | 117 | fn busy(&self) -> bool { |
|
| 118 | - | !self.field.still || self.field.easing() |
|
| 118 | + | self.field.easing() |
|
| 119 | 119 | } |
|
| 120 | 120 | fn still(&self) -> bool { |
|
| 121 | 121 | self.field.still |
|
| … | 28 unchanged lines |
modified crates/backdrop/src/ripples.rs +1 −1
| … | 189 unchanged lines |
||
| 190 | 190 | self.field.label() |
|
| 191 | 191 | } |
|
| 192 | 192 | fn busy(&self) -> bool { |
|
| 193 | - | !self.field.still || self.field.easing() |
|
| 193 | + | self.field.easing() |
|
| 194 | 194 | } |
|
| 195 | 195 | fn still(&self) -> bool { |
|
| 196 | 196 | self.field.still |
|
| … | 47 unchanged lines |
modified crates/backdrop/src/scene.rs +8 −2
| 1 | 1 | //! A backdrop's scene (docs/backgrounds.md §2): what it draws and how it |
|
| 2 | 2 | //! moves. The engine (lib.rs) drives any of them the same way. |
|
| 3 | 3 | ||
| 4 | - | use crate::sky::Colours; |
|
| 4 | + | use crate::sky::{Colours, Kind}; |
|
| 5 | 5 | ||
| 6 | 6 | pub trait Scene { |
|
| 7 | 7 | /// The view is now `w` by `h` CSS pixels, the page's content column |
|
| … | 9 unchanged lines |
||
| 17 | 17 | fn links(&self, c: &Colours, out: &mut Vec<f32>); |
|
| 18 | 18 | /// The name a resting cursor shows, and where. |
|
| 19 | 19 | fn label(&self) -> Option<(&str, f32, f32)>; |
|
| 20 | - | /// Whether a frame would show anything new. |
|
| 20 | + | /// New activity in repository `repo` (§5, "Live"); only the sky shows it. |
|
| 21 | + | fn arrive(&mut self, _repo: &str, _kind: Kind, _n: u32) {} |
|
| 22 | + | /// Whether a frame would show anything new besides the drift, which |
|
| 23 | + | /// the engine stops drawing a while after the last input (§6). |
|
| 21 | 24 | fn busy(&self) -> bool; |
|
| 22 | 25 | fn still(&self) -> bool; |
|
| 23 | 26 | fn size(&self) -> (f32, f32); |
|
| … | 18 unchanged lines |
||
| 42 | 45 | fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 43 | 46 | crate::sky::Sky::label(self) |
|
| 44 | 47 | } |
|
| 48 | + | fn arrive(&mut self, repo: &str, kind: Kind, n: u32) { |
|
| 49 | + | crate::sky::Sky::arrive(self, repo, kind, n) |
|
| 50 | + | } |
|
| 45 | 51 | fn busy(&self) -> bool { |
|
| 46 | 52 | crate::sky::Sky::busy(self) |
|
| 47 | 53 | } |
|
| … | 7 unchanged lines |
modified crates/backdrop/src/sky.rs +117 −4
| … | 39 unchanged lines |
||
| 40 | 40 | const MIN_MARGIN: f32 = 180.0; |
|
| 41 | 41 | /// The width a cluster gets in a margin's grid. |
|
| 42 | 42 | const CELL: f32 = 260.0; |
|
| 43 | + | /// An arrival's ring (§4): seconds it lasts, and its radius from first to |
|
| 44 | + | /// last, in CSS pixels before the zoom. |
|
| 45 | + | const RING_S: f32 = 1.2; |
|
| 46 | + | const RING_FROM: f32 = 6.0; |
|
| 47 | + | const RING_TO: f32 = 46.0; |
|
| 43 | 48 | ||
| 44 | 49 | /// What a mark stands for, which decides its shape. |
|
| 45 | 50 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| … | 106 unchanged lines |
||
| 152 | 157 | pub bad: Rgba, |
|
| 153 | 158 | } |
|
| 154 | 159 | ||
| 160 | + | /// A widening ring at a cluster's core: something just arrived there. |
|
| 161 | + | #[derive(Clone, Copy, Debug)] |
|
| 162 | + | pub struct Ring { |
|
| 163 | + | pub cluster: usize, |
|
| 164 | + | /// Seconds since it began. |
|
| 165 | + | pub age: f32, |
|
| 166 | + | } |
|
| 167 | + | ||
| 155 | 168 | /// The scene. |
|
| 156 | 169 | pub struct Sky { |
|
| 157 | 170 | pub clusters: Vec<Cluster>, |
|
| … | 13 unchanged lines |
||
| 171 | 184 | avoid: Option<(f32, f32)>, |
|
| 172 | 185 | parallax: [(f32, f32); 3], |
|
| 173 | 186 | link_fade: f32, |
|
| 187 | + | /// Arrivals' rings, while they widen. |
|
| 188 | + | pub rings: Vec<Ring>, |
|
| 189 | + | /// Marks added since the page loaded, for their places in the lanes. |
|
| 190 | + | arrived: u32, |
|
| 174 | 191 | } |
|
| 175 | 192 | ||
| 176 | 193 | /// FNV-1a, then splitmix64's finaliser: a small, stable hash, so a |
|
| … | 74 unchanged lines |
||
| 251 | 268 | lit: 0.0, |
|
| 252 | 269 | }); |
|
| 253 | 270 | } |
|
| 254 | - | let mut sky = |
|
| 255 | - | Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, avoid: None, parallax: [(0.0, 0.0); 3], link_fade: 0.0 }; |
|
| 271 | + | let mut sky = Sky { |
|
| 272 | + | clusters, |
|
| 273 | + | marks, |
|
| 274 | + | w, |
|
| 275 | + | h, |
|
| 276 | + | cursor: None, |
|
| 277 | + | rested: 0.0, |
|
| 278 | + | still, |
|
| 279 | + | avoid: None, |
|
| 280 | + | parallax: [(0.0, 0.0); 3], |
|
| 281 | + | link_fade: 0.0, |
|
| 282 | + | rings: Vec::new(), |
|
| 283 | + | arrived: 0, |
|
| 284 | + | }; |
|
| 256 | 285 | sky.layout(); |
|
| 257 | 286 | sky |
|
| 258 | 287 | } |
|
| … | 65 unchanged lines |
||
| 324 | 353 | pub fn point(&mut self, at: Option<(f32, f32)>) { |
|
| 325 | 354 | self.cursor = at; |
|
| 326 | 355 | self.rested = 0.0; |
|
| 356 | + | } |
|
| 357 | + | ||
| 358 | + | /// New activity in the repository `name` (§5, "Live"): its marks join |
|
| 359 | + | /// its cluster as the newest, and one ring widens from its core. A |
|
| 360 | + | /// repository the sky does not draw is ignored; a still sky adds the |
|
| 361 | + | /// marks without the ring. |
|
| 362 | + | pub fn arrive(&mut self, name: &str, kind: Kind, n: u32) { |
|
| 363 | + | let Some(c) = self.clusters.iter().position(|cl| cl.name == name) else { |
|
| 364 | + | return; |
|
| 365 | + | }; |
|
| 366 | + | let add = if kind == Kind::Push { n.clamp(1, PER_PUSH) } else { 1 }; |
|
| 367 | + | for m in self.marks.iter_mut().filter(|m| m.cluster == c && m.kind != Kind::Repo) { |
|
| 368 | + | m.order += add; |
|
| 369 | + | } |
|
| 370 | + | let have = self.marks.iter().filter(|m| m.cluster == c && m.kind != Kind::Repo).count() as u32; |
|
| 371 | + | let lanes = (6 + (have + add) / 30).min(14); |
|
| 372 | + | let dir = if hash(name) & 1 == 0 { 1.0 } else { -1.0 }; |
|
| 373 | + | for i in 0..add { |
|
| 374 | + | let h = hash(&format!("{name}+{}", self.arrived)); |
|
| 375 | + | self.arrived += 1; |
|
| 376 | + | let radius = (16.0 + ((have + i) % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM; |
|
| 377 | + | let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16)); |
|
| 378 | + | self.marks.push(Mark { cluster: c, kind, order: add - 1 - i, radius, angle: unit(h, 32) * TAU, speed, bright: 1.0, lit: 0.0 }); |
|
| 379 | + | } |
|
| 380 | + | // Over the limit, the cluster's oldest marks make room. |
|
| 381 | + | while self.marks.len() > MAX_MARKS { |
|
| 382 | + | let Some(oldest) = self |
|
| 383 | + | .marks |
|
| 384 | + | .iter() |
|
| 385 | + | .enumerate() |
|
| 386 | + | .filter(|(_, m)| m.cluster == c && m.kind != Kind::Repo) |
|
| 387 | + | .max_by_key(|(_, m)| m.order) |
|
| 388 | + | .map(|(i, _)| i) |
|
| 389 | + | else { |
|
| 390 | + | break; |
|
| 391 | + | }; |
|
| 392 | + | self.marks.swap_remove(oldest); |
|
| 393 | + | } |
|
| 394 | + | if !self.still { |
|
| 395 | + | self.rings.retain(|r| r.cluster != c); |
|
| 396 | + | self.rings.push(Ring { cluster: c, age: 0.0 }); |
|
| 397 | + | } |
|
| 327 | 398 | } |
|
| 328 | 399 | ||
| 329 | 400 | /// Where cluster `c`'s core is now. |
|
| … | 13 unchanged lines |
||
| 343 | 414 | pub fn step(&mut self, dt: f32) { |
|
| 344 | 415 | let dt = dt.clamp(0.0, 0.1); |
|
| 345 | 416 | self.rested += dt; |
|
| 417 | + | for r in &mut self.rings { |
|
| 418 | + | r.age += dt; |
|
| 419 | + | } |
|
| 420 | + | self.rings.retain(|r| r.age < RING_S); |
|
| 346 | 421 | if !self.still { |
|
| 347 | 422 | for m in &mut self.marks { |
|
| 348 | 423 | m.angle = (m.angle + m.speed * dt) % TAU; |
|
| … | 44 unchanged lines |
||
| 393 | 468 | }; |
|
| 394 | 469 | } |
|
| 395 | 470 | ||
| 396 | - | /// Whether anything is still changing: a frame is worth drawing. |
|
| 471 | + | /// Whether anything besides the drift is still changing: lighting, |
|
| 472 | + | /// links or a ring. The engine keeps drawing a moving sky for a while |
|
| 473 | + | /// after the last input anyway (§6), then stops until something is. |
|
| 397 | 474 | pub fn busy(&self) -> bool { |
|
| 398 | - | !self.still || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0) |
|
| 475 | + | !self.rings.is_empty() || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0) |
|
| 399 | 476 | } |
|
| 400 | 477 | ||
| 401 | 478 | /// One instance per mark: centre x, y and radius in CSS pixels, its |
|
| … | 7 unchanged lines |
||
| 409 | 486 | let size = m.kind.size() * ZOOM * (1.0 + 0.35 * m.lit); |
|
| 410 | 487 | out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]); |
|
| 411 | 488 | } |
|
| 489 | + | // Arrivals: a thin ring in the lit colour, widening as it fades. |
|
| 490 | + | for r in &self.rings { |
|
| 491 | + | let t = (r.age / RING_S).clamp(0.0, 1.0); |
|
| 492 | + | let (x, y) = self.core(r.cluster); |
|
| 493 | + | let size = (RING_FROM + (RING_TO - RING_FROM) * (1.0 - (1.0 - t) * (1.0 - t))) * ZOOM; |
|
| 494 | + | let a = c.lit[3] * (1.0 - t) * 0.8; |
|
| 495 | + | out.extend_from_slice(&[x, y, size, Kind::Repo.shape(), c.lit[0] * a, c.lit[1] * a, c.lit[2] * a, a]); |
|
| 496 | + | } |
|
| 412 | 497 | } |
|
| 413 | 498 | ||
| 414 | 499 | /// The links among lit marks: each repository's lit marks in the order |
|
| … | 188 unchanged lines |
||
| 603 | 688 | octants[((m.angle / TAU * 8.0) as usize).min(7)] += 1; |
|
| 604 | 689 | } |
|
| 605 | 690 | assert!(octants.iter().all(|&n| (12..=40).contains(&n)), "{octants:?}"); |
|
| 691 | + | } |
|
| 692 | + | ||
| 693 | + | #[test] |
|
| 694 | + | fn an_arrival_joins_its_cluster_with_one_ring() { |
|
| 695 | + | let mut s = sky(false); |
|
| 696 | + | let before = s.marks.iter().filter(|m| m.cluster == 1).count(); |
|
| 697 | + | s.arrive("andy/oxygen", Kind::Push, 2); |
|
| 698 | + | s.arrive("someone/else", Kind::Issue, 1); |
|
| 699 | + | let marks: Vec<&Mark> = s.marks.iter().filter(|m| m.cluster == 1).collect(); |
|
| 700 | + | assert_eq!(marks.len(), before + 2, "a dot for each change pushed; an unknown repository adds nothing"); |
|
| 701 | + | let newest: Vec<&&Mark> = marks.iter().filter(|m| m.kind == Kind::Push).collect(); |
|
| 702 | + | assert!(newest.iter().all(|m| m.order < 2 && m.bright == 1.0), "the newest, at full brightness"); |
|
| 703 | + | assert!(marks.iter().any(|m| m.kind == Kind::Release && m.order == 2), "older marks move back"); |
|
| 704 | + | assert_eq!(s.rings.len(), 1); |
|
| 705 | + | assert!(s.busy(), "a ring is drawn while it widens"); |
|
| 706 | + | let colours = Colours { lit: [0.0, 0.4, 0.9, 0.7], ..Default::default() }; |
|
| 707 | + | let mut inst = Vec::new(); |
|
| 708 | + | s.instances(&colours, &mut inst); |
|
| 709 | + | assert_eq!(inst.len(), (s.marks.len() + 1) * 8, "the ring is one more instance"); |
|
| 710 | + | for _ in 0..80 { |
|
| 711 | + | s.step(1.0 / 60.0); |
|
| 712 | + | } |
|
| 713 | + | assert!(s.rings.is_empty() && !s.busy(), "gone after 1.2 s, and nothing left to draw but the drift"); |
|
| 714 | + | // A still sky takes the marks without the ring. |
|
| 715 | + | let mut still = sky(true); |
|
| 716 | + | still.arrive("andy/hub", Kind::Release, 1); |
|
| 717 | + | assert!(still.rings.is_empty()); |
|
| 718 | + | assert_eq!(still.marks.iter().filter(|m| m.kind == Kind::Release).count(), 2); |
|
| 606 | 719 | } |
|
| 607 | 720 | ||
| 608 | 721 | #[test] |
|
| … | 8 unchanged lines |
modified crates/backdrop/src/swarm.rs +1 −1
| … | 167 unchanged lines |
||
| 168 | 168 | self.field.label() |
|
| 169 | 169 | } |
|
| 170 | 170 | fn busy(&self) -> bool { |
|
| 171 | - | !self.field.still || self.field.easing() |
|
| 171 | + | self.field.easing() |
|
| 172 | 172 | } |
|
| 173 | 173 | fn still(&self) -> bool { |
|
| 174 | 174 | self.field.still |
|
| … | 30 unchanged lines |
modified crates/hub/src/web/home.rs +60 −4
| … | 73 unchanged lines |
||
| 74 | 74 | /// push of `n` changes, 1 a proposal event, 2 an issue event, 3 a release, |
|
| 75 | 75 | /// 4 a Flow run. Only the database is read, never a repository's store; |
|
| 76 | 76 | /// `<` is escaped so the JSON is safe in a script. |
|
| 77 | - | fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64) -> String { |
|
| 77 | + | fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64, live: &str) -> String { |
|
| 78 | 78 | let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect(); |
|
| 79 | 79 | let evs: Vec<serde_json::Value> = events |
|
| 80 | 80 | .iter() |
|
| … | 2 unchanged lines |
||
| 83 | 83 | Some(serde_json::json!([repo, e.kind, e.at_ms, e.n])) |
|
| 84 | 84 | }) |
|
| 85 | 85 | .collect(); |
|
| 86 | - | serde_json::json!({ "now": now, "repos": slugs, "events": evs }).to_string().replace('<', "\\u003c") |
|
| 86 | + | serde_json::json!({ "now": now, "repos": slugs, "events": evs, "live": live }).to_string().replace('<', "\\u003c") |
|
| 87 | 87 | } |
|
| 88 | 88 | ||
| 89 | + | /// The repositories a sky draws: `mine`, the viewer's own (member) ones, |
|
| 90 | + | /// as the dashboard's; `hub`, the public ones the viewer may see, as |
|
| 91 | + | /// Explore's. Never a private repository in the hub's sky. |
|
| 92 | + | async fn sky_repos(app: &Shared, shell: &Shell, viewer: &Viewer, scene: &str) -> Result<Vec<db::repos::RepoRow>, Error> { |
|
| 93 | + | let db_err = |e| Error::from_db(shell.clone(), e); |
|
| 94 | + | let seen = visible(app, shell, viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?; |
|
| 95 | + | match (scene, viewer.account()) { |
|
| 96 | + | ("mine", Some(account)) => { |
|
| 97 | + | let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?; |
|
| 98 | + | Ok(seen.into_iter().filter(|r| member.contains(&r.id)).collect()) |
|
| 99 | + | } |
|
| 100 | + | ("hub", _) => { |
|
| 101 | + | let mut public: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| r.visibility == "public").collect(); |
|
| 102 | + | public.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); |
|
| 103 | + | public.truncate(SKY_REPOS); |
|
| 104 | + | Ok(public) |
|
| 105 | + | } |
|
| 106 | + | _ => Ok(Vec::new()), |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | #[derive(Deserialize)] |
|
| 111 | + | pub struct ArrivalsQuery { |
|
| 112 | + | #[serde(default)] |
|
| 113 | + | scene: String, |
|
| 114 | + | #[serde(default)] |
|
| 115 | + | since: i64, |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /// `GET /sky/arrivals?scene=mine|hub&since=MS` (docs/backgrounds.md §4, |
|
| 119 | + | /// "Arrivals"): what happened in a sky's repositories since `since`, by |
|
| 120 | + | /// repository name, for the page to add without reloading. The same |
|
| 121 | + | /// repositories as the sky it feeds, never more. |
|
| 122 | + | pub async fn arrivals(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ArrivalsQuery>) -> Result<Response, Error> { |
|
| 123 | + | let shell = Shell::new(&app, "/sky/arrivals", &viewer); |
|
| 124 | + | let repos = sky_repos(&app, &shell, &viewer, &q.scene).await?; |
|
| 125 | + | let now = db::now_ms(); |
|
| 126 | + | // At most a day back: a page left open longer starts again. |
|
| 127 | + | let since = q.since.clamp(now - 86_400_000, now); |
|
| 128 | + | let ids: Vec<i64> = repos.iter().map(|r| r.id).collect(); |
|
| 129 | + | let events = if ids.is_empty() { |
|
| 130 | + | Vec::new() |
|
| 131 | + | } else { |
|
| 132 | + | db::activity::sky(&app.db, &ids, since + 1, 200).await.map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 133 | + | }; |
|
| 134 | + | let evs: Vec<serde_json::Value> = events |
|
| 135 | + | .iter() |
|
| 136 | + | .filter_map(|e| { |
|
| 137 | + | let r = repos.iter().find(|r| r.id == e.repo_id)?; |
|
| 138 | + | Some(serde_json::json!([format!("{}/{}", r.owner, r.name), e.kind, e.at_ms, e.n])) |
|
| 139 | + | }) |
|
| 140 | + | .collect(); |
|
| 141 | + | let body = serde_json::json!({ "now": now, "events": evs }); |
|
| 142 | + | Ok(([(axum::http::header::CACHE_CONTROL, "private, no-store")], axum::Json(body)).into_response()) |
|
| 143 | + | } |
|
| 144 | + | ||
| 89 | 145 | /// `/`: the dashboard when signed in, every visible repository otherwise. |
|
| 90 | 146 | pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> { |
|
| 91 | 147 | let Some(account) = viewer.account().cloned() else { |
|
| … | 14 unchanged lines |
||
| 106 | 162 | let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect(); |
|
| 107 | 163 | let now = db::now_ms(); |
|
| 108 | 164 | let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; |
|
| 109 | - | let backdrop = sky_json(&mine, &sky, now); |
|
| 165 | + | let backdrop = sky_json(&mine, &sky, now, "mine"); |
|
| 110 | 166 | Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response()) |
|
| 111 | 167 | } |
|
| 112 | 168 | ||
| … | 31 unchanged lines |
||
| 144 | 200 | let ids: Vec<i64> = public.iter().map(|r| r.id).collect(); |
|
| 145 | 201 | let now = db::now_ms(); |
|
| 146 | 202 | let sky = db::activity::sky(&app.db, &ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; |
|
| 147 | - | let backdrop = sky_json(&public, &sky, now); |
|
| 203 | + | let backdrop = sky_json(&public, &sky, now, "hub"); |
|
| 148 | 204 | Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars, backdrop })) |
|
| 149 | 205 | } |
|
| 150 | 206 | ||
| … | 179 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 88 unchanged lines |
||
| 89 | 89 | .route("/settings/keys/verify", post(settings::keys_verify)) |
|
| 90 | 90 | .route("/settings/keys/unlink", post(settings::keys_unlink)) |
|
| 91 | 91 | .route("/settings/tokens", get(settings::tokens).post(settings::tokens_create)) |
|
| 92 | + | .route("/sky/arrivals", get(home::arrivals)) |
|
| 92 | 93 | .route("/settings/backups", get(backups::user).post(backups::user_save)) |
|
| 93 | 94 | .route("/settings/backups/run", post(backups::user_run)) |
|
| 94 | 95 | .route("/orgs/{name}/settings/backups", get(backups::org).post(backups::org_save)) |
|
| … | 656 unchanged lines |
modified crates/hub/tests/issues.rs +23 −0
| … | 417 unchanged lines |
||
| 418 | 418 | .unwrap(); |
|
| 419 | 419 | assert_eq!(res.headers()[header::CONTENT_TYPE], "application/wasm"); |
|
| 420 | 420 | assert!(res.into_body().collect().await.unwrap().to_bytes().starts_with(b"\0asm")); |
|
| 421 | + | // Live arrivals (#172): what happened since, by the sky's own rule. |
|
| 422 | + | assert!(sky_of(&page.body).contains(r#""live":"mine""#) && hub_sky.contains(r#""live":"hub""#)); |
|
| 423 | + | let since = db::now_ms() - 3_600_000; |
|
| 424 | + | let arrivals = |res: Res| -> Vec<String> { |
|
| 425 | + | assert_eq!(res.status, StatusCode::OK, "{}", res.body); |
|
| 426 | + | let v: serde_json::Value = serde_json::from_str(&res.body).unwrap(); |
|
| 427 | + | v["events"].as_array().unwrap().iter().map(|e| e[0].as_str().unwrap().to_string()).collect() |
|
| 428 | + | }; |
|
| 429 | + | let mine = arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&andy)).await); |
|
| 430 | + | assert!(mine.iter().any(|r| r == "andy/demo") && mine.iter().any(|r| r == "andy/secret"), "{mine:?}"); |
|
| 431 | + | let hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), Some(&andy)).await); |
|
| 432 | + | assert!(hub.iter().any(|r| r == "andy/demo") && !hub.iter().any(|r| r == "andy/secret"), "the hub's sky is public only: {hub:?}"); |
|
| 433 | + | let anon_hub = arrivals(get(app, &format!("/sky/arrivals?scene=hub&since={since}"), None).await); |
|
| 434 | + | assert!(!anon_hub.iter().any(|r| r == "andy/secret"), "{anon_hub:?}"); |
|
| 435 | + | assert!(arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), None).await).is_empty(), "signed out has no `mine`"); |
|
| 436 | + | assert!( |
|
| 437 | + | arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={since}"), Some(&bob)).await).is_empty(), |
|
| 438 | + | "Bob's sky has none of Andy's" |
|
| 439 | + | ); |
|
| 440 | + | assert!( |
|
| 441 | + | arrivals(get(app, &format!("/sky/arrivals?scene=mine&since={}", db::now_ms()), Some(&andy)).await).is_empty(), |
|
| 442 | + | "nothing since now" |
|
| 443 | + | ); |
|
| 421 | 444 | ||
| 422 | 445 | // Once he reviews one, it leaves his queue. |
|
| 423 | 446 | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| … | 607 unchanged lines |
modified docs/backgrounds.md +14 −4
| … | 139 unchanged lines |
||
| 140 | 140 | - A busy repository gets more orbit lanes (6 to 14), so its marks spread |
|
| 141 | 141 | out instead of piling up. |
|
| 142 | 142 | ||
| 143 | - | **Live.** When the page learns of new activity (it already polls for the |
|
| 144 | - | notification count), the event joins its cluster with an arrival ring |
|
| 145 | - | (§4). Nothing reloads. |
|
| 143 | + | **Live.** Built (#172). The page's data names its sky (`"live": "mine"` |
|
| 144 | + | on the dashboard, `"hub"` on Explore and the signed-out home), and every |
|
| 145 | + | 30 s while the page is shown it asks `GET /sky/arrivals?scene=…&since=MS` |
|
| 146 | + | what happened since (at most a day back). The answer is the same query |
|
| 147 | + | over the same repositories as the sky it feeds: the viewer's member |
|
| 148 | + | repositories for `mine` (nothing signed out), the public ones for `hub`, |
|
| 149 | + | never a private repository in the hub's sky; it is `private, no-store`. |
|
| 150 | + | Each event joins its cluster as the newest marks with an arrival ring |
|
| 151 | + | (§4), a thin ring in the lit colour widening from 6 to 46 px (times the |
|
| 152 | + | zoom) and fading over 1.2 s; a repository the sky does not draw is |
|
| 153 | + | ignored, and a still sky adds the marks without the ring. Nothing |
|
| 154 | + | reloads. |
|
| 146 | 155 | ||
| 147 | 156 | ## 5.1 The other scenes |
|
| 148 | 157 | ||
| … | 50 unchanged lines |
||
| 199 | 208 | - **Frame rate.** It runs at 60 fps while the cursor moves, eases to |
|
| 200 | 209 | 30 fps when the cursor is still, and pauses on `visibilitychange`. |
|
| 201 | 210 | After 2 minutes without input it draws one last frame and stops until |
|
| 202 | - | the next movement. |
|
| 211 | + | the next movement or arrival (a scene's drift alone never keeps it |
|
| 212 | + | running; lighting, links and rings do, while they change). |
|
| 203 | 213 | - **Reduced motion.** With `prefers-reduced-motion: reduce` it draws one |
|
| 204 | 214 | still frame with no drift, parallax or arrival rings; lit marks still |
|
| 205 | 215 | respond, instantly. |
|
| … | 36 unchanged lines |
modified templates/backdrop.html +21 −2
| … | 1 unchanged lines |
||
| 2 | 2 | `#backdrop-data` names (written just before this include) drawn by the |
|
| 3 | 3 | engine, Rust compiled to WebAssembly. The viewer's choice (Settings › Appearance) is a cookie; |
|
| 4 | 4 | unset, a touch-only device or Save-Data gets a still sky. Any failure |
|
| 5 | - | leaves no backdrop and nothing else changed. #} |
|
| 5 | + | leaves no backdrop and nothing else changed. A sky with `live` polls |
|
| 6 | + | `/sky/arrivals` for what happened since and adds it. #} |
|
| 6 | 7 | <canvas id="backdrop" aria-hidden="true"></canvas> |
|
| 7 | 8 | <script type="module"> |
|
| 8 | 9 | const canvas = document.getElementById("backdrop"); |
|
| … | 6 unchanged lines |
||
| 15 | 16 | try { |
|
| 16 | 17 | const bd = await import("/static/backdrop/backdrop.js?v={{ shell.assets() }}"); |
|
| 17 | 18 | await bd.default({ module_or_path: "/static/backdrop/backdrop_bg.wasm?v={{ shell.assets() }}" }); |
|
| 18 | - | bd.start(canvas, JSON.parse(document.getElementById("backdrop-data").textContent), pick === "still"); |
|
| 19 | + | const data = JSON.parse(document.getElementById("backdrop-data").textContent); |
|
| 20 | + | bd.start(canvas, data, pick === "still"); |
|
| 21 | + | // Live (§5): a sky that names its repositories (`mine` or `hub`) asks |
|
| 22 | + | // every 30 s, while the page is shown, what happened since. |
|
| 23 | + | if (data.live) { |
|
| 24 | + | let since = data.now; |
|
| 25 | + | const poll = async () => { |
|
| 26 | + | if (document.hidden) return; |
|
| 27 | + | try { |
|
| 28 | + | const r = await fetch(`/sky/arrivals?scene=${data.live}&since=${since}`, { headers: { accept: "application/json" } }); |
|
| 29 | + | if (!r.ok) return; |
|
| 30 | + | const got = await r.json(); |
|
| 31 | + | since = got.now; |
|
| 32 | + | if (got.events.length) bd.arrive(got); |
|
| 33 | + | } catch (e) {} |
|
| 34 | + | }; |
|
| 35 | + | setInterval(poll, 30000); |
|
| 36 | + | document.addEventListener("visibilitychange", poll); |
|
| 37 | + | } |
|
| 19 | 38 | } catch (e) { |
|
| 20 | 39 | canvas.remove(); |
|
| 21 | 40 | } |
|
| … | 2 unchanged lines |