privmsg.rs 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464
  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 privmsg type node: insertion semantics and basic rendering.
  19. //!
  20. //! One scene sub-node (created with the chatview, stable across
  21. //! channels) carries the type's properties, signals, and lifecycle
  22. //! methods; per-id instances carry data + rendered state. Insertion
  23. //! persists through the loader (the sole kvdb writer), dedups by the
  24. //! composite key, and confirms unconfirmed messages in place. The
  25. //! rendered state is a pure cache of (data, props): a signature check
  26. //! skips re-layouts, and regen drops it for a full rebuild.
  27. use async_lock::Mutex as AsyncMutex;
  28. use async_trait::async_trait;
  29. use chrono::{Local, TimeZone};
  30. use darkfi_serial::{Decodable, Encodable};
  31. use parking_lot::Mutex as SyncMutex;
  32. use std::{
  33. collections::{HashMap, HashSet},
  34. hash::{DefaultHasher, Hash, Hasher},
  35. io::Cursor,
  36. sync::{Arc, Weak},
  37. };
  38. use url::Url;
  39. use crate::{
  40. gfx::{gfxtag, DrawInstruction, EpochTracker, Point, Rectangle, Renderer},
  41. mesh::Color,
  42. prop::{
  43. Property, PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyPermission,
  44. PropertyPtr, PropertyStr, PropertySubType, PropertyType, Role,
  45. },
  46. scene::{CallArgType, Pimpl, SceneNode, SceneNodePtr, SceneNodeType, SceneNodeWeak},
  47. text,
  48. ui::UIObject,
  49. ExecutorPtr,
  50. };
  51. use super::{evict_beyond, filemsg::get_file_url, DrawOutcome, Hit, SharedProps};
  52. use crate::ui::chatview::{
  53. buffer::MsgBuffer, codec, loader::Loader, ChatView, MessageId, MsgRecord, MsgType, Timestamp,
  54. Wakeup,
  55. };
  56. macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::privmsg", $($arg)*); } }
  57. /// Instance map key: the composite record key (derived records share
  58. /// synthetic ids).
  59. pub type InstKey = (Timestamp, MessageId);
  60. /// Unconfirmed bodies render gray.
  61. const UNCONF_COLOR: [f32; 4] = [0.4, 0.4, 0.4, 1.];
  62. /// IRC CTCP ACTION framing prefix, e.g. `\x01ACTION waves\x01`.
  63. const CTCP_ACTION_PREFIX: &str = "\u{1}ACTION ";
  64. /// If `text` is a CTCP ACTION-framed message body, return the stripped
  65. /// action text. A single trailing `\x01` delimiter is stripped when
  66. /// present but is not required, since bodies truncated by length
  67. /// limits may lose it.
  68. fn parse_ctcp_action(text: &str) -> Option<&str> {
  69. let body = text.strip_prefix(CTCP_ACTION_PREFIX)?;
  70. let body = body.strip_suffix('\u{1}').unwrap_or(body);
  71. Some(body)
  72. }
  73. fn is_notice(nick: &str) -> bool {
  74. nick == "NOTICE"
  75. }
  76. /// Stable per-nick color: hash the nick, index into the palette. An
  77. /// empty palette falls back to white.
  78. fn select_nick_color(nick: &str, nick_colors: &[Color]) -> Color {
  79. if nick_colors.is_empty() {
  80. return [1., 1., 1., 1.]
  81. }
  82. let mut hasher = DefaultHasher::new();
  83. nick.hash(&mut hasher);
  84. let i = hasher.finish() as usize;
  85. nick_colors[i % nick_colors.len()]
  86. }
  87. fn read_nick_colors(prop: &PropertyPtr) -> Vec<Color> {
  88. let mut colors = vec![];
  89. let mut color = [0f32; 4];
  90. for i in 0..prop.get_len() {
  91. color[i % 4] = prop.get_f32(i).expect("prop logic err");
  92. if i > 0 && i % 4 == 0 {
  93. let color = std::mem::take(&mut color);
  94. colors.push(color);
  95. }
  96. }
  97. colors
  98. }
  99. fn gen_timestr(timestamp: Timestamp) -> String {
  100. let Some(dt) = Local.timestamp_millis_opt(timestamp as i64).single() else {
  101. return String::new()
  102. };
  103. dt.format("%H:%M").to_string()
  104. }
  105. /// The type-specific property handles, wrapped off this node.
  106. #[derive(Clone)]
  107. pub struct PrivOwnProps {
  108. pub nick_colors: PropertyPtr,
  109. pub action_text_color: PropertyColor,
  110. pub url_text_color: PropertyColor,
  111. pub url_bg_color: PropertyColor,
  112. pub url_bg_border_size: PropertyFloat32,
  113. pub url_bg_border_color: PropertyColor,
  114. pub cap_max_height: PropertyFloat32,
  115. }
  116. /// Everything a materialized instance needs to render: data decoded
  117. /// from the record payload, plus a pure cache of (data, props).
  118. pub struct PrivMsg {
  119. /// Decoded payload state.
  120. pub data: PrivData,
  121. /// The payload the rendered state was built from (data changes for
  122. /// a live id — e.g. confirmation — invalidate the cache).
  123. payload: Vec<u8>,
  124. /// Signature the rendered state was built with.
  125. sig: LayoutSig,
  126. txt_layout: text::TextLayout,
  127. ts_layout: text::TextLayout,
  128. instrs: Option<Vec<DrawInstruction>>,
  129. /// URL hit rects in message-local coordinates, tagged with their
  130. /// URL string.
  131. url_rects: Vec<(String, Rectangle)>,
  132. /// Nick-prefix hit rects in message-local coordinates, tagged with
  133. /// the nick.
  134. nick_rects: Vec<(String, Rectangle)>,
  135. /// The expand/collapse affordance hit rect, when the message is
  136. /// over the cap.
  137. affordance_rect: Option<Rectangle>,
  138. /// The un-capped text height.
  139. full_text_height: f32,
  140. /// Whether the message's full height exceeds the cap.
  141. over_cap: bool,
  142. /// Whether the message is currently drawn collapsed.
  143. collapsed: bool,
  144. /// Measured height incl. message spacing.
  145. pub height: f32,
  146. }
  147. pub struct PrivData {
  148. pub ts: Timestamp,
  149. pub id: MessageId,
  150. pub nick: String,
  151. pub text: String,
  152. pub confirmed: bool,
  153. /// IRC-style CTCP ACTION (`/me`); `text` holds the stripped body.
  154. pub is_action: bool,
  155. pub is_notice: bool,
  156. pub expanded: bool,
  157. }
  158. impl PrivData {
  159. /// The full rendered line text: NOTICE renders the body alone,
  160. /// normal messages render "<nick> <body>", actions
  161. /// "* <nick> <body>".
  162. pub fn line_text(&self) -> String {
  163. if self.is_notice {
  164. return self.text.clone()
  165. }
  166. if self.is_action {
  167. return format!("* {} {}", self.nick, self.text)
  168. }
  169. format!("{} {}", self.nick, self.text)
  170. }
  171. /// Byte offset of the body within the rendered line text. This is
  172. /// also the end of the nick-colored prefix.
  173. pub fn body_offset(&self) -> usize {
  174. if self.is_notice {
  175. return 0
  176. }
  177. if self.is_action {
  178. // "* " + nick + " "
  179. return self.nick.len() + 3
  180. }
  181. self.nick.len() + 1
  182. }
  183. }
  184. /// The inputs a rendered state depends on; any mismatch forces a
  185. /// re-layout. Colors are included so palette/role changes invalidate.
  186. #[derive(PartialEq)]
  187. struct LayoutSig {
  188. width: f32,
  189. font_size: f32,
  190. timestamp_font_size: f32,
  191. line_height: f32,
  192. window_scale: f32,
  193. confirmed: bool,
  194. expanded: bool,
  195. body_color: Color,
  196. nick_color: Color,
  197. ts_color: Color,
  198. }
  199. struct PrivInner {
  200. instances: HashMap<InstKey, PrivMsg>,
  201. /// Last-access counter per instance, for LRU eviction.
  202. touches: HashMap<InstKey, u64>,
  203. /// Monotonic access counter driving `touches`.
  204. access: u64,
  205. /// Epoch-scoped mesh caches die with the epoch.
  206. epoch_tracker: Option<EpochTracker>,
  207. /// How many layouts have been (re)built; cache-behavior test hook.
  208. layout_builds: usize,
  209. }
  210. pub type PrivMsgNodePtr = Arc<PrivMsgNode>;
  211. /// The privmsg type node.
  212. pub struct PrivMsgNode {
  213. node: SceneNodeWeak,
  214. shared: SharedProps,
  215. own: PrivOwnProps,
  216. loader: Arc<Loader>,
  217. buffer: Arc<AsyncMutex<MsgBuffer>>,
  218. chat: Weak<ChatView>,
  219. inner: SyncMutex<PrivInner>,
  220. /// Messages the user expanded (over-cap ones collapse by default);
  221. /// survives rendered-state regens.
  222. expanded: SyncMutex<HashSet<InstKey>>,
  223. /// "Copied link" overlay styling/content; the chatview-drawn toast
  224. /// reads these through the getters below.
  225. url_copy_text: PropertyStr,
  226. url_copy_fg_color: PropertyColor,
  227. url_copy_bg_color: PropertyColor,
  228. url_copy_font_size: PropertyFloat32,
  229. url_copy_padding: PropertyFloat32,
  230. url_copy_offset: PropertyFloat32,
  231. url_copy_duration: PropertyFloat32,
  232. }
  233. impl PrivMsgNode {
  234. pub async fn new(
  235. node: SceneNodeWeak,
  236. shared: SharedProps,
  237. loader: Arc<Loader>,
  238. buffer: Arc<AsyncMutex<MsgBuffer>>,
  239. chat: Weak<ChatView>,
  240. ) -> Pimpl {
  241. let node_ref = &node.upgrade().unwrap();
  242. let nick_colors = node_ref.get_property("nick_colors").expect("privmsg nick_colors");
  243. let action_text_color =
  244. PropertyColor::wrap(node_ref, Role::Internal, "action_text_color").unwrap();
  245. let url_text_color =
  246. PropertyColor::wrap(node_ref, Role::Internal, "url_text_color").unwrap();
  247. let url_bg_color = PropertyColor::wrap(node_ref, Role::Internal, "url_bg_color").unwrap();
  248. let url_bg_border_size =
  249. PropertyFloat32::wrap(node_ref, Role::Internal, "url_bg_border_size", 0).unwrap();
  250. let url_bg_border_color =
  251. PropertyColor::wrap(node_ref, Role::Internal, "url_bg_border_color").unwrap();
  252. let cap_max_height =
  253. PropertyFloat32::wrap(node_ref, Role::Internal, "cap_max_height", 0).unwrap();
  254. let url_copy_text =
  255. PropertyStr::wrap(node_ref, Role::Internal, "url_copy_text", 0).unwrap();
  256. let url_copy_fg_color =
  257. PropertyColor::wrap(node_ref, Role::Internal, "url_copy_fg_color").unwrap();
  258. let url_copy_bg_color =
  259. PropertyColor::wrap(node_ref, Role::Internal, "url_copy_bg_color").unwrap();
  260. let url_copy_font_size =
  261. PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_font_size", 0).unwrap();
  262. let url_copy_padding =
  263. PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_padding", 0).unwrap();
  264. let url_copy_offset =
  265. PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_offset", 0).unwrap();
  266. let url_copy_duration =
  267. PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_duration", 0).unwrap();
  268. let self_ = Arc::new(Self {
  269. node: node.clone(),
  270. shared,
  271. own: PrivOwnProps {
  272. nick_colors,
  273. action_text_color,
  274. url_text_color,
  275. url_bg_color,
  276. url_bg_border_size,
  277. url_bg_border_color,
  278. cap_max_height,
  279. },
  280. loader,
  281. buffer,
  282. chat,
  283. inner: SyncMutex::new(PrivInner {
  284. instances: HashMap::new(),
  285. touches: HashMap::new(),
  286. access: 0,
  287. epoch_tracker: None,
  288. layout_builds: 0,
  289. }),
  290. expanded: SyncMutex::new(HashSet::new()),
  291. url_copy_text,
  292. url_copy_fg_color,
  293. url_copy_bg_color,
  294. url_copy_font_size,
  295. url_copy_padding,
  296. url_copy_offset,
  297. url_copy_duration,
  298. });
  299. Pimpl::PrivMsgNode(self_)
  300. }
  301. /// Read the current layout signature off the live properties.
  302. fn current_sig(&self, data: &PrivData) -> LayoutSig {
  303. let width = self.shared.rect.get().w - self.shared.timestamp_width.get();
  304. let font_size = if data.is_notice {
  305. self.shared.font_size.get() * 0.8
  306. } else {
  307. self.shared.font_size.get()
  308. };
  309. let nick_colors = read_nick_colors(&self.own.nick_colors);
  310. let nick_color = select_nick_color(&data.nick, &nick_colors);
  311. let body_color = if data.is_action {
  312. if data.confirmed {
  313. self.own.action_text_color.get()
  314. } else {
  315. UNCONF_COLOR
  316. }
  317. } else if data.confirmed {
  318. self.shared.text_color.get()
  319. } else {
  320. UNCONF_COLOR
  321. };
  322. LayoutSig {
  323. width,
  324. font_size,
  325. timestamp_font_size: self.shared.timestamp_font_size.get(),
  326. line_height: self.shared.line_height.get(),
  327. window_scale: self.shared.window_scale.get(),
  328. confirmed: data.confirmed,
  329. expanded: data.expanded,
  330. body_color,
  331. nick_color,
  332. ts_color: self.shared.timestamp_color.get(),
  333. }
  334. }
  335. /// Build the instance's rendered state from live props + data.
  336. fn build_rendered(&self, key: &InstKey, data: PrivData) -> PrivMsg {
  337. let expanded = self.expanded.lock().contains(key);
  338. let data = PrivData { expanded, ..data };
  339. let sig = self.current_sig(&data);
  340. let linetext = data.line_text();
  341. let line_height = self.shared.line_height.get();
  342. let window_scale = self.shared.window_scale.get();
  343. let mut foreground_colors = vec![];
  344. if !data.is_notice {
  345. foreground_colors.push((0..data.body_offset(), sig.nick_color));
  346. }
  347. foreground_colors.extend(url_color_ranges(
  348. &data.text,
  349. data.body_offset(),
  350. self.own.url_text_color.get(),
  351. ));
  352. let txt_layout = if data.is_notice {
  353. text::make_layout2(
  354. &linetext,
  355. sig.body_color,
  356. sig.font_size,
  357. line_height / sig.font_size,
  358. window_scale,
  359. Some(sig.width),
  360. &[],
  361. &[],
  362. parley::Alignment::Start,
  363. parley::OverflowWrap::Normal,
  364. )
  365. } else {
  366. text::make_layout2(
  367. &linetext,
  368. sig.body_color,
  369. sig.font_size,
  370. line_height / sig.font_size,
  371. window_scale,
  372. Some(sig.width),
  373. &[],
  374. &foreground_colors,
  375. parley::Alignment::Start,
  376. parley::OverflowWrap::Normal,
  377. )
  378. };
  379. let timestr = gen_timestr(data.ts);
  380. let ts_layout = text::make_layout(
  381. &timestr,
  382. sig.ts_color,
  383. sig.timestamp_font_size,
  384. line_height / sig.timestamp_font_size,
  385. window_scale,
  386. None,
  387. &[],
  388. );
  389. // Hit rects: URL-colored and nick-colored glyph runs become
  390. // clickable regions in message-local coordinates.
  391. let url_color = self.own.url_text_color.get();
  392. let nick_color = sig.nick_color;
  393. let timestamp_width = self.shared.timestamp_width.get();
  394. let url_rects = Self::compute_hit_rects(
  395. &txt_layout,
  396. &data,
  397. timestamp_width,
  398. url_color,
  399. &url_ranges_of(&data, url_color),
  400. |raw| sanitize_url(raw),
  401. );
  402. let nick_rects = if data.is_notice {
  403. vec![]
  404. } else {
  405. let nick_range = 0..data.body_offset();
  406. Self::compute_hit_rects(
  407. &txt_layout,
  408. &data,
  409. timestamp_width,
  410. nick_color,
  411. &[nick_range],
  412. |raw| Some(raw.trim_end().to_string()),
  413. )
  414. };
  415. // Cap/expand: long messages collapse to the cap by default.
  416. let full_text_height = txt_layout.height();
  417. let cap = self.own.cap_max_height.get();
  418. let over_cap = cap > 0. && full_text_height > cap;
  419. let collapsed = over_cap && !expanded;
  420. let affordance_rect = over_cap.then(|| {
  421. let line_height = self.shared.line_height.get();
  422. let width = self.shared.rect.get().w;
  423. let y = if collapsed { cap - line_height } else { full_text_height - line_height };
  424. Rectangle::new(width - 60., y.max(0.), 60., line_height)
  425. });
  426. let text_height = if collapsed { cap } else { full_text_height };
  427. let height = text_height + self.shared.message_spacing.get();
  428. PrivMsg {
  429. data,
  430. payload: vec![],
  431. sig,
  432. txt_layout,
  433. ts_layout,
  434. instrs: None,
  435. url_rects,
  436. nick_rects,
  437. affordance_rect,
  438. full_text_height,
  439. over_cap,
  440. collapsed,
  441. height,
  442. }
  443. }
  444. /// The instance, materializing (or re-materializing) as needed.
  445. /// Returns the measured height.
  446. fn ensure_materialized(&self, rec: &MsgRecord) -> f32 {
  447. let key = (rec.ts, rec.id);
  448. let mut inner = self.inner.lock();
  449. inner.access += 1;
  450. let access = inner.access;
  451. inner.touches.insert(key, access);
  452. if let Some(inst) = inner.instances.get_mut(&key) {
  453. let sig = self.current_sig(&inst.data);
  454. if inst.sig == sig && inst.payload == rec.payload {
  455. return inst.height
  456. }
  457. }
  458. let (nick, text, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
  459. let (is_action, text) = match parse_ctcp_action(&text) {
  460. Some(action) => (true, action.to_string()),
  461. None => (false, text),
  462. };
  463. let is_notice = is_notice(&nick);
  464. let data = PrivData {
  465. ts: rec.ts,
  466. id: rec.id,
  467. nick,
  468. text,
  469. confirmed,
  470. is_action,
  471. is_notice,
  472. expanded: true,
  473. };
  474. let inst = self.build_rendered(&key, data);
  475. let height = inst.height;
  476. let inst = PrivMsg { payload: rec.payload.clone(), ..inst };
  477. inner.instances.insert(key, inst);
  478. inner.layout_builds += 1;
  479. t!("materialized id={} height={height}", rec.id);
  480. height
  481. }
  482. /// Measure a record: materialize if needed, return the height.
  483. pub fn measure(&self, rec: &MsgRecord) -> f32 {
  484. self.ensure_materialized(rec)
  485. }
  486. /// Whether the instance currently holds rendered state.
  487. pub fn is_materialized(&self, rec: &MsgRecord) -> bool {
  488. self.inner.lock().instances.contains_key(&(rec.ts, rec.id))
  489. }
  490. /// How many instances are materialized (LRU bookkeeping, tests).
  491. pub fn instance_count(&self) -> usize {
  492. self.inner.lock().instances.len()
  493. }
  494. /// Drop an instance's rendered state (eviction). Render-scoped
  495. /// tasks would be cancelled here; none exist yet.
  496. pub fn release(&self, key: &InstKey) {
  497. let mut inner = self.inner.lock();
  498. if inner.instances.remove(key).is_some() {
  499. inner.touches.remove(key);
  500. t!("released id={}", key.1);
  501. }
  502. }
  503. /// Drop every instance's rendered state.
  504. pub fn release_all(&self) {
  505. let mut inner = self.inner.lock();
  506. let count = inner.instances.len();
  507. inner.instances.clear();
  508. inner.touches.clear();
  509. t!("released all ({count})");
  510. }
  511. /// Rebuild rendered state from live props + current data.
  512. pub fn regen(&self, key: &InstKey) {
  513. self.release(key);
  514. }
  515. /// Rebuild every instance's rendered state.
  516. pub fn regen_all(&self) {
  517. self.release_all();
  518. }
  519. /// The instance's measured height, if materialized.
  520. pub fn height(&self, key: &InstKey) -> Option<f32> {
  521. self.inner.lock().instances.get(key).map(|inst| inst.height)
  522. }
  523. /// Release the out-of-window instances beyond the LRU budget:
  524. /// `keep` are window members, the `budget` most recently touched
  525. /// of the rest survive.
  526. pub fn sweep(&self, keep: &std::collections::HashSet<InstKey>, budget: usize) {
  527. let mut inner = self.inner.lock();
  528. let releases = evict_beyond(keep, &inner.touches, budget);
  529. drop(inner);
  530. for key in releases {
  531. self.release(&key);
  532. }
  533. }
  534. /// Renderer-bound draw instructions in message-local coordinates
  535. /// (y grows downward from the message's top edge). Mesh allocation
  536. /// happens here — the draw edge — and is cached until the epoch or
  537. /// the layout signature invalidates it. Collapsed long messages
  538. /// come back [`DrawOutcome::Clipped`] so the chatview emits
  539. /// them as sibling calls with their own view.
  540. /// Draw-path re-evaluation of the expr-bound own styling props
  541. /// (see ChatView::eval_style for rationale).
  542. pub fn eval_style(&self, atom: &mut PropertyAtomicGuard) {
  543. self.own.action_text_color.eval(atom).expect("action_text_color");
  544. self.own.url_text_color.eval(atom).expect("url_text_color");
  545. self.own.cap_max_height.eval(atom).expect("cap_max_height");
  546. }
  547. pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> super::DrawOutcome {
  548. let key = (rec.ts, rec.id);
  549. let mut inner = self.inner.lock();
  550. inner.access += 1;
  551. let access = inner.access;
  552. inner.touches.insert(key, access);
  553. let epoch_changed =
  554. inner.epoch_tracker.get_or_insert_with(|| EpochTracker::new(renderer)).changed();
  555. if epoch_changed {
  556. for inst in inner.instances.values_mut() {
  557. inst.instrs = None;
  558. }
  559. }
  560. let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
  561. if inst.instrs.is_none() {
  562. let mut instrs =
  563. text::render_layout(&inst.ts_layout, renderer, gfxtag!("chatview_privmsg_ts"));
  564. instrs.push(DrawInstruction::Move(Point::new(self.shared.timestamp_width.get(), 0.)));
  565. // URL backgrounds (and optional borders) behind the URL
  566. // runs, under the glyphs. render_backgrounds matches runs
  567. // by their style brush, so only URL-colored runs get a box.
  568. if url_regex().is_match(&inst.data.text) {
  569. let bg_instrs = text::render_backgrounds(
  570. &inst.txt_layout,
  571. self.own.url_text_color.get(),
  572. self.own.url_bg_color.get(),
  573. self.own.url_bg_border_color.get(),
  574. self.own.url_bg_border_size.get(),
  575. renderer,
  576. gfxtag!("chatview_privmsg_urlbg"),
  577. );
  578. instrs.extend(bg_instrs);
  579. }
  580. let text_instrs =
  581. text::render_layout(&inst.txt_layout, renderer, gfxtag!("chatview_privmsg_text"));
  582. instrs.extend(text_instrs);
  583. // The expand/collapse affordance label, centered in its rect.
  584. if let Some(rect) = &inst.affordance_rect {
  585. let label = if inst.collapsed { "+" } else { "-" };
  586. let label_layout = text::make_layout(
  587. label,
  588. self.shared.text_color.get(),
  589. self.shared.font_size.get() * 0.8,
  590. 1.,
  591. self.shared.window_scale.get(),
  592. None,
  593. &[],
  594. );
  595. instrs.push(DrawInstruction::Move(Point::new(
  596. rect.x + (rect.w - label_layout.width()) / 2.,
  597. rect.y + (rect.h - label_layout.height()) / 2.,
  598. )));
  599. let label_instrs =
  600. text::render_layout(&label_layout, renderer, gfxtag!("chatview_privmsg_cap"));
  601. instrs.extend(label_instrs);
  602. }
  603. inst.instrs = Some(instrs);
  604. }
  605. let instrs = inst.instrs.clone().unwrap_or_default();
  606. if inst.collapsed {
  607. let clip_h = inst.height - self.shared.message_spacing.get();
  608. DrawOutcome::Clipped { instrs, clip_h }
  609. } else {
  610. DrawOutcome::Inline(instrs)
  611. }
  612. }
  613. /// Clipboard contribution when selected: the rendered line.
  614. pub fn copy_text(&self, rec: &MsgRecord) -> Option<String> {
  615. let inner = self.inner.lock();
  616. inner.instances.get(&(rec.ts, rec.id)).map(|inst| inst.data.line_text())
  617. }
  618. /// Hit dispatch in message-local coordinates: URL rects first,
  619. /// then the nick prefix, then the expand affordance.
  620. pub fn hit_test(&self, rec: &MsgRecord, pos: Point) -> Option<Hit> {
  621. let inner = self.inner.lock();
  622. let Some(inst) = inner.instances.get(&(rec.ts, rec.id)) else { return None };
  623. for (url, rect) in &inst.url_rects {
  624. if rect.contains(pos) {
  625. return Some(Hit::Url(url.clone()))
  626. }
  627. }
  628. for (nick, rect) in &inst.nick_rects {
  629. if rect.contains(pos) {
  630. return Some(Hit::Nick(nick.clone()))
  631. }
  632. }
  633. if let Some(rect) = &inst.affordance_rect {
  634. if rect.contains(pos) {
  635. return Some(Hit::Expand)
  636. }
  637. }
  638. None
  639. }
  640. /// Toggle a capped message's expanded state and regen its rendered
  641. /// state; returns the new measured height.
  642. pub fn toggle_expand(&self, rec: &MsgRecord) -> f32 {
  643. let key = (rec.ts, rec.id);
  644. let mut expanded = self.expanded.lock();
  645. if !expanded.remove(&key) {
  646. expanded.insert(key);
  647. }
  648. drop(expanded);
  649. self.regen(&key);
  650. self.measure(rec)
  651. }
  652. /// The URL hit rects of a materialized instance (test access).
  653. pub(crate) fn url_rects(&self, rec: &MsgRecord) -> Vec<(String, Rectangle)> {
  654. let inner = self.inner.lock();
  655. inner
  656. .instances
  657. .get(&(rec.ts, rec.id))
  658. .map(|inst| inst.url_rects.clone())
  659. .unwrap_or_default()
  660. }
  661. /// The nick hit rects of a materialized instance (test access).
  662. pub(crate) fn nick_rects(&self, rec: &MsgRecord) -> Vec<(String, Rectangle)> {
  663. let inner = self.inner.lock();
  664. inner
  665. .instances
  666. .get(&(rec.ts, rec.id))
  667. .map(|inst| inst.nick_rects.clone())
  668. .unwrap_or_default()
  669. }
  670. /// Build hit rects for the glyph runs whose brush matches
  671. /// `match_color`, mapping each run to its entry in `ranges` by
  672. /// intersecting the (coarse) font-run text range. Layout
  673. /// coordinates are physical, so divide by the scale to get the
  674. /// message-local virtual units hit tests use.
  675. fn compute_hit_rects(
  676. layout: &text::TextLayout,
  677. data: &PrivData,
  678. timestamp_width: f32,
  679. match_color: Color,
  680. ranges: &[std::ops::Range<usize>],
  681. payload_fn: impl Fn(&str) -> Option<String>,
  682. ) -> Vec<(String, Rectangle)> {
  683. let mut rects = vec![];
  684. if ranges.is_empty() {
  685. return rects
  686. }
  687. let linetext = data.line_text();
  688. let scale = layout.scale();
  689. for line in layout.lines() {
  690. for item in line.items() {
  691. let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
  692. if glyph_run.style().brush != match_color {
  693. continue
  694. }
  695. let font_range = glyph_run.run().text_range();
  696. let Some(hit_range) =
  697. ranges.iter().find(|r| r.start < font_range.end && r.end > font_range.start)
  698. else {
  699. continue
  700. };
  701. let Some(payload) = payload_fn(&linetext[hit_range.clone()]) else { continue };
  702. let metrics = glyph_run.run().metrics();
  703. let x = timestamp_width + glyph_run.offset() / scale;
  704. let y = (glyph_run.baseline() - metrics.ascent) / scale;
  705. let w = glyph_run.advance() / scale;
  706. let h = (metrics.ascent + metrics.descent) / scale;
  707. rects.push((payload, Rectangle::new(x, y, w, h)));
  708. }
  709. }
  710. rects
  711. }
  712. /// Insert a confirmed privmsg: persist via the loader (dedup by
  713. /// composite key), measure, insert into the buffer, keep the view
  714. /// stable if the message grows content below the viewport. An
  715. /// insert for an already-stored message acts as its confirmation.
  716. pub async fn insert_line(&self, ts: Timestamp, id: MessageId, nick: String, text: String) {
  717. self.insert_privmsg(ts, id, nick, text, true).await
  718. }
  719. /// Insert an unconfirmed (sent, not yet seen on the network)
  720. /// privmsg; persisted with the confirmed flag in the payload.
  721. pub async fn insert_unconf_line(
  722. &self,
  723. ts: Timestamp,
  724. id: MessageId,
  725. nick: String,
  726. text: String,
  727. ) {
  728. self.insert_privmsg(ts, id, nick, text, false).await
  729. }
  730. async fn insert_privmsg(
  731. &self,
  732. ts: Timestamp,
  733. id: MessageId,
  734. nick: String,
  735. text: String,
  736. confirmed: bool,
  737. ) {
  738. if ts <= 6047051717 {
  739. error!(target: "ui::chatview::privmsg", "rejecting insert with non-millisecond timestamp {ts}");
  740. return
  741. }
  742. t!("insert ts={ts} id={id} nick={nick} confirmed={confirmed}");
  743. let payload = codec::encode_privmsg_payload(&nick, &text, confirmed);
  744. if !self.loader.store(ts, &id, MsgType::PrivMsg, &payload) {
  745. // Already stored — this is the confirmation of a message we
  746. // have been showing as unconfirmed (or a duplicate relay).
  747. self.confirm(id).await;
  748. return
  749. }
  750. let rec = MsgRecord { ts, id: id.clone(), msg_type: MsgType::PrivMsg, payload, height: 0. };
  751. let height = self.measure(&rec);
  752. let full_rec = MsgRecord { height, ..rec };
  753. let mut buffer = self.buffer.lock().await;
  754. if !buffer.insert(full_rec.clone()) {
  755. return
  756. }
  757. let top = buffer.pos_of_key(&(full_rec.ts, full_rec.id));
  758. drop(buffer);
  759. // A fud URL in the text derives its file message below the line.
  760. if get_file_url(&text).is_some() {
  761. if let Some(chat) = self.chat.upgrade() {
  762. chat.derive_filemsg(&full_rec, &nick, &text).await;
  763. }
  764. }
  765. if let (Some(top), Some(chat)) = (top, self.chat.upgrade()) {
  766. // The appended message sits at the very bottom: when the
  767. // user reads history, shift by its height so nothing moves.
  768. let mut ctl = chat.controller.lock();
  769. let scroll = ctl.scroll();
  770. ctl.compensate(height, top <= scroll);
  771. }
  772. if let Some(chat) = self.chat.upgrade() {
  773. chat.sync_is_at_bottom();
  774. chat.redraw.trigger();
  775. chat.loader.wake(Wakeup::Insert);
  776. }
  777. }
  778. /// Mark an unconfirmed message confirmed: rewrite the payload in
  779. /// place (same kvdb entry), update the record, regen for styling.
  780. pub async fn confirm(&self, id: MessageId) {
  781. let (ts, payload) = {
  782. let buffer = self.buffer.lock().await;
  783. let Some(rec) = buffer.record(&id) else {
  784. t!("confirm of unloaded id={id}");
  785. return
  786. };
  787. (rec.ts, rec.payload.clone())
  788. };
  789. let (nick, text, confirmed) = codec::decode_privmsg_payload(&payload, ts, &id);
  790. if confirmed {
  791. return
  792. }
  793. let new_payload = codec::encode_privmsg_payload(&nick, &text, true);
  794. self.loader.update(ts, &id, MsgType::PrivMsg, &new_payload);
  795. {
  796. let mut buffer = self.buffer.lock().await;
  797. if let Some(rec) = buffer.record_mut(&id) {
  798. rec.payload = new_payload;
  799. }
  800. }
  801. self.regen(&(ts, id));
  802. if let Some(chat) = self.chat.upgrade() {
  803. chat.redraw.trigger();
  804. }
  805. t!("confirmed id={id}");
  806. }
  807. /// The scene node handle, for the chatview's method wiring.
  808. pub fn node(&self) -> &SceneNodeWeak {
  809. &self.node
  810. }
  811. /// The "Copied link" toast label.
  812. pub fn url_copy_text(&self) -> String {
  813. self.url_copy_text.get()
  814. }
  815. /// The toast's foreground color.
  816. pub fn url_copy_fg_color(&self) -> Color {
  817. self.url_copy_fg_color.get()
  818. }
  819. /// The toast's background color.
  820. pub fn url_copy_bg_color(&self) -> Color {
  821. self.url_copy_bg_color.get()
  822. }
  823. /// The toast label's font size.
  824. pub fn url_copy_font_size(&self) -> f32 {
  825. self.url_copy_font_size.get()
  826. }
  827. /// The toast's inner padding.
  828. pub fn url_copy_padding(&self) -> f32 {
  829. self.url_copy_padding.get()
  830. }
  831. /// The toast's lift above the anchor.
  832. pub fn url_copy_offset(&self) -> f32 {
  833. self.url_copy_offset.get()
  834. }
  835. /// How long the toast stays, in seconds.
  836. pub fn url_copy_duration(&self) -> f32 {
  837. self.url_copy_duration.get()
  838. }
  839. }
  840. static URL_REGEX: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
  841. fn url_regex() -> &'static regex::Regex {
  842. URL_REGEX
  843. .get_or_init(|| regex::Regex::new(r"https?://[^\s]+|fud://[^\s]+|www\.[^\s]+").unwrap())
  844. }
  845. /// Sanitize an extracted URL match for use as a hit payload (opening,
  846. /// copying): strip trailing punctuation and control characters (an
  847. /// interior NUL would abort the Android intent JNI path), resolve
  848. /// schemeless `www.` hosts to https, and reject anything that does not
  849. /// parse to an http/https/fud URL. Returns None for matches that are
  850. /// not safely openable.
  851. fn sanitize_url(raw: &str) -> Option<String> {
  852. let trimmed = raw.trim_end_matches(['.', ',', '!', '?', ')', ']', '}', '\'', '"', ';', ':']);
  853. let trimmed = trimmed.trim_end_matches(|c: char| c.is_control());
  854. if trimmed.chars().any(|c| c.is_control()) {
  855. return None
  856. }
  857. let candidate = if trimmed.starts_with("www.") {
  858. format!("https://{trimmed}")
  859. } else {
  860. trimmed.to_string()
  861. };
  862. let url = Url::parse(&candidate).ok()?;
  863. match url.scheme() {
  864. "http" | "https" | "fud" => Some(url.to_string()),
  865. _ => None,
  866. }
  867. }
  868. /// URL byte ranges (no colors) within the rendered line text.
  869. fn url_ranges_of(data: &PrivData, color: Color) -> Vec<std::ops::Range<usize>> {
  870. let mut ranges = vec![];
  871. for (range, _) in url_color_ranges(&data.text, data.body_offset(), color) {
  872. ranges.push(range);
  873. }
  874. ranges
  875. }
  876. /// URL byte ranges within the rendered line text, colored with the URL
  877. /// color so backgrounds and hit rects can find them by brush.
  878. fn url_color_ranges(
  879. text: &str,
  880. offset: usize,
  881. color: Color,
  882. ) -> Vec<(std::ops::Range<usize>, Color)> {
  883. let mut ranges = vec![];
  884. for m in url_regex().find_iter(text) {
  885. ranges.push((m.start() + offset..m.end() + offset, color));
  886. }
  887. ranges
  888. }
  889. /// Scene node factory for the privmsg type node.
  890. /// Decode `(ts, id, nick, text)` method-call data.
  891. pub fn decode_insert_data(data: &[u8]) -> Option<(Timestamp, MessageId, String, String)> {
  892. let mut cur = Cursor::new(data);
  893. let ts = Timestamp::decode(&mut cur).ok()?;
  894. let id = MessageId::decode(&mut cur).ok()?;
  895. let nick = String::decode(&mut cur).ok()?;
  896. let text = String::decode(&mut cur).ok()?;
  897. Some((ts, id, nick, text))
  898. }
  899. /// Encode `(ts, id, nick, text)` for method-call data.
  900. pub fn encode_insert_data(ts: Timestamp, id: &MessageId, nick: &str, text: &str) -> Vec<u8> {
  901. let mut data = vec![];
  902. ts.encode(&mut data).unwrap();
  903. id.encode(&mut data).unwrap();
  904. nick.encode(&mut data).unwrap();
  905. text.encode(&mut data).unwrap();
  906. data
  907. }
  908. #[async_trait]
  909. impl UIObject for PrivMsgNode {
  910. fn priority(&self) -> u32 {
  911. 0
  912. }
  913. }
  914. impl std::fmt::Debug for PrivMsgNode {
  915. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  916. write!(f, "{:?}", self.node.upgrade())
  917. }
  918. }
  919. #[cfg(test)]
  920. mod tests {
  921. use super::*;
  922. use crate::{app::node::create_privmsg_node, prop::PropertyAtomicGuard};
  923. use std::collections::HashSet;
  924. /// A scale=1.0 property standing in for the window scale.
  925. fn scale_prop() -> PropertyFloat32 {
  926. let mut node = SceneNode::new("w", SceneNodeType::Object);
  927. let prop = Property::new(
  928. "scale",
  929. PropertyType::Float32,
  930. PropertySubType::Null,
  931. PropertyPermission::default(),
  932. );
  933. node.add_property(prop).unwrap();
  934. let node = node.setup_null();
  935. let atom = &mut PropertyAtomicGuard::none();
  936. node.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
  937. PropertyFloat32::wrap(&node, Role::Internal, "scale", 0).unwrap()
  938. }
  939. /// A loader over a throwaway tree, for insert-path tests.
  940. fn fixture_loader(tag: &str) -> (Arc<Loader>, kvdb_overlay::Tree) {
  941. let path = std::env::temp_dir()
  942. .join(format!("darkfi-chatview-privmsg-{tag}-{}.db", std::process::id()));
  943. let _ = std::fs::remove_file(&path);
  944. let db = kvdb_overlay::Database::open_default(&path).unwrap();
  945. let tree = db.open_tree_default("chat").unwrap();
  946. (
  947. Loader::new(
  948. Arc::new(AsyncMutex::new(MsgBuffer::new())),
  949. crate::ui::RedrawTrigger::new().0,
  950. ),
  951. tree.clone(),
  952. )
  953. }
  954. async fn make_node(tag: &str) -> (SceneNodePtr, PrivMsgNodePtr, Arc<AsyncMutex<MsgBuffer>>) {
  955. // A chatview-shaped parent supplies the shared styling; the
  956. // privmsg node carries the type-specific properties.
  957. let chat = crate::app::node::create_chatview("chatview");
  958. let chat = chat.setup_null();
  959. let atom = &mut PropertyAtomicGuard::none();
  960. chat.set_property_f32(atom, Role::App, "font_size", 14.).unwrap();
  961. chat.set_property_f32(atom, Role::App, "timestamp_font_size", 10.).unwrap();
  962. chat.set_property_f32(atom, Role::App, "timestamp_width", 50.).unwrap();
  963. chat.set_property_f32(atom, Role::App, "line_height", 20.).unwrap();
  964. chat.set_property_f32(atom, Role::App, "message_spacing", 4.).unwrap();
  965. let prop = chat.get_property("rect").unwrap();
  966. prop.set_f32(atom, Role::App, 0, 0.).unwrap();
  967. prop.set_f32(atom, Role::App, 1, 0.).unwrap();
  968. prop.set_f32(atom, Role::App, 2, 800.).unwrap();
  969. prop.set_f32(atom, Role::App, 3, 600.).unwrap();
  970. let prop = chat.get_property("text_color").unwrap();
  971. for (i, c) in [1., 1., 1., 1.].iter().enumerate() {
  972. prop.set_f32(atom, Role::App, i, *c).unwrap();
  973. }
  974. let prop = chat.get_property("timestamp_color").unwrap();
  975. for (i, c) in [0.5, 0.5, 0.5, 1.].iter().enumerate() {
  976. prop.set_f32(atom, Role::App, i, *c).unwrap();
  977. }
  978. let shared = SharedProps::wrap(&chat, scale_prop());
  979. let mut raw = MsgBuffer::new();
  980. raw.disable_separators();
  981. let buffer = Arc::new(AsyncMutex::new(raw));
  982. let (redraw, _rx) = crate::ui::RedrawTrigger::new();
  983. let loader = Loader::new(buffer.clone(), redraw);
  984. loader.bind(tag.to_string(), fixture_loader(tag).1);
  985. // A dangling chat weak: insert still persists and buffers, it
  986. // just skips view compensation (no chatview to talk to).
  987. let chat_weak: Weak<ChatView> = Weak::new();
  988. let node = create_privmsg_node("privmsg");
  989. let atom = &mut PropertyAtomicGuard::none();
  990. let prop = node.get_property("nick_colors").unwrap();
  991. for c in [1., 0., 0., 1.] {
  992. prop.push_f32(atom, Role::App, c).unwrap();
  993. }
  994. let prop = node.get_property("action_text_color").unwrap();
  995. for (i, c) in [0.5, 0.25, 0.75, 1.].iter().enumerate() {
  996. prop.set_f32(atom, Role::App, i, *c).unwrap();
  997. }
  998. let shared2 = shared.clone();
  999. let loader2 = loader.clone();
  1000. let buffer2 = buffer.clone();
  1001. let node =
  1002. node.setup(|me| async move {
  1003. PrivMsgNode::new(me, shared2, loader2, buffer2, chat_weak).await
  1004. })
  1005. .await;
  1006. chat.link(node.clone());
  1007. let Pimpl::PrivMsgNode(ptr) = node.pimpl() else { panic!() };
  1008. (chat, ptr.clone(), buffer)
  1009. }
  1010. fn rec_of(ts: Timestamp, idb: u8, text: &str, confirmed: bool) -> MsgRecord {
  1011. let mut id = [0u8; 32];
  1012. id[0] = idb;
  1013. let payload = codec::encode_privmsg_payload("alice", text, confirmed);
  1014. MsgRecord { ts, id: MessageId(id), msg_type: MsgType::PrivMsg, payload, height: 0. }
  1015. }
  1016. #[test]
  1017. fn measure_reports_height_and_caches_layout() {
  1018. let (_chat, node, _buffer) = smol::block_on(make_node("cache"));
  1019. let rec = rec_of(1_000_000, b'a', "hello world", true);
  1020. let h1 = node.measure(&rec);
  1021. assert!(h1 > 4., "height incl. spacing: {h1}");
  1022. assert_eq!(node.inner.lock().layout_builds, 1);
  1023. // Repeated measures reuse the cached layout.
  1024. let h2 = node.measure(&rec);
  1025. assert_eq!(h1, h2);
  1026. assert_eq!(node.inner.lock().layout_builds, 1);
  1027. assert!(node.is_materialized(&rec));
  1028. }
  1029. #[test]
  1030. fn release_then_materialize_rebuilds_state() {
  1031. let (_chat, node, _buffer) = smol::block_on(make_node("release"));
  1032. let rec = rec_of(1_000_000, b'a', "hello", true);
  1033. let h1 = node.measure(&rec);
  1034. node.release(&(rec.ts, rec.id));
  1035. assert!(!node.is_materialized(&rec));
  1036. let h2 = node.measure(&rec);
  1037. assert_eq!(h1, h2);
  1038. assert_eq!(node.inner.lock().layout_builds, 2);
  1039. }
  1040. #[test]
  1041. fn width_change_rewraps() {
  1042. let (chat, node, _buffer) = smol::block_on(make_node("width"));
  1043. let long = "word ".repeat(60);
  1044. let rec = rec_of(1_000_000, b'a', &long, true);
  1045. let wide = node.measure(&rec);
  1046. assert_eq!(node.inner.lock().layout_builds, 1);
  1047. // Narrow the chatview rect: the signature changes, the layout
  1048. // re-wraps taller.
  1049. let prop = chat.get_property("rect").unwrap();
  1050. let atom = &mut PropertyAtomicGuard::none();
  1051. prop.set_f32(atom, Role::App, 2, 200.).unwrap();
  1052. let narrow = node.measure(&rec);
  1053. assert!(narrow > wide, "narrow={narrow} wide={wide}");
  1054. assert_eq!(node.inner.lock().layout_builds, 2);
  1055. }
  1056. #[test]
  1057. fn styling_and_data_changes_invalidate() {
  1058. let (chat, node, _buffer) = smol::block_on(make_node("styling"));
  1059. let rec = rec_of(1_000_000, b'a', "hello", true);
  1060. node.measure(&rec);
  1061. assert_eq!(node.inner.lock().layout_builds, 1);
  1062. // Styling change (font size on the chatview node).
  1063. let atom = &mut PropertyAtomicGuard::none();
  1064. chat.set_property_f32(atom, Role::App, "font_size", 20.).unwrap();
  1065. node.measure(&rec);
  1066. assert_eq!(node.inner.lock().layout_builds, 2, "font size change rebuilds");
  1067. // Data change: confirm flips the body color (part of the signature).
  1068. let rec_unconf = rec_of(1_000_000, b'a', "hello", false);
  1069. node.measure(&rec_unconf);
  1070. assert_eq!(node.inner.lock().layout_builds, 3, "confirmed flag rebuilds");
  1071. }
  1072. #[test]
  1073. fn insert_line_persists_and_buffers() {
  1074. let (_chat, node, buffer) = smol::block_on(make_node("insert"));
  1075. smol::block_on(async {
  1076. node.insert_line(
  1077. 1_756_000_000_000,
  1078. MessageId([b'x'; 32]),
  1079. "alice".to_string(),
  1080. "hi there".to_string(),
  1081. )
  1082. .await;
  1083. });
  1084. let buffer = smol::block_on(buffer.lock());
  1085. assert_eq!(buffer.len(), 1);
  1086. let rec = buffer.record(&MessageId([b'x'; 32])).unwrap();
  1087. assert!(rec.height > 0., "inserted with measured height");
  1088. let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
  1089. assert!(confirmed);
  1090. }
  1091. #[test]
  1092. fn insert_dedups_and_confirms_in_place() {
  1093. let (_chat, node, buffer) = smol::block_on(make_node("dedup"));
  1094. let id = MessageId([b'x'; 32]);
  1095. let ts = 1_756_000_000_000;
  1096. smol::block_on(async {
  1097. node.insert_unconf_line(ts, id, "alice".to_string(), "hi".to_string()).await;
  1098. });
  1099. {
  1100. let buffer = smol::block_on(buffer.lock());
  1101. assert_eq!(buffer.len(), 1);
  1102. let rec = buffer.record(&id).unwrap();
  1103. let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
  1104. assert!(!confirmed, "starts unconfirmed");
  1105. }
  1106. // The confirmed relay of the same message updates in place:
  1107. // one entry, confirmed payload, regen invalidated the rendered
  1108. // state (lazily rebuilt on the next touch).
  1109. smol::block_on(async {
  1110. node.insert_line(ts, id, "alice".to_string(), "hi".to_string()).await;
  1111. });
  1112. {
  1113. let buffer = smol::block_on(buffer.lock());
  1114. assert_eq!(buffer.len(), 1, "no duplicate record");
  1115. let rec = buffer.record(&id).unwrap();
  1116. let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
  1117. assert!(confirmed, "confirmed in place");
  1118. }
  1119. assert!(
  1120. !{
  1121. let buffer = smol::block_on(buffer.lock());
  1122. let rec = buffer.record(&id).unwrap().clone();
  1123. node.is_materialized(&rec)
  1124. },
  1125. "regen dropped the rendered state"
  1126. );
  1127. let builds_before = node.inner.lock().layout_builds;
  1128. let rec = {
  1129. let buffer = smol::block_on(buffer.lock());
  1130. buffer.record(&id).unwrap().clone()
  1131. };
  1132. node.measure(&rec);
  1133. assert_eq!(
  1134. node.inner.lock().layout_builds,
  1135. builds_before + 1,
  1136. "rebuilt with confirmed data"
  1137. );
  1138. // A plain duplicate changes nothing.
  1139. let builds = node.inner.lock().layout_builds;
  1140. smol::block_on(async {
  1141. node.insert_line(ts, id, "alice".to_string(), "hi".to_string()).await;
  1142. });
  1143. let buffer = smol::block_on(buffer.lock());
  1144. assert_eq!(buffer.len(), 1);
  1145. drop(buffer);
  1146. assert_eq!(node.inner.lock().layout_builds, builds, "already-confirmed duplicate is inert");
  1147. }
  1148. #[test]
  1149. fn line_text_variants() {
  1150. let data = PrivData {
  1151. ts: 0,
  1152. id: MessageId([0; 32]),
  1153. nick: "alice".to_string(),
  1154. text: "waves".to_string(),
  1155. confirmed: true,
  1156. is_action: true,
  1157. is_notice: false,
  1158. expanded: true,
  1159. };
  1160. assert_eq!(data.line_text(), "* alice waves");
  1161. assert_eq!(data.body_offset(), "alice".len() + 3);
  1162. let data = PrivData { is_action: false, ..data };
  1163. assert_eq!(data.line_text(), "alice waves");
  1164. assert_eq!(data.body_offset(), "alice".len() + 1);
  1165. let data = PrivData { nick: "NOTICE".to_string(), is_notice: true, ..data };
  1166. assert_eq!(data.line_text(), "waves");
  1167. assert_eq!(data.body_offset(), 0);
  1168. }
  1169. #[test]
  1170. fn url_hit_rects_resolve_through_hit_test() {
  1171. let (_chat, node, _buffer) = smol::block_on(make_node("urls"));
  1172. let rec = rec_of(1_000_000, b'a', "see https://example.com/now okay", true);
  1173. node.measure(&rec);
  1174. let rects = node.url_rects(&rec);
  1175. assert_eq!(rects.len(), 1, "one URL run");
  1176. let (url, rect) = &rects[0];
  1177. assert_eq!(url, "https://example.com/now");
  1178. // Inside the rect: the URL; on the nick prefix: the nick;
  1179. // elsewhere: nothing.
  1180. let mid = Point::new(rect.x + rect.w / 2., rect.y + rect.h / 2.);
  1181. assert_eq!(node.hit_test(&rec, mid), Some(Hit::Url(url.clone())));
  1182. let nicks = node.nick_rects(&rec);
  1183. assert!(!nicks.is_empty(), "nick prefix is clickable");
  1184. let (nick, nrect) = &nicks[0];
  1185. assert_eq!(nick, "alice");
  1186. assert_eq!(
  1187. node.hit_test(&rec, Point::new(nrect.x + nrect.w / 2., nrect.y + nrect.h / 2.)),
  1188. Some(Hit::Nick("alice".to_string()))
  1189. );
  1190. assert_eq!(node.hit_test(&rec, Point::new(400., rect.y + rect.h / 2.)), None);
  1191. }
  1192. #[test]
  1193. fn wrapped_url_produces_hit_rects() {
  1194. let (_chat, node, _buffer) = smol::block_on(make_node("wrapurls"));
  1195. // A long URL prefix that must wrap across lines still yields
  1196. // hit rects (one per wrapped run).
  1197. let mut text = String::from("look ");
  1198. for _ in 0..30 {
  1199. text.push_str("https://example.com/very/long/path/segment ");
  1200. }
  1201. let rec = rec_of(1_000_000, b'a', &text, true);
  1202. node.measure(&rec);
  1203. let rects = node.url_rects(&rec);
  1204. assert!(rects.len() >= 2, "wrapped URL runs: {}", rects.len());
  1205. for (url, _rect) in &rects {
  1206. assert!(url.starts_with("https://example.com/"), "{url}");
  1207. }
  1208. }
  1209. #[test]
  1210. fn sweep_releases_out_of_window_and_rematerialize_rebuilds() {
  1211. let (_chat, node, _buffer) = smol::block_on(make_node("sweep"));
  1212. // Materialize a window of 5 records plus older strays.
  1213. let mut recs = vec![];
  1214. for i in 0..8u64 {
  1215. recs.push(rec_of(1_000_000 + i * 60_000, i as u8 + b'a', "text", true));
  1216. }
  1217. for rec in &recs {
  1218. node.measure(rec);
  1219. }
  1220. assert_eq!(node.instance_count(), 8);
  1221. // The window keeps the newest 5; budget 0 releases the rest.
  1222. let mut keep = HashSet::new();
  1223. for rec in &recs[3..] {
  1224. keep.insert((rec.ts, rec.id));
  1225. }
  1226. node.sweep(&keep, 0);
  1227. assert_eq!(node.instance_count(), 5, "window members survive");
  1228. for rec in &recs[..3] {
  1229. assert!(!node.is_materialized(rec), "stray released");
  1230. }
  1231. // Rematerializing a released record rebuilds identical state.
  1232. let h1 = node.measure(&recs[0]);
  1233. assert!(h1 > 0.);
  1234. assert!(node.is_materialized(&recs[0]));
  1235. }
  1236. #[test]
  1237. fn capped_measurement_and_expand_height_reporting() {
  1238. let (chat, node, _buffer) = smol::block_on(make_node("cap"));
  1239. // Cap the node at 60 px; a very long wrapped message towers
  1240. // over it. Shorten the width so even modest text wraps tall.
  1241. let atom = &mut PropertyAtomicGuard::none();
  1242. node.node()
  1243. .upgrade()
  1244. .unwrap()
  1245. .set_property_f32(atom, Role::App, "cap_max_height", 60.)
  1246. .unwrap();
  1247. let prop = chat.get_property("rect").unwrap();
  1248. prop.set_f32(atom, Role::App, 2, 240.).unwrap();
  1249. let long = "wrap me please ".repeat(80);
  1250. let rec = rec_of(1_000_000, b'a', &long, true);
  1251. let collapsed_h = node.measure(&rec);
  1252. assert!((collapsed_h - 60. - 4.).abs() < 0.5, "collapsed to cap + spacing: {collapsed_h}");
  1253. // The affordance is hit-testable in message-local coordinates.
  1254. let affordance = {
  1255. let inner = node.inner.lock();
  1256. let inst = inner.instances.get(&(rec.ts, rec.id)).unwrap();
  1257. inst.affordance_rect.clone().unwrap()
  1258. };
  1259. assert_eq!(
  1260. node.hit_test(&rec, Point::new(affordance.x + 5., affordance.y + 5.)),
  1261. Some(Hit::Expand)
  1262. );
  1263. // Expanding reports the full wrapped height.
  1264. let expanded_h = node.toggle_expand(&rec);
  1265. assert!(expanded_h > 200., "expanded height: {expanded_h}");
  1266. assert_eq!(node.measure(&rec), expanded_h, "stable while expanded");
  1267. // Collapsing again restores the cap.
  1268. let again = node.toggle_expand(&rec);
  1269. assert!((again - collapsed_h).abs() < 0.5, "re-collapsed: {again}");
  1270. }
  1271. #[test]
  1272. fn short_messages_are_never_capped() {
  1273. let (chat, node, _buffer) = smol::block_on(make_node("nocap"));
  1274. let atom = &mut PropertyAtomicGuard::none();
  1275. node.node()
  1276. .upgrade()
  1277. .unwrap()
  1278. .set_property_f32(atom, Role::App, "cap_max_height", 60.)
  1279. .unwrap();
  1280. let rec = rec_of(1_000_000, b'a', "tiny", true);
  1281. let h = node.measure(&rec);
  1282. assert!(h < 60., "single short line: {h}");
  1283. let inner = node.inner.lock();
  1284. let inst = inner.instances.get(&(rec.ts, rec.id)).unwrap();
  1285. assert!(inst.affordance_rect.is_none());
  1286. }
  1287. #[test]
  1288. fn url_sanitization() {
  1289. use super::sanitize_url;
  1290. assert_eq!(sanitize_url("https://example.com/").as_deref(), Some("https://example.com/"));
  1291. assert_eq!(
  1292. sanitize_url("https://example.com/path.").as_deref(),
  1293. Some("https://example.com/path")
  1294. );
  1295. assert_eq!(
  1296. sanitize_url("https://example.com/a,b!").as_deref(),
  1297. Some("https://example.com/a,b")
  1298. );
  1299. assert_eq!(sanitize_url("www.example.com/x").as_deref(), Some("https://www.example.com/x"));
  1300. // Interior control characters (incl. NUL): rejected. A trailing
  1301. // one is trimmed — the cleaned URL stays usable.
  1302. assert_eq!(sanitize_url("https://evil.com/\u{0}x"), None);
  1303. assert_eq!(sanitize_url("https://evil.com/x\u{0}").as_deref(), Some("https://evil.com/x"));
  1304. // Non-http(s)/fud schemes cannot appear via the regex, but the
  1305. // parser gate holds anyway.
  1306. assert_eq!(sanitize_url("file:///etc/passwd"), None);
  1307. // fud URLs stay untouched.
  1308. assert_eq!(
  1309. sanitize_url("fud://abcdef012345/file.png").as_deref(),
  1310. Some("fud://abcdef012345/file.png")
  1311. );
  1312. }
  1313. #[test]
  1314. fn ctcp_action_parsing() {
  1315. assert_eq!(parse_ctcp_action("\u{1}ACTION waves\u{1}"), Some("waves"));
  1316. assert_eq!(parse_ctcp_action("\u{1}ACTION waves"), Some("waves"));
  1317. assert_eq!(parse_ctcp_action("waves"), None);
  1318. assert_eq!(parse_ctcp_action("\u{1}PING\u{1}"), None);
  1319. }
  1320. }