/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
//! The fud file-message type node.
//!
//! File messages are derived from privmsg text containing fud URLs —
//! never stored, keyed `(privmsg ts, derived id)` so the box sorts
//! directly below its source line. Status and decoded images are
//! content-addressed state on the node, surviving instance release:
//! re-materialization attaches to current progress instead of
//! restarting. The download tasks themselves live in the fud plugin;
//! this node only requests them (via `download_request`) and renders
//! their progress (via `set_file_status`).
use async_lock::Mutex as AsyncMutex;
use async_trait::async_trait;
use darkfi_serial::{Decodable, Encodable, FutAsyncWriteExt, SerialDecodable, SerialEncodable};
use image::{ImageBuffer, Rgba};
use parking_lot::Mutex as SyncMutex;
use std::{
collections::HashMap,
io::Cursor,
sync::{Arc, Weak},
};
use url::Url;
use crate::{
gfx::{gfxtag, DrawInstruction, EpochTracker, Point, Rectangle, RenderApi, Renderer},
mesh::{Color, MeshBuilder, COLOR_CYAN, COLOR_GREEN, COLOR_RED, COLOR_WHITE},
prop::{Property, PropertyColor, PropertyPermission, PropertySubType, PropertyType, Role},
scene::{CallArgType, Pimpl, SceneNode, SceneNodeType, SceneNodeWeak},
text,
ui::UIObject,
util::i18n::I18nBabelFish,
};
use super::{DrawOutcome, Hit, SharedProps};
use crate::ui::chatview::{
buffer::MsgBuffer, loader::Loader, ChatView, MessageId, MsgRecord, MsgType, Timestamp,
};
macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::filemsg", $($arg)*); } }
macro_rules! i { ($($arg:tt)*) => { info!(target: "ui::chatview::filemsg", $($arg)*); } }
/// The file transfer lifecycle of a fud file message.
#[derive(Debug, Clone, PartialEq, SerialEncodable, SerialDecodable)]
pub enum FileMsgStatus {
Initializing,
Idle,
Downloading { progress: f32 },
Downloaded { path: String },
Error { msg: String, progress: f32 },
}
type GenericImageBuffer = ImageBuffer, Vec>;
/// Content-addressed state for one file URL: the download status and,
/// once decoded, the image. Survives instance release (eviction) —
/// re-materialization attaches to this instead of restarting.
pub struct FileContent {
pub status: FileMsgStatus,
pub imgbuf: Option,
}
struct FileInner {
instances: HashMap,
touches: HashMap,
access: u64,
epoch_tracker: Option,
}
/// The per-message rendered cache: layouts for the status lines, the
/// active (click-to-download) rect, cached draw instructions.
pub struct FileMsgInstance {
url: Url,
lines: Vec,
line_height: f32,
max_width: f32,
status_strs: Vec,
active_rect: Option,
instrs: Option>,
height: f32,
}
pub type FileMsgNodePtr = Arc;
pub struct FileMsgNode {
node: SceneNodeWeak,
shared: SharedProps,
i18n: I18nBabelFish,
loader: Arc,
buffer: Arc>,
chat: Weak,
inner: SyncMutex,
content: SyncMutex>,
}
/// Extract the first fud URL from a privmsg body, if any.
pub fn get_file_url(text: &str) -> Option {
let re = regex::Regex::new(r"fud://[^\s]+").unwrap();
re.find(text).and_then(|m| Url::parse(m.as_str()).ok())
}
/// The synthetic id of a file message derived from its privmsg: a
/// domain-separated hash with the top byte forced high, so the box
/// sorts directly below its source line (older in display order).
pub fn derived_file_id(source: &MessageId) -> MessageId {
let mut hash = blake3::hash(&source.0).as_bytes()[..8].to_vec();
hash[0] = 0xff;
let mut id = [0u8; 32];
id[..8].copy_from_slice(&hash);
MessageId(id)
}
/// Encode a file message payload: the fud URL.
pub fn encode_filemsg_payload(url: &Url) -> Vec {
let mut payload = vec![];
url.to_string().encode(&mut payload).unwrap();
payload
}
/// Decode a file message payload back into its URL.
///
/// ## Panics
///
/// If the payload does not decode, identifying the entry.
pub fn decode_filemsg_payload(payload: &[u8], ts: Timestamp, id: &MessageId) -> Url {
let url: String = String::decode(&mut Cursor::new(payload))
.unwrap_or_else(|e| panic!("corrupt chat entry: bad filemsg url [ts={ts} id={id}]: {e}"));
Url::parse(&url).unwrap_or_else(|e| panic!("corrupt chat entry: bad filemsg url [{url}]: {e}"))
}
/// Build the derived filemsg record for a privmsg record, if its text
/// carries a fud URL.
pub fn derive_filemsg(privmsg: &MsgRecord, text: &str) -> Option {
let url = get_file_url(text)?;
Some(MsgRecord {
ts: privmsg.ts,
id: derived_file_id(&privmsg.id),
msg_type: MsgType::FileMsg,
payload: encode_filemsg_payload(&url),
height: 0.,
})
}
impl FileMsgNode {
pub async fn new(
node: SceneNodeWeak,
shared: SharedProps,
i18n: I18nBabelFish,
loader: Arc,
buffer: Arc>,
chat: Weak,
) -> Pimpl {
let self_ = Arc::new(Self {
node: node.clone(),
shared,
i18n,
loader,
buffer,
chat,
inner: SyncMutex::new(FileInner {
instances: HashMap::new(),
touches: HashMap::new(),
access: 0,
epoch_tracker: None,
}),
content: SyncMutex::new(HashMap::new()),
});
Pimpl::FileMsgNode(self_)
}
/// The (translated) status line for a status. Fluent keys are the
/// stable ids below; untranslated ids fall back to English.
fn status_str(&self, status: &FileMsgStatus) -> String {
let fallback = |id: &str, english: &str| {
self.i18n
.tr(&format!("chatview-file-status-{id}"))
.unwrap_or_else(|| english.to_string())
};
match status {
FileMsgStatus::Initializing => fallback("initializing", "starting fud"),
FileMsgStatus::Idle => fallback("idle", "tap to download"),
FileMsgStatus::Downloading { progress } => {
format!("{} [{progress:.1}%]", fallback("downloading", "downloading"))
}
FileMsgStatus::Downloaded { .. } => fallback("downloaded", "downloaded"),
FileMsgStatus::Error { msg, progress } => {
let msg = msg.to_lowercase();
if *progress > 0. {
format!("{msg} [{progress:.1}%]")
} else {
msg
}
}
}
}
/// The two box lines: shortened file hash and the status string.
fn file_strs(&self, url: &Url, status: &FileMsgStatus) -> Vec {
let hash = url.host_str().unwrap_or("???");
let short = if hash.chars().count() >= 12 {
let head: String = hash.chars().take(4).collect();
let tail: String =
hash.chars().rev().take(4).collect::>().into_iter().rev().collect();
format!("{head}...{tail}")
} else {
hash.to_string()
};
vec![short, self.status_str(status)]
}
fn status_color(status: &FileMsgStatus, timestamp_color: Color) -> Color {
match status {
FileMsgStatus::Initializing => timestamp_color,
FileMsgStatus::Idle => timestamp_color,
FileMsgStatus::Downloading { .. } => COLOR_CYAN,
FileMsgStatus::Downloaded { .. } => COLOR_GREEN,
FileMsgStatus::Error { .. } => COLOR_RED,
}
}
fn load_img(path: &str) -> Option {
let data = Arc::new(SyncMutex::new(vec![]));
let data2 = data.clone();
miniquad::fs::load_file(path, move |res| {
if let Ok(res) = res {
*data2.lock() = res;
}
});
let data = std::mem::take(&mut *data.lock());
let img =
image::ImageReader::new(Cursor::new(data)).with_guessed_format().ok()?.decode().ok()?;
Some(img.to_rgba8())
}
fn img_size(&self, imgbuf: &GenericImageBuffer) -> (f32, f32) {
const IMG_MAX_HEIGHT: f32 = 500.;
let max_width = self.shared.rect.get().w - self.shared.timestamp_width.get();
let img_w = imgbuf.width() as f32;
let img_h = imgbuf.height() as f32;
let scale = (max_width / img_w).min(IMG_MAX_HEIGHT / img_h);
(img_w * scale, img_h * scale)
}
/// Measure a record: materialize if needed, return the height.
pub fn measure(&self, rec: &MsgRecord) -> f32 {
let key = (rec.ts, rec.id);
let mut inner = self.inner.lock();
inner.access += 1;
let access = inner.access;
inner.touches.insert(key, access);
if inner.instances.contains_key(&key) {
let inst = inner.instances.get(&key).unwrap();
return inst.height
}
let url = decode_filemsg_payload(&rec.payload, rec.ts, &rec.id);
let height = {
let mut content = self.content.lock();
let entry = content.entry(url.clone()).or_insert_with(|| FileContent {
status: FileMsgStatus::Initializing,
imgbuf: None,
});
if entry.status == FileMsgStatus::Initializing {
// First sight: idle until a download is requested.
entry.status = FileMsgStatus::Idle;
}
if let Some(imgbuf) = &entry.imgbuf {
let (_, img_h) = self.img_size(imgbuf);
img_h + Self::MARGIN_TOP + Self::MARGIN_BOTTOM + self.shared.message_spacing.get()
} else {
self.box_height()
}
};
let inst = FileMsgInstance {
url,
lines: vec![],
line_height: self.shared.line_height.get(),
max_width: 0.,
status_strs: vec![],
active_rect: None,
instrs: None,
height,
};
inner.instances.insert(key, inst);
t!("materialized id={} height={height}", rec.id);
height
}
const MARGIN_TOP: f32 = 4.;
const MARGIN_BOTTOM: f32 = 10.;
/// The status box height.
fn box_height(&self) -> f32 {
const BOX_PADDING_Y: f32 = 12.;
let line_height = self.shared.line_height.get();
2. * line_height +
BOX_PADDING_Y * 2. +
Self::MARGIN_TOP +
Self::MARGIN_BOTTOM +
self.shared.message_spacing.get()
}
/// Whether the instance currently holds rendered state.
pub fn is_materialized(&self, rec: &MsgRecord) -> bool {
self.inner.lock().instances.contains_key(&(rec.ts, rec.id))
}
/// Drop an instance's rendered state; content-addressed state
/// survives for re-materialization to attach to.
pub fn release(&self, key: &super::privmsg::InstKey) {
let mut inner = self.inner.lock();
inner.instances.remove(key);
inner.touches.remove(key);
}
/// Drop every instance's rendered state.
pub fn release_all(&self) {
let mut inner = self.inner.lock();
inner.instances.clear();
inner.touches.clear();
}
/// Rebuild rendered state from live props + current data.
pub fn regen(&self, key: &super::privmsg::InstKey) {
self.release(key);
}
/// Rebuild every instance's rendered state.
pub fn regen_all(&self) {
self.release_all();
}
/// Release the out-of-window instances beyond the LRU budget.
pub fn sweep(&self, keep: &std::collections::HashSet, budget: usize) {
let releases = {
let inner = self.inner.lock();
super::evict_beyond(keep, &inner.touches, budget)
};
for key in releases {
self.release(&key);
}
}
/// Renderer-bound draw instructions in message-local coordinates.
pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> DrawOutcome {
const BOX_PADDING_Y: f32 = 12.;
const BOX_PADDING_X: f32 = 15.;
const GLOW_SIZE: f32 = 20.;
let key = (rec.ts, rec.id);
let mut inner = self.inner.lock();
inner.access += 1;
let access = inner.access;
inner.touches.insert(key, access);
let epoch_changed =
inner.epoch_tracker.get_or_insert_with(|| EpochTracker::new(renderer)).changed();
if epoch_changed {
for inst in inner.instances.values_mut() {
inst.instrs = None;
}
}
let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
let line_height = self.shared.line_height.get();
let timestamp_width = self.shared.timestamp_width.get();
let timestamp_color = self.shared.timestamp_color.get();
let font_size = self.shared.font_size.get();
let window_scale = self.shared.window_scale.get();
let max_width = self.shared.rect.get().w - timestamp_width - GLOW_SIZE;
if inst.instrs.is_none() {
let (status, imgbuf) = {
let content = self.content.lock();
content
.get(&inst.url)
.map(|c| (c.status.clone(), c.imgbuf.clone()))
.unwrap_or_else(|| (FileMsgStatus::Initializing, None))
};
let mut instrs = vec![];
if let Some(imgbuf) = imgbuf {
// Downloaded image: fitted to bounds, with a glow.
let (img_w, img_h) = self.img_size(&imgbuf);
let mesh_rect = Rectangle::from([timestamp_width, Self::MARGIN_TOP, img_w, img_h]);
let width = imgbuf.width() as u16;
let height = imgbuf.height() as u16;
let bmp = imgbuf.as_raw().clone();
let texture = renderer.new_texture(
width,
height,
bmp,
miniquad::TextureFormat::RGBA8,
gfxtag!("chatview_fileimg_texture"),
);
let mut mesh_gradient = MeshBuilder::new(gfxtag!("chatview_fileimg_glow"));
let glow_color = [timestamp_color[0], timestamp_color[1], timestamp_color[2], 0.5];
mesh_gradient.draw_box_shadow(&mesh_rect, glow_color, GLOW_SIZE);
instrs.push(DrawInstruction::Draw(mesh_gradient.alloc(renderer).draw_untextured()));
let mut mesh_img = MeshBuilder::new(gfxtag!("chatview_fileimg"));
let uv_rect = Rectangle::from([0., 0., 1., 1.]);
mesh_img.draw_box(&mesh_rect, COLOR_WHITE, &uv_rect);
instrs.push(DrawInstruction::Draw(
mesh_img.alloc(renderer).draw_with_textures(vec![texture]),
));
inst.active_rect = Some(mesh_rect);
} else {
// Status box: outline + glow + the two text lines.
let color = Self::status_color(&status, timestamp_color);
let file_strs = self.file_strs(&inst.url, &status);
let mut layouts = Vec::with_capacity(file_strs.len());
let mut text_width = 0.;
for file_str in &file_strs {
let layout = text::make_layout(
file_str,
color,
font_size,
line_height / font_size,
window_scale,
Some(max_width),
&[],
);
if layout.width() > text_width {
text_width = layout.width();
}
layouts.push(layout);
}
inst.status_strs = file_strs;
let box_height = 2. * line_height + BOX_PADDING_Y * 2.;
let box_width = if text_width > max_width { max_width } else { text_width } +
BOX_PADDING_X * 2.;
let mesh_rect =
Rectangle::new(timestamp_width, Self::MARGIN_TOP, box_width, box_height);
let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg_box"));
mesh.draw_outline(&mesh_rect, color, 1.);
let glow_color = [color[0], color[1], color[2], 0.3];
mesh.draw_box_shadow(&mesh_rect, glow_color, GLOW_SIZE);
instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
instrs.push(DrawInstruction::Move(Point::new(
timestamp_width + BOX_PADDING_X,
Self::MARGIN_TOP + BOX_PADDING_Y,
)));
for layout in layouts {
let text_instrs =
text::render_layout(&layout, renderer, gfxtag!("chatview_filemsg_text"));
instrs.extend(text_instrs);
instrs.push(DrawInstruction::Move(Point::new(0., line_height)));
}
inst.active_rect = Some(mesh_rect);
inst.lines = vec![];
}
inst.instrs = Some(instrs);
}
DrawOutcome::Inline(inst.instrs.clone().unwrap_or_default())
}
/// Clipboard contribution when selected: the file URL.
pub fn copy_text(&self, rec: &MsgRecord) -> Option {
let inner = self.inner.lock();
inner.instances.get(&(rec.ts, rec.id)).map(|inst| inst.url.to_string())
}
/// Hit dispatch: the active rect (image or status box) activates a
/// download request when idle or errored.
pub fn hit_test(&self, rec: &MsgRecord, pos: Point) -> Option {
let inner = self.inner.lock();
let inst = inner.instances.get(&(rec.ts, rec.id))?;
let rect = inst.active_rect?;
if !rect.contains(pos) {
return None
}
let status = {
let content = self.content.lock();
content.get(&inst.url).map(|c| c.status.clone()).unwrap_or(FileMsgStatus::Initializing)
};
match status {
FileMsgStatus::Idle | FileMsgStatus::Error { .. } => Some(Hit::File(inst.url.clone())),
_ => None,
}
}
/// Update the status of every file message with this URL; heights
/// re-flow into geometry with scroll compensation, `status_changed`
/// fires for each affected message, and a finished download decodes
/// its image into the content store.
pub async fn set_file_status(&self, url: &Url, status: FileMsgStatus) {
t!("set_file_status({url}, {status:?})");
{
let mut content = self.content.lock();
let Some(entry) = content.get_mut(url) else { return };
if entry.status != status {
entry.status = status.clone();
if let FileMsgStatus::Downloaded { path } = &status {
entry.imgbuf = Self::load_img(path);
t!("decoded image for {url}: {}", entry.imgbuf.is_some());
}
}
}
// Regen every loaded record carrying this URL and flow the new
// heights into geometry.
let mut buffer = self.buffer.lock().await;
let mut keys = vec![];
for rec in buffer.iter_display_order() {
if rec.msg_type == MsgType::FileMsg {
let rec_url = decode_filemsg_payload(&rec.payload, rec.ts, &rec.id);
if &rec_url == url {
keys.push((rec.ts, rec.id));
}
}
}
drop(buffer);
for key in keys {
self.regen(&key);
let rec = {
let buffer = self.buffer.lock().await;
buffer.record(&key.1).filter(|r| r.ts == key.0).cloned()
};
let Some(rec) = rec else { continue };
let height = self.measure(&rec);
let mut buffer = self.buffer.lock().await;
let below = match buffer.pos_of(&key.1) {
Some(top) => {
let scroll = self.controller_scroll();
top <= scroll
}
None => false,
};
if let Some(delta) = buffer.set_height_key(&key, height) {
if let Some(chat) = self.chat.upgrade() {
let mut ctl = chat.controller.lock();
ctl.compensate(delta, below);
}
}
if let Some(node) = self.node.upgrade() {
let mut data = vec![];
key.1.encode(&mut data).unwrap();
let _ = node.trigger("status_changed", data).await;
}
}
if let Some(chat) = self.chat.upgrade() {
chat.redraw.trigger();
}
}
fn controller_scroll(&self) -> f32 {
self.chat.upgrade().map(|chat| chat.controller.lock().scroll()).unwrap_or(0.)
}
/// Request the download of a file message: emits
/// `download_request(id, url)`.
pub async fn request_download(&self, id: &MessageId, url: &Url) {
t!("download requested: {url}");
if let Some(node) = self.node.upgrade() {
let mut data = vec![];
id.encode(&mut data).unwrap();
url.encode(&mut data).unwrap();
let _ = node.trigger("download_request", data).await;
}
}
/// The scene node handle.
pub fn node(&self) -> &SceneNodeWeak {
&self.node
}
/// The content state of a file URL (test access).
pub(crate) fn status_of(&self, url: &Url) -> Option {
self.content.lock().get(url).map(|c| c.status.clone())
}
/// The box lines for a status (test access).
pub(crate) fn file_strs_for_test(&self, url: &Url, status: &FileMsgStatus) -> Vec {
self.file_strs(url, status)
}
}
/// Scene node factory for the file-message type node.
#[async_trait]
impl UIObject for FileMsgNode {
fn priority(&self) -> u32 {
0
}
}
impl std::fmt::Debug for FileMsgNode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.node.upgrade())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::node::create_filemsg_node, prop::PropertyAtomicGuard, ui::chatview::codec};
async fn make_node(tag: &str, i18n_src: &str) -> (FileMsgNodePtr, Arc>) {
let chat = crate::app::node::create_chatview("chatview");
let chat = chat.setup_null();
let atom = &mut PropertyAtomicGuard::none();
chat.set_property_f32(atom, Role::App, "font_size", 14.).unwrap();
chat.set_property_f32(atom, Role::App, "timestamp_font_size", 10.).unwrap();
chat.set_property_f32(atom, Role::App, "timestamp_width", 50.).unwrap();
chat.set_property_f32(atom, Role::App, "line_height", 20.).unwrap();
chat.set_property_f32(atom, Role::App, "message_spacing", 4.).unwrap();
let prop = chat.get_property("rect").unwrap();
prop.set_f32(atom, Role::App, 0, 0.).unwrap();
prop.set_f32(atom, Role::App, 1, 0.).unwrap();
prop.set_f32(atom, Role::App, 2, 800.).unwrap();
prop.set_f32(atom, Role::App, 3, 600.).unwrap();
let prop = chat.get_property("timestamp_color").unwrap();
for (i, c) in [0.5, 0.5, 0.5, 1.].iter().enumerate() {
prop.set_f32(atom, Role::App, i, *c).unwrap();
}
let mut wscale = crate::scene::SceneNode::new("w", crate::scene::SceneNodeType::Object);
wscale
.add_property(Property::new(
"scale",
PropertyType::Float32,
PropertySubType::Null,
PropertyPermission::default(),
))
.unwrap();
let wscale = wscale.setup_null();
wscale.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
let window_scale =
crate::prop::PropertyFloat32::wrap(&wscale, Role::Internal, "scale", 0).unwrap();
let shared = super::super::SharedProps::wrap(&chat, window_scale);
let mut raw = MsgBuffer::new();
raw.disable_separators();
let buffer = Arc::new(AsyncMutex::new(raw));
let (redraw, _rx) = crate::ui::RedrawTrigger::new();
let loader = Loader::new(buffer.clone(), redraw);
let i18n = I18nBabelFish::new(i18n_src.to_string(), "en-US");
let chat_weak: Weak = Weak::new();
let node = create_filemsg_node("filemsg");
let shared2 = shared.clone();
let i18n2 = i18n.clone();
let loader2 = loader.clone();
let buffer2 = buffer.clone();
let node = node
.setup(|me| async move {
FileMsgNode::new(me, shared2, i18n2, loader2, buffer2, chat_weak).await
})
.await;
chat.link(node.clone());
let Pimpl::FileMsgNode(ptr) = node.pimpl() else { panic!() };
(ptr.clone(), buffer)
}
fn file_rec(ts: Timestamp, idb: u8, url: &Url) -> MsgRecord {
let mut id = [0u8; 32];
id[0] = idb;
MsgRecord {
ts,
id: MessageId(id),
msg_type: MsgType::FileMsg,
payload: encode_filemsg_payload(url),
height: 0.,
}
}
#[test]
fn derivation_keys_and_orders() {
let payload =
codec::encode_privmsg_payload("alice", "grab fud://abcdef/file.tar now", true);
let privmsg = MsgRecord {
ts: 1000,
id: MessageId([7; 32]),
msg_type: MsgType::PrivMsg,
payload,
height: 0.,
};
let file = derive_filemsg(&privmsg, "grab fud://abcdef/file.tar now").expect("derived");
assert_eq!(file.ts, privmsg.ts, "shares the privmsg timestamp");
assert!(file.id.0 > privmsg.id.0, "sorts directly below its source line");
assert_eq!(file.msg_type, MsgType::FileMsg);
let url = decode_filemsg_payload(&file.payload, file.ts, &file.id);
assert_eq!(url.host_str(), Some("abcdef"));
assert!(derive_filemsg(&privmsg, "no urls here").is_none());
}
#[test]
fn statuses_measured_and_content_survives_release() {
let (node, _buffer) = smol::block_on(make_node("status", ""));
let url = Url::parse("fud://abcdef012345/file.png").unwrap();
let rec = file_rec(1000, b'a', &url);
let h1 = node.measure(&rec);
// 2 text lines + paddings + margins + spacing.
assert!((h1 - (2. * 20. + 12. * 2. + 4. + 10. + 4.)).abs() < 0.01, "{h1}");
// First sight registers Idle content state.
assert_eq!(node.status_of(&url), Some(FileMsgStatus::Idle));
// A status update lands in the content store and regens.
smol::block_on(async {
node.set_file_status(&url, FileMsgStatus::Downloading { progress: 42. }).await;
});
assert_eq!(node.status_of(&url), Some(FileMsgStatus::Downloading { progress: 42. }));
// Eviction drops the instance; the content-addressed state
// survives and re-materialization attaches to it.
node.release(&(rec.ts, rec.id));
assert!(!node.is_materialized(&rec));
assert_eq!(node.status_of(&url), Some(FileMsgStatus::Downloading { progress: 42. }));
let h2 = node.measure(&rec);
assert_eq!(h1, h2, "same status box height");
}
#[test]
fn status_strings_translate() {
let (node, _buffer) = smol::block_on(make_node(
"i18n",
"chatview-file-status-idle = zum Herunterladen tippen\n",
));
let url = Url::parse("fud://abcdef012345/file.png").unwrap();
let rec = file_rec(1000, b'a', &url);
node.measure(&rec);
let strs = node.file_strs_for_test(&url, &FileMsgStatus::Idle);
assert_eq!(strs[1], "zum Herunterladen tippen");
// Untranslated statuses fall back to the English string.
let strs =
node.file_strs_for_test(&url, &FileMsgStatus::Downloaded { path: String::new() });
assert_eq!(strs[1], "downloaded");
}
}