/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
//! The privmsg type node: insertion semantics and basic rendering.
//!
//! One scene sub-node (created with the chatview, stable across
//! channels) carries the type's properties, signals, and lifecycle
//! methods; per-id instances carry data + rendered state. Insertion
//! persists through the loader (the sole kvdb writer), dedups by the
//! composite key, and confirms unconfirmed messages in place. The
//! rendered state is a pure cache of (data, props): a signature check
//! skips re-layouts, and regen drops it for a full rebuild.
use async_lock::Mutex as AsyncMutex;
use async_trait::async_trait;
use chrono::{Local, TimeZone};
use darkfi_serial::{Decodable, Encodable};
use parking_lot::Mutex as SyncMutex;
use std::{
collections::{HashMap, HashSet},
hash::{DefaultHasher, Hash, Hasher},
io::Cursor,
sync::{Arc, Weak},
};
use url::Url;
use crate::{
gfx::{gfxtag, DrawInstruction, EpochTracker, Point, Rectangle, Renderer},
mesh::Color,
prop::{
Property, PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyPermission,
PropertyPtr, PropertyStr, PropertySubType, PropertyType, Role,
},
scene::{CallArgType, Pimpl, SceneNode, SceneNodePtr, SceneNodeType, SceneNodeWeak},
text,
ui::UIObject,
ExecutorPtr,
};
use super::{evict_beyond, filemsg::get_file_url, DrawOutcome, Hit, SharedProps};
use crate::ui::chatview::{
buffer::MsgBuffer, codec, loader::Loader, ChatView, MessageId, MsgRecord, MsgType, Timestamp,
Wakeup,
};
macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::privmsg", $($arg)*); } }
/// Instance map key: the composite record key (derived records share
/// synthetic ids).
pub type InstKey = (Timestamp, MessageId);
/// Unconfirmed bodies render gray.
const UNCONF_COLOR: [f32; 4] = [0.4, 0.4, 0.4, 1.];
/// IRC CTCP ACTION framing prefix, e.g. `\x01ACTION waves\x01`.
const CTCP_ACTION_PREFIX: &str = "\u{1}ACTION ";
/// If `text` is a CTCP ACTION-framed message body, return the stripped
/// action text. A single trailing `\x01` delimiter is stripped when
/// present but is not required, since bodies truncated by length
/// limits may lose it.
fn parse_ctcp_action(text: &str) -> Option<&str> {
let body = text.strip_prefix(CTCP_ACTION_PREFIX)?;
let body = body.strip_suffix('\u{1}').unwrap_or(body);
Some(body)
}
fn is_notice(nick: &str) -> bool {
nick == "NOTICE"
}
/// Stable per-nick color: hash the nick, index into the palette. An
/// empty palette falls back to white.
fn select_nick_color(nick: &str, nick_colors: &[Color]) -> Color {
if nick_colors.is_empty() {
return [1., 1., 1., 1.]
}
let mut hasher = DefaultHasher::new();
nick.hash(&mut hasher);
let i = hasher.finish() as usize;
nick_colors[i % nick_colors.len()]
}
fn read_nick_colors(prop: &PropertyPtr) -> Vec {
let mut colors = vec![];
let mut color = [0f32; 4];
for i in 0..prop.get_len() {
color[i % 4] = prop.get_f32(i).expect("prop logic err");
if i > 0 && i % 4 == 0 {
let color = std::mem::take(&mut color);
colors.push(color);
}
}
colors
}
fn gen_timestr(timestamp: Timestamp) -> String {
let Some(dt) = Local.timestamp_millis_opt(timestamp as i64).single() else {
return String::new()
};
dt.format("%H:%M").to_string()
}
/// The type-specific property handles, wrapped off this node.
#[derive(Clone)]
pub struct PrivOwnProps {
pub nick_colors: PropertyPtr,
pub action_text_color: PropertyColor,
pub url_text_color: PropertyColor,
pub url_bg_color: PropertyColor,
pub url_bg_border_size: PropertyFloat32,
pub url_bg_border_color: PropertyColor,
pub cap_max_height: PropertyFloat32,
}
/// Everything a materialized instance needs to render: data decoded
/// from the record payload, plus a pure cache of (data, props).
pub struct PrivMsg {
/// Decoded payload state.
pub data: PrivData,
/// The payload the rendered state was built from (data changes for
/// a live id — e.g. confirmation — invalidate the cache).
payload: Vec,
/// Signature the rendered state was built with.
sig: LayoutSig,
txt_layout: text::TextLayout,
ts_layout: text::TextLayout,
instrs: Option>,
/// URL hit rects in message-local coordinates, tagged with their
/// URL string.
url_rects: Vec<(String, Rectangle)>,
/// Nick-prefix hit rects in message-local coordinates, tagged with
/// the nick.
nick_rects: Vec<(String, Rectangle)>,
/// The expand/collapse affordance hit rect, when the message is
/// over the cap.
affordance_rect: Option,
/// The un-capped text height.
full_text_height: f32,
/// Whether the message's full height exceeds the cap.
over_cap: bool,
/// Whether the message is currently drawn collapsed.
collapsed: bool,
/// Measured height incl. message spacing.
pub height: f32,
}
pub struct PrivData {
pub ts: Timestamp,
pub id: MessageId,
pub nick: String,
pub text: String,
pub confirmed: bool,
/// IRC-style CTCP ACTION (`/me`); `text` holds the stripped body.
pub is_action: bool,
pub is_notice: bool,
pub expanded: bool,
}
impl PrivData {
/// The full rendered line text: NOTICE renders the body alone,
/// normal messages render " ", actions
/// "* ".
pub fn line_text(&self) -> String {
if self.is_notice {
return self.text.clone()
}
if self.is_action {
return format!("* {} {}", self.nick, self.text)
}
format!("{} {}", self.nick, self.text)
}
/// Byte offset of the body within the rendered line text. This is
/// also the end of the nick-colored prefix.
pub fn body_offset(&self) -> usize {
if self.is_notice {
return 0
}
if self.is_action {
// "* " + nick + " "
return self.nick.len() + 3
}
self.nick.len() + 1
}
}
/// The inputs a rendered state depends on; any mismatch forces a
/// re-layout. Colors are included so palette/role changes invalidate.
#[derive(PartialEq)]
struct LayoutSig {
width: f32,
font_size: f32,
timestamp_font_size: f32,
line_height: f32,
window_scale: f32,
confirmed: bool,
expanded: bool,
body_color: Color,
nick_color: Color,
ts_color: Color,
}
struct PrivInner {
instances: HashMap,
/// Last-access counter per instance, for LRU eviction.
touches: HashMap,
/// Monotonic access counter driving `touches`.
access: u64,
/// Epoch-scoped mesh caches die with the epoch.
epoch_tracker: Option,
/// How many layouts have been (re)built; cache-behavior test hook.
layout_builds: usize,
}
pub type PrivMsgNodePtr = Arc;
/// The privmsg type node.
pub struct PrivMsgNode {
node: SceneNodeWeak,
shared: SharedProps,
own: PrivOwnProps,
loader: Arc,
buffer: Arc>,
chat: Weak,
inner: SyncMutex,
/// Messages the user expanded (over-cap ones collapse by default);
/// survives rendered-state regens.
expanded: SyncMutex>,
/// "Copied link" overlay styling/content; the chatview-drawn toast
/// reads these through the getters below.
url_copy_text: PropertyStr,
url_copy_fg_color: PropertyColor,
url_copy_bg_color: PropertyColor,
url_copy_font_size: PropertyFloat32,
url_copy_padding: PropertyFloat32,
url_copy_offset: PropertyFloat32,
url_copy_duration: PropertyFloat32,
}
impl PrivMsgNode {
pub async fn new(
node: SceneNodeWeak,
shared: SharedProps,
loader: Arc,
buffer: Arc>,
chat: Weak,
) -> Pimpl {
let node_ref = &node.upgrade().unwrap();
let nick_colors = node_ref.get_property("nick_colors").expect("privmsg nick_colors");
let action_text_color =
PropertyColor::wrap(node_ref, Role::Internal, "action_text_color").unwrap();
let url_text_color =
PropertyColor::wrap(node_ref, Role::Internal, "url_text_color").unwrap();
let url_bg_color = PropertyColor::wrap(node_ref, Role::Internal, "url_bg_color").unwrap();
let url_bg_border_size =
PropertyFloat32::wrap(node_ref, Role::Internal, "url_bg_border_size", 0).unwrap();
let url_bg_border_color =
PropertyColor::wrap(node_ref, Role::Internal, "url_bg_border_color").unwrap();
let cap_max_height =
PropertyFloat32::wrap(node_ref, Role::Internal, "cap_max_height", 0).unwrap();
let url_copy_text =
PropertyStr::wrap(node_ref, Role::Internal, "url_copy_text", 0).unwrap();
let url_copy_fg_color =
PropertyColor::wrap(node_ref, Role::Internal, "url_copy_fg_color").unwrap();
let url_copy_bg_color =
PropertyColor::wrap(node_ref, Role::Internal, "url_copy_bg_color").unwrap();
let url_copy_font_size =
PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_font_size", 0).unwrap();
let url_copy_padding =
PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_padding", 0).unwrap();
let url_copy_offset =
PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_offset", 0).unwrap();
let url_copy_duration =
PropertyFloat32::wrap(node_ref, Role::Internal, "url_copy_duration", 0).unwrap();
let self_ = Arc::new(Self {
node: node.clone(),
shared,
own: PrivOwnProps {
nick_colors,
action_text_color,
url_text_color,
url_bg_color,
url_bg_border_size,
url_bg_border_color,
cap_max_height,
},
loader,
buffer,
chat,
inner: SyncMutex::new(PrivInner {
instances: HashMap::new(),
touches: HashMap::new(),
access: 0,
epoch_tracker: None,
layout_builds: 0,
}),
expanded: SyncMutex::new(HashSet::new()),
url_copy_text,
url_copy_fg_color,
url_copy_bg_color,
url_copy_font_size,
url_copy_padding,
url_copy_offset,
url_copy_duration,
});
Pimpl::PrivMsgNode(self_)
}
/// Read the current layout signature off the live properties.
fn current_sig(&self, data: &PrivData) -> LayoutSig {
let width = self.shared.rect.get().w - self.shared.timestamp_width.get();
let font_size = if data.is_notice {
self.shared.font_size.get() * 0.8
} else {
self.shared.font_size.get()
};
let nick_colors = read_nick_colors(&self.own.nick_colors);
let nick_color = select_nick_color(&data.nick, &nick_colors);
let body_color = if data.is_action {
if data.confirmed {
self.own.action_text_color.get()
} else {
UNCONF_COLOR
}
} else if data.confirmed {
self.shared.text_color.get()
} else {
UNCONF_COLOR
};
LayoutSig {
width,
font_size,
timestamp_font_size: self.shared.timestamp_font_size.get(),
line_height: self.shared.line_height.get(),
window_scale: self.shared.window_scale.get(),
confirmed: data.confirmed,
expanded: data.expanded,
body_color,
nick_color,
ts_color: self.shared.timestamp_color.get(),
}
}
/// Build the instance's rendered state from live props + data.
fn build_rendered(&self, key: &InstKey, data: PrivData) -> PrivMsg {
let expanded = self.expanded.lock().contains(key);
let data = PrivData { expanded, ..data };
let sig = self.current_sig(&data);
let linetext = data.line_text();
let line_height = self.shared.line_height.get();
let window_scale = self.shared.window_scale.get();
let mut foreground_colors = vec![];
if !data.is_notice {
foreground_colors.push((0..data.body_offset(), sig.nick_color));
}
foreground_colors.extend(url_color_ranges(
&data.text,
data.body_offset(),
self.own.url_text_color.get(),
));
let txt_layout = if data.is_notice {
text::make_layout2(
&linetext,
sig.body_color,
sig.font_size,
line_height / sig.font_size,
window_scale,
Some(sig.width),
&[],
&[],
parley::Alignment::Start,
parley::OverflowWrap::Normal,
)
} else {
text::make_layout2(
&linetext,
sig.body_color,
sig.font_size,
line_height / sig.font_size,
window_scale,
Some(sig.width),
&[],
&foreground_colors,
parley::Alignment::Start,
parley::OverflowWrap::Normal,
)
};
let timestr = gen_timestr(data.ts);
let ts_layout = text::make_layout(
×tr,
sig.ts_color,
sig.timestamp_font_size,
line_height / sig.timestamp_font_size,
window_scale,
None,
&[],
);
// Hit rects: URL-colored and nick-colored glyph runs become
// clickable regions in message-local coordinates.
let url_color = self.own.url_text_color.get();
let nick_color = sig.nick_color;
let timestamp_width = self.shared.timestamp_width.get();
let url_rects = Self::compute_hit_rects(
&txt_layout,
&data,
timestamp_width,
url_color,
&url_ranges_of(&data, url_color),
|raw| sanitize_url(raw),
);
let nick_rects = if data.is_notice {
vec![]
} else {
let nick_range = 0..data.body_offset();
Self::compute_hit_rects(
&txt_layout,
&data,
timestamp_width,
nick_color,
&[nick_range],
|raw| Some(raw.trim_end().to_string()),
)
};
// Cap/expand: long messages collapse to the cap by default.
let full_text_height = txt_layout.height();
let cap = self.own.cap_max_height.get();
let over_cap = cap > 0. && full_text_height > cap;
let collapsed = over_cap && !expanded;
let affordance_rect = over_cap.then(|| {
let line_height = self.shared.line_height.get();
let width = self.shared.rect.get().w;
let y = if collapsed { cap - line_height } else { full_text_height - line_height };
Rectangle::new(width - 60., y.max(0.), 60., line_height)
});
let text_height = if collapsed { cap } else { full_text_height };
let height = text_height + self.shared.message_spacing.get();
PrivMsg {
data,
payload: vec![],
sig,
txt_layout,
ts_layout,
instrs: None,
url_rects,
nick_rects,
affordance_rect,
full_text_height,
over_cap,
collapsed,
height,
}
}
/// The instance, materializing (or re-materializing) as needed.
/// Returns the measured height.
fn ensure_materialized(&self, rec: &MsgRecord) -> f32 {
let key = (rec.ts, rec.id);
let mut inner = self.inner.lock();
inner.access += 1;
let access = inner.access;
inner.touches.insert(key, access);
if let Some(inst) = inner.instances.get_mut(&key) {
let sig = self.current_sig(&inst.data);
if inst.sig == sig && inst.payload == rec.payload {
return inst.height
}
}
let (nick, text, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
let (is_action, text) = match parse_ctcp_action(&text) {
Some(action) => (true, action.to_string()),
None => (false, text),
};
let is_notice = is_notice(&nick);
let data = PrivData {
ts: rec.ts,
id: rec.id,
nick,
text,
confirmed,
is_action,
is_notice,
expanded: true,
};
let inst = self.build_rendered(&key, data);
let height = inst.height;
let inst = PrivMsg { payload: rec.payload.clone(), ..inst };
inner.instances.insert(key, inst);
inner.layout_builds += 1;
t!("materialized id={} height={height}", rec.id);
height
}
/// Measure a record: materialize if needed, return the height.
pub fn measure(&self, rec: &MsgRecord) -> f32 {
self.ensure_materialized(rec)
}
/// Whether the instance currently holds rendered state.
pub fn is_materialized(&self, rec: &MsgRecord) -> bool {
self.inner.lock().instances.contains_key(&(rec.ts, rec.id))
}
/// How many instances are materialized (LRU bookkeeping, tests).
pub fn instance_count(&self) -> usize {
self.inner.lock().instances.len()
}
/// Drop an instance's rendered state (eviction). Render-scoped
/// tasks would be cancelled here; none exist yet.
pub fn release(&self, key: &InstKey) {
let mut inner = self.inner.lock();
if inner.instances.remove(key).is_some() {
inner.touches.remove(key);
t!("released id={}", key.1);
}
}
/// Drop every instance's rendered state.
pub fn release_all(&self) {
let mut inner = self.inner.lock();
let count = inner.instances.len();
inner.instances.clear();
inner.touches.clear();
t!("released all ({count})");
}
/// Rebuild rendered state from live props + current data.
pub fn regen(&self, key: &InstKey) {
self.release(key);
}
/// Rebuild every instance's rendered state.
pub fn regen_all(&self) {
self.release_all();
}
/// The instance's measured height, if materialized.
pub fn height(&self, key: &InstKey) -> Option {
self.inner.lock().instances.get(key).map(|inst| inst.height)
}
/// Release the out-of-window instances beyond the LRU budget:
/// `keep` are window members, the `budget` most recently touched
/// of the rest survive.
pub fn sweep(&self, keep: &std::collections::HashSet, budget: usize) {
let mut inner = self.inner.lock();
let releases = evict_beyond(keep, &inner.touches, budget);
drop(inner);
for key in releases {
self.release(&key);
}
}
/// Renderer-bound draw instructions in message-local coordinates
/// (y grows downward from the message's top edge). Mesh allocation
/// happens here — the draw edge — and is cached until the epoch or
/// the layout signature invalidates it. Collapsed long messages
/// come back [`DrawOutcome::Clipped`] so the chatview emits
/// them as sibling calls with their own view.
/// Draw-path re-evaluation of the expr-bound own styling props
/// (see ChatView::eval_style for rationale).
pub fn eval_style(&self, atom: &mut PropertyAtomicGuard) {
self.own.action_text_color.eval(atom).expect("action_text_color");
self.own.url_text_color.eval(atom).expect("url_text_color");
self.own.cap_max_height.eval(atom).expect("cap_max_height");
}
pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> super::DrawOutcome {
let key = (rec.ts, rec.id);
let mut inner = self.inner.lock();
inner.access += 1;
let access = inner.access;
inner.touches.insert(key, access);
let epoch_changed =
inner.epoch_tracker.get_or_insert_with(|| EpochTracker::new(renderer)).changed();
if epoch_changed {
for inst in inner.instances.values_mut() {
inst.instrs = None;
}
}
let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
if inst.instrs.is_none() {
let mut instrs =
text::render_layout(&inst.ts_layout, renderer, gfxtag!("chatview_privmsg_ts"));
instrs.push(DrawInstruction::Move(Point::new(self.shared.timestamp_width.get(), 0.)));
// URL backgrounds (and optional borders) behind the URL
// runs, under the glyphs. render_backgrounds matches runs
// by their style brush, so only URL-colored runs get a box.
if url_regex().is_match(&inst.data.text) {
let bg_instrs = text::render_backgrounds(
&inst.txt_layout,
self.own.url_text_color.get(),
self.own.url_bg_color.get(),
self.own.url_bg_border_color.get(),
self.own.url_bg_border_size.get(),
renderer,
gfxtag!("chatview_privmsg_urlbg"),
);
instrs.extend(bg_instrs);
}
let text_instrs =
text::render_layout(&inst.txt_layout, renderer, gfxtag!("chatview_privmsg_text"));
instrs.extend(text_instrs);
// The expand/collapse affordance label, centered in its rect.
if let Some(rect) = &inst.affordance_rect {
let label = if inst.collapsed { "+" } else { "-" };
let label_layout = text::make_layout(
label,
self.shared.text_color.get(),
self.shared.font_size.get() * 0.8,
1.,
self.shared.window_scale.get(),
None,
&[],
);
instrs.push(DrawInstruction::Move(Point::new(
rect.x + (rect.w - label_layout.width()) / 2.,
rect.y + (rect.h - label_layout.height()) / 2.,
)));
let label_instrs =
text::render_layout(&label_layout, renderer, gfxtag!("chatview_privmsg_cap"));
instrs.extend(label_instrs);
}
inst.instrs = Some(instrs);
}
let instrs = inst.instrs.clone().unwrap_or_default();
if inst.collapsed {
let clip_h = inst.height - self.shared.message_spacing.get();
DrawOutcome::Clipped { instrs, clip_h }
} else {
DrawOutcome::Inline(instrs)
}
}
/// Clipboard contribution when selected: the rendered line.
pub fn copy_text(&self, rec: &MsgRecord) -> Option {
let inner = self.inner.lock();
inner.instances.get(&(rec.ts, rec.id)).map(|inst| inst.data.line_text())
}
/// Hit dispatch in message-local coordinates: URL rects first,
/// then the nick prefix, then the expand affordance.
pub fn hit_test(&self, rec: &MsgRecord, pos: Point) -> Option {
let inner = self.inner.lock();
let Some(inst) = inner.instances.get(&(rec.ts, rec.id)) else { return None };
for (url, rect) in &inst.url_rects {
if rect.contains(pos) {
return Some(Hit::Url(url.clone()))
}
}
for (nick, rect) in &inst.nick_rects {
if rect.contains(pos) {
return Some(Hit::Nick(nick.clone()))
}
}
if let Some(rect) = &inst.affordance_rect {
if rect.contains(pos) {
return Some(Hit::Expand)
}
}
None
}
/// Toggle a capped message's expanded state and regen its rendered
/// state; returns the new measured height.
pub fn toggle_expand(&self, rec: &MsgRecord) -> f32 {
let key = (rec.ts, rec.id);
let mut expanded = self.expanded.lock();
if !expanded.remove(&key) {
expanded.insert(key);
}
drop(expanded);
self.regen(&key);
self.measure(rec)
}
/// The URL hit rects of a materialized instance (test access).
pub(crate) fn url_rects(&self, rec: &MsgRecord) -> Vec<(String, Rectangle)> {
let inner = self.inner.lock();
inner
.instances
.get(&(rec.ts, rec.id))
.map(|inst| inst.url_rects.clone())
.unwrap_or_default()
}
/// The nick hit rects of a materialized instance (test access).
pub(crate) fn nick_rects(&self, rec: &MsgRecord) -> Vec<(String, Rectangle)> {
let inner = self.inner.lock();
inner
.instances
.get(&(rec.ts, rec.id))
.map(|inst| inst.nick_rects.clone())
.unwrap_or_default()
}
/// Build hit rects for the glyph runs whose brush matches
/// `match_color`, mapping each run to its entry in `ranges` by
/// intersecting the (coarse) font-run text range. Layout
/// coordinates are physical, so divide by the scale to get the
/// message-local virtual units hit tests use.
fn compute_hit_rects(
layout: &text::TextLayout,
data: &PrivData,
timestamp_width: f32,
match_color: Color,
ranges: &[std::ops::Range],
payload_fn: impl Fn(&str) -> Option,
) -> Vec<(String, Rectangle)> {
let mut rects = vec![];
if ranges.is_empty() {
return rects
}
let linetext = data.line_text();
let scale = layout.scale();
for line in layout.lines() {
for item in line.items() {
let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
if glyph_run.style().brush != match_color {
continue
}
let font_range = glyph_run.run().text_range();
let Some(hit_range) =
ranges.iter().find(|r| r.start < font_range.end && r.end > font_range.start)
else {
continue
};
let Some(payload) = payload_fn(&linetext[hit_range.clone()]) else { continue };
let metrics = glyph_run.run().metrics();
let x = timestamp_width + glyph_run.offset() / scale;
let y = (glyph_run.baseline() - metrics.ascent) / scale;
let w = glyph_run.advance() / scale;
let h = (metrics.ascent + metrics.descent) / scale;
rects.push((payload, Rectangle::new(x, y, w, h)));
}
}
rects
}
/// Insert a confirmed privmsg: persist via the loader (dedup by
/// composite key), measure, insert into the buffer, keep the view
/// stable if the message grows content below the viewport. An
/// insert for an already-stored message acts as its confirmation.
pub async fn insert_line(&self, ts: Timestamp, id: MessageId, nick: String, text: String) {
self.insert_privmsg(ts, id, nick, text, true).await
}
/// Insert an unconfirmed (sent, not yet seen on the network)
/// privmsg; persisted with the confirmed flag in the payload.
pub async fn insert_unconf_line(
&self,
ts: Timestamp,
id: MessageId,
nick: String,
text: String,
) {
self.insert_privmsg(ts, id, nick, text, false).await
}
async fn insert_privmsg(
&self,
ts: Timestamp,
id: MessageId,
nick: String,
text: String,
confirmed: bool,
) {
if ts <= 6047051717 {
error!(target: "ui::chatview::privmsg", "rejecting insert with non-millisecond timestamp {ts}");
return
}
t!("insert ts={ts} id={id} nick={nick} confirmed={confirmed}");
let payload = codec::encode_privmsg_payload(&nick, &text, confirmed);
if !self.loader.store(ts, &id, MsgType::PrivMsg, &payload) {
// Already stored — this is the confirmation of a message we
// have been showing as unconfirmed (or a duplicate relay).
self.confirm(id).await;
return
}
let rec = MsgRecord { ts, id: id.clone(), msg_type: MsgType::PrivMsg, payload, height: 0. };
let height = self.measure(&rec);
let full_rec = MsgRecord { height, ..rec };
let mut buffer = self.buffer.lock().await;
if !buffer.insert(full_rec.clone()) {
return
}
let top = buffer.pos_of_key(&(full_rec.ts, full_rec.id));
drop(buffer);
// A fud URL in the text derives its file message below the line.
if get_file_url(&text).is_some() {
if let Some(chat) = self.chat.upgrade() {
chat.derive_filemsg(&full_rec, &nick, &text).await;
}
}
if let (Some(top), Some(chat)) = (top, self.chat.upgrade()) {
// The appended message sits at the very bottom: when the
// user reads history, shift by its height so nothing moves.
let mut ctl = chat.controller.lock();
let scroll = ctl.scroll();
ctl.compensate(height, top <= scroll);
}
if let Some(chat) = self.chat.upgrade() {
chat.sync_is_at_bottom();
chat.redraw.trigger();
chat.loader.wake(Wakeup::Insert);
}
}
/// Mark an unconfirmed message confirmed: rewrite the payload in
/// place (same kvdb entry), update the record, regen for styling.
pub async fn confirm(&self, id: MessageId) {
let (ts, payload) = {
let buffer = self.buffer.lock().await;
let Some(rec) = buffer.record(&id) else {
t!("confirm of unloaded id={id}");
return
};
(rec.ts, rec.payload.clone())
};
let (nick, text, confirmed) = codec::decode_privmsg_payload(&payload, ts, &id);
if confirmed {
return
}
let new_payload = codec::encode_privmsg_payload(&nick, &text, true);
self.loader.update(ts, &id, MsgType::PrivMsg, &new_payload);
{
let mut buffer = self.buffer.lock().await;
if let Some(rec) = buffer.record_mut(&id) {
rec.payload = new_payload;
}
}
self.regen(&(ts, id));
if let Some(chat) = self.chat.upgrade() {
chat.redraw.trigger();
}
t!("confirmed id={id}");
}
/// The scene node handle, for the chatview's method wiring.
pub fn node(&self) -> &SceneNodeWeak {
&self.node
}
/// The "Copied link" toast label.
pub fn url_copy_text(&self) -> String {
self.url_copy_text.get()
}
/// The toast's foreground color.
pub fn url_copy_fg_color(&self) -> Color {
self.url_copy_fg_color.get()
}
/// The toast's background color.
pub fn url_copy_bg_color(&self) -> Color {
self.url_copy_bg_color.get()
}
/// The toast label's font size.
pub fn url_copy_font_size(&self) -> f32 {
self.url_copy_font_size.get()
}
/// The toast's inner padding.
pub fn url_copy_padding(&self) -> f32 {
self.url_copy_padding.get()
}
/// The toast's lift above the anchor.
pub fn url_copy_offset(&self) -> f32 {
self.url_copy_offset.get()
}
/// How long the toast stays, in seconds.
pub fn url_copy_duration(&self) -> f32 {
self.url_copy_duration.get()
}
}
static URL_REGEX: std::sync::OnceLock = std::sync::OnceLock::new();
fn url_regex() -> &'static regex::Regex {
URL_REGEX
.get_or_init(|| regex::Regex::new(r"https?://[^\s]+|fud://[^\s]+|www\.[^\s]+").unwrap())
}
/// Sanitize an extracted URL match for use as a hit payload (opening,
/// copying): strip trailing punctuation and control characters (an
/// interior NUL would abort the Android intent JNI path), resolve
/// schemeless `www.` hosts to https, and reject anything that does not
/// parse to an http/https/fud URL. Returns None for matches that are
/// not safely openable.
fn sanitize_url(raw: &str) -> Option {
let trimmed = raw.trim_end_matches(['.', ',', '!', '?', ')', ']', '}', '\'', '"', ';', ':']);
let trimmed = trimmed.trim_end_matches(|c: char| c.is_control());
if trimmed.chars().any(|c| c.is_control()) {
return None
}
let candidate = if trimmed.starts_with("www.") {
format!("https://{trimmed}")
} else {
trimmed.to_string()
};
let url = Url::parse(&candidate).ok()?;
match url.scheme() {
"http" | "https" | "fud" => Some(url.to_string()),
_ => None,
}
}
/// URL byte ranges (no colors) within the rendered line text.
fn url_ranges_of(data: &PrivData, color: Color) -> Vec> {
let mut ranges = vec![];
for (range, _) in url_color_ranges(&data.text, data.body_offset(), color) {
ranges.push(range);
}
ranges
}
/// URL byte ranges within the rendered line text, colored with the URL
/// color so backgrounds and hit rects can find them by brush.
fn url_color_ranges(
text: &str,
offset: usize,
color: Color,
) -> Vec<(std::ops::Range, Color)> {
let mut ranges = vec![];
for m in url_regex().find_iter(text) {
ranges.push((m.start() + offset..m.end() + offset, color));
}
ranges
}
/// Scene node factory for the privmsg type node.
/// Decode `(ts, id, nick, text)` method-call data.
pub fn decode_insert_data(data: &[u8]) -> Option<(Timestamp, MessageId, String, String)> {
let mut cur = Cursor::new(data);
let ts = Timestamp::decode(&mut cur).ok()?;
let id = MessageId::decode(&mut cur).ok()?;
let nick = String::decode(&mut cur).ok()?;
let text = String::decode(&mut cur).ok()?;
Some((ts, id, nick, text))
}
/// Encode `(ts, id, nick, text)` for method-call data.
pub fn encode_insert_data(ts: Timestamp, id: &MessageId, nick: &str, text: &str) -> Vec {
let mut data = vec![];
ts.encode(&mut data).unwrap();
id.encode(&mut data).unwrap();
nick.encode(&mut data).unwrap();
text.encode(&mut data).unwrap();
data
}
#[async_trait]
impl UIObject for PrivMsgNode {
fn priority(&self) -> u32 {
0
}
}
impl std::fmt::Debug for PrivMsgNode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.node.upgrade())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::node::create_privmsg_node, prop::PropertyAtomicGuard};
use std::collections::HashSet;
/// A scale=1.0 property standing in for the window scale.
fn scale_prop() -> PropertyFloat32 {
let mut node = SceneNode::new("w", SceneNodeType::Object);
let prop = Property::new(
"scale",
PropertyType::Float32,
PropertySubType::Null,
PropertyPermission::default(),
);
node.add_property(prop).unwrap();
let node = node.setup_null();
let atom = &mut PropertyAtomicGuard::none();
node.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
PropertyFloat32::wrap(&node, Role::Internal, "scale", 0).unwrap()
}
/// A loader over a throwaway tree, for insert-path tests.
fn fixture_loader(tag: &str) -> (Arc, kvdb_overlay::Tree) {
let path = std::env::temp_dir()
.join(format!("darkfi-chatview-privmsg-{tag}-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let db = kvdb_overlay::Database::open_default(&path).unwrap();
let tree = db.open_tree_default("chat").unwrap();
(
Loader::new(
Arc::new(AsyncMutex::new(MsgBuffer::new())),
crate::ui::RedrawTrigger::new().0,
),
tree.clone(),
)
}
async fn make_node(tag: &str) -> (SceneNodePtr, PrivMsgNodePtr, Arc>) {
// A chatview-shaped parent supplies the shared styling; the
// privmsg node carries the type-specific properties.
let chat = crate::app::node::create_chatview("chatview");
let chat = chat.setup_null();
let atom = &mut PropertyAtomicGuard::none();
chat.set_property_f32(atom, Role::App, "font_size", 14.).unwrap();
chat.set_property_f32(atom, Role::App, "timestamp_font_size", 10.).unwrap();
chat.set_property_f32(atom, Role::App, "timestamp_width", 50.).unwrap();
chat.set_property_f32(atom, Role::App, "line_height", 20.).unwrap();
chat.set_property_f32(atom, Role::App, "message_spacing", 4.).unwrap();
let prop = chat.get_property("rect").unwrap();
prop.set_f32(atom, Role::App, 0, 0.).unwrap();
prop.set_f32(atom, Role::App, 1, 0.).unwrap();
prop.set_f32(atom, Role::App, 2, 800.).unwrap();
prop.set_f32(atom, Role::App, 3, 600.).unwrap();
let prop = chat.get_property("text_color").unwrap();
for (i, c) in [1., 1., 1., 1.].iter().enumerate() {
prop.set_f32(atom, Role::App, i, *c).unwrap();
}
let prop = chat.get_property("timestamp_color").unwrap();
for (i, c) in [0.5, 0.5, 0.5, 1.].iter().enumerate() {
prop.set_f32(atom, Role::App, i, *c).unwrap();
}
let shared = SharedProps::wrap(&chat, scale_prop());
let mut raw = MsgBuffer::new();
raw.disable_separators();
let buffer = Arc::new(AsyncMutex::new(raw));
let (redraw, _rx) = crate::ui::RedrawTrigger::new();
let loader = Loader::new(buffer.clone(), redraw);
loader.bind(tag.to_string(), fixture_loader(tag).1);
// A dangling chat weak: insert still persists and buffers, it
// just skips view compensation (no chatview to talk to).
let chat_weak: Weak = Weak::new();
let node = create_privmsg_node("privmsg");
let atom = &mut PropertyAtomicGuard::none();
let prop = node.get_property("nick_colors").unwrap();
for c in [1., 0., 0., 1.] {
prop.push_f32(atom, Role::App, c).unwrap();
}
let prop = node.get_property("action_text_color").unwrap();
for (i, c) in [0.5, 0.25, 0.75, 1.].iter().enumerate() {
prop.set_f32(atom, Role::App, i, *c).unwrap();
}
let shared2 = shared.clone();
let loader2 = loader.clone();
let buffer2 = buffer.clone();
let node =
node.setup(|me| async move {
PrivMsgNode::new(me, shared2, loader2, buffer2, chat_weak).await
})
.await;
chat.link(node.clone());
let Pimpl::PrivMsgNode(ptr) = node.pimpl() else { panic!() };
(chat, ptr.clone(), buffer)
}
fn rec_of(ts: Timestamp, idb: u8, text: &str, confirmed: bool) -> MsgRecord {
let mut id = [0u8; 32];
id[0] = idb;
let payload = codec::encode_privmsg_payload("alice", text, confirmed);
MsgRecord { ts, id: MessageId(id), msg_type: MsgType::PrivMsg, payload, height: 0. }
}
#[test]
fn measure_reports_height_and_caches_layout() {
let (_chat, node, _buffer) = smol::block_on(make_node("cache"));
let rec = rec_of(1_000_000, b'a', "hello world", true);
let h1 = node.measure(&rec);
assert!(h1 > 4., "height incl. spacing: {h1}");
assert_eq!(node.inner.lock().layout_builds, 1);
// Repeated measures reuse the cached layout.
let h2 = node.measure(&rec);
assert_eq!(h1, h2);
assert_eq!(node.inner.lock().layout_builds, 1);
assert!(node.is_materialized(&rec));
}
#[test]
fn release_then_materialize_rebuilds_state() {
let (_chat, node, _buffer) = smol::block_on(make_node("release"));
let rec = rec_of(1_000_000, b'a', "hello", true);
let h1 = node.measure(&rec);
node.release(&(rec.ts, rec.id));
assert!(!node.is_materialized(&rec));
let h2 = node.measure(&rec);
assert_eq!(h1, h2);
assert_eq!(node.inner.lock().layout_builds, 2);
}
#[test]
fn width_change_rewraps() {
let (chat, node, _buffer) = smol::block_on(make_node("width"));
let long = "word ".repeat(60);
let rec = rec_of(1_000_000, b'a', &long, true);
let wide = node.measure(&rec);
assert_eq!(node.inner.lock().layout_builds, 1);
// Narrow the chatview rect: the signature changes, the layout
// re-wraps taller.
let prop = chat.get_property("rect").unwrap();
let atom = &mut PropertyAtomicGuard::none();
prop.set_f32(atom, Role::App, 2, 200.).unwrap();
let narrow = node.measure(&rec);
assert!(narrow > wide, "narrow={narrow} wide={wide}");
assert_eq!(node.inner.lock().layout_builds, 2);
}
#[test]
fn styling_and_data_changes_invalidate() {
let (chat, node, _buffer) = smol::block_on(make_node("styling"));
let rec = rec_of(1_000_000, b'a', "hello", true);
node.measure(&rec);
assert_eq!(node.inner.lock().layout_builds, 1);
// Styling change (font size on the chatview node).
let atom = &mut PropertyAtomicGuard::none();
chat.set_property_f32(atom, Role::App, "font_size", 20.).unwrap();
node.measure(&rec);
assert_eq!(node.inner.lock().layout_builds, 2, "font size change rebuilds");
// Data change: confirm flips the body color (part of the signature).
let rec_unconf = rec_of(1_000_000, b'a', "hello", false);
node.measure(&rec_unconf);
assert_eq!(node.inner.lock().layout_builds, 3, "confirmed flag rebuilds");
}
#[test]
fn insert_line_persists_and_buffers() {
let (_chat, node, buffer) = smol::block_on(make_node("insert"));
smol::block_on(async {
node.insert_line(
1_756_000_000_000,
MessageId([b'x'; 32]),
"alice".to_string(),
"hi there".to_string(),
)
.await;
});
let buffer = smol::block_on(buffer.lock());
assert_eq!(buffer.len(), 1);
let rec = buffer.record(&MessageId([b'x'; 32])).unwrap();
assert!(rec.height > 0., "inserted with measured height");
let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
assert!(confirmed);
}
#[test]
fn insert_dedups_and_confirms_in_place() {
let (_chat, node, buffer) = smol::block_on(make_node("dedup"));
let id = MessageId([b'x'; 32]);
let ts = 1_756_000_000_000;
smol::block_on(async {
node.insert_unconf_line(ts, id, "alice".to_string(), "hi".to_string()).await;
});
{
let buffer = smol::block_on(buffer.lock());
assert_eq!(buffer.len(), 1);
let rec = buffer.record(&id).unwrap();
let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
assert!(!confirmed, "starts unconfirmed");
}
// The confirmed relay of the same message updates in place:
// one entry, confirmed payload, regen invalidated the rendered
// state (lazily rebuilt on the next touch).
smol::block_on(async {
node.insert_line(ts, id, "alice".to_string(), "hi".to_string()).await;
});
{
let buffer = smol::block_on(buffer.lock());
assert_eq!(buffer.len(), 1, "no duplicate record");
let rec = buffer.record(&id).unwrap();
let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
assert!(confirmed, "confirmed in place");
}
assert!(
!{
let buffer = smol::block_on(buffer.lock());
let rec = buffer.record(&id).unwrap().clone();
node.is_materialized(&rec)
},
"regen dropped the rendered state"
);
let builds_before = node.inner.lock().layout_builds;
let rec = {
let buffer = smol::block_on(buffer.lock());
buffer.record(&id).unwrap().clone()
};
node.measure(&rec);
assert_eq!(
node.inner.lock().layout_builds,
builds_before + 1,
"rebuilt with confirmed data"
);
// A plain duplicate changes nothing.
let builds = node.inner.lock().layout_builds;
smol::block_on(async {
node.insert_line(ts, id, "alice".to_string(), "hi".to_string()).await;
});
let buffer = smol::block_on(buffer.lock());
assert_eq!(buffer.len(), 1);
drop(buffer);
assert_eq!(node.inner.lock().layout_builds, builds, "already-confirmed duplicate is inert");
}
#[test]
fn line_text_variants() {
let data = PrivData {
ts: 0,
id: MessageId([0; 32]),
nick: "alice".to_string(),
text: "waves".to_string(),
confirmed: true,
is_action: true,
is_notice: false,
expanded: true,
};
assert_eq!(data.line_text(), "* alice waves");
assert_eq!(data.body_offset(), "alice".len() + 3);
let data = PrivData { is_action: false, ..data };
assert_eq!(data.line_text(), "alice waves");
assert_eq!(data.body_offset(), "alice".len() + 1);
let data = PrivData { nick: "NOTICE".to_string(), is_notice: true, ..data };
assert_eq!(data.line_text(), "waves");
assert_eq!(data.body_offset(), 0);
}
#[test]
fn url_hit_rects_resolve_through_hit_test() {
let (_chat, node, _buffer) = smol::block_on(make_node("urls"));
let rec = rec_of(1_000_000, b'a', "see https://example.com/now okay", true);
node.measure(&rec);
let rects = node.url_rects(&rec);
assert_eq!(rects.len(), 1, "one URL run");
let (url, rect) = &rects[0];
assert_eq!(url, "https://example.com/now");
// Inside the rect: the URL; on the nick prefix: the nick;
// elsewhere: nothing.
let mid = Point::new(rect.x + rect.w / 2., rect.y + rect.h / 2.);
assert_eq!(node.hit_test(&rec, mid), Some(Hit::Url(url.clone())));
let nicks = node.nick_rects(&rec);
assert!(!nicks.is_empty(), "nick prefix is clickable");
let (nick, nrect) = &nicks[0];
assert_eq!(nick, "alice");
assert_eq!(
node.hit_test(&rec, Point::new(nrect.x + nrect.w / 2., nrect.y + nrect.h / 2.)),
Some(Hit::Nick("alice".to_string()))
);
assert_eq!(node.hit_test(&rec, Point::new(400., rect.y + rect.h / 2.)), None);
}
#[test]
fn wrapped_url_produces_hit_rects() {
let (_chat, node, _buffer) = smol::block_on(make_node("wrapurls"));
// A long URL prefix that must wrap across lines still yields
// hit rects (one per wrapped run).
let mut text = String::from("look ");
for _ in 0..30 {
text.push_str("https://example.com/very/long/path/segment ");
}
let rec = rec_of(1_000_000, b'a', &text, true);
node.measure(&rec);
let rects = node.url_rects(&rec);
assert!(rects.len() >= 2, "wrapped URL runs: {}", rects.len());
for (url, _rect) in &rects {
assert!(url.starts_with("https://example.com/"), "{url}");
}
}
#[test]
fn sweep_releases_out_of_window_and_rematerialize_rebuilds() {
let (_chat, node, _buffer) = smol::block_on(make_node("sweep"));
// Materialize a window of 5 records plus older strays.
let mut recs = vec![];
for i in 0..8u64 {
recs.push(rec_of(1_000_000 + i * 60_000, i as u8 + b'a', "text", true));
}
for rec in &recs {
node.measure(rec);
}
assert_eq!(node.instance_count(), 8);
// The window keeps the newest 5; budget 0 releases the rest.
let mut keep = HashSet::new();
for rec in &recs[3..] {
keep.insert((rec.ts, rec.id));
}
node.sweep(&keep, 0);
assert_eq!(node.instance_count(), 5, "window members survive");
for rec in &recs[..3] {
assert!(!node.is_materialized(rec), "stray released");
}
// Rematerializing a released record rebuilds identical state.
let h1 = node.measure(&recs[0]);
assert!(h1 > 0.);
assert!(node.is_materialized(&recs[0]));
}
#[test]
fn capped_measurement_and_expand_height_reporting() {
let (chat, node, _buffer) = smol::block_on(make_node("cap"));
// Cap the node at 60 px; a very long wrapped message towers
// over it. Shorten the width so even modest text wraps tall.
let atom = &mut PropertyAtomicGuard::none();
node.node()
.upgrade()
.unwrap()
.set_property_f32(atom, Role::App, "cap_max_height", 60.)
.unwrap();
let prop = chat.get_property("rect").unwrap();
prop.set_f32(atom, Role::App, 2, 240.).unwrap();
let long = "wrap me please ".repeat(80);
let rec = rec_of(1_000_000, b'a', &long, true);
let collapsed_h = node.measure(&rec);
assert!((collapsed_h - 60. - 4.).abs() < 0.5, "collapsed to cap + spacing: {collapsed_h}");
// The affordance is hit-testable in message-local coordinates.
let affordance = {
let inner = node.inner.lock();
let inst = inner.instances.get(&(rec.ts, rec.id)).unwrap();
inst.affordance_rect.clone().unwrap()
};
assert_eq!(
node.hit_test(&rec, Point::new(affordance.x + 5., affordance.y + 5.)),
Some(Hit::Expand)
);
// Expanding reports the full wrapped height.
let expanded_h = node.toggle_expand(&rec);
assert!(expanded_h > 200., "expanded height: {expanded_h}");
assert_eq!(node.measure(&rec), expanded_h, "stable while expanded");
// Collapsing again restores the cap.
let again = node.toggle_expand(&rec);
assert!((again - collapsed_h).abs() < 0.5, "re-collapsed: {again}");
}
#[test]
fn short_messages_are_never_capped() {
let (chat, node, _buffer) = smol::block_on(make_node("nocap"));
let atom = &mut PropertyAtomicGuard::none();
node.node()
.upgrade()
.unwrap()
.set_property_f32(atom, Role::App, "cap_max_height", 60.)
.unwrap();
let rec = rec_of(1_000_000, b'a', "tiny", true);
let h = node.measure(&rec);
assert!(h < 60., "single short line: {h}");
let inner = node.inner.lock();
let inst = inner.instances.get(&(rec.ts, rec.id)).unwrap();
assert!(inst.affordance_rect.is_none());
}
#[test]
fn url_sanitization() {
use super::sanitize_url;
assert_eq!(sanitize_url("https://example.com/").as_deref(), Some("https://example.com/"));
assert_eq!(
sanitize_url("https://example.com/path.").as_deref(),
Some("https://example.com/path")
);
assert_eq!(
sanitize_url("https://example.com/a,b!").as_deref(),
Some("https://example.com/a,b")
);
assert_eq!(sanitize_url("www.example.com/x").as_deref(), Some("https://www.example.com/x"));
// Interior control characters (incl. NUL): rejected. A trailing
// one is trimmed — the cleaned URL stays usable.
assert_eq!(sanitize_url("https://evil.com/\u{0}x"), None);
assert_eq!(sanitize_url("https://evil.com/x\u{0}").as_deref(), Some("https://evil.com/x"));
// Non-http(s)/fud schemes cannot appear via the regex, but the
// parser gate holds anyway.
assert_eq!(sanitize_url("file:///etc/passwd"), None);
// fud URLs stay untouched.
assert_eq!(
sanitize_url("fud://abcdef012345/file.png").as_deref(),
Some("fud://abcdef012345/file.png")
);
}
#[test]
fn ctcp_action_parsing() {
assert_eq!(parse_ctcp_action("\u{1}ACTION waves\u{1}"), Some("waves"));
assert_eq!(parse_ctcp_action("\u{1}ACTION waves"), Some("waves"));
assert_eq!(parse_ctcp_action("waves"), None);
assert_eq!(parse_ctcp_action("\u{1}PING\u{1}"), None);
}
}