karma-web: online status, user counts, join links, rising/chart/most-debated/milestones/reasons, filter+sort, expandable history, OG tags, JSON API
All checks were successful
ci / check (push) Successful in 46s

This commit is contained in:
Jean Chevronnet 2026-07-30 01:27:39 +00:00
parent 26496b5147
commit 6af92c1217
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 714 additions and 101 deletions

View file

@ -79,17 +79,24 @@ lists them at runtime and `help <command>` describes one.
## Karma web page ## Karma web page
The live karma board is published at **<https://karma.devtronic.pro>** — a hero The live karma board is published at **<https://karma.devtronic.pro>**. It has a
with live stats, a top-3 podium, a ranked ledger with per-thing trend hero with live stats, a top-3 podium, a ranked ledger (filter + sort, click a
sparklines, a recent-activity feed, and a top-givers board. Each network's board row to expand its full history), per-thing trend sparklines, a "rising this
shows where its karma comes from — server, channels, and each channel's IRC week" strip, a karma-over-time chart, a recent-activity feed, top givers, most
topic (e.g. `#argentina`). A second binary, `karma-web`, reads the per-network debated, and a milestones timeline. Each network's board shows where its karma
`karma.*.json` totals, the append-only `karma.*.events.jsonl` history the bot comes from — an online/offline dot, server, joinable channel links, live user
writes on every bump, and a `karma.*.meta.json` source sidecar (server + counts, and each channel's IRC topic (URLs linkified). The page carries Open
channels + topics, which the bot refreshes as it joins and as topics change), Graph tags for rich link previews, and the same data is exposed as JSON at
and renders one self-contained HTML page (no CSS/JS/font dependencies, `/karma.json`.
all IRC-supplied text HTML-escaped). A systemd timer regenerates it every minute
and nginx serves it over TLS. A second binary, `karma-web`, reads the per-network `karma.*.json` totals, the
append-only `karma.*.events.jsonl` history the bot writes on every bump, and a
`karma.*.meta.json` source sidecar (server, webchat base, a liveness heartbeat,
and per-channel topic + user count, which the bot refreshes as it joins, on
topic/NAMES changes, and on each server PING). It renders one self-contained
HTML page (no CSS/JS/font dependencies, all IRC-supplied text HTML-escaped) plus
`karma.json`. A systemd timer regenerates them every minute and nginx serves
over TLS.
```sh ```sh
cargo build --release --bin karma-web cargo build --release --bin karma-web
@ -127,7 +134,9 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
`channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default `channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default
on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and
`locale` (`en`/`es`/`fr`, default `en`), and `karma_url` (public web board — `locale` (`en`/`es`/`fr`, default `en`), `webchat` (per-network webchat base
for the board's channel "join" links, e.g. `https://web.libera.chat/`), and
`karma_url` (public web board —
enables the `karmaweb` command and a link on the `karma`/`karmabottom` top enables the `karmaweb` command and a link on the `karma`/`karmabottom` top
lists; empty disables both). Whole-line `#`/`;` comments only — lists; empty disables both). Whole-line `#`/`;` comments only —
inline comments would clash with channel `#`s. inline comments would clash with channel `#`s.

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use rustbot::i18n::Locale; use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Board}; use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Board};
fn main() { fn main() {
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
@ -40,9 +40,11 @@ fn main() {
} }
let meta = p.with_file_name(format!("karma.{net}.meta.json")); let meta = p.with_file_name(format!("karma.{net}.meta.json"));
if let Ok(mbody) = fs::read_to_string(&meta) { if let Ok(mbody) = fs::read_to_string(&meta) {
let (server, channels) = parse_meta(&mbody); let m = parse_meta(&mbody);
board.server = server; board.server = m.server;
board.channels = channels; board.webchat = m.webchat;
board.seen = m.seen;
board.channels = m.channels;
} }
boards.push(board); boards.push(board);
} }
@ -58,12 +60,22 @@ fn main() {
match out { match out {
Some(path) => { Some(path) => {
let tmp = format!("{path}.tmp"); if let Err(e) = write_atomic(&path, &html) {
if let Err(e) = fs::write(&tmp, &html).and_then(|_| fs::rename(&tmp, &path)) {
eprintln!("karma-web: could not write {path}: {e}"); eprintln!("karma-web: could not write {path}: {e}");
std::process::exit(1); std::process::exit(1);
} }
// emit the JSON API next to the page
if let Some(dir) = PathBuf::from(&path).parent() {
let json_path = dir.join("karma.json").to_string_lossy().into_owned();
let _ = write_atomic(&json_path, &render_json(&boards, epoch));
}
} }
None => print!("{html}"), None => print!("{html}"),
} }
} }
fn write_atomic(path: &str, body: &str) -> std::io::Result<()> {
let tmp = format!("{path}.tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, path)
}

View file

@ -23,6 +23,8 @@ pub struct Bot {
modules: Vec<Box<dyn Module>>, modules: Vec<Box<dyn Module>>,
routes: HashMap<&'static str, usize>, routes: HashMap<&'static str, usize>,
topics: HashMap<String, String>, topics: HashMap<String, String>,
users: HashMap<String, u32>,
names_building: HashMap<String, u32>,
} }
impl Bot { impl Bot {
@ -59,28 +61,44 @@ impl Bot {
modules: mods, modules: mods,
routes, routes,
topics: HashMap::new(), topics: HashMap::new(),
users: HashMap::new(),
names_building: HashMap::new(),
}; };
bot.write_source_meta(); bot.write_source_meta();
bot bot
} }
/// Rewrite the per-network source sidecar the web board reads: server, /// Rewrite the per-network source sidecar the web board reads: server,
/// the configured channels, and whatever topics we've learned so far. /// webchat base, a liveness timestamp, and per-channel topic + user count.
fn write_source_meta(&self) { fn write_source_meta(&self) {
let channels: Vec<(String, Option<String>)> = self let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let channels: Vec<String> = self
.config .config
.channels .channels
.iter() .iter()
.map(|c| { .map(|c| {
let topic = self let key = c.to_lowercase();
.topics let mut obj = format!("{{\"name\":\"{}\"", crate::json::escape(c));
.get(&c.to_lowercase()) if let Some(t) = self.topics.get(&key).filter(|t| !t.is_empty()) {
.filter(|t| !t.is_empty()) obj.push_str(&format!(",\"topic\":\"{}\"", crate::json::escape(t)));
.cloned(); }
(c.clone(), topic) if let Some(u) = self.users.get(&key) {
obj.push_str(&format!(",\"users\":{u}"));
}
obj.push('}');
obj
}) })
.collect(); .collect();
modules::karma::write_meta(&self.config.name, &self.config.server, &channels); let body = format!(
"{{\"server\":\"{}\",\"webchat\":\"{}\",\"seen\":{now},\"channels\":[{}]}}\n",
crate::json::escape(&self.config.server),
crate::json::escape(&self.config.webchat),
channels.join(",")
);
modules::karma::write_meta(&self.config.name, &body);
} }
fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) { fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) {
@ -117,6 +135,8 @@ impl Bot {
"PING" => { "PING" => {
let token = msg.trailing().unwrap_or("").to_string(); let token = msg.trailing().unwrap_or("").to_string();
self.conn.pong(&token)?; self.conn.pong(&token)?;
// heartbeat: refresh the sidecar's liveness timestamp
self.write_source_meta();
} }
"CAP" => self.handle_cap(msg)?, "CAP" => self.handle_cap(msg)?,
"AUTHENTICATE" => self.handle_authenticate(msg)?, "AUTHENTICATE" => self.handle_authenticate(msg)?,
@ -140,6 +160,22 @@ impl Bot {
"331" => self.set_topic(msg.param(1), Some("")), "331" => self.set_topic(msg.param(1), Some("")),
// live topic change: :who TOPIC <channel> :<topic> // live topic change: :who TOPIC <channel> :<topic>
"TOPIC" => self.set_topic(msg.param(0), msg.trailing()), "TOPIC" => self.set_topic(msg.param(0), msg.trailing()),
// RPL_NAMREPLY: <nick> <sym> <channel> :n1 n2 n3 (may repeat)
"353" => {
if let (Some(chan), Some(names)) = (msg.param(2), msg.trailing()) {
let n = names.split_whitespace().count() as u32;
*self.names_building.entry(chan.to_lowercase()).or_insert(0) += n;
}
}
// RPL_ENDOFNAMES: finalize the count for this channel
"366" => {
if let Some(chan) = msg.param(1) {
let key = chan.to_lowercase();
let count = self.names_building.remove(&key).unwrap_or(0);
self.users.insert(key, count);
self.write_source_meta();
}
}
"PRIVMSG" => self.handle_privmsg(msg)?, "PRIVMSG" => self.handle_privmsg(msg)?,
_ => {} _ => {}
} }

View file

@ -438,23 +438,9 @@ fn data_path(network: &str, ext: &str) -> PathBuf {
/// Record where a network's karma comes from (server + channels) so the web /// Record where a network's karma comes from (server + channels) so the web
/// page can show its source. Best-effort — a failure just omits the line. /// page can show its source. Best-effort — a failure just omits the line.
pub fn write_meta(network: &str, server: &str, channels: &[(String, Option<String>)]) { /// Atomically write the per-network source sidecar the web board reads. The
let chans = channels /// caller (`Bot`) owns the JSON schema (server, webchat, seen, channels...).
.iter() pub fn write_meta(network: &str, body: &str) {
.map(|(name, topic)| match topic {
Some(t) => format!(
"{{\"name\":\"{}\",\"topic\":\"{}\"}}",
json::escape(name),
json::escape(t)
),
None => format!("{{\"name\":\"{}\"}}", json::escape(name)),
})
.collect::<Vec<_>>()
.join(",");
let body = format!(
"{{\"server\":\"{}\",\"channels\":[{chans}]}}\n",
json::escape(server)
);
let path = data_path(network, "meta.json"); let path = data_path(network, "meta.json");
let mut tmp = path.clone().into_os_string(); let mut tmp = path.clone().into_os_string();
tmp.push(".tmp"); tmp.push(".tmp");

View file

@ -20,6 +20,7 @@ pub struct Config {
pub sasl_pass: Option<String>, pub sasl_pass: Option<String>,
pub locale: String, pub locale: String,
pub karma_url: String, pub karma_url: String,
pub webchat: String,
} }
impl Default for Config { impl Default for Config {
@ -40,6 +41,7 @@ impl Default for Config {
sasl_pass: None, sasl_pass: None,
locale: "en".to_string(), locale: "en".to_string(),
karma_url: String::new(), karma_url: String::new(),
webchat: String::new(),
} }
} }
} }
@ -166,6 +168,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
"locale" | "lang" => c.locale = value.to_string(), "locale" | "lang" => c.locale = value.to_string(),
"karma_url" | "web_url" => c.karma_url = value.to_string(), "karma_url" | "web_url" => c.karma_url = value.to_string(),
"webchat" => c.webchat = value.to_string(),
other => log(&format!("{where_}: unknown key '{other}'")), other => log(&format!("{where_}: unknown key '{other}'")),
} }
} }
@ -229,6 +232,9 @@ fn apply_env(c: &mut Config) {
if let Ok(v) = std::env::var("IRC_KARMA_URL") { if let Ok(v) = std::env::var("IRC_KARMA_URL") {
c.karma_url = v; c.karma_url = v;
} }
if let Ok(v) = std::env::var("IRC_WEBCHAT") {
c.webchat = v;
}
} }
fn parse_bool(value: &str) -> bool { fn parse_bool(value: &str) -> bool {

View file

@ -21,6 +21,14 @@ pub struct Event {
pub struct Channel { pub struct Channel {
pub name: String, pub name: String,
pub topic: Option<String>, pub topic: Option<String>,
pub users: Option<u32>,
}
pub struct Meta {
pub server: String,
pub webchat: String,
pub seen: u64,
pub channels: Vec<Channel>,
} }
pub struct Board { pub struct Board {
@ -28,6 +36,8 @@ pub struct Board {
pub rows: Vec<Row>, pub rows: Vec<Row>,
pub events: Vec<Event>, pub events: Vec<Event>,
pub server: String, pub server: String,
pub webchat: String,
pub seen: u64,
pub channels: Vec<Channel>, pub channels: Vec<Channel>,
} }
@ -54,6 +64,8 @@ pub fn parse_board(network: &str, body: &str) -> Board {
rows, rows,
events: Vec::new(), events: Vec::new(),
server: String::new(), server: String::new(),
webchat: String::new(),
seen: 0,
channels: Vec::new(), channels: Vec::new(),
} }
} }
@ -61,15 +73,17 @@ pub fn parse_board(network: &str, body: &str) -> Board {
/// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this /// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this
/// network's karma comes from. Channels may be bare strings (old format) or /// network's karma comes from. Channels may be bare strings (old format) or
/// `{"name","topic"}` objects. /// `{"name","topic"}` objects.
pub fn parse_meta(body: &str) -> (String, Vec<Channel>) { pub fn parse_meta(body: &str) -> Meta {
let Some(j) = Json::parse(body) else { let empty = || Meta {
return (String::new(), Vec::new()); server: String::new(),
webchat: String::new(),
seen: 0,
channels: Vec::new(),
}; };
let server = j let Some(j) = Json::parse(body) else {
.get("server") return empty();
.and_then(Json::as_str) };
.unwrap_or("") let string = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or("").to_string();
.to_string();
let channels = match j.get("channels") { let channels = match j.get("channels") {
Some(Json::Arr(items)) => items Some(Json::Arr(items)) => items
.iter() .iter()
@ -78,6 +92,7 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
return (!name.is_empty()).then(|| Channel { return (!name.is_empty()).then(|| Channel {
name: name.to_string(), name: name.to_string(),
topic: None, topic: None,
users: None,
}); });
} }
let name = it.get("name").and_then(Json::as_str)?; let name = it.get("name").and_then(Json::as_str)?;
@ -89,15 +104,22 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
.and_then(Json::as_str) .and_then(Json::as_str)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(str::to_string); .map(str::to_string);
let users = it.get("users").and_then(Json::as_f64).map(|n| n as u32);
Some(Channel { Some(Channel {
name: name.to_string(), name: name.to_string(),
topic, topic,
users,
}) })
}) })
.collect(), .collect(),
_ => Vec::new(), _ => Vec::new(),
}; };
(server, channels) Meta {
server: string("server"),
webchat: string("webchat"),
seen: j.get("seen").and_then(Json::as_f64).unwrap_or(0.0) as u64,
channels,
}
} }
pub fn parse_events(body: &str) -> Vec<Event> { pub fn parse_events(body: &str) -> Vec<Event> {
@ -328,6 +350,20 @@ struct Lex {
give: &'static str, give: &'static str,
built: &'static str, built: &'static str,
updated: &'static str, updated: &'static str,
online: &'static str,
offline: &'static str,
rising: &'static str,
over_time: &'static str,
most_debated: &'static str,
milestones: &'static str,
reasons_recent: &'static str,
history: &'static str,
reached: &'static str,
bumps: &'static str,
filter_ph: &'static str,
sort_karma: &'static str,
sort_name: &'static str,
sort_active: &'static str,
} }
fn lex(l: Locale) -> Lex { fn lex(l: Locale) -> Lex {
@ -353,6 +389,20 @@ fn lex(l: Locale) -> Lex {
give: "give karma by saying <code>nick++</code> in the channel", give: "give karma by saying <code>nick++</code> in the channel",
built: "built by", built: "built by",
updated: "updated", updated: "updated",
online: "online",
offline: "offline",
rising: "rising this week",
over_time: "karma over time",
most_debated: "most debated",
milestones: "milestones",
reasons_recent: "recent reasons",
history: "history",
reached: "reached",
bumps: "bumps",
filter_ph: "filter…",
sort_karma: "karma",
sort_name: "name",
sort_active: "active",
}, },
Locale::Es => Lex { Locale::Es => Lex {
tagline: "reputación ganada de a un <code>nick++</code>, en todos los canales.", tagline: "reputación ganada de a un <code>nick++</code>, en todos los canales.",
@ -375,6 +425,20 @@ fn lex(l: Locale) -> Lex {
give: "da karma escribiendo <code>nick++</code> en el canal", give: "da karma escribiendo <code>nick++</code> en el canal",
built: "hecho con", built: "hecho con",
updated: "actualizado", updated: "actualizado",
online: "en línea",
offline: "desconectado",
rising: "en alza esta semana",
over_time: "karma en el tiempo",
most_debated: "más debatidos",
milestones: "hitos",
reasons_recent: "razones recientes",
history: "historial",
reached: "llegó a",
bumps: "cambios",
filter_ph: "filtrar…",
sort_karma: "karma",
sort_name: "nombre",
sort_active: "activos",
}, },
Locale::Fr => Lex { Locale::Fr => Lex {
tagline: "la réputation gagnée un <code>nick++</code> à la fois, sur les canaux.", tagline: "la réputation gagnée un <code>nick++</code> à la fois, sur les canaux.",
@ -397,6 +461,20 @@ fn lex(l: Locale) -> Lex {
give: "donne du karma en disant <code>nick++</code> dans le canal", give: "donne du karma en disant <code>nick++</code> dans le canal",
built: "fait avec", built: "fait avec",
updated: "mis à jour", updated: "mis à jour",
online: "en ligne",
offline: "hors ligne",
rising: "en hausse cette semaine",
over_time: "karma dans le temps",
most_debated: "les plus débattus",
milestones: "jalons",
reasons_recent: "raisons récentes",
history: "historique",
reached: "a atteint",
bumps: "changements",
filter_ph: "filtrer…",
sort_karma: "karma",
sort_name: "nom",
sort_active: "actifs",
}, },
} }
} }
@ -417,7 +495,10 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
.collect(); .collect();
let gifts = feed.len(); let gifts = feed.len();
let mut s = String::from(HEAD); let now = updated_epoch;
let mut s = HEAD
.replace("__LANG__", locale.code())
.replace("__OG__", &og_tags(boards, &lx));
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1; let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>"); s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
@ -443,20 +524,102 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
continue; continue;
} }
s.push_str("<section class=\"board fade\">"); s.push_str("<section class=\"board fade\">");
s.push_str(&source_line(b, &lx)); s.push_str(&source_line(b, now, &lx));
s.push_str(&podium(b)); s.push_str(&podium(b, &lx));
s.push_str(&ledger(b, &lx)); s.push_str(&ledger(b, &lx));
s.push_str("</section>"); s.push_str("</section>");
} }
s.push_str(&rising(&feed, now, &lx));
s.push_str(&over_time(&feed, now, &lx));
s.push_str("<div class=\"band\">"); s.push_str("<div class=\"band\">");
s.push_str(&activity(&feed, multi, &lx)); s.push_str(&activity(&feed, multi, &lx));
s.push_str(&givers_panel(&feed, &lx)); s.push_str(&givers_panel(&feed, &lx));
s.push_str("</div>"); s.push_str("</div>");
s.push_str("<div class=\"band3\">");
s.push_str(&most_debated(&feed, &lx));
s.push_str(&milestones(&feed, &lx));
s.push_str(&reasons_wall(&feed, &lx));
s.push_str("</div>");
finish(s, updated_epoch, locale, &lx) finish(s, updated_epoch, locale, &lx)
} }
/// A machine-readable snapshot of the board(s) — served alongside the page as
/// `karma.json` so other tools/bots can read the data.
pub fn render_json(boards: &[Board], updated_epoch: u64) -> String {
let nets: Vec<String> = boards
.iter()
.map(|b| {
let channels: Vec<String> = b
.channels
.iter()
.map(|c| {
let mut o = format!("{{\"name\":\"{}\"", json::escape(&c.name));
if let Some(t) = &c.topic {
o.push_str(&format!(",\"topic\":\"{}\"", json::escape(t)));
}
if let Some(u) = c.users {
o.push_str(&format!(",\"users\":{u}"));
}
o.push('}');
o
})
.collect();
let things: Vec<String> = b
.rows
.iter()
.map(|r| {
let mut o = format!(
"{{\"thing\":\"{}\",\"karma\":{}",
json::escape(&r.thing),
r.count
);
if let Some(w) = &r.reason {
o.push_str(&format!(",\"reason\":\"{}\"", json::escape(w)));
}
if let Some(by) = &r.by {
o.push_str(&format!(",\"by\":\"{}\"", json::escape(by)));
}
o.push('}');
o
})
.collect();
format!(
"{{\"network\":\"{}\",\"server\":\"{}\",\"channels\":[{}],\"things\":[{}]}}",
json::escape(&b.network),
json::escape(&b.server),
channels.join(","),
things.join(",")
)
})
.collect();
format!(
"{{\"generated\":{updated_epoch},\"networks\":[{}]}}\n",
nets.join(",")
)
}
fn og_tags(boards: &[Board], lx: &Lex) -> String {
let things: usize = boards.iter().map(|b| b.rows.len()).sum();
let top = boards
.iter()
.flat_map(|b| &b.rows)
.max_by_key(|r| r.count)
.map(|r| format!(" · {} {}", esc(&r.thing), signed(r.count)))
.unwrap_or_default();
let desc = format!("{things} {}{top}", lx.s_tracked);
format!(
"<meta property=\"og:type\" content=\"website\">\
<meta property=\"og:title\" content=\"karma · devtronic\">\
<meta property=\"og:description\" content=\"{desc}\">\
<meta name=\"twitter:card\" content=\"summary\">\
<meta name=\"description\" content=\"{desc}\">"
)
}
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String { fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
s.push_str(&footer(epoch, lx)); s.push_str(&footer(epoch, lx));
s.push_str("<script>var C="); s.push_str("<script>var C=");
@ -469,13 +632,29 @@ fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
} }
/// "from #argentina · irc.libera.chat" — where this board's karma comes from. /// "from #argentina · irc.libera.chat" — where this board's karma comes from.
fn source_line(b: &Board, lx: &Lex) -> String { fn source_line(b: &Board, now: u64, lx: &Lex) -> String {
let online = b.seen > 0 && now.saturating_sub(b.seen) < 300;
let dot = format!(
"<span class=\"dot {}\" title=\"{}\"></span>",
if online { "on" } else { "off" },
if online { lx.online } else { lx.offline }
);
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
if !b.channels.is_empty() { if !b.channels.is_empty() {
let chans = b let chans = b
.channels .channels
.iter() .iter()
.map(|c| esc(&c.name)) .map(|c| {
let users = match c.users {
Some(u) => format!(" <span class=\"users\">{u} {}</span>", lx.online),
None => String::new(),
};
format!(
"<a class=\"chan\" href=\"{}\" rel=\"noopener\" target=\"_blank\">{}</a>{users}",
join_url(b, &c.name),
esc(&c.name)
)
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
parts.push(format!("<span class=\"chans\">{chans}</span>")); parts.push(format!("<span class=\"chans\">{chans}</span>"));
@ -487,7 +666,7 @@ fn source_line(b: &Board, lx: &Lex) -> String {
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network))); parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
} }
let mut out = format!( let mut out = format!(
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>", "<div class=\"source\">{dot}<span class=\"lbl\">{}</span>{}</div>",
lx.from, lx.from,
parts.join("<span class=\"sep\">·</span>") parts.join("<span class=\"sep\">·</span>")
); );
@ -495,6 +674,19 @@ fn source_line(b: &Board, lx: &Lex) -> String {
out out
} }
/// A clickable join target for a channel: the configured webchat, else an
/// `ircs://` URL. Returned already escaped for use in an `href`.
fn join_url(b: &Board, channel: &str) -> String {
let raw = if !b.webchat.is_empty() {
format!("{}{}", b.webchat, channel)
} else if !b.server.is_empty() {
format!("ircs://{}/{}", b.server, channel.replace('#', "%23"))
} else {
String::new()
};
esc(&raw)
}
fn topics_block(b: &Board) -> String { fn topics_block(b: &Board) -> String {
let multi = b.channels.len() > 1; let multi = b.channels.len() > 1;
let items: Vec<String> = b let items: Vec<String> = b
@ -544,7 +736,52 @@ fn stat(num: &str, label: &str) -> String {
) )
} }
fn podium(b: &Board) -> String { fn hist_html(thing: &str, events: &[Event], lx: &Lex) -> String {
let key = thing.to_lowercase();
let mut evs: Vec<&Event> = events
.iter()
.filter(|e| e.thing.to_lowercase() == key)
.collect();
if evs.is_empty() {
return String::new();
}
evs.sort_by_key(|e| std::cmp::Reverse(e.t));
let items: String = evs
.iter()
.take(20)
.map(|e| {
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
let chip = if e.delta >= 0 { "++" } else { "--" };
let why = match &e.why {
Some(w) => format!(" <span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
None => String::new(),
};
format!(
"<li class=\"hev\"><span class=\"chip {kl}\">{chip}</span> <b>{}</b>{why} \
<time class=\"ago\" data-t=\"{}\"></time></li>",
esc(&by),
e.t,
kl = klass(e.delta),
)
})
.collect();
format!("<ol class=\"hist\" hidden>{items}</ol>")
}
/// `(class-suffix, title-attr)` marking an element expandable when it has a
/// non-empty history block.
fn expand_attrs(hist: &str, lx: &Lex) -> (String, String) {
if hist.is_empty() {
(String::new(), String::new())
} else {
(
" expand".to_string(),
format!(" title=\"{}\"", esc(lx.history)),
)
}
}
fn podium(b: &Board, lx: &Lex) -> String {
let top = b.rows.iter().take(3).collect::<Vec<_>>(); let top = b.rows.iter().take(3).collect::<Vec<_>>();
let mut out = String::from("<ol class=\"podium\">"); let mut out = String::from("<ol class=\"podium\">");
for (i, r) in top.iter().enumerate() { for (i, r) in top.iter().enumerate() {
@ -565,9 +802,11 @@ fn podium(b: &Board) -> String {
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)), (Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
_ => String::new(), _ => String::new(),
}; };
let hist = hist_html(&r.thing, &b.events, lx);
let (expand, title) = expand_attrs(&hist, lx);
out.push_str(&format!( out.push_str(&format!(
"<li class=\"pillar r{rank}\"><div class=\"crown\">{crown}</div>{}\ "<li class=\"pillar r{rank}{expand}\"{title}><div class=\"crown\">{crown}</div>{}\
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}</li>", <div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}{hist}</li>",
avatar(&r.thing, "lg"), avatar(&r.thing, "lg"),
esc(&r.thing), esc(&r.thing),
klass(r.count), klass(r.count),
@ -582,8 +821,19 @@ fn ledger(b: &Board, lx: &Lex) -> String {
if b.rows.len() <= 3 { if b.rows.len() <= 3 {
return String::new(); return String::new();
} }
let controls = format!(
"<div class=\"controls\">\
<input id=\"q\" class=\"filter\" type=\"search\" placeholder=\"{ph}\" autocomplete=\"off\" aria-label=\"{ph}\">\
<div class=\"sorts\"><button type=\"button\" data-sort=\"score\" class=\"on\">{}</button>\
<button type=\"button\" data-sort=\"name\">{}</button>\
<button type=\"button\" data-sort=\"active\">{}</button></div></div>",
lx.sort_karma,
lx.sort_name,
lx.sort_active,
ph = esc(lx.filter_ph),
);
let mut out = format!( let mut out = format!(
"<div class=\"eyebrow\">{}</div><ol class=\"list\">", "<div class=\"eyebrow\">{}</div>{controls}<ol class=\"list ledger\">",
lx.ledger lx.ledger
); );
for (i, r) in b.rows.iter().enumerate().skip(3) { for (i, r) in b.rows.iter().enumerate().skip(3) {
@ -598,14 +848,25 @@ fn ledger(b: &Board, lx: &Lex) -> String {
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)), (Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
_ => String::new(), _ => String::new(),
}; };
let key = r.thing.to_lowercase();
let active = b
.events
.iter()
.filter(|e| e.thing.to_lowercase() == key)
.count();
let hist = hist_html(&r.thing, &b.events, lx);
let (expand, title) = expand_attrs(&hist, lx);
out.push_str(&format!( out.push_str(&format!(
"<li class=\"row\"><span class=\"rank\">{rank}</span>{}\ "<li class=\"row thing{expand}\"{title} data-name=\"{namel}\" data-score=\"{score}\" data-active=\"{active}\">\
<div class=\"who\"><div class=\"nick\">{}</div>{reason}</div>\ <span class=\"rank\">{rank}</span>{av}\
{spark}<span class=\"score {}\">{}</span></li>", <div class=\"who\"><div class=\"nick\">{name}</div>{reason}</div>\
avatar(&r.thing, "sm"), {spark}<span class=\"score {kl}\">{sg}</span>{hist}</li>",
esc(&r.thing), namel = esc(&key),
klass(r.count), score = r.count,
signed(r.count), av = avatar(&r.thing, "sm"),
name = esc(&r.thing),
kl = klass(r.count),
sg = signed(r.count),
)); ));
} }
out.push_str("</ol>"); out.push_str("</ol>");
@ -693,6 +954,182 @@ fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
out out
} }
fn rising(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
let week = now.saturating_sub(7 * 86400);
let mut m: HashMap<String, (String, i64)> = HashMap::new();
for (_, e) in feed {
if e.t >= week {
let ent = m
.entry(e.thing.to_lowercase())
.or_insert((e.thing.clone(), 0));
ent.1 += e.delta;
}
}
let mut v: Vec<(String, i64)> = m.into_values().filter(|(_, d)| *d != 0).collect();
if v.is_empty() {
return String::new();
}
v.sort_by(|a, b| {
b.1.abs()
.cmp(&a.1.abs())
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
v.truncate(8);
let chips: String = v
.iter()
.map(|(name, d)| {
format!(
"<span class=\"tchip {}\">{} <b>{}</b></span>",
klass(*d),
esc(name),
signed(*d)
)
})
.collect();
format!(
"<section class=\"strip fade\"><div class=\"eyebrow\">🔥 {}</div><div class=\"chips\">{chips}</div></section>",
lx.rising
)
}
fn over_time(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
const DAYS: usize = 30;
let day = 86400u64;
let start = now.saturating_sub((DAYS as u64 - 1) * day);
let mut buckets = [0u32; DAYS];
for (_, e) in feed {
if e.t >= start {
let idx = ((e.t - start) / day) as usize;
if idx < DAYS {
buckets[idx] += 1;
}
}
}
let max = *buckets.iter().max().unwrap_or(&0);
if max == 0 {
return String::new();
}
let bars: String = buckets
.iter()
.map(|c| {
let h = (*c as f64 / max as f64 * 100.0).round() as u32;
format!(
"<span class=\"bar\" style=\"height:{}%\" title=\"{c}\"></span>",
h.max(3)
)
})
.collect();
let total: u32 = buckets.iter().sum();
format!(
"<section class=\"chart fade\"><div class=\"eyebrow\">{} \
<span class=\"count\">{total} · {DAYS}d</span></div><div class=\"bars\">{bars}</div></section>",
lx.over_time
)
}
fn most_debated(feed: &[(&str, &Event)], lx: &Lex) -> String {
let mut m: HashMap<String, (String, i64, i64)> = HashMap::new();
for (_, e) in feed {
let ent = m
.entry(e.thing.to_lowercase())
.or_insert((e.thing.clone(), 0, 0));
ent.1 += 1;
ent.2 += e.delta;
}
let mut v: Vec<(String, i64, i64)> = m.into_values().collect();
if v.is_empty() {
return String::new();
}
v.sort_by(|a, b| {
b.1.cmp(&a.1)
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
v.truncate(6);
let items: String = v
.iter()
.map(|(name, bumps, net)| {
format!(
"<li class=\"row\"><div class=\"who\"><div class=\"nick\">{}</div>\
<span class=\"why\">{bumps} {}</span></div><span class=\"score {}\">{}</span></li>",
esc(name),
lx.bumps,
klass(*net),
signed(*net)
)
})
.collect();
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
lx.most_debated
)
}
fn milestones(feed: &[(&str, &Event)], lx: &Lex) -> String {
const LEVELS: &[i64] = &[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
let mut evs: Vec<&(&str, &Event)> = feed.iter().collect();
evs.sort_by_key(|(_, e)| e.t);
let mut cum: HashMap<String, i64> = HashMap::new();
let mut hits: Vec<(String, i64, u64)> = Vec::new();
for (_, e) in evs {
let c = cum.entry(e.thing.to_lowercase()).or_insert(0);
let before = *c;
*c += e.delta;
let after = *c;
for &lv in LEVELS {
if before < lv && after >= lv {
hits.push((e.thing.clone(), lv, e.t));
}
}
}
if hits.is_empty() {
return String::new();
}
hits.sort_by_key(|(_, _, t)| std::cmp::Reverse(*t));
hits.truncate(8);
let items: String = hits
.iter()
.map(|(name, lv, t)| {
format!(
"<li class=\"hev\">🎉 <b>{}</b> {} <b>{lv}</b> \
<time class=\"ago\" data-t=\"{t}\"></time></li>",
esc(name),
lx.reached
)
})
.collect();
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
lx.milestones
)
}
fn reasons_wall(feed: &[(&str, &Event)], lx: &Lex) -> String {
let items: String = feed
.iter()
.filter(|(_, e)| e.why.is_some())
.take(6)
.map(|(_, e)| {
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
format!(
"<li class=\"quote\"><div class=\"q\">\u{201C}{}\u{201D}</div>\
<div class=\"qm\"><b>{}</b> \u{2192} <b>{}</b> \
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
esc(e.why.as_deref().unwrap_or("")),
esc(&by),
esc(&e.thing),
e.t
)
})
.collect();
if items.is_empty() {
return String::new();
}
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ul class=\"quotes\">{items}</ul></section>",
lx.reasons_recent
)
}
fn footer(epoch: u64, lx: &Lex) -> String { fn footer(epoch: u64, lx: &Lex) -> String {
format!( format!(
"<footer class=\"fade\">{} &middot; {} \ "<footer class=\"fade\">{} &middot; {} \
@ -732,11 +1169,12 @@ fn time_cfg(l: Locale) -> String {
} }
const HEAD: &str = r##"<!doctype html> const HEAD: &str = r##"<!doctype html>
<html lang="en"> <html lang="__LANG__">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>karma · devtronic</title> <title>karma · devtronic</title>
__OG__
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E">
<style> <style>
:root{ :root{
@ -844,6 +1282,41 @@ border-radius:6px;padding:0 6px;margin-left:2px}
.ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums} .ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums}
footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2} footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2}
.dot{width:9px;height:9px;border-radius:50%;flex:none}
.dot.on{background:var(--up);box-shadow:0 0 0 3px rgba(95,211,159,.16)}
.dot.off{background:var(--faint)}
.source .chan{font-family:var(--mono);color:var(--gold);font-weight:600}
.source .chan:hover{text-decoration:underline}
.source .users{font-family:var(--mono);color:var(--faint);font-size:11px}
.strip{margin:30px 2px 6px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.tchip{font-family:var(--mono);font-size:13px;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:5px 12px}
.tchip.up{color:var(--up)}.tchip.down{color:var(--down)}
.tchip b{font-weight:700}
.chart{margin:24px 2px 6px}
.chart .eyebrow{margin:0 0 10px}
.chart .count{margin-left:auto;color:var(--faint);font-family:var(--mono);font-size:11px}
.bars{display:flex;align-items:flex-end;gap:3px;height:64px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px}
.bar{flex:1;background:linear-gradient(180deg,var(--gold),rgba(245,182,66,.3));border-radius:2px;min-height:2px}
.band3{display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px}
@media(min-width:720px){.band3{grid-template-columns:1fr 1fr 1fr}}
.quotes{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
.quote .q{color:var(--fg);font-size:13.5px;line-height:1.5}
.quote .qm{color:var(--muted);font-size:12px;margin-top:2px}
.quote .qm b{font-family:var(--mono);font-weight:600}
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 2px 12px}
.filter{flex:1;min-width:150px;padding:9px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font-size:14px}
.filter:focus{outline:none;border-color:var(--gold)}
.sorts{display:flex;gap:6px}
.sorts button{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 11px;cursor:pointer}
.sorts button.on{color:var(--bg);background:var(--gold);border-color:var(--gold);font-weight:600}
.row.thing,.pillar.expand{cursor:pointer}
.row.thing{flex-wrap:wrap}
.hist{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;flex-basis:100%;width:100%}
.pillar .hist{text-align:left}
.hev{font-size:12.5px;color:var(--muted);word-break:break-word}
.hev b{font-family:var(--mono);color:var(--fg);font-weight:600}
.hev .chip{font-size:11px;padding:0 5px}
.empty{text-align:center;color:var(--muted);padding:56px 10px} .empty{text-align:center;color:var(--muted);padding:56px 10px}
@media(max-width:560px){ @media(max-width:560px){
@ -878,6 +1351,38 @@ return C.pre+d+C.u[6]+C.post;
document.querySelectorAll("time.ago[data-t]").forEach(function(t){ document.querySelectorAll("time.ago[data-t]").forEach(function(t){
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v); var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
}); });
// click a row/pillar to toggle its history
document.querySelectorAll(".row.thing,.pillar.expand").forEach(function(el){
el.addEventListener("click",function(e){
if(e.target.closest("a"))return;
var h=el.querySelector(".hist");if(h)h.hidden=!h.hidden;
});
});
// filter the ledger
var q=document.getElementById("q");
if(q){q.addEventListener("input",function(){
var t=q.value.toLowerCase();
document.querySelectorAll("ol.ledger>li.row").forEach(function(li){
li.style.display=li.textContent.toLowerCase().indexOf(t)>=0?"":"none";
});
});}
// sort the ledger
document.querySelectorAll(".sorts button").forEach(function(btn){
btn.addEventListener("click",function(){
var key=btn.getAttribute("data-sort");
var list=document.querySelector("ol.ledger");if(!list)return;
document.querySelectorAll(".sorts button").forEach(function(b){b.classList.remove("on");});
btn.classList.add("on");
var rows=Array.prototype.slice.call(list.querySelectorAll("li.row"));
rows.sort(function(a,b){
if(key=="name")return a.getAttribute("data-name").localeCompare(b.getAttribute("data-name"));
var an=parseInt(a.getAttribute(key=="active"?"data-active":"data-score"),10);
var bn=parseInt(b.getAttribute(key=="active"?"data-active":"data-score"),10);
return bn-an;
});
rows.forEach(function(r){list.appendChild(r);});
});
});
setTimeout(function(){location.reload();},60000); setTimeout(function(){location.reload();},60000);
})();"##; })();"##;

View file

@ -1,5 +1,5 @@
use rustbot::i18n::Locale; use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Channel}; use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Channel};
fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board { fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board {
let mut b = parse_board(net, store); let mut b = parse_board(net, store);
@ -102,51 +102,64 @@ fn renders_spanish_chrome() {
} }
#[test] #[test]
fn parses_meta_channels_with_and_without_topic() { fn parses_meta_channels_topic_and_users() {
// new format: objects with an optional topic let m = parse_meta(
let (server, channels) = parse_meta( r##"{"server":"irc.libera.chat","webchat":"https://web.libera.chat/","seen":123,"channels":[{"name":"#argentina","topic":"Bienvenidos","users":42},{"name":"#ar"}]}"##,
r##"{"server":"irc.libera.chat","channels":[{"name":"#argentina","topic":"Bienvenidos"},{"name":"#ar"}]}"##,
); );
assert_eq!(server, "irc.libera.chat"); assert_eq!(m.server, "irc.libera.chat");
assert_eq!(channels.len(), 2); assert_eq!(m.webchat, "https://web.libera.chat/");
assert_eq!(channels[0].name, "#argentina"); assert_eq!(m.seen, 123);
assert_eq!(channels[0].topic.as_deref(), Some("Bienvenidos")); assert_eq!(m.channels.len(), 2);
assert!(channels[1].topic.is_none()); assert_eq!(m.channels[0].name, "#argentina");
// old format (bare strings) still parses assert_eq!(m.channels[0].topic.as_deref(), Some("Bienvenidos"));
let (_, old) = parse_meta(r##"{"server":"x","channels":["#c"]}"##); assert_eq!(m.channels[0].users, Some(42));
assert_eq!(old[0].name, "#c"); assert!(m.channels[1].topic.is_none() && m.channels[1].users.is_none());
assert!(old[0].topic.is_none()); // old bare-string format still parses
let old = parse_meta(r##"{"server":"x","channels":["#c"]}"##);
assert_eq!(old.channels[0].name, "#c");
// garbage degrades to empty // garbage degrades to empty
let (s, c) = parse_meta("not json"); let g = parse_meta("not json");
assert!(s.is_empty() && c.is_empty()); assert!(g.server.is_empty() && g.channels.is_empty());
} }
#[test] #[test]
fn renders_source_channel_server_and_topic() { fn renders_source_topic_users_join_and_online() {
let mut b = parse_board("libera", r#"{"romaka":2}"#); let mut b = parse_board("libera", r#"{"romaka":2}"#);
b.server = "irc.libera.chat".to_string(); b.server = "irc.libera.chat".to_string();
b.webchat = "https://web.libera.chat/".to_string();
b.seen = 1000;
b.channels = vec![Channel { b.channels = vec![Channel {
name: "#argentina".to_string(), name: "#argentina".to_string(),
topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()), topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()),
users: Some(42),
}]; }];
let en = render(&[b], 0, Locale::En); let en = render(&[b], 1100, Locale::En); // now close to seen -> online
assert!(en.contains("#argentina"), "channel missing"); assert!(en.contains("#argentina"), "channel missing");
assert!(en.contains("irc.libera.chat"), "server missing"); assert!(en.contains("irc.libera.chat"), "server missing");
assert!(en.contains(">from<"), "from label missing"); assert!(en.contains("Argentina en Libera"), "topic missing");
// the topic text shows, and its URL becomes a safe link
assert!(en.contains("Argentina en Libera"), "topic text missing");
assert!( assert!(
en.contains("<a href=\"https://qadlo.example/\""), en.contains("<a href=\"https://qadlo.example/\""),
"topic url not linkified: {en}" "topic url not linkified"
); );
// spanish label still works with no topic assert!(en.contains("42 online"), "user count missing");
let mut b2 = parse_board("libera", r#"{"romaka":2}"#); assert!(
b2.channels = vec![Channel { en.contains("https://web.libera.chat/#argentina"),
name: "#argentina".to_string(), "join link missing"
topic: None, );
}]; assert!(en.contains("class=\"dot on\""), "online dot missing");
let es = render(&[b2], 0, Locale::Es); // og description present
assert!(es.contains(">desde<") && es.contains("#argentina"), "{es}"); assert!(
en.contains("property=\"og:description\""),
"og tags missing"
);
}
#[test]
fn online_dot_goes_offline_when_stale() {
let mut b = parse_board("t", r#"{"x":1}"#);
b.seen = 1000;
let html = render(&[b], 9_999_999, Locale::En);
assert!(html.contains("class=\"dot off\""), "should be offline");
} }
#[test] #[test]
@ -155,6 +168,7 @@ fn topic_cannot_inject_markup() {
b.channels = vec![Channel { b.channels = vec![Channel {
name: "#c".to_string(), name: "#c".to_string(),
topic: Some("<script>evil</script> javascript:alert(1)".to_string()), topic: Some("<script>evil</script> javascript:alert(1)".to_string()),
users: None,
}]; }];
let html = render(&[b], 0, Locale::En); let html = render(&[b], 0, Locale::En);
assert!(!html.contains("<script>evil"), "topic markup leaked"); assert!(!html.contains("<script>evil"), "topic markup leaked");
@ -162,6 +176,51 @@ fn topic_cannot_inject_markup() {
assert!(html.contains("&lt;script&gt;evil"), "topic not escaped"); assert!(html.contains("&lt;script&gt;evil"), "topic not escaped");
} }
#[test]
fn renders_analytics_sections() {
let now = 2_000_000u64;
let mut log = String::new();
for i in 0..12u64 {
let t = now - (12 - i) * 3600;
let why = if i == 0 {
",\"why\":\"clean code\""
} else {
""
};
log.push_str(&format!(
"{{\"t\":{t},\"o\":\"rust\",\"d\":1,\"by\":\"nadia\"{why}}}\n"
));
}
log.push_str(&format!(
"{{\"t\":{},\"o\":\"php\",\"d\":-1,\"by\":\"sam\",\"why\":\"footgun\"}}\n",
now - 1000
));
let b = board_with_events("libera", r#"{"rust":12,"php":-1}"#, &log);
let html = render(&[b], now, Locale::En);
assert!(html.contains("rising this week"), "no rising strip");
assert!(html.contains("karma over time"), "no chart");
assert!(html.contains("most debated"), "no most-debated");
assert!(
html.contains("milestones") && html.contains("reached"),
"no milestones"
);
assert!(html.contains("recent reasons"), "no reasons wall");
assert!(
html.contains("clean code") && html.contains("footgun"),
"reasons missing"
);
}
#[test]
fn render_json_is_machine_readable() {
let b = parse_board("libera", r#"{"rust":{"n":5,"why":"clean","by":"a"}}"#);
let j = render_json(&[b], 42);
assert!(j.contains("\"generated\":42"), "{j}");
assert!(j.contains("\"network\":\"libera\""), "{j}");
assert!(j.contains("\"thing\":\"rust\",\"karma\":5"), "{j}");
assert!(j.contains("\"reason\":\"clean\""), "{j}");
}
#[test] #[test]
fn escapes_untrusted_irc_input_everywhere() { fn escapes_untrusted_irc_input_everywhere() {
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n"; let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";