privmsg.rs 54 KB

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