filemsg.rs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. /* This file is part of DarkFi (https://dark.fi)
  2. *
  3. * Copyright (C) 2020-2026 Dyne.org foundation
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU Affero General Public License as
  7. * published by the Free Software Foundation, either version 3 of the
  8. * License, or (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU Affero General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU Affero General Public License
  16. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. */
  18. //! The fud file-message type node.
  19. //!
  20. //! File messages are derived from privmsg text containing fud URLs —
  21. //! never stored, keyed `(privmsg ts, derived id)` so the box sorts
  22. //! directly below its source line. Status and decoded images are
  23. //! content-addressed state on the node, surviving instance release:
  24. //! re-materialization attaches to current progress instead of
  25. //! restarting. The download tasks themselves live in the fud plugin;
  26. //! this node only requests them (via `download_request`) and renders
  27. //! their progress (via `set_file_status`).
  28. use async_lock::Mutex as AsyncMutex;
  29. use async_trait::async_trait;
  30. use darkfi_serial::{Decodable, Encodable, FutAsyncWriteExt, SerialDecodable, SerialEncodable};
  31. use image::{ImageBuffer, Rgba};
  32. use parking_lot::Mutex as SyncMutex;
  33. use std::{
  34. collections::HashMap,
  35. io::Cursor,
  36. sync::{Arc, Weak},
  37. };
  38. use url::Url;
  39. use crate::{
  40. gfx::{gfxtag, DrawInstruction, EpochTracker, Point, Rectangle, RenderApi, Renderer},
  41. mesh::{Color, MeshBuilder, COLOR_CYAN, COLOR_GREEN, COLOR_RED, COLOR_WHITE},
  42. prop::{Property, PropertyColor, PropertyPermission, PropertySubType, PropertyType, Role},
  43. scene::{CallArgType, Pimpl, SceneNode, SceneNodeType, SceneNodeWeak},
  44. text,
  45. ui::UIObject,
  46. util::i18n::I18nBabelFish,
  47. };
  48. use super::{DrawOutcome, Hit, SharedProps};
  49. use crate::ui::chatview::{
  50. buffer::MsgBuffer, loader::Loader, ChatView, MessageId, MsgRecord, MsgType, Timestamp,
  51. };
  52. macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::filemsg", $($arg)*); } }
  53. macro_rules! i { ($($arg:tt)*) => { info!(target: "ui::chatview::filemsg", $($arg)*); } }
  54. /// The file transfer lifecycle of a fud file message.
  55. #[derive(Debug, Clone, PartialEq, SerialEncodable, SerialDecodable)]
  56. pub enum FileMsgStatus {
  57. Initializing,
  58. Idle,
  59. Downloading { progress: f32 },
  60. Downloaded { path: String },
  61. Error { msg: String, progress: f32 },
  62. }
  63. type GenericImageBuffer = ImageBuffer<Rgba<u8>, Vec<u8>>;
  64. /// Content-addressed state for one file URL: the download status and,
  65. /// once decoded, the image. Survives instance release (eviction) —
  66. /// re-materialization attaches to this instead of restarting.
  67. pub struct FileContent {
  68. pub status: FileMsgStatus,
  69. pub imgbuf: Option<GenericImageBuffer>,
  70. }
  71. struct FileInner {
  72. instances: HashMap<super::privmsg::InstKey, FileMsgInstance>,
  73. touches: HashMap<super::privmsg::InstKey, u64>,
  74. access: u64,
  75. epoch_tracker: Option<EpochTracker>,
  76. }
  77. /// The per-message rendered cache: layouts for the status lines, the
  78. /// active (click-to-download) rect, cached draw instructions.
  79. pub struct FileMsgInstance {
  80. url: Url,
  81. lines: Vec<text::TextLayout>,
  82. line_height: f32,
  83. max_width: f32,
  84. status_strs: Vec<String>,
  85. active_rect: Option<Rectangle>,
  86. instrs: Option<Vec<DrawInstruction>>,
  87. height: f32,
  88. }
  89. pub type FileMsgNodePtr = Arc<FileMsgNode>;
  90. pub struct FileMsgNode {
  91. node: SceneNodeWeak,
  92. shared: SharedProps,
  93. i18n: I18nBabelFish,
  94. loader: Arc<Loader>,
  95. buffer: Arc<AsyncMutex<MsgBuffer>>,
  96. chat: Weak<ChatView>,
  97. inner: SyncMutex<FileInner>,
  98. content: SyncMutex<HashMap<Url, FileContent>>,
  99. }
  100. /// Extract the first fud URL from a privmsg body, if any.
  101. pub fn get_file_url(text: &str) -> Option<Url> {
  102. let re = regex::Regex::new(r"fud://[^\s]+").unwrap();
  103. re.find(text).and_then(|m| Url::parse(m.as_str()).ok())
  104. }
  105. /// The synthetic id of a file message derived from its privmsg: a
  106. /// domain-separated hash with the top byte forced high, so the box
  107. /// sorts directly below its source line (older in display order).
  108. pub fn derived_file_id(source: &MessageId) -> MessageId {
  109. let mut hash = blake3::hash(&source.0).as_bytes()[..8].to_vec();
  110. hash[0] = 0xff;
  111. let mut id = [0u8; 32];
  112. id[..8].copy_from_slice(&hash);
  113. MessageId(id)
  114. }
  115. /// Encode a file message payload: the fud URL.
  116. pub fn encode_filemsg_payload(url: &Url) -> Vec<u8> {
  117. let mut payload = vec![];
  118. url.to_string().encode(&mut payload).unwrap();
  119. payload
  120. }
  121. /// Decode a file message payload back into its URL.
  122. ///
  123. /// ## Panics
  124. ///
  125. /// If the payload does not decode, identifying the entry.
  126. pub fn decode_filemsg_payload(payload: &[u8], ts: Timestamp, id: &MessageId) -> Url {
  127. let url: String = String::decode(&mut Cursor::new(payload))
  128. .unwrap_or_else(|e| panic!("corrupt chat entry: bad filemsg url [ts={ts} id={id}]: {e}"));
  129. Url::parse(&url).unwrap_or_else(|e| panic!("corrupt chat entry: bad filemsg url [{url}]: {e}"))
  130. }
  131. /// Build the derived filemsg record for a privmsg record, if its text
  132. /// carries a fud URL.
  133. pub fn derive_filemsg(privmsg: &MsgRecord, text: &str) -> Option<MsgRecord> {
  134. let url = get_file_url(text)?;
  135. Some(MsgRecord {
  136. ts: privmsg.ts,
  137. id: derived_file_id(&privmsg.id),
  138. msg_type: MsgType::FileMsg,
  139. payload: encode_filemsg_payload(&url),
  140. height: 0.,
  141. })
  142. }
  143. impl FileMsgNode {
  144. pub async fn new(
  145. node: SceneNodeWeak,
  146. shared: SharedProps,
  147. i18n: I18nBabelFish,
  148. loader: Arc<Loader>,
  149. buffer: Arc<AsyncMutex<MsgBuffer>>,
  150. chat: Weak<ChatView>,
  151. ) -> Pimpl {
  152. let self_ = Arc::new(Self {
  153. node: node.clone(),
  154. shared,
  155. i18n,
  156. loader,
  157. buffer,
  158. chat,
  159. inner: SyncMutex::new(FileInner {
  160. instances: HashMap::new(),
  161. touches: HashMap::new(),
  162. access: 0,
  163. epoch_tracker: None,
  164. }),
  165. content: SyncMutex::new(HashMap::new()),
  166. });
  167. Pimpl::FileMsgNode(self_)
  168. }
  169. /// The (translated) status line for a status. Fluent keys are the
  170. /// stable ids below; untranslated ids fall back to English.
  171. fn status_str(&self, status: &FileMsgStatus) -> String {
  172. let fallback = |id: &str, english: &str| {
  173. self.i18n
  174. .tr(&format!("chatview-file-status-{id}"))
  175. .unwrap_or_else(|| english.to_string())
  176. };
  177. match status {
  178. FileMsgStatus::Initializing => fallback("initializing", "starting fud"),
  179. FileMsgStatus::Idle => fallback("idle", "tap to download"),
  180. FileMsgStatus::Downloading { progress } => {
  181. format!("{} [{progress:.1}%]", fallback("downloading", "downloading"))
  182. }
  183. FileMsgStatus::Downloaded { .. } => fallback("downloaded", "downloaded"),
  184. FileMsgStatus::Error { msg, progress } => {
  185. let msg = msg.to_lowercase();
  186. if *progress > 0. {
  187. format!("{msg} [{progress:.1}%]")
  188. } else {
  189. msg
  190. }
  191. }
  192. }
  193. }
  194. /// The two box lines: shortened file hash and the status string.
  195. fn file_strs(&self, url: &Url, status: &FileMsgStatus) -> Vec<String> {
  196. let hash = url.host_str().unwrap_or("???");
  197. let short = if hash.chars().count() >= 12 {
  198. let head: String = hash.chars().take(4).collect();
  199. let tail: String =
  200. hash.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
  201. format!("{head}...{tail}")
  202. } else {
  203. hash.to_string()
  204. };
  205. vec![short, self.status_str(status)]
  206. }
  207. fn status_color(status: &FileMsgStatus, timestamp_color: Color) -> Color {
  208. match status {
  209. FileMsgStatus::Initializing => timestamp_color,
  210. FileMsgStatus::Idle => timestamp_color,
  211. FileMsgStatus::Downloading { .. } => COLOR_CYAN,
  212. FileMsgStatus::Downloaded { .. } => COLOR_GREEN,
  213. FileMsgStatus::Error { .. } => COLOR_RED,
  214. }
  215. }
  216. fn load_img(path: &str) -> Option<GenericImageBuffer> {
  217. let data = Arc::new(SyncMutex::new(vec![]));
  218. let data2 = data.clone();
  219. miniquad::fs::load_file(path, move |res| {
  220. if let Ok(res) = res {
  221. *data2.lock() = res;
  222. }
  223. });
  224. let data = std::mem::take(&mut *data.lock());
  225. let img =
  226. image::ImageReader::new(Cursor::new(data)).with_guessed_format().ok()?.decode().ok()?;
  227. Some(img.to_rgba8())
  228. }
  229. fn img_size(&self, imgbuf: &GenericImageBuffer) -> (f32, f32) {
  230. const IMG_MAX_HEIGHT: f32 = 500.;
  231. let max_width = self.shared.rect.get().w - self.shared.timestamp_width.get();
  232. let img_w = imgbuf.width() as f32;
  233. let img_h = imgbuf.height() as f32;
  234. let scale = (max_width / img_w).min(IMG_MAX_HEIGHT / img_h);
  235. (img_w * scale, img_h * scale)
  236. }
  237. /// Measure a record: materialize if needed, return the height.
  238. pub fn measure(&self, rec: &MsgRecord) -> f32 {
  239. let key = (rec.ts, rec.id);
  240. let mut inner = self.inner.lock();
  241. inner.access += 1;
  242. let access = inner.access;
  243. inner.touches.insert(key, access);
  244. if inner.instances.contains_key(&key) {
  245. let inst = inner.instances.get(&key).unwrap();
  246. return inst.height
  247. }
  248. let url = decode_filemsg_payload(&rec.payload, rec.ts, &rec.id);
  249. let height = {
  250. let mut content = self.content.lock();
  251. let entry = content.entry(url.clone()).or_insert_with(|| FileContent {
  252. status: FileMsgStatus::Initializing,
  253. imgbuf: None,
  254. });
  255. if entry.status == FileMsgStatus::Initializing {
  256. // First sight: idle until a download is requested.
  257. entry.status = FileMsgStatus::Idle;
  258. }
  259. if let Some(imgbuf) = &entry.imgbuf {
  260. let (_, img_h) = self.img_size(imgbuf);
  261. img_h + Self::MARGIN_TOP + Self::MARGIN_BOTTOM + self.shared.message_spacing.get()
  262. } else {
  263. self.box_height()
  264. }
  265. };
  266. let inst = FileMsgInstance {
  267. url,
  268. lines: vec![],
  269. line_height: self.shared.line_height.get(),
  270. max_width: 0.,
  271. status_strs: vec![],
  272. active_rect: None,
  273. instrs: None,
  274. height,
  275. };
  276. inner.instances.insert(key, inst);
  277. t!("materialized id={} height={height}", rec.id);
  278. height
  279. }
  280. const MARGIN_TOP: f32 = 4.;
  281. const MARGIN_BOTTOM: f32 = 10.;
  282. /// The status box height.
  283. fn box_height(&self) -> f32 {
  284. const BOX_PADDING_Y: f32 = 12.;
  285. let line_height = self.shared.line_height.get();
  286. 2. * line_height +
  287. BOX_PADDING_Y * 2. +
  288. Self::MARGIN_TOP +
  289. Self::MARGIN_BOTTOM +
  290. self.shared.message_spacing.get()
  291. }
  292. /// Whether the instance currently holds rendered state.
  293. pub fn is_materialized(&self, rec: &MsgRecord) -> bool {
  294. self.inner.lock().instances.contains_key(&(rec.ts, rec.id))
  295. }
  296. /// Drop an instance's rendered state; content-addressed state
  297. /// survives for re-materialization to attach to.
  298. pub fn release(&self, key: &super::privmsg::InstKey) {
  299. let mut inner = self.inner.lock();
  300. inner.instances.remove(key);
  301. inner.touches.remove(key);
  302. }
  303. /// Drop every instance's rendered state.
  304. pub fn release_all(&self) {
  305. let mut inner = self.inner.lock();
  306. inner.instances.clear();
  307. inner.touches.clear();
  308. }
  309. /// Rebuild rendered state from live props + current data.
  310. pub fn regen(&self, key: &super::privmsg::InstKey) {
  311. self.release(key);
  312. }
  313. /// Rebuild every instance's rendered state.
  314. pub fn regen_all(&self) {
  315. self.release_all();
  316. }
  317. /// Release the out-of-window instances beyond the LRU budget.
  318. pub fn sweep(&self, keep: &std::collections::HashSet<super::privmsg::InstKey>, budget: usize) {
  319. let releases = {
  320. let inner = self.inner.lock();
  321. super::evict_beyond(keep, &inner.touches, budget)
  322. };
  323. for key in releases {
  324. self.release(&key);
  325. }
  326. }
  327. /// Renderer-bound draw instructions in message-local coordinates.
  328. pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> DrawOutcome {
  329. const BOX_PADDING_Y: f32 = 12.;
  330. const BOX_PADDING_X: f32 = 15.;
  331. const GLOW_SIZE: f32 = 20.;
  332. let key = (rec.ts, rec.id);
  333. let mut inner = self.inner.lock();
  334. inner.access += 1;
  335. let access = inner.access;
  336. inner.touches.insert(key, access);
  337. let epoch_changed =
  338. inner.epoch_tracker.get_or_insert_with(|| EpochTracker::new(renderer)).changed();
  339. if epoch_changed {
  340. for inst in inner.instances.values_mut() {
  341. inst.instrs = None;
  342. }
  343. }
  344. let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
  345. let line_height = self.shared.line_height.get();
  346. let timestamp_width = self.shared.timestamp_width.get();
  347. let timestamp_color = self.shared.timestamp_color.get();
  348. let font_size = self.shared.font_size.get();
  349. let window_scale = self.shared.window_scale.get();
  350. let max_width = self.shared.rect.get().w - timestamp_width - GLOW_SIZE;
  351. if inst.instrs.is_none() {
  352. let (status, imgbuf) = {
  353. let content = self.content.lock();
  354. content
  355. .get(&inst.url)
  356. .map(|c| (c.status.clone(), c.imgbuf.clone()))
  357. .unwrap_or_else(|| (FileMsgStatus::Initializing, None))
  358. };
  359. let mut instrs = vec![];
  360. if let Some(imgbuf) = imgbuf {
  361. // Downloaded image: fitted to bounds, with a glow.
  362. let (img_w, img_h) = self.img_size(&imgbuf);
  363. let mesh_rect = Rectangle::from([timestamp_width, Self::MARGIN_TOP, img_w, img_h]);
  364. let width = imgbuf.width() as u16;
  365. let height = imgbuf.height() as u16;
  366. let bmp = imgbuf.as_raw().clone();
  367. let texture = renderer.new_texture(
  368. width,
  369. height,
  370. bmp,
  371. miniquad::TextureFormat::RGBA8,
  372. gfxtag!("chatview_fileimg_texture"),
  373. );
  374. let mut mesh_gradient = MeshBuilder::new(gfxtag!("chatview_fileimg_glow"));
  375. let glow_color = [timestamp_color[0], timestamp_color[1], timestamp_color[2], 0.5];
  376. mesh_gradient.draw_box_shadow(&mesh_rect, glow_color, GLOW_SIZE);
  377. instrs.push(DrawInstruction::Draw(mesh_gradient.alloc(renderer).draw_untextured()));
  378. let mut mesh_img = MeshBuilder::new(gfxtag!("chatview_fileimg"));
  379. let uv_rect = Rectangle::from([0., 0., 1., 1.]);
  380. mesh_img.draw_box(&mesh_rect, COLOR_WHITE, &uv_rect);
  381. instrs.push(DrawInstruction::Draw(
  382. mesh_img.alloc(renderer).draw_with_textures(vec![texture]),
  383. ));
  384. inst.active_rect = Some(mesh_rect);
  385. } else {
  386. // Status box: outline + glow + the two text lines.
  387. let color = Self::status_color(&status, timestamp_color);
  388. let file_strs = self.file_strs(&inst.url, &status);
  389. let mut layouts = Vec::with_capacity(file_strs.len());
  390. let mut text_width = 0.;
  391. for file_str in &file_strs {
  392. let layout = text::make_layout(
  393. file_str,
  394. color,
  395. font_size,
  396. line_height / font_size,
  397. window_scale,
  398. Some(max_width),
  399. &[],
  400. );
  401. if layout.width() > text_width {
  402. text_width = layout.width();
  403. }
  404. layouts.push(layout);
  405. }
  406. inst.status_strs = file_strs;
  407. let box_height = 2. * line_height + BOX_PADDING_Y * 2.;
  408. let box_width = if text_width > max_width { max_width } else { text_width } +
  409. BOX_PADDING_X * 2.;
  410. let mesh_rect =
  411. Rectangle::new(timestamp_width, Self::MARGIN_TOP, box_width, box_height);
  412. let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg_box"));
  413. mesh.draw_outline(&mesh_rect, color, 1.);
  414. let glow_color = [color[0], color[1], color[2], 0.3];
  415. mesh.draw_box_shadow(&mesh_rect, glow_color, GLOW_SIZE);
  416. instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
  417. instrs.push(DrawInstruction::Move(Point::new(
  418. timestamp_width + BOX_PADDING_X,
  419. Self::MARGIN_TOP + BOX_PADDING_Y,
  420. )));
  421. for layout in layouts {
  422. let text_instrs =
  423. text::render_layout(&layout, renderer, gfxtag!("chatview_filemsg_text"));
  424. instrs.extend(text_instrs);
  425. instrs.push(DrawInstruction::Move(Point::new(0., line_height)));
  426. }
  427. inst.active_rect = Some(mesh_rect);
  428. inst.lines = vec![];
  429. }
  430. inst.instrs = Some(instrs);
  431. }
  432. DrawOutcome::Inline(inst.instrs.clone().unwrap_or_default())
  433. }
  434. /// Clipboard contribution when selected: the file URL.
  435. pub fn copy_text(&self, rec: &MsgRecord) -> Option<String> {
  436. let inner = self.inner.lock();
  437. inner.instances.get(&(rec.ts, rec.id)).map(|inst| inst.url.to_string())
  438. }
  439. /// Hit dispatch: the active rect (image or status box) activates a
  440. /// download request when idle or errored.
  441. pub fn hit_test(&self, rec: &MsgRecord, pos: Point) -> Option<Hit> {
  442. let inner = self.inner.lock();
  443. let inst = inner.instances.get(&(rec.ts, rec.id))?;
  444. let rect = inst.active_rect?;
  445. if !rect.contains(pos) {
  446. return None
  447. }
  448. let status = {
  449. let content = self.content.lock();
  450. content.get(&inst.url).map(|c| c.status.clone()).unwrap_or(FileMsgStatus::Initializing)
  451. };
  452. match status {
  453. FileMsgStatus::Idle | FileMsgStatus::Error { .. } => Some(Hit::File(inst.url.clone())),
  454. _ => None,
  455. }
  456. }
  457. /// Update the status of every file message with this URL; heights
  458. /// re-flow into geometry with scroll compensation, `status_changed`
  459. /// fires for each affected message, and a finished download decodes
  460. /// its image into the content store.
  461. pub async fn set_file_status(&self, url: &Url, status: FileMsgStatus) {
  462. t!("set_file_status({url}, {status:?})");
  463. {
  464. let mut content = self.content.lock();
  465. let Some(entry) = content.get_mut(url) else { return };
  466. if entry.status != status {
  467. entry.status = status.clone();
  468. if let FileMsgStatus::Downloaded { path } = &status {
  469. entry.imgbuf = Self::load_img(path);
  470. t!("decoded image for {url}: {}", entry.imgbuf.is_some());
  471. }
  472. }
  473. }
  474. // Regen every loaded record carrying this URL and flow the new
  475. // heights into geometry.
  476. let mut buffer = self.buffer.lock().await;
  477. let mut keys = vec![];
  478. for rec in buffer.iter_display_order() {
  479. if rec.msg_type == MsgType::FileMsg {
  480. let rec_url = decode_filemsg_payload(&rec.payload, rec.ts, &rec.id);
  481. if &rec_url == url {
  482. keys.push((rec.ts, rec.id));
  483. }
  484. }
  485. }
  486. drop(buffer);
  487. for key in keys {
  488. self.regen(&key);
  489. let rec = {
  490. let buffer = self.buffer.lock().await;
  491. buffer.record(&key.1).filter(|r| r.ts == key.0).cloned()
  492. };
  493. let Some(rec) = rec else { continue };
  494. let height = self.measure(&rec);
  495. let mut buffer = self.buffer.lock().await;
  496. let below = match buffer.pos_of(&key.1) {
  497. Some(top) => {
  498. let scroll = self.controller_scroll();
  499. top <= scroll
  500. }
  501. None => false,
  502. };
  503. if let Some(delta) = buffer.set_height_key(&key, height) {
  504. if let Some(chat) = self.chat.upgrade() {
  505. let mut ctl = chat.controller.lock();
  506. ctl.compensate(delta, below);
  507. }
  508. }
  509. if let Some(node) = self.node.upgrade() {
  510. let mut data = vec![];
  511. key.1.encode(&mut data).unwrap();
  512. let _ = node.trigger("status_changed", data).await;
  513. }
  514. }
  515. if let Some(chat) = self.chat.upgrade() {
  516. chat.redraw.trigger();
  517. }
  518. }
  519. fn controller_scroll(&self) -> f32 {
  520. self.chat.upgrade().map(|chat| chat.controller.lock().scroll()).unwrap_or(0.)
  521. }
  522. /// Request the download of a file message: emits
  523. /// `download_request(id, url)`.
  524. pub async fn request_download(&self, id: &MessageId, url: &Url) {
  525. t!("download requested: {url}");
  526. if let Some(node) = self.node.upgrade() {
  527. let mut data = vec![];
  528. id.encode(&mut data).unwrap();
  529. url.encode(&mut data).unwrap();
  530. let _ = node.trigger("download_request", data).await;
  531. }
  532. }
  533. /// The scene node handle.
  534. pub fn node(&self) -> &SceneNodeWeak {
  535. &self.node
  536. }
  537. /// The content state of a file URL (test access).
  538. pub(crate) fn status_of(&self, url: &Url) -> Option<FileMsgStatus> {
  539. self.content.lock().get(url).map(|c| c.status.clone())
  540. }
  541. /// The box lines for a status (test access).
  542. pub(crate) fn file_strs_for_test(&self, url: &Url, status: &FileMsgStatus) -> Vec<String> {
  543. self.file_strs(url, status)
  544. }
  545. }
  546. /// Scene node factory for the file-message type node.
  547. #[async_trait]
  548. impl UIObject for FileMsgNode {
  549. fn priority(&self) -> u32 {
  550. 0
  551. }
  552. }
  553. impl std::fmt::Debug for FileMsgNode {
  554. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  555. write!(f, "{:?}", self.node.upgrade())
  556. }
  557. }
  558. #[cfg(test)]
  559. mod tests {
  560. use super::*;
  561. use crate::{app::node::create_filemsg_node, prop::PropertyAtomicGuard, ui::chatview::codec};
  562. async fn make_node(tag: &str, i18n_src: &str) -> (FileMsgNodePtr, Arc<AsyncMutex<MsgBuffer>>) {
  563. let chat = crate::app::node::create_chatview("chatview");
  564. let chat = chat.setup_null();
  565. let atom = &mut PropertyAtomicGuard::none();
  566. chat.set_property_f32(atom, Role::App, "font_size", 14.).unwrap();
  567. chat.set_property_f32(atom, Role::App, "timestamp_font_size", 10.).unwrap();
  568. chat.set_property_f32(atom, Role::App, "timestamp_width", 50.).unwrap();
  569. chat.set_property_f32(atom, Role::App, "line_height", 20.).unwrap();
  570. chat.set_property_f32(atom, Role::App, "message_spacing", 4.).unwrap();
  571. let prop = chat.get_property("rect").unwrap();
  572. prop.set_f32(atom, Role::App, 0, 0.).unwrap();
  573. prop.set_f32(atom, Role::App, 1, 0.).unwrap();
  574. prop.set_f32(atom, Role::App, 2, 800.).unwrap();
  575. prop.set_f32(atom, Role::App, 3, 600.).unwrap();
  576. let prop = chat.get_property("timestamp_color").unwrap();
  577. for (i, c) in [0.5, 0.5, 0.5, 1.].iter().enumerate() {
  578. prop.set_f32(atom, Role::App, i, *c).unwrap();
  579. }
  580. let mut wscale = crate::scene::SceneNode::new("w", crate::scene::SceneNodeType::Object);
  581. wscale
  582. .add_property(Property::new(
  583. "scale",
  584. PropertyType::Float32,
  585. PropertySubType::Null,
  586. PropertyPermission::default(),
  587. ))
  588. .unwrap();
  589. let wscale = wscale.setup_null();
  590. wscale.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
  591. let window_scale =
  592. crate::prop::PropertyFloat32::wrap(&wscale, Role::Internal, "scale", 0).unwrap();
  593. let shared = super::super::SharedProps::wrap(&chat, window_scale);
  594. let mut raw = MsgBuffer::new();
  595. raw.disable_separators();
  596. let buffer = Arc::new(AsyncMutex::new(raw));
  597. let (redraw, _rx) = crate::ui::RedrawTrigger::new();
  598. let loader = Loader::new(buffer.clone(), redraw);
  599. let i18n = I18nBabelFish::new(i18n_src.to_string(), "en-US");
  600. let chat_weak: Weak<ChatView> = Weak::new();
  601. let node = create_filemsg_node("filemsg");
  602. let shared2 = shared.clone();
  603. let i18n2 = i18n.clone();
  604. let loader2 = loader.clone();
  605. let buffer2 = buffer.clone();
  606. let node = node
  607. .setup(|me| async move {
  608. FileMsgNode::new(me, shared2, i18n2, loader2, buffer2, chat_weak).await
  609. })
  610. .await;
  611. chat.link(node.clone());
  612. let Pimpl::FileMsgNode(ptr) = node.pimpl() else { panic!() };
  613. (ptr.clone(), buffer)
  614. }
  615. fn file_rec(ts: Timestamp, idb: u8, url: &Url) -> MsgRecord {
  616. let mut id = [0u8; 32];
  617. id[0] = idb;
  618. MsgRecord {
  619. ts,
  620. id: MessageId(id),
  621. msg_type: MsgType::FileMsg,
  622. payload: encode_filemsg_payload(url),
  623. height: 0.,
  624. }
  625. }
  626. #[test]
  627. fn derivation_keys_and_orders() {
  628. let payload =
  629. codec::encode_privmsg_payload("alice", "grab fud://abcdef/file.tar now", true);
  630. let privmsg = MsgRecord {
  631. ts: 1000,
  632. id: MessageId([7; 32]),
  633. msg_type: MsgType::PrivMsg,
  634. payload,
  635. height: 0.,
  636. };
  637. let file = derive_filemsg(&privmsg, "grab fud://abcdef/file.tar now").expect("derived");
  638. assert_eq!(file.ts, privmsg.ts, "shares the privmsg timestamp");
  639. assert!(file.id.0 > privmsg.id.0, "sorts directly below its source line");
  640. assert_eq!(file.msg_type, MsgType::FileMsg);
  641. let url = decode_filemsg_payload(&file.payload, file.ts, &file.id);
  642. assert_eq!(url.host_str(), Some("abcdef"));
  643. assert!(derive_filemsg(&privmsg, "no urls here").is_none());
  644. }
  645. #[test]
  646. fn statuses_measured_and_content_survives_release() {
  647. let (node, _buffer) = smol::block_on(make_node("status", ""));
  648. let url = Url::parse("fud://abcdef012345/file.png").unwrap();
  649. let rec = file_rec(1000, b'a', &url);
  650. let h1 = node.measure(&rec);
  651. // 2 text lines + paddings + margins + spacing.
  652. assert!((h1 - (2. * 20. + 12. * 2. + 4. + 10. + 4.)).abs() < 0.01, "{h1}");
  653. // First sight registers Idle content state.
  654. assert_eq!(node.status_of(&url), Some(FileMsgStatus::Idle));
  655. // A status update lands in the content store and regens.
  656. smol::block_on(async {
  657. node.set_file_status(&url, FileMsgStatus::Downloading { progress: 42. }).await;
  658. });
  659. assert_eq!(node.status_of(&url), Some(FileMsgStatus::Downloading { progress: 42. }));
  660. // Eviction drops the instance; the content-addressed state
  661. // survives and re-materialization attaches to it.
  662. node.release(&(rec.ts, rec.id));
  663. assert!(!node.is_materialized(&rec));
  664. assert_eq!(node.status_of(&url), Some(FileMsgStatus::Downloading { progress: 42. }));
  665. let h2 = node.measure(&rec);
  666. assert_eq!(h1, h2, "same status box height");
  667. }
  668. #[test]
  669. fn status_strings_translate() {
  670. let (node, _buffer) = smol::block_on(make_node(
  671. "i18n",
  672. "chatview-file-status-idle = zum Herunterladen tippen\n",
  673. ));
  674. let url = Url::parse("fud://abcdef012345/file.png").unwrap();
  675. let rec = file_rec(1000, b'a', &url);
  676. node.measure(&rec);
  677. let strs = node.file_strs_for_test(&url, &FileMsgStatus::Idle);
  678. assert_eq!(strs[1], "zum Herunterladen tippen");
  679. // Untranslated statuses fall back to the English string.
  680. let strs =
  681. node.file_strs_for_test(&url, &FileMsgStatus::Downloaded { path: String::new() });
  682. assert_eq!(strs[1], "downloaded");
  683. }
  684. }