Explorar o código

app: add chatview2 which supports rich content, no more O(n) scans (worst case O(log n) usually better), and a single chat screen which can be switched between different channel trees. still requires more review.

darkfi hai 2 semanas
pai
achega
2ae24ea767
Modificáronse 42 ficheiros con 8244 adicións e 3052 borrados
  1. 1 0
      bin/app/Cargo.toml
  2. 1 1
      bin/app/Makefile
  3. 82 0
      bin/app/script/drive_chatview2.py
  4. 6 0
      bin/app/src/app/mod.rs
  5. 143 62
      bin/app/src/app/node.rs
  6. 138 98
      bin/app/src/app/schema/chat.rs
  7. 15 55
      bin/app/src/app/schema/menu/channel.rs
  8. 9 25
      bin/app/src/app/schema/menu/contact.rs
  9. 19 23
      bin/app/src/app/schema/menu/mod.rs
  10. 14 26
      bin/app/src/app/schema/mod.rs
  11. 41 35
      bin/app/src/app/schema/test.rs
  12. 372 0
      bin/app/src/app/schema/test_chatview.rs
  13. 2 5
      bin/app/src/app/schema/test_edit.rs
  14. 2 2
      bin/app/src/app/schema/test_scroll_layer.rs
  15. 4 9
      bin/app/src/app/schema/wallet/send_step2.rs
  16. 0 8
      bin/app/src/app/schema/wallet/util.rs
  17. 10 1
      bin/app/src/gfx/mod.rs
  18. 20 20
      bin/app/src/main.rs
  19. 0 1
      bin/app/src/net.rs
  20. 1 1
      bin/app/src/plugin/fud.rs
  21. 8 2
      bin/app/src/scene.rs
  22. 1150 0
      bin/app/src/ui/chatview/buffer.rs
  23. 271 0
      bin/app/src/ui/chatview/codec.rs
  24. 510 0
      bin/app/src/ui/chatview/loader.rs
  25. 882 777
      bin/app/src/ui/chatview/mod.rs
  26. 329 0
      bin/app/src/ui/chatview/msg/datemsg.rs
  27. 758 0
      bin/app/src/ui/chatview/msg/filemsg.rs
  28. 368 0
      bin/app/src/ui/chatview/msg/mod.rs
  29. 1445 0
      bin/app/src/ui/chatview/msg/privmsg.rs
  30. 0 1858
      bin/app/src/ui/chatview/page.rs
  31. 550 0
      bin/app/src/ui/chatview/scroll.rs
  32. 6 1
      bin/app/src/ui/edit/mod.rs
  33. 8 2
      bin/app/src/ui/mod.rs
  34. 449 0
      bin/app/src/util/fenwick.rs
  35. 1 0
      bin/app/src/util/mod.rs
  36. 0 0
      openspec/changes/archive/2026-09-04-app-chatview/.openspec.yaml
  37. 0 0
      openspec/changes/archive/2026-09-04-app-chatview/design.md
  38. 0 0
      openspec/changes/archive/2026-09-04-app-chatview/proposal.md
  39. 0 0
      openspec/changes/archive/2026-09-04-app-chatview/specs/chatview/spec.md
  40. 40 40
      openspec/changes/archive/2026-09-04-app-chatview/tasks.md
  41. 32 0
      openspec/config.yaml
  42. 557 0
      openspec/specs/chatview/spec.md

+ 1 - 0
bin/app/Cargo.toml

@@ -94,6 +94,7 @@ schema-app = []
 schema-test = []
 schema-test-edit = []
 schema-test-scroll-layer = []
+schema-test-chatview = []
 
 [patch.crates-io]
 halo2_proofs = { git="https://github.com/parazyd/halo2", branch="v050" }

+ 1 - 1
bin/app/Makefile

@@ -55,7 +55,7 @@ build-release: $(SRC) $(PROOFS_BIN) fonts assets/forest_1920x1080.ivf
 
 # Download font data
 
-fonts: data/font/ibm-plex-mono-regular.otf data/font/NotoColorEmoji.ttf
+fonts: data/font/ibm-plex-mono-regular.otf data/font/NotoColorEmoji.ttf data/font/darkfi-custom-emoji.ttf
 
 data/font/ibm-plex-mono-regular.otf:
 	mkdir -p data/font/

+ 82 - 0
bin/app/script/drive_chatview2.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+# Drives the single-screen chatview2 cutover over netdebug:
+# set_channel, insert lines, switch channels, verify per-channel state.
+import sys
+sys.path.insert(0, "pydrk")
+from pydrk.api import Api
+from pydrk import serial as s
+
+CHATTY = "/window/content/chat/main_chat_layer/content/chatty"
+PRIV = CHATTY + "/privmsg"
+
+
+def enc_insert(ts, mid, nick, text):
+    b = bytearray()
+    s.write_u64(b, ts)
+    b += mid
+    s.encode_str(b, nick)
+    s.encode_str(b, text)
+    return bytes(b)
+
+
+def dec_line_ids(data):
+    cur = s.Cursor(bytearray(data))
+    out = []
+    while cur.i < len(cur.by):
+        ts = s.read_u64(cur)
+        mid = bytes(cur.read(32))
+        out.append((ts, mid[:4].hex()))
+    return out
+
+
+api = Api()
+api.hello()
+print("== scene has chatview2:", api.get_children(CHATTY)[0][0] if api.get_children(CHATTY) else None)
+
+# Bind #dev and insert history
+api.call_method(CHATTY, "set_channel", s.encode_str(bytearray(), "#dev"))
+for i in range(5):
+    api.call_method(
+        PRIV, "insert_line",
+        enc_insert(1_756_000_000_000 + i * 60_000, bytes([i]) + b"\x00" * 31, "alice", f"dev message {i}"),
+    )
+
+res = api.call_method(CHATTY, "get_line_ids", b"")
+lines = dec_line_ids(res) if res else []
+print(f"== #dev lines: {len(lines)}")
+for ts, mid in lines:
+    print("  ", ts, mid)
+
+# Switch to #random, insert 2 lines
+api.call_method(CHATTY, "set_channel", s.encode_str(bytearray(), "#random"))
+for i in range(2):
+    api.call_method(
+        PRIV, "insert_line",
+        enc_insert(1_756_000_100_000 + i * 60_000, bytes([10 + i]) + b"\x00" * 31, "bob", f"random message {i}"),
+    )
+res = api.call_method(CHATTY, "get_line_ids", b"")
+print(f"== #random lines: {len(dec_line_ids(res) if res else [])}")
+
+# Back to #dev: its lines must be back (buffer reload), newest first
+api.call_method(CHATTY, "set_channel", s.encode_str(bytearray(), "#dev"))
+res = api.call_method(CHATTY, "get_line_ids", b"")
+lines = dec_line_ids(res) if res else []
+print(f"== #dev lines after round-trip: {len(lines)} (expect 5)")
+assert len(lines) == 5, "channel state not restored"
+
+# is_at_bottom property readable
+v = api.get_property_value(CHATTY, "is_at_bottom")
+print("== is_at_bottom:", v)
+
+# delete_line removes one and it survives the next reload
+mid4 = bytes([4]) + b"\x00" * 31
+b = bytearray()
+b += mid4
+api.call_method(CHATTY, "delete_line", bytes(b))
+api.call_method(CHATTY, "set_channel", s.encode_str(bytearray(), "#dev"))
+res = api.call_method(CHATTY, "get_line_ids", b"")
+lines = dec_line_ids(res) if res else []
+print(f"== #dev lines after delete+reload: {len(lines)} (expect 4)")
+assert len(lines) == 4
+
+print("OK")

+ 6 - 0
bin/app/src/app/mod.rs

@@ -157,9 +157,15 @@ impl App {
         #[cfg(feature = "schema-test-scroll-layer")]
         schema::test_scroll_layer::make(&self, window.clone(), &i18n_fish).await;
 
+        #[cfg(feature = "schema-test-chatview")]
+        schema::test_chatview::make(&self, window.clone(), &i18n_fish).await;
+
         #[cfg(all(feature = "schema-app", feature = "schema-test"))]
         compile_error!("Only one schema can be selected");
 
+        #[cfg(all(feature = "schema-app", feature = "schema-test-chatview"))]
+        compile_error!("Only one schema can be selected");
+
         d!("Schema loaded");
     }
 

+ 143 - 62
bin/app/src/app/node.rs

@@ -565,16 +565,14 @@ pub fn create_decimal_edit(name: &str) -> SceneNode {
 pub fn create_chatview(name: &str) -> SceneNode {
     let mut node = SceneNode::new(name, SceneNodeType::ChatView);
 
+    let prop = Property::new("channel", PropertyType::Str, PropertySubType::Null);
+    node.add_property(prop).unwrap();
+
     let mut prop = Property::new("rect", PropertyType::Float32, PropertySubType::Pixel);
     prop.set_array_len(4);
     prop.allow_exprs();
     node.add_property(prop).unwrap();
 
-    let mut prop = Property::new("scroll", PropertyType::Float32, PropertySubType::Null);
-    prop.set_ui_text("Scroll", "Scroll up from the bottom");
-    prop.set_range_f32(0., f32::MAX);
-    node.add_property(prop).unwrap();
-
     let prop = Property::new("font_size", PropertyType::Float32, PropertySubType::Pixel);
     node.add_property(prop).unwrap();
 
@@ -590,6 +588,9 @@ pub fn create_chatview(name: &str) -> SceneNode {
     let prop = Property::new("message_spacing", PropertyType::Float32, PropertySubType::Pixel);
     node.add_property(prop).unwrap();
 
+    let prop = Property::new("baseline", PropertyType::Float32, PropertySubType::Pixel);
+    node.add_property(prop).unwrap();
+
     let mut prop = Property::new("timestamp_color", PropertyType::Float32, PropertySubType::Color);
     prop.set_array_len(4);
     prop.set_range_f32(0., 1.);
@@ -600,6 +601,77 @@ pub fn create_chatview(name: &str) -> SceneNode {
     prop.set_range_f32(0., 1.);
     node.add_property(prop).unwrap();
 
+    let mut prop = Property::new("hi_bg_color", PropertyType::Float32, PropertySubType::Color);
+    prop.set_array_len(4);
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("wheel_page_frac", PropertyType::Float32, PropertySubType::Null);
+    prop.set_ui_text("Wheel page fraction", "Viewport fraction scrolled per wheel notch");
+    prop.set_defaults_f32(vec![0.5]).unwrap();
+    prop.set_range_f32(0., f32::MAX);
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("is_at_bottom", PropertyType::Bool, PropertySubType::Null);
+    prop.set_ui_text("At bottom", "Whether the view sits at the live bottom");
+    prop.set_defaults_bool(vec![true]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("z_index", PropertyType::Uint32, PropertySubType::Null);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("priority", PropertyType::Uint32, PropertySubType::Null);
+    node.add_property(prop).unwrap();
+
+    node.add_signal(
+        "select_changed",
+        "Selection presence changed",
+        vec![("selected", "Whether any line is selected", CallArgType::Bool)],
+    )
+    .unwrap();
+
+    node.add_method("set_channel", vec![("channel", "Channel name", CallArgType::Str)], None)
+        .unwrap();
+
+    node.add_method(
+        "receive",
+        vec![
+            ("channel", "Channel the message arrived on", CallArgType::Str),
+            ("timestamp", "Timestamp", CallArgType::Uint64),
+            ("id", "Message ID", CallArgType::Hash),
+            ("nick", "Nickname", CallArgType::Str),
+            ("text", "Text", CallArgType::Str),
+        ],
+        None,
+    )
+    .unwrap();
+
+    node.add_method("copy_select", vec![], None).unwrap();
+
+    node.add_method("unselect", vec![], None).unwrap();
+
+    node.add_method("scroll_to_bottom", vec![], None).unwrap();
+
+    node.add_method(
+        "get_line_ids",
+        vec![],
+        Some(vec![("lines", "Loaded (ts, id) pairs in display order", CallArgType::Hash)]),
+    )
+    .unwrap();
+
+    node.add_method("delete_line", vec![("id", "Message ID", CallArgType::Hash)], None).unwrap();
+
+    node
+}
+
+pub fn create_privmsg_node(name: &str) -> SceneNode {
+    let mut node = SceneNode::new(name, SceneNodeType::PrivMsgNode);
+
+    let mut prop = Property::new("nick_colors", PropertyType::Float32, PropertySubType::Pixel);
+    prop.set_unbounded();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
     let mut prop =
         Property::new("action_text_color", PropertyType::Float32, PropertySubType::Color);
     prop.set_array_len(4);
@@ -627,6 +699,10 @@ pub fn create_chatview(name: &str) -> SceneNode {
     prop.set_range_f32(0., 1.);
     node.add_property(prop).unwrap();
 
+    let mut prop = Property::new("cap_max_height", PropertyType::Float32, PropertySubType::Pixel);
+    prop.set_range_f32(0., f32::MAX);
+    node.add_property(prop).unwrap();
+
     // "Copied link" overlay (right-click / long-hold on a URL)
     let mut prop = Property::new("url_copy_text", PropertyType::Str, PropertySubType::Null);
     prop.set_defaults_str(vec!["Copied link".to_string()]).unwrap();
@@ -659,63 +735,17 @@ pub fn create_chatview(name: &str) -> SceneNode {
     prop.set_defaults_f32(vec![2.]).unwrap();
     node.add_property(prop).unwrap();
 
-    let mut prop = Property::new("nick_colors", PropertyType::Float32, PropertySubType::Pixel);
-    prop.set_unbounded();
-    prop.set_range_f32(0., 1.);
-    node.add_property(prop).unwrap();
-
-    let mut prop = Property::new("hi_bg_color", PropertyType::Float32, PropertySubType::Color);
-    prop.set_array_len(4);
-    prop.set_range_f32(0., 1.);
-    node.add_property(prop).unwrap();
-
-    let prop = Property::new("baseline", PropertyType::Float32, PropertySubType::Pixel);
-    node.add_property(prop).unwrap();
-
-    let prop = Property::new("z_index", PropertyType::Uint32, PropertySubType::Null);
-    node.add_property(prop).unwrap();
-
-    let prop = Property::new("priority", PropertyType::Uint32, PropertySubType::Null);
-    node.add_property(prop).unwrap();
-
-    let prop = Property::new("debug", PropertyType::Bool, PropertySubType::Null);
-    node.add_property(prop).unwrap();
-
-    let mut prop =
-        Property::new("scroll_start_accel", PropertyType::Float32, PropertySubType::Pixel);
-    prop.set_ui_text("Scroll Start Acceleration", "Initial acceperation when scrolling");
-    prop.set_defaults_f32(vec![4.]).unwrap();
-    node.add_property(prop).unwrap();
-
-    let mut prop = Property::new("scroll_resist", PropertyType::Float32, PropertySubType::Pixel);
-    prop.set_ui_text("Scroll Resistance", "How quickly scrolling speed is dampened");
-    prop.set_range_f32(0., 1.);
-    prop.set_defaults_f32(vec![0.9]).unwrap();
-    node.add_property(prop).unwrap();
-
-    let mut prop = Property::new("key_scroll_speed", PropertyType::Float32, PropertySubType::Pixel);
-    prop.set_ui_text("Page Up/Down Scroll Speed", "Scroll speed when pressing page up/down");
-    prop.set_defaults_f32(vec![6.]).unwrap();
-    node.add_property(prop).unwrap();
-
     node.add_signal(
-        "fileurl_detected",
-        "File URL detected in message",
-        vec![("url", "File URL", CallArgType::Str)],
+        "nick_clicked",
+        "A nick was clicked",
+        vec![("id", "Message ID", CallArgType::Hash), ("nick", "Nickname", CallArgType::Str)],
     )
     .unwrap();
 
     node.add_signal(
-        "file_download_request",
-        "User requested file download",
-        vec![("url", "File URL", CallArgType::Str)],
-    )
-    .unwrap();
-
-    node.add_signal(
-        "select_changed",
-        "Selection presence changed",
-        vec![("selected", "Whether any line is selected", CallArgType::Bool)],
+        "url_clicked",
+        "A URL was clicked",
+        vec![("id", "Message ID", CallArgType::Hash), ("url", "URL", CallArgType::Str)],
     )
     .unwrap();
 
@@ -743,17 +773,68 @@ pub fn create_chatview(name: &str) -> SceneNode {
     )
     .unwrap();
 
+    node.add_method("confirm", vec![("id", "Message ID", CallArgType::Hash)], None).unwrap();
+
+    node
+}
+
+pub fn create_datemsg_node(name: &str) -> SceneNode {
+    let mut node = SceneNode::new(name, SceneNodeType::DateMsgNode);
+
+    // Null = inherit the chatview's font size.
+    let mut prop = Property::new("font_size", PropertyType::Float32, PropertySubType::Pixel);
+    prop.allow_null_values();
+    prop.set_defaults_null().unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("color", PropertyType::Float32, PropertySubType::Color);
+    prop.set_array_len(4);
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    node
+}
+
+pub fn create_filemsg_node(name: &str) -> SceneNode {
+    let mut node = SceneNode::new(name, SceneNodeType::FileMsgNode);
+
+    let prop = Property::new("max_height", PropertyType::Float32, PropertySubType::Pixel);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("z_index", PropertyType::Uint32, PropertySubType::Null);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("priority", PropertyType::Uint32, PropertySubType::Null);
+    node.add_property(prop).unwrap();
+
+    node.add_signal(
+        "fileurl_detected",
+        "A fud file URL was detected in a message",
+        vec![("url", "File URL", CallArgType::Str)],
+    )
+    .unwrap();
+
+    node.add_signal(
+        "download_request",
+        "A file download was requested",
+        vec![("id", "Message ID", CallArgType::Hash), ("url", "File URL", CallArgType::Str)],
+    )
+    .unwrap();
+
+    node.add_signal(
+        "status_changed",
+        "A file message's status changed",
+        vec![("id", "Message ID", CallArgType::Hash)],
+    )
+    .unwrap();
+
     node.add_method(
         "set_file_status",
-        vec![("url", "File URL", CallArgType::Str), ("status", "File status", CallArgType::Str)],
+        vec![("url", "File URL", CallArgType::Str), ("status", "Status", CallArgType::Str)],
         None,
     )
     .unwrap();
 
-    node.add_method("copy_select", vec![], None).unwrap();
-
-    node.add_method("unselect", vec![], None).unwrap();
-
     node
 }
 

+ 138 - 98
bin/app/src/app/schema/chat.rs

@@ -42,13 +42,17 @@ use crate::{
     scene::{Pimpl, SceneNodePtr, Slot},
     shape,
     ui::{
-        chatview, emoji_picker, BaseEdit, BaseEditType, Button, ChatView, EmojiPicker, Layer,
-        RedrawTrigger, Shortcut, Text, VectorArt, VectorShape,
+        chatview::MessageId, emoji_picker, BaseEdit, BaseEditType, Button, ChatView, EmojiPicker,
+        Layer, RedrawTrigger, Shortcut, Text, VectorArt, VectorShape,
     },
     util::{i18n::I18nBabelFish, unixtime},
     ExecutorPtr,
 };
 
+use std::io::Cursor;
+
+use url::Url;
+
 use super::{ColorScheme, COLOR_SCHEME};
 
 #[cfg(any(target_os = "android", feature = "emulate-android"))]
@@ -87,6 +91,7 @@ mod android_ui_consts {
     pub const MESSAGE_SPACING: f32 = 15.;
     pub const LINE_HEIGHT: f32 = 58.;
     pub const CHATVIEW_BASELINE: f32 = 36.;
+    pub const CHATVIEW_DATE_FONTSIZE: f32 = 32.;
 
     pub const CMD_HELP_HEIGHT: f32 = 110.;
     pub const CMD_HELP_GAP: f32 = 10.;
@@ -170,6 +175,7 @@ mod ui_consts {
     pub const MESSAGE_SPACING: f32 = 5.;
     pub const LINE_HEIGHT: f32 = 30.;
     pub const CHATVIEW_BASELINE: f32 = 20.;
+    pub const CHATVIEW_DATE_FONTSIZE: f32 = 16.;
 
     pub const CMD_HELP_HEIGHT: f32 = 55.;
     pub const CMD_HELP_GAP: f32 = 5.;
@@ -232,7 +238,6 @@ pub async fn make(
     renderer: &Renderer,
     ex: &ExecutorPtr,
     content: SceneNodePtr,
-    channel: &str,
     kv_db: &KvDb,
     i18n_fish: &I18nBabelFish,
     emoji_meshes: emoji_picker::EmojiMeshesPtr,
@@ -263,7 +268,7 @@ pub async fn make(
     cc.add_const_f32("NETSTATUS_ICON_SIZE", super::NETSTATUS_ICON_SIZE);
 
     // Main view
-    let layer_node = create_layer(&(channel.to_string() + "_chat_layer"));
+    let layer_node = create_layer("main_chat_layer");
     let prop = layer_node.get_property("rect").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.).unwrap();
@@ -447,9 +452,7 @@ pub async fn make(
     prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
     prop.set_f32(atom, Role::App, 3, CHATEDIT_HEIGHT).unwrap();
     node.set_property_f32(atom, Role::App, "font_size", FONTSIZE).unwrap();
-    node.set_property_str(atom, Role::App, "text", channel).unwrap();
-    //node.set_property_bool(atom, Role::App, "debug", true).unwrap();
-    //node.set_property_str(atom, Role::App, "text", "anon1").unwrap();
+    node.set_property_str(atom, Role::App, "text", "").unwrap();
     let prop = node.get_property("text_color").unwrap();
     if COLOR_SCHEME == ColorScheme::DarkMode {
         prop.set_f32(atom, Role::App, 0, 1.).unwrap();
@@ -464,12 +467,12 @@ pub async fn make(
     }
     node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
 
-    let node = node
+    let label_node = node
         .setup(|me| {
             Text::new(me, window_scale.clone(), renderer.clone(), i18n_fish.clone(), redraw.clone())
         })
         .await;
-    layer_node.link(node);
+    layer_node.link(label_node.clone());
 
     // Create the emoji picker
     let mut node = create_emoji_picker("emoji_picker");
@@ -567,16 +570,9 @@ pub async fn make(
     node.set_property_f32(atom, Role::App, "timestamp_width", TIMESTAMP_WIDTH).unwrap();
     node.set_property_f32(atom, Role::App, "line_height", LINE_HEIGHT).unwrap();
     node.set_property_f32(atom, Role::App, "message_spacing", MESSAGE_SPACING).unwrap();
+    node.set_property_f32(atom, Role::App, "wheel_page_frac", 0.2).unwrap();
     node.set_property_f32(atom, Role::App, "baseline", CHATVIEW_BASELINE).unwrap();
     node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
-    //node.set_property_bool(atom, Role::App, "debug", true).unwrap();
-
-    #[cfg(target_os = "android")]
-    node.set_property_f32(atom, Role::App, "scroll_start_accel", 40.).unwrap();
-    #[cfg(target_os = "linux")]
-    node.set_property_f32(atom, Role::App, "scroll_start_accel", 15.).unwrap();
-
-    node.set_property_f32(atom, Role::App, "scroll_resist", 0.9).unwrap();
 
     let prop = node.get_property("timestamp_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.407).unwrap();
@@ -596,52 +592,95 @@ pub async fn make(
         prop.set_f32(atom, Role::App, 3, 1.).unwrap();
     }
 
-    let prop = node.get_property("action_text_color").unwrap();
+    let prop = node.get_property("hi_bg_color").unwrap();
     if COLOR_SCHEME == ColorScheme::PaperLight {
-        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, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
         prop.set_f32(atom, Role::App, 3, 1.).unwrap();
     } else if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 1.).unwrap();
-        prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+        prop.set_f32(atom, Role::App, 1, 0.2).unwrap();
+        prop.set_f32(atom, Role::App, 2, 0.2).unwrap();
         prop.set_f32(atom, Role::App, 3, 1.).unwrap();
     }
 
-    let prop = node.get_property("url_text_color").unwrap();
+    let chatview_node = node
+        .setup(|me| {
+            ChatView::new(
+                me,
+                kv_db.clone(),
+                window_scale.clone(),
+                i18n_fish.clone(),
+                renderer.clone(),
+                redraw.clone(),
+                ex.clone(),
+            )
+        })
+        .await;
+    layer_node.link(chatview_node.clone());
+
+    let tree_name = "#dev__chat_tree_v2";
+    let tree = kv_db.open_tree_default(&tree_name).unwrap();
+    if tree.is_empty().expect("cannot read dev chat tree") {
+        populate_tree(&tree);
+    }
+
+    // The label follows the bound channel.
+    {
+        let channel_prop = PropertyStr::wrap(&chatview_node, Role::App, "channel", 0).unwrap();
+        let channel_sub = channel_prop.prop().subscribe_modify();
+        let label_text = PropertyStr::wrap(&label_node, Role::App, "text", 0).unwrap();
+        let redraw2 = redraw.clone();
+        let label_task = ex.spawn(async move {
+            while let Ok(_) = channel_sub.receive().await {
+                let atom = &mut redraw2.make_guard(gfxtag!("channel label"));
+                label_text.set(atom, channel_prop.get());
+            }
+        });
+        chatview_node.push_task(label_task);
+    }
+
+    // Type-specific styling lives on the privmsg type sub-node.
+    let privmsg_node = chatview_node.lookup_node("/privmsg").expect("privmsg type node");
+    privmsg_node.set_property_f32(atom, Role::App, "cap_max_height", 1000.).unwrap();
+    let prop = privmsg_node.get_property("action_text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.25).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.75).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    let prop = privmsg_node.get_property("url_text_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.94).unwrap();
     prop.set_f32(atom, Role::App, 2, 1.).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    let prop = node.get_property("url_bg_color").unwrap();
+    let prop = privmsg_node.get_property("url_bg_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.13).unwrap();
     prop.set_f32(atom, Role::App, 2, 0.08).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    node.set_property_f32(atom, Role::App, "url_bg_border_size", 1.).unwrap();
-    let prop = node.get_property("url_bg_border_color").unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_bg_border_size", 1.).unwrap();
+    let prop = privmsg_node.get_property("url_bg_border_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.11).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.6).unwrap();
     prop.set_f32(atom, Role::App, 2, 0.63).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
 
     // "Copied link" overlay styling (mirrors the edit action menu)
-    let prop = node.get_property("url_copy_fg_color").unwrap();
+    let prop = privmsg_node.get_property("url_copy_fg_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.94).unwrap();
     prop.set_f32(atom, Role::App, 2, 1.).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    let prop = node.get_property("url_copy_bg_color").unwrap();
+    let prop = privmsg_node.get_property("url_copy_bg_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.1).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.1).unwrap();
     prop.set_f32(atom, Role::App, 2, 0.1).unwrap();
     prop.set_f32(atom, Role::App, 3, 0.9).unwrap();
-    node.set_property_f32(atom, Role::App, "url_copy_font_size", FONTSIZE).unwrap();
-    node.set_property_f32(atom, Role::App, "url_copy_padding", ACTION_PADDING).unwrap();
-    node.set_property_f32(atom, Role::App, "url_copy_offset", ACTION_PADDING).unwrap();
-
-    let prop = node.get_property("nick_colors").unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_copy_font_size", FONTSIZE).unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_copy_padding", ACTION_PADDING).unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_copy_offset", ACTION_PADDING).unwrap();
+    let prop = privmsg_node.get_property("nick_colors").unwrap();
     #[rustfmt::skip]
     let nick_colors = [
         0.00, 0.94, 1.00, 1.,
@@ -659,41 +698,21 @@ pub async fn make(
         prop.push_f32(atom, Role::App, c).unwrap();
     }
 
-    let prop = node.get_property("hi_bg_color").unwrap();
-    if COLOR_SCHEME == ColorScheme::PaperLight {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else if COLOR_SCHEME == ColorScheme::DarkMode {
-        prop.set_f32(atom, Role::App, 0, 0.).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.2).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.2).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
+    // Date separator styling.
+    let datemsg_node = chatview_node.lookup_node("/datemsg").expect("datemsg type node");
+    datemsg_node.set_property_f32(atom, Role::App, "font_size", CHATVIEW_DATE_FONTSIZE).unwrap();
+    let prop = datemsg_node.get_property("color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
 
-    let tree_name = channel.to_string() + "__chat_tree";
-    let chat_tree = kv_db.open_tree_default(&tree_name).unwrap();
-    //if chat_tree.is_empty() {
-    //    populate_tree(&chat_tree);
-    //}
-    debug!(target: "app", "Loaded {channel} history: {} lines", chat_tree.len().unwrap());
-    let chatview_node = node
-        .setup(|me| {
-            ChatView::new(
-                me,
-                chat_tree,
-                window_scale.clone(),
-                renderer.clone(),
-                redraw.clone(),
-                ex.clone(),
-            )
-        })
-        .await;
-    layer_node.link(chatview_node.clone());
+    // File signals live on the filemsg type node. The download request
+    // payload carries (id, url); the fud plugin takes the url.
+    let filemsg_node = chatview_node.lookup_node("/filemsg").expect("filemsg type node");
 
     let (slot, recvr) = Slot::new("fileurl_detect");
-    chatview_node.register("fileurl_detected", slot).unwrap();
+    filemsg_node.register("fileurl_detected", slot).unwrap();
     let sg_root2 = sg_root.clone();
     let listen_fileurl = ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
@@ -704,13 +723,18 @@ pub async fn make(
     });
     layer_node.push_task(listen_fileurl);
 
-    let (slot, recvr) = Slot::new("file_download_request");
-    chatview_node.register("file_download_request", slot).unwrap();
+    let (slot, recvr) = Slot::new("file_download");
+    filemsg_node.register("download_request", slot).unwrap();
     let sg_root2 = sg_root.clone();
     let listen_file_download = ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
+            let mut cur = Cursor::new(&data);
+            let Ok(_id) = MessageId::decode(&mut cur) else { continue };
+            let Ok(url) = Url::decode(&mut cur) else { continue };
             if let Some(fud_node) = sg_root2.lookup_node("/plugin/fud") {
-                let _ = fud_node.call_method("get", data).await;
+                let mut fud_data = vec![];
+                url.encode(&mut fud_data).unwrap();
+                let _ = fud_node.call_method("get", fud_data).await;
             }
         }
     });
@@ -734,19 +758,16 @@ pub async fn make(
 
     let down_layer_is_visible =
         PropertyBool::wrap(&down_layer, Role::App, "is_visible", 0).unwrap();
-    let chatview_scroll = PropertyFloat32::wrap(&chatview_node, Role::App, "scroll", 0).unwrap();
-    let chatview_scroll_sub = chatview_scroll.prop().subscribe_modify();
+    let chatview_at_bottom =
+        PropertyBool::wrap(&chatview_node, Role::App, "is_at_bottom", 0).unwrap();
+    let at_bottom_sub = chatview_at_bottom.prop().subscribe_modify();
     let redraw2 = redraw.clone();
-    let chatview_scroll2 = chatview_scroll.clone();
+    let chatview_at_bottom2 = chatview_at_bottom.clone();
     let monitor_scroll_task = ex.spawn(async move {
-        while let Ok(_) = chatview_scroll_sub.receive().await {
-            let scroll = chatview_scroll2.get();
+        while let Ok(_) = at_bottom_sub.receive().await {
+            let at_bottom = chatview_at_bottom2.get();
             let atom = &mut redraw2.make_guard(gfxtag!("down arrow visibility change"));
-            if scroll > 0. {
-                down_layer_is_visible.set(atom, true);
-            } else {
-                down_layer_is_visible.set(atom, false);
-            }
+            down_layer_is_visible.set(atom, !at_bottom);
         }
     });
     down_layer.push_task(monitor_scroll_task);
@@ -777,11 +798,10 @@ pub async fn make(
     prop.set_f32(atom, Role::App, 3, DOWNARROW_H).unwrap();
     let (slot, recvr) = Slot::new("scroll_bottom");
     node.register("click", slot).unwrap();
-    let redraw2 = redraw.clone();
+    let chatview_node2 = chatview_node.clone();
     let listen_click = ex.spawn(async move {
         while let Ok(_) = recvr.recv().await {
-            let atom = &mut redraw2.make_guard(gfxtag!("down arrow clicked"));
-            chatview_scroll.set(atom, 0.);
+            let _ = chatview_node2.call_method("scroll_to_bottom", vec![]).await;
         }
     });
     down_layer.push_task(listen_click);
@@ -793,7 +813,7 @@ pub async fn make(
     // the netstatus layer, so its single background box draws over the netstatus
     // icons and its buttons win click hit-testing. It carries `unselect_btn`
     // (over `back_btn`) and `copy_btn` (over the reconnect button).
-    let select_layer = create_layer(&(channel.to_string() + "_select_layer"));
+    let select_layer = create_layer("select_layer");
     let prop = select_layer.get_property("rect").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.).unwrap();
@@ -1122,6 +1142,7 @@ pub async fn make(
     //node.set_property_bool(atom, Role::App, "debug", true).unwrap();
 
     let editz_text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
+
     let editz_select_text = node.get_property("select_text").unwrap();
 
     //let editbox_focus = PropertyBool::wrap(node, Role::App, "is_focused", 0).unwrap();
@@ -1158,6 +1179,25 @@ pub async fn make(
     let chatedit_node = node.clone();
     layer_node.link(node);
 
+    // Nick clicks on messages insert the nick at the editor cursor,
+    // like the emoji picker.
+    {
+        let (slot, recvr) = Slot::new("nick_clicked");
+        privmsg_node.register("nick_clicked", slot).unwrap();
+        let chatedit_node3 = chatedit_node.clone();
+        let listen_nick = ex.spawn(async move {
+            while let Ok(data) = recvr.recv().await {
+                let mut cur = Cursor::new(&data);
+                let Ok(_id) = MessageId::decode(&mut cur) else { continue };
+                let Ok(nick) = String::decode(&mut cur) else { continue };
+                let mut edit_data = vec![];
+                nick.encode(&mut edit_data).unwrap();
+                chatedit_node3.call_method("insert_text", edit_data).await.unwrap();
+            }
+        });
+        layer_node.push_task(listen_nick);
+    }
+
     let (slot, recvr) = Slot::new("emoji_selected");
     emoji_picker_node.register("emoji_select", slot).unwrap();
     let chatedit_node2 = chatedit_node.clone();
@@ -1219,18 +1259,18 @@ pub async fn make(
     prop.set_f32(atom, Role::App, 3, SENDBTN_BOX[3]).unwrap();
 
     let editz_text2 = editz_text.clone();
-    let channel2 = channel.to_string();
     let sg_root2 = sg_root.clone();
     let redraw2 = redraw.clone();
     let sendmsg = move || {
         let editz_text = editz_text2.clone();
-        let channel = channel2.clone();
         let sg_root = sg_root2.clone();
         let chatview_node = chatview_node.clone();
         let redraw = redraw2.clone();
         async move {
             let mut text = editz_text.get();
-            info!(target: "app::chat", "Send '{text}' to channel: {channel}");
+            let channel = chatview_node.get_property_str("channel").unwrap_or_default();
+            let privmsg_node = chatview_node.lookup_node("/privmsg").expect("privmsg type node");
+            trace!(target: "app::chat", "send to channel: {channel}");
             {
                 let atom = &mut redraw.make_guard(gfxtag!("sendmsg clear edit"));
                 editz_text.set(atom, "");
@@ -1257,7 +1297,7 @@ pub async fn make(
                 id.encode(&mut data).unwrap();
                 "NOTICE".encode(&mut data).unwrap();
                 msg.encode(&mut data).unwrap();
-                chatview_node.call_method("insert_line", data).await.unwrap();
+                privmsg_node.call_method("insert_line", data).await.unwrap();
 
                 return
             }
@@ -1799,9 +1839,11 @@ pub async fn make(
 // Just for testing
 #[allow(dead_code)]
 pub(super) fn populate_tree(tree: &Tree) {
+    use crate::ui::chatview::{codec, MessageId, MsgType};
     use chrono::{NaiveDate, NaiveDateTime};
+
     let chat_txt = include_str!("../../../data/chat.txt");
-    for line in chat_txt.lines() {
+    for (idx, line) in chat_txt.lines().enumerate() {
         let parts: Vec<&str> = line.splitn(3, ' ').collect();
         assert_eq!(parts.len(), 3);
         let time_parts: Vec<&str> = parts[0].splitn(2, ':').collect();
@@ -1815,16 +1857,14 @@ pub(super) fn populate_tree(tree: &Tree) {
         let nick = parts[1].to_string();
         let text = parts[2].to_string();
 
-        // serial order is important here
-        let timest = timest.to_be_bytes();
-        assert_eq!(timest.len(), 8);
-        let mut key = [0u8; 8 + 32];
-        key[..8].clone_from_slice(&timest);
-
-        let msg = chatview::ChatMsg { nick, text };
-        let mut val = vec![];
-        msg.encode(&mut val).unwrap();
+        // Unique id per line: the minute timestamp alone can repeat.
+        let mut id_bytes = [0u8; 32];
+        id_bytes[..8].copy_from_slice(&(idx as u64).to_be_bytes());
+        let id = MessageId(id_bytes);
 
+        let payload = codec::encode_privmsg_payload(&nick, &text, true);
+        let val = codec::encode_value(MsgType::PrivMsg, &payload);
+        let key = codec::encode_key(timest, &id);
         tree.insert(&key, &val).unwrap();
     }
     // O(n)

+ 15 - 55
bin/app/src/app/schema/menu/channel.rs

@@ -1267,12 +1267,9 @@ pub async fn make(
             debug!(target: "app::menu", "secret paste button clicked");
             match miniquad::window::clipboard_get() {
                 Some(clipboard_text) => {
-                    let text_prop = secedit_node2.get_property("text").unwrap();
                     let atom = &mut redraw2.make_guard(gfxtag!("secret paste"));
-                    text_prop.set_str(atom, Role::App, 0, &clipboard_text).unwrap();
-                    if let crate::scene::Pimpl::Edit(edit) = secedit_node2.pimpl() {
-                        edit.on_text_prop_changed();
-                    }
+                    secedit_node2.set_property_str(atom, Role::App, "text", clipboard_text)
+                        .unwrap();
                 }
                 None => warn!(target: "app::menu", "clipboard_get() returned None (empty or unsupported on this platform)"),
             }
@@ -1429,12 +1426,8 @@ pub async fn make(
             debug!(target: "app::menu", "gen secret button clicked");
             let secret_bytes: [u8; 32] = OsRng.gen();
             let secret = bs58::encode(secret_bytes).into_string();
-            let text_prop = secedit_node3.get_property("text").unwrap();
             let atom = &mut redraw_clone.make_guard(gfxtag!("gen secret"));
-            text_prop.set_str(atom, Role::App, 0, &secret).unwrap();
-            if let crate::scene::Pimpl::Edit(edit) = secedit_node3.pimpl() {
-                edit.on_text_prop_changed();
-            }
+            secedit_node3.set_property_str(atom, Role::App, "text", secret).unwrap();
         }
     });
     app.tasks.lock().unwrap().push(listen_click);
@@ -1615,70 +1608,37 @@ pub async fn make(
     menu_node.register("select", slot).unwrap();
 
     let sg_root = app.sg_root.clone();
-    let renderer = app.renderer.clone();
-    let ex = app.ex.clone();
     let channel_vis = channel_is_visible.clone();
-    let window_scale2 = window_scale.clone();
-    let kv_db2 = kv_db.clone();
-    let i18n_fish2 = i18n_fish.clone();
-    let emoji_meshes2 = emoji_meshes.clone();
     let redraw2 = app.redraw_trigger.clone();
 
     let listen_select = app.ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
             let channel: String = deserialize(&data).unwrap();
             i!("Selected channel: {channel}");
-            let path = format!("/window/content/chat/{}_chat_layer", &channel);
 
             let atom = &mut redraw2.make_guard(gfxtag!("channel_selected"));
-
-            // Check if chat layer already exists
-            if let Some(node) = sg_root.lookup_node(&path) {
-                node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-                channel_vis.set(atom, false);
-                continue;
-            }
-
-            let content = sg_root.lookup_node("/window/content/chat").unwrap();
-            // Create the chat layer and get the node
-            let node = chat::make(
-                &sg_root,
-                &renderer,
-                &ex,
-                content,
-                &channel,
-                &kv_db2,
-                &i18n_fish2,
-                emoji_meshes2.clone(),
-                redraw2.clone(),
-            )
-            .await;
-            match node.pimpl() {
-                Pimpl::Layer(layer) => layer.clone().start(ex.clone()).await,
-                _ => panic!("wrong pimpl"),
-            }
-            d!("Added channel layer: {}", node.get_full_path().unwrap());
-
-            // Show the chat layer
-            node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-
-            // Add to main_menu items (check if not already there)
             let main_menu =
                 sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
             let items_prop = main_menu.get_property("items").unwrap();
 
-            if !items_prop.contains_str(&channel) {
-                items_prop.push_str(atom, Role::App, &channel).unwrap();
+            // One chat screen: a channel already in the menu just
+            // retargets the chatview; an unknown one is newly joined.
+            if items_prop.contains_str(&channel) {
+                let node = sg_root.lookup_node("/window/content/chat/main_chat_layer").unwrap();
+                node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                channel_vis.set(atom, false);
+                let chatty = node.lookup_node("/content/chatty").unwrap();
+                let mut chan_data = vec![];
+                channel.encode(&mut chan_data).unwrap();
+                let _ = chatty.call_method("set_channel", chan_data).await;
+                continue
             }
 
+            items_prop.push_str(atom, Role::App, &channel).unwrap();
             append_joined_channel(&channel);
 
             // Hide channel screen
             channel_vis.set(atom, false);
-
-            // Trigger a draw pass so the newly added node's parent_rect
-            // gets set. The pass walks the whole tree so the new layer is
-            // drawn with correct geometry.
             redraw2.trigger();
 
             // Trigger rescan for this channel

+ 9 - 25
bin/app/src/app/schema/menu/contact.rs

@@ -1270,12 +1270,10 @@ pub async fn make(
             debug!(target: "app::menu", "secret paste button clicked");
             match clipboard::get() {
                 Some(clipboard_text) => {
-                    let text_prop = secedit_node2.get_property("text").unwrap();
                     let atom = &mut redraw_clone.make_guard(gfxtag!("secret paste"));
-                    text_prop.set_str(atom, Role::App, 0, &clipboard_text).unwrap();
-                    if let crate::scene::Pimpl::Edit(edit) = secedit_node2.pimpl() {
-                        edit.on_text_prop_changed();
-                    }
+                    secedit_node2
+                        .set_property_str(atom, Role::App, "text", clipboard_text)
+                        .unwrap();
                 }
                 None => warn!(target: "app::menu", "clipboard_get() returned None (empty or unsupported on this platform)"),
             }
@@ -1533,31 +1531,17 @@ pub async fn make(
             let path = format!("/window/content/chat/{}_chat_layer", &contact);
             let atom = &mut redraw2.make_guard(gfxtag!("contact_selected"));
 
-            if let Some(node) = sg_root.lookup_node(&path) {
+            // One chat screen: retarget the chatview.
+            if let Some(node) = sg_root.lookup_node("/window/content/chat/main_chat_layer") {
                 node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
                 contact_vis.set(atom, false);
+                let chatty = node.lookup_node("/content/chatty").unwrap();
+                let mut chan_data = vec![];
+                contact.encode(&mut chan_data).unwrap();
+                let _ = chatty.call_method("set_channel", chan_data).await;
                 continue;
             }
 
-            let content = sg_root.lookup_node("/window/content/chat").unwrap();
-            let node = chat::make(
-                &sg_root,
-                &renderer,
-                &ex,
-                content,
-                &contact,
-                &kv_db2,
-                &i18n_fish2,
-                emoji_meshes2.clone(),
-                redraw2.clone(),
-            )
-            .await;
-            match node.pimpl() {
-                Pimpl::Layer(layer) => layer.clone().start(ex.clone()).await,
-                _ => panic!("wrong pimpl"),
-            }
-            node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
-
             let main_menu =
                 sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
             let items_prop = main_menu.get_property("items").unwrap();

+ 19 - 23
bin/app/src/app/schema/menu/mod.rs

@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use darkfi_serial::deserialize;
+use darkfi_serial::{deserialize, Encodable};
 use kvdb_overlay::Database as KvDb;
 use std::io::Write;
 use ui_consts::*;
@@ -439,18 +439,24 @@ pub async fn make(
     let redraw = app.redraw_trigger.clone();
     let role1_group = node.get_property("role1_group").unwrap();
     let role2_group = node.get_property("role2_group").unwrap();
+    let role1_group2 = role1_group.clone();
+    let role2_group2 = role2_group.clone();
     let listen_click = app.ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
             let channel: String = deserialize(&data).unwrap();
-            let path = format!("/window/content/chat/{}_chat_layer", channel);
-            if let Some(node) = sg_root.lookup_node(path) {
+            // One chat screen: retarget the chatview via set_channel.
+            if let Some(node) = sg_root.lookup_node("/window/content/chat/main_chat_layer") {
                 let atom = &mut redraw.make_guard(gfxtag!("channel_clicked"));
                 info!(target: "app::menu", "clicked: {channel}!");
                 sfx::play_click();
-                role1_group.remove_str_item(atom, Role::App, &channel);
-                role2_group.remove_str_item(atom, Role::App, &channel);
+                role1_group2.remove_str_item(atom, Role::App, &channel);
+                role2_group2.remove_str_item(atom, Role::App, &channel);
                 node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
                 menu_is_visible.set(atom, false);
+                let chatty = node.lookup_node("/content/chatty").unwrap();
+                let mut chan_data = vec![];
+                channel.encode(&mut chan_data).unwrap();
+                let _ = chatty.call_method("set_channel", chan_data).await;
             }
         }
     });
@@ -472,9 +478,10 @@ pub async fn make(
     // Subscribe to edit_done signal to persist the joined list and unlink removed layers
     let (edit_done_slot, edit_done_recvr) = Slot::new("edit_done");
     menu_node.register("edit_done", edit_done_slot).unwrap();
-    let sg_root = app.sg_root.clone();
     let menu_node2 = menu_node.clone();
     let redraw = app.redraw_trigger.clone();
+    let role1_group = role1_group.clone();
+    let role2_group = role2_group.clone();
     let edit_done_listen = app.ex.spawn(async move {
         while let Ok(data) = edit_done_recvr.recv().await {
             let deleted_items: Vec<String> = deserialize(&data).unwrap();
@@ -483,24 +490,13 @@ pub async fn make(
                 menu_node2.get_property("items").unwrap().get_str_vec().unwrap();
             write_joined_channels(&current);
 
+            // One chat screen: removed channels have no per-channel
+            // scene to unlink; clear their unread markers and leave
+            // their trees on disk (re-joining restores history).
+            let atom = &mut redraw.make_guard(gfxtag!("edit_done cleanup"));
             for item in &deleted_items {
-                let path = format!("/window/content/chat/{}_chat_layer", item);
-                if let Some(node) = sg_root.lookup_node(&path) {
-                    node.clear_tasks();
-                    debug!(target: "app::menu", "deleted item: {item}");
-                    node.unlink();
-                }
-
-                // The selection overlay is a sibling of the chat layer, not
-                // a child: it needs z_index/priority above netstatus_layer,
-                // which it can only get in the shared parent. Remove it
-                // alongside so it does not leak.
-                let path = format!("/window/content/chat/{}_select_layer", item);
-                if let Some(node) = sg_root.lookup_node(&path) {
-                    node.clear_tasks();
-                    debug!(target: "app::menu", "deleted select overlay: {item}");
-                    node.unlink();
-                }
+                role1_group.remove_str_item(atom, Role::App, item);
+                role2_group.remove_str_item(atom, Role::App, item);
             }
 
             // Unlinking changes no property, so request a pass explicitly:

+ 14 - 26
bin/app/src/app/schema/mod.rs

@@ -45,6 +45,7 @@ mod chat;
 pub mod menu;
 use menu::channel::Channel;
 pub mod test;
+pub mod test_chatview;
 pub mod test_edit;
 pub mod test_scroll_layer;
 mod wallet;
@@ -1060,32 +1061,19 @@ pub async fn make(
     menu::make(app, chat_layer.clone(), i18n_fish, app_db.clone(), &kv_db, emoji_meshes.clone())
         .await;
 
-    // Create chat layers only for joined channels/contacts, in joined order.
-    for name in read_joined_channels() {
-        let bare = if name.starts_with('#') || name.starts_with('@') { &name[1..] } else { &name };
-        let in_db = match name.chars().next() {
-            Some('#') => app_db.channel_get(bare).await.ok().flatten().is_some(),
-            Some('@') => app_db.contact_get(bare).await.ok().flatten().is_some(),
-            _ => false,
-        };
-        if !in_db {
-            warn!(target: "app::schema", "Joined entry '{name}' not found in kv_db; skipping");
-            continue
-        }
-
-        chat::make(
-            &app.sg_root,
-            &app.renderer,
-            &app.ex,
-            chat_layer.clone(),
-            &name,
-            &kv_db,
-            i18n_fish,
-            emoji_meshes.clone(),
-            app.redraw_trigger.clone(),
-        )
-        .await;
-    }
+    // The single chat screen; channel switching goes through
+    // `set_channel` instead of per-channel layers.
+    chat::make(
+        &app.sg_root,
+        &app.renderer,
+        &app.ex,
+        chat_layer.clone(),
+        &kv_db,
+        i18n_fish,
+        emoji_meshes.clone(),
+        app.redraw_trigger.clone(),
+    )
+    .await;
 
     wallet::make(app, content.clone(), i18n_fish).await;
 

+ 41 - 35
bin/app/src/app/schema/test.rs

@@ -27,10 +27,11 @@ use crate::{
     expr::{self, Compiler},
     mesh::COLOR_PURPLE,
     prop::{PropertyAtomicGuard, PropertyFloat32, Role},
-    scene::SceneNodePtr,
+    scene::{Pimpl, SceneNodePtr},
     ui::{ChatView, Layer, Text, VectorArt, VectorShape, Video},
     util::i18n::I18nBabelFish,
 };
+use darkfi_serial::Encodable;
 use kvdb_overlay::Database as KvDb;
 
 const LIGHTMODE: bool = false;
@@ -323,7 +324,6 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
     node.set_property_f32(atom, Role::App, "line_height", 30.).unwrap();
     node.set_property_f32(atom, Role::App, "baseline", 20.).unwrap();
     node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-    //node.set_property_bool(atom, Role::App, "debug", true).unwrap();
 
     let prop = node.get_property("timestamp_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
@@ -343,7 +343,36 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         prop.set_f32(atom, Role::App, 3, 1.).unwrap();
     }
 
-    let prop = node.get_property("nick_colors").unwrap();
+    let prop = node.get_property("hi_bg_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let kv_db = KvDb::open_default(&get_chatdb_path()).expect("cannot open kvdb");
+    let chat_tree = kv_db.open_tree_default(&ChatView::tree_name("chat")).unwrap();
+    if chat_tree.is_empty().unwrap() {
+        populate_tree(&chat_tree);
+    }
+    debug!(target: "app", "db has {} lines", chat_tree.len().unwrap());
+    let node = node
+        .setup(|me| {
+            ChatView::new(
+                me,
+                kv_db,
+                window_scale.clone(),
+                i18n_fish.clone(),
+                app.renderer.clone(),
+                app.redraw_trigger.clone(),
+                app.ex.clone(),
+            )
+        })
+        .await;
+    layer_node.link(node.clone());
+
+    // Type-specific styling lives on the privmsg sub-node.
+    let privmsg_node = node.lookup_node("/privmsg").unwrap();
+    let prop = privmsg_node.get_property("nick_colors").unwrap();
     #[rustfmt::skip]
     let nick_colors = [
         0.00, 0.94, 1.00, 1.,
@@ -361,38 +390,15 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         prop.push_f32(atom, Role::App, c).unwrap();
     }
 
-    let prop = node.get_property("hi_bg_color").unwrap();
-    if LIGHTMODE {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    } else {
-        prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
-        prop.set_f32(atom, Role::App, 3, 1.).unwrap();
-    }
-
-    let kv_db = KvDb::open_default(&get_chatdb_path()).expect("cannot open kvdb");
-    let chat_tree = kv_db.open_tree(b"chat").unwrap();
-    if chat_tree.is_empty() {
-        populate_tree(&chat_tree);
-    }
-    debug!(target: "app", "db has {} lines", chat_tree.len());
-    let node = node
-        .setup(|me| {
-            ChatView::new(
-                me,
-                chat_tree,
-                window_scale.clone(),
-                app.renderer.clone(),
-                app.redraw_trigger.clone(),
-                app.ex.clone(),
-            )
-        })
-        .await;
-    layer_node.link(node);
+    let bind_task = app.ex.spawn(async move {
+        // Over the method bus once start() has subscribed; the delay
+        // covers the setup -> start gap so the call isn't dropped.
+        darkfi::system::sleep(1).await;
+        let mut data = vec![];
+        "chat".encode(&mut data).unwrap();
+        let _ = node.call_method("set_channel", data).await;
+    });
+    app.tasks.lock().unwrap().push(bind_task);
 
     // Text edit
     let node = create_singleline_edit("editz");

+ 372 - 0
bin/app/src/app/schema/test_chatview.rs

@@ -0,0 +1,372 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! Dev schema hosting one chatview node, selected by the
+//! `schema-test-chatview` cargo feature. Development and live-testing
+//! screen for the chatview rework until the cutover phases rework the
+//! real chat screen.
+
+use crate::{
+    app::{
+        node::{create_button, create_chatview, create_layer, create_vector_art},
+        App,
+    },
+    expr::{self, Compiler},
+    gfx::gfxtag,
+    mesh::rgba,
+    prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
+    scene::{Pimpl, SceneNodePtr, Slot},
+    shape,
+    ui::{Button, ChatView, Layer, VectorArt},
+    util::i18n::I18nBabelFish,
+};
+use darkfi_serial::Encodable;
+use kvdb_overlay::{Database as KvDb, Tree};
+
+#[cfg(target_os = "android")]
+mod ui_consts {
+    use crate::android::get_appdata_path;
+    use std::path::PathBuf;
+
+    pub fn get_chatdb_path() -> PathBuf {
+        get_appdata_path().join("chatdb2")
+    }
+}
+
+#[cfg(not(target_os = "android"))]
+mod ui_consts {
+    use std::path::PathBuf;
+
+    pub fn get_chatdb_path() -> PathBuf {
+        PathBuf::from("chatdb2")
+    }
+}
+
+use ui_consts::*;
+
+/// The channel the dev screen binds.
+const DEV_CHANNEL: &str = "dev";
+
+pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
+    let atom = &mut PropertyAtomicGuard::none();
+    let cc = Compiler::new();
+
+    let layer_node = create_layer("view");
+    let prop = layer_node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    layer_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    let layer_node = layer_node
+        .setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
+        .await;
+    window.link(layer_node.clone());
+
+    let node = create_chatview("chatview");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_expr(atom, Role::App, 3, expr::load_var("h")).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
+
+    node.set_property_f32(atom, Role::App, "font_size", 20.).unwrap();
+    node.set_property_f32(atom, Role::App, "timestamp_font_size", 10.).unwrap();
+    node.set_property_f32(atom, Role::App, "timestamp_width", 80.).unwrap();
+    node.set_property_f32(atom, Role::App, "line_height", 30.).unwrap();
+    node.set_property_f32(atom, Role::App, "message_spacing", 6.).unwrap();
+    node.set_property_f32(atom, Role::App, "baseline", 20.).unwrap();
+
+    let prop = node.get_property("timestamp_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let prop = node.get_property("text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let prop = node.get_property("hi_bg_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.2).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.2).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let kv_db = KvDb::open_default(&get_chatdb_path()).expect("cannot open chatdb2");
+
+    // Seed the dev channel's tree on first run so there is history to load.
+    let dev_tree = kv_db
+        .open_tree_default(&ChatView::tree_name(DEV_CHANNEL))
+        .expect("cannot open dev chat tree");
+    if dev_tree.is_empty().expect("cannot read dev chat tree") {
+        populate_tree(&dev_tree);
+    }
+    drop(dev_tree);
+
+    let window_scale =
+        PropertyFloat32::wrap(&app.sg_root.lookup_node("/window").unwrap(), Role::App, "scale", 0)
+            .unwrap();
+    let chatview_node = node
+        .setup(|me| {
+            ChatView::new(
+                me,
+                kv_db,
+                window_scale.clone(),
+                i18n_fish.clone(),
+                app.renderer.clone(),
+                app.redraw_trigger.clone(),
+                app.ex.clone(),
+            )
+        })
+        .await;
+    layer_node.link(chatview_node.clone());
+    let chatview_node_for_bind = chatview_node.clone();
+
+    // Type-specific styling lives on the privmsg type sub-node.
+    let privmsg_node = app
+        .sg_root
+        .lookup_node("/window/view/chatview/privmsg")
+        .expect("privmsg type node not linked");
+    let prop = privmsg_node.get_property("action_text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.25).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.75).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let prop = privmsg_node.get_property("url_text_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.94).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    let prop = privmsg_node.get_property("url_bg_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.13).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.08).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_bg_border_size", 1.).unwrap();
+    let prop = privmsg_node.get_property("url_bg_border_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.11).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.6).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.63).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    // "Copied link" overlay styling (mirrors the edit action menu).
+    let prop = privmsg_node.get_property("url_copy_fg_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.94).unwrap();
+    prop.set_f32(atom, Role::App, 2, 1.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+    let prop = privmsg_node.get_property("url_copy_bg_color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.1).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.1).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.1).unwrap();
+    prop.set_f32(atom, Role::App, 3, 0.9).unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_copy_font_size", 20.).unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_copy_padding", 8.).unwrap();
+    privmsg_node.set_property_f32(atom, Role::App, "url_copy_offset", 8.).unwrap();
+
+    privmsg_node.set_property_f32(atom, Role::App, "cap_max_height", 300.).unwrap();
+
+    let prop = privmsg_node.get_property("nick_colors").unwrap();
+    #[rustfmt::skip]
+    let nick_colors = [
+        0.00, 0.94, 1.00, 1.,
+        0.36, 1.00, 0.69, 1.,
+        0.29, 1.00, 0.45, 1.,
+        0.00, 0.73, 0.38, 1.,
+        0.21, 0.67, 0.67, 1.,
+        0.56, 0.61, 1.00, 1.,
+        0.84, 0.48, 1.00, 1.,
+        1.00, 0.61, 0.94, 1.,
+        1.00, 0.36, 0.48, 1.,
+        1.00, 0.30, 0.00, 1.
+    ];
+    for c in nick_colors {
+        prop.push_f32(atom, Role::App, c).unwrap();
+    }
+
+    // Date-separator styling.
+    let datemsg_node = app
+        .sg_root
+        .lookup_node("/window/view/chatview/datemsg")
+        .expect("datemsg type node not linked");
+    let prop = datemsg_node.get_property("color").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 2, 0.5).unwrap();
+    prop.set_f32(atom, Role::App, 3, 1.).unwrap();
+
+    // Relay the filemsg node's signals to the fud plugin when loaded.
+    let filemsg_node = app
+        .sg_root
+        .lookup_node("/window/view/chatview/filemsg")
+        .expect("filemsg type node not linked");
+
+    let (slot, recvr) = Slot::new("fileurl_detect");
+    filemsg_node.register("fileurl_detected", slot).unwrap();
+    let sg_root2 = app.sg_root.clone();
+    let listen_fileurl = app.ex.spawn(async move {
+        while let Ok(data) = recvr.recv().await {
+            if let Some(fud_node) = sg_root2.lookup_node("/plugin/fud") {
+                let _ = fud_node.call_method("track_file", data).await;
+            }
+        }
+    });
+    filemsg_node.push_task(listen_fileurl);
+
+    let (slot, recvr) = Slot::new("file_download");
+    filemsg_node.register("download_request", slot).unwrap();
+    let sg_root2 = app.sg_root.clone();
+    let listen_download = app.ex.spawn(async move {
+        use crate::ui::chatview::MessageId;
+        use darkfi_serial::{Decodable, Encodable};
+        while let Ok(data) = recvr.recv().await {
+            // The payload carries (id, url); the fud plugin takes the url.
+            let mut cur = std::io::Cursor::new(&data);
+            let Ok(_id) = MessageId::decode(&mut cur) else { continue };
+            let Ok(url) = url::Url::decode(&mut cur) else { continue };
+            if let Some(fud_node) = sg_root2.lookup_node("/plugin/fud") {
+                let mut fud_data = vec![];
+                url.encode(&mut fud_data).unwrap();
+                let _ = fud_node.call_method("get", fud_data).await;
+            }
+        }
+    });
+    filemsg_node.push_task(listen_download);
+
+    let bind_task = app.ex.spawn(async move {
+        // Over the method bus once start() has subscribed; the delay
+        // covers the setup -> start gap so the call isn't dropped.
+        darkfi::system::sleep(1).await;
+        let mut data = vec![];
+        DEV_CHANNEL.encode(&mut data).unwrap();
+        let _ = chatview_node_for_bind.call_method("set_channel", data).await;
+    });
+    app.tasks.lock().unwrap().push(bind_task);
+
+    // Scroll-to-bottom arrow: floats over the chatview, so it carries
+    // priority above it for the gesture session's ordered targeting.
+    let down_layer = create_layer("chat_down_arrow");
+    let prop = down_layer.get_property("rect").unwrap();
+    let code = cc.compile("w - 120").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    let code = cc.compile("h - 160").unwrap();
+    prop.set_expr(atom, Role::App, 1, code).unwrap();
+    prop.set_f32(atom, Role::App, 2, 100.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 100.).unwrap();
+    down_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    down_layer.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+    down_layer.set_property_u32(atom, Role::App, "priority", 1).unwrap();
+    let down_layer = down_layer
+        .setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
+        .await;
+    layer_node.link(down_layer.clone());
+
+    let down_layer_is_visible =
+        PropertyBool::wrap(&down_layer, Role::App, "is_visible", 0).unwrap();
+    let chatview_at_bottom =
+        PropertyBool::wrap(&chatview_node, Role::App, "is_at_bottom", 0).unwrap();
+    let chatview_at_bottom_sub = chatview_at_bottom.prop().subscribe_modify();
+    let redraw2 = app.redraw_trigger.clone();
+    let monitor_task = app.ex.spawn(async move {
+        while let Ok(_) = chatview_at_bottom_sub.receive().await {
+            let atom = &mut redraw2.make_guard(gfxtag!("down arrow visibility change"));
+            down_layer_is_visible.set(atom, !chatview_at_bottom.get());
+        }
+    });
+    down_layer.push_task(monitor_task);
+
+    let node = create_vector_art("downbg");
+    let prop = node.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, 100.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 100.).unwrap();
+    node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 0).unwrap();
+    let mut arrow_shape =
+        shape::create_down_bgtab(rgba!(0x003232ff), rgba!(0x294f60ff), 0.1).scaled(0.2);
+    arrow_shape.join(shape::create_down_arrow(rgba!(0x00f0ffff), 1.));
+    node.set_property_shape(atom, Role::App, "shape", arrow_shape).unwrap();
+    let node =
+        node.setup(|me| VectorArt::new(me, app.renderer.clone(), app.redraw_trigger.clone())).await;
+    down_layer.link(node);
+
+    let node = create_button("scroll_bottom_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.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, 100.).unwrap();
+    prop.set_f32(atom, Role::App, 3, 100.).unwrap();
+    let (slot, recvr) = Slot::new("scroll_bottom");
+    node.register("click", slot).unwrap();
+    let chatview_node2 = chatview_node.clone();
+    let redraw2 = app.redraw_trigger.clone();
+    let listen_click = app.ex.spawn(async move {
+        while let Ok(_) = recvr.recv().await {
+            let _ = chatview_node2.call_method("scroll_to_bottom", vec![]).await;
+            let _ = redraw2;
+        }
+    });
+    down_layer.push_task(listen_click);
+    let node =
+        node.setup(|me| Button::new(me, app.renderer.clone(), app.redraw_trigger.clone())).await;
+    down_layer.link(node);
+}
+
+/// Seed the dev channel's tree with test messages (in the v2 tagged
+/// format), so the screen has something to load. Copied from the old
+/// chat schema's fixture, re-encoded through the chatview codec.
+fn populate_tree(tree: &Tree) {
+    use crate::ui::chatview::{codec, MessageId, MsgType};
+    use chrono::{NaiveDate, NaiveDateTime};
+
+    let chat_txt = include_str!("../../../data/chat.txt");
+    for (idx, line) in chat_txt.lines().enumerate() {
+        let parts: Vec<&str> = line.splitn(3, ' ').collect();
+        assert_eq!(parts.len(), 3);
+        let time_parts: Vec<&str> = parts[0].splitn(2, ':').collect();
+        let (hour, min) = (time_parts[0], time_parts[1]);
+        let hour = hour.parse::<u32>().unwrap();
+        let min = min.parse::<u32>().unwrap();
+        let dt: NaiveDateTime =
+            NaiveDate::from_ymd_opt(2024, 8, 6).unwrap().and_hms_opt(hour, min, 0).unwrap();
+        let timest = dt.and_utc().timestamp_millis() as u64;
+
+        let nick = parts[1].to_string();
+        let text = parts[2].to_string();
+
+        // Unique id per line (the minute timestamp alone can repeat).
+        let mut id_bytes = [0u8; 32];
+        id_bytes[..8].copy_from_slice(&(idx as u64).to_be_bytes());
+        let id = MessageId(id_bytes);
+
+        let payload = codec::encode_privmsg_payload(&nick, &text, true);
+        let val = codec::encode_value(MsgType::PrivMsg, &payload);
+        let key = codec::encode_key(timest, &id);
+        tree.insert(&key, &val).unwrap();
+    }
+    // O(n)
+    debug!(target: "app::schema", "populated db with {} lines", tree.len().unwrap());
+}

+ 2 - 5
bin/app/src/app/schema/test_edit.rs

@@ -20,17 +20,14 @@
 
 use crate::{
     app::{
-        node::{
-            create_chatview, create_layer, create_multiline_edit, create_text, create_vector_art,
-            create_video,
-        },
+        node::{create_layer, create_multiline_edit, create_text, create_vector_art, create_video},
         App,
     },
     expr::{self, Compiler},
     mesh::COLOR_PURPLE,
     prop::{PropertyAtomicGuard, PropertyFloat32, PropertyStr, Role},
     scene::{SceneNodePtr, Slot},
-    ui::{BaseEdit, BaseEditType, ChatView, Layer, Text, VectorArt, VectorShape, Video},
+    ui::{BaseEdit, BaseEditType, Layer, Text, VectorArt, VectorShape, Video},
     util::i18n::I18nBabelFish,
 };
 

+ 2 - 2
bin/app/src/app/schema/test_scroll_layer.rs

@@ -20,14 +20,14 @@
 
 use crate::{
     app::{
-        node::{create_chatview, create_layer, create_text, create_vector_art, create_video},
+        node::{create_layer, create_text, create_vector_art, create_video},
         App,
     },
     expr::{self, Compiler},
     mesh::COLOR_PURPLE,
     prop::{PropertyAtomicGuard, PropertyFloat32, Role},
     scene::SceneNodePtr,
-    ui::{ChatView, Layer, Text, VectorArt, VectorShape, Video},
+    ui::{Layer, Text, VectorArt, VectorShape, Video},
     util::i18n::I18nBabelFish,
 };
 

+ 4 - 9
bin/app/src/app/schema/wallet/send_step2.rs

@@ -339,12 +339,8 @@ pub async fn make(
     let listen_click = app.ex.spawn(async move {
         while let Ok(_) = recvr.recv().await {
             if let Some(clipboard_text) = clipboard::get() {
-                let text_prop = recipient_input2.get_property("text").unwrap();
                 let atom = &mut redraw_clone.make_guard(gfxtag!("step2 recipient paste"));
-                text_prop.set_str(atom, Role::App, 0, &clipboard_text).unwrap();
-                if let crate::scene::Pimpl::Edit(edit) = recipient_input2.pimpl() {
-                    edit.on_text_prop_changed();
-                }
+                recipient_input2.set_property_str(atom, Role::App, "text", clipboard_text).unwrap();
             }
         }
     });
@@ -443,10 +439,9 @@ pub async fn make(
 
                 // Update amount token symbol
                 if let Some(token_symbol_node) = sg_root.lookup_node("/window/content/wallet/send_step3_layer/send_amount_wrapper/send_amount_token_symbol") {
-                    token_symbol_node.set_property_str(atom, Role::Internal, "text", token_symbol).unwrap();
-                    if let Pimpl::Edit(edit) = token_symbol_node.pimpl() {
-                        edit.on_text_prop_changed();
-                    }
+                    // Role::App: schema-driven write, so the edit's text
+                    // watcher syncs its internal buffer.
+                    token_symbol_node.set_property_str(atom, Role::App, "text", token_symbol).unwrap();
                 }
             }
             if let Some(recipient_str) = &data.recipient_str {

+ 0 - 8
bin/app/src/app/schema/wallet/util.rs

@@ -91,14 +91,6 @@ pub async fn update_amount_screen(
     let amount_rect = amount_input_node.get_property("rect").unwrap();
     amount_rect.set_expr(atom, Role::App, 2, width_code).unwrap();
 
-    // Reset scroll to prevent content from being cropped
-    if let Pimpl::Edit(edit) = amount_input_node.pimpl() {
-        edit.reset_scroll();
-    }
-    if let Pimpl::Edit(edit) = token_node.pimpl() {
-        edit.reset_scroll();
-    }
-
     // Update token symbol position
     let token_rect = token_node.get_property("rect").unwrap();
     token_rect

+ 10 - 1
bin/app/src/gfx/mod.rs

@@ -376,6 +376,10 @@ impl<'a> RenderContext<'a> {
 
         let overlays = std::mem::take(&mut self.overlays);
         for overlay in overlays {
+            // screen_size() is physical; dividing by the overlay's
+            // scale virtualizes it into the same unit space the defer
+            // origin and Move offsets use (mirrors Window::draw's
+            // screen_size/scale for the root view).
             self.view = Rectangle::new(0., 0., screen_w, screen_h);
             self.scale = overlay.scale;
             self.view.w /= self.scale;
@@ -568,7 +572,12 @@ impl<'a> RenderContext<'a> {
                     }
                 }
                 GfxDrawInstruction::Overlay(instrs) => {
-                    let pos = self.view.pos() / self.scale + self.cursor;
+                    // `self.view` is in virtual units and draw_overlays
+                    // consumes this in virtual units (Move adds virtual
+                    // offsets onto it), so the view origin passes
+                    // through unscaled; dividing by the window scale
+                    // here misplaces overlays on scaled displays.
+                    let pos = self.view.pos() + self.cursor;
                     self.overlays.push(OverlayDefer {
                         scale: self.scale,
                         pos,

+ 20 - 20
bin/app/src/main.rs

@@ -325,30 +325,29 @@ async fn load_plugins(
             let nick = String::decode(&mut cur).unwrap();
             let msg = String::decode(&mut cur).unwrap();
 
-            let node_path = format!("/window/content/chat/{channel}_chat_layer/content/chatty");
+            let node_path = "/window/content/chat/main_chat_layer/content/chatty";
             t!("Attempting to relay message to {node_path}");
-            let Some(chatview) = sg_root2.lookup_node(&node_path) else {
-                d!("Ignoring message since {node_path} doesn't exist");
-                continue
-            };
+            let chatview = sg_root2.lookup_node(node_path).unwrap();
 
-            // I prefer to just re-encode because the code is clearer.
+            // The chatview routes: active channel inserts live, anything
+            // else persists to its own tree.
             let mut data = vec![];
+            channel.encode(&mut data).unwrap();
             timestamp.encode(&mut data).unwrap();
             id.encode(&mut data).unwrap();
             nick.encode(&mut data).unwrap();
             msg.encode(&mut data).unwrap();
-            if let Err(err) = chatview.call_method("insert_line", data).await {
-                error!(
-                    target: "app",
-                    "Call method {node_path}::insert_line({timestamp}, {id}, {nick}, '{msg}'): {err:?}"
-                );
+            if let Err(err) = chatview.call_method("receive", data).await {
+                error!(target: "app", "Call method {node_path}::receive({channel}, {timestamp}, {id}): {err:?}");
             }
 
-            // Apply coloring when you get a message
-            let chat_path = format!("/window/content/chat/{channel}_chat_layer");
-            let chat_layer = sg_root2.lookup_node(chat_path).unwrap();
-            if chat_layer.get_property_bool("is_visible").unwrap() {
+            // Apply coloring when the message is not being viewed:
+            // either another channel is open, or the user is not in the
+            // chat screen at all.
+            let chat_layer = sg_root2.lookup_node("/window/content/chat/main_chat_layer").unwrap();
+            let viewing = chat_layer.get_property_bool("is_visible").unwrap() &&
+                chatview.get_property_str("channel").unwrap_or_default() == channel;
+            if viewing {
                 continue
             }
 
@@ -436,16 +435,17 @@ async fn load_plugins(
         let sg_root2 = sg_root.clone();
         let listen_file_status = ex.spawn(async move {
             while let Ok(data) = recv.recv().await {
-                let window = sg_root2.lookup_node("/window/content").unwrap();
                 let mut cur = Cursor::new(&data);
                 let url = Url::decode(&mut cur).unwrap();
-                let status = chatview::FileMessageStatus::decode(&mut cur).unwrap();
-                for child in window.get_children() {
-                    if let Some(chatty) = child.lookup_node("/content/chatty") {
+                let status = chatview::msg::filemsg::FileMsgStatus::decode(&mut cur).unwrap();
+                if let Some(chatty) =
+                    sg_root2.lookup_node("/window/content/chat/main_chat_layer/content/chatty")
+                {
+                    if let Some(filemsg) = chatty.lookup_node("/filemsg") {
                         let mut data = vec![];
                         url.encode(&mut data).unwrap();
                         status.encode(&mut data).unwrap();
-                        let _ = chatty.call_method("set_file_status", data).await;
+                        let _ = filemsg.call_method("set_file_status", data).await;
                     }
                 }
             }

+ 0 - 1
bin/app/src/net.rs

@@ -48,7 +48,6 @@ fn stop_ui_subtree(node: &SceneNodePtr) {
             Pimpl::Text(_) |
             Pimpl::TextScramble(_) |
             Pimpl::Edit(_) |
-            Pimpl::ChatView(_) |
             Pimpl::Image(_) |
             Pimpl::Video(_) |
             Pimpl::Button(_) |

+ 1 - 1
bin/app/src/plugin/fud.rs

@@ -47,7 +47,7 @@ use crate::{
     error::{Error, Result},
     prop::{PropertyAtomicGuard, PropertyBool, Role},
     scene::{MethodCall, MethodCallSub, Pimpl, SceneNodePtr, SceneNodeWeak},
-    ui::chatview::FileMessageStatus,
+    ui::chatview::msg::filemsg::FileMsgStatus as FileMessageStatus,
     ExecutorPtr,
 };
 

+ 8 - 2
bin/app/src/scene.rs

@@ -111,7 +111,6 @@ pub enum SceneNodeType {
     Texture = 10,
     Fonts = 11,
     Font = 12,
-    ChatView = 13,
     Edit = 14,
     Image = 15,
     Button = 16,
@@ -121,6 +120,10 @@ pub enum SceneNodeType {
     Menu = 22,
     TokenTable = 23,
     TextScramble = 24,
+    ChatView = 25,
+    PrivMsgNode = 26,
+    DateMsgNode = 27,
+    FileMsgNode = 28,
     PluginRoot = 100,
     Plugin = 101,
 }
@@ -603,7 +606,6 @@ pub enum Pimpl {
     VectorArt(ui::VectorArtPtr),
     Text(ui::TextPtr),
     Edit(ui::BaseEditPtr),
-    ChatView(ui::ChatViewPtr),
     Image(ui::ImagePtr),
     Video(ui::VideoPtr),
     Button(ui::ButtonPtr),
@@ -613,6 +615,10 @@ pub enum Pimpl {
     TokenTable(ui::TokenTablePtr),
     Setting(SettingPtr),
     TextScramble(ui::TextScramblePtr),
+    ChatView(ui::ChatViewPtr),
+    PrivMsgNode(ui::chatview::msg::PrivMsgNodePtr),
+    DateMsgNode(ui::chatview::msg::DateMsgNodePtr),
+    FileMsgNode(ui::chatview::msg::FileMsgNodePtr),
     #[cfg(feature = "enable-plugin-darkirc")]
     DarkIrc(plugin::DarkIrcPtr),
     #[cfg(feature = "enable-plugin-fud")]

+ 1150 - 0
bin/app/src/ui/chatview/buffer.rs

@@ -0,0 +1,1150 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! The chatview record store: ordering, dedup, and height indexing.
+//! Pure data — no rendering, no scene, no I/O.
+//!
+//! Records live in a slot arena (stable keys, O(1) insert/remove, keys
+//! are recycled through a free list); `order` holds arena slots sorted
+//! ascending by the `(timestamp, msg_id)` composite key, and `index`
+//! maps the composite key to its slot. The composite (not the bare msg
+//! id) is the dedup and identity key because derived records reuse
+//! synthetic ids.
+//!
+//! Geometry: a Fenwick tree over heights, parallel to `order`, answers
+//! total height, display positions, and viewport windows in O(log n).
+//! The array is oldest-first so a live arrival (the hot path) appends
+//! with `fenwick.push`. Public display-order APIs reverse the array
+//! (display index 0 = newest = bottom of the screen), and
+//! px-from-bottom queries convert through top-based offsets
+//! (`total − x`).
+
+use std::{collections::HashMap, ops::Range};
+
+use chrono::{Local, TimeZone};
+
+use super::{MessageId, MsgType, Timestamp};
+use crate::{ui::chatview::codec, util::fenwick::Fenwick};
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::buffer", $($arg)*); } }
+
+/// A stable handle to a record in the slot arena. Only ever held by
+/// `order`/`index` inside this module, so recycled slots cannot alias.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) struct SlotKey(u32);
+
+/// Minimal slot arena with slotmap semantics: stable O(1) keys across
+/// removals, free-list recycling. Implemented in-crate because the
+/// design allows no new dependencies.
+#[derive(Debug)]
+pub(crate) struct Arena<T> {
+    slots: Vec<Option<T>>,
+    free: Vec<u32>,
+}
+
+impl<T> Default for Arena<T> {
+    fn default() -> Self {
+        Self { slots: vec![], free: vec![] }
+    }
+}
+
+impl<T> Arena<T> {
+    fn insert(&mut self, val: T) -> SlotKey {
+        if let Some(idx) = self.free.pop() {
+            self.slots[idx as usize] = Some(val);
+            return SlotKey(idx)
+        }
+        self.slots.push(Some(val));
+        SlotKey(self.slots.len() as u32 - 1)
+    }
+
+    fn remove(&mut self, key: SlotKey) -> Option<T> {
+        let val = self.slots.get_mut(key.0 as usize)?.take()?;
+        self.free.push(key.0);
+        Some(val)
+    }
+
+    fn get(&self, key: SlotKey) -> Option<&T> {
+        self.slots.get(key.0 as usize)?.as_ref()
+    }
+
+    fn get_mut(&mut self, key: SlotKey) -> Option<&mut T> {
+        self.slots.get_mut(key.0 as usize)?.as_mut()
+    }
+
+    fn clear(&mut self) {
+        self.slots.clear();
+        self.free.clear();
+    }
+}
+
+/// One loaded message. Layout is public to the loader and the codec;
+/// per-type state (e.g. privmsg's `confirmed`) lives inside `payload`,
+/// owned by the message type — not on this record.
+#[derive(Debug, Clone)]
+pub struct MsgRecord {
+    /// Unix-millisecond send time; sorts messages into display order.
+    pub ts: Timestamp,
+    /// Unique identity; the zero id marks derived records.
+    pub id: MessageId,
+    /// The message's type; decides how `payload` is interpreted.
+    pub msg_type: MsgType,
+    /// Type-owned encoded state.
+    pub payload: Vec<u8>,
+    /// Last height reported by the owning type node, in px.
+    pub height: f32,
+}
+
+impl MsgRecord {
+    /// A record with no payload and zero height, for tests and as a
+    /// base for builders.
+    pub fn new(ts: Timestamp, id: MessageId, msg_type: MsgType) -> Self {
+        Self { ts, id, msg_type, payload: vec![], height: 0. }
+    }
+
+    pub(crate) fn key(&self) -> (Timestamp, MessageId) {
+        (self.ts, self.id)
+    }
+}
+/// The record store. Internally `order` is sorted ascending by
+/// `(ts, msg_id)` (oldest first); public display-order APIs are
+/// newest-first, reversed.
+#[derive(Debug)]
+pub struct MsgBuffer {
+    records: Arena<MsgRecord>,
+    /// Arena slots sorted ascending by `(ts, msg_id)`; the last slot is
+    /// the newest record (the bottom of the screen).
+    order: Vec<SlotKey>,
+    /// `(ts, msg_id)` -> arena slot. The dedup and identity map.
+    index: HashMap<(Timestamp, MessageId), SlotKey>,
+    /// `msg_id` -> arena slot, for records with unique ids (everything
+    /// except derived types).
+    id_index: HashMap<MessageId, SlotKey>,
+    /// Cumulative heights over `order` (aligned to the ascending array).
+    fenwick: Fenwick,
+    /// Whether derived-record (date separator) maintenance runs.
+    /// Production buffers keep this on; geometry/ordering tests that
+    /// model records directly turn it off.
+    separators: bool,
+}
+
+impl Default for MsgBuffer {
+    fn default() -> Self {
+        Self {
+            records: Arena::default(),
+            order: vec![],
+            index: HashMap::new(),
+            id_index: HashMap::new(),
+            fenwick: Fenwick::empty(),
+            separators: true,
+        }
+    }
+}
+
+impl MsgBuffer {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Disable derived-record maintenance (tests modeling records
+    /// directly; separator behavior has its own tests).
+    pub fn disable_separators(&mut self) {
+        self.separators = false;
+    }
+
+    /// Number of loaded records (derived records included).
+    pub fn len(&self) -> usize {
+        self.order.len()
+    }
+
+    /// Whether no records are loaded.
+    pub fn is_empty(&self) -> bool {
+        self.order.is_empty()
+    }
+
+    /// Drop every record.
+    pub fn clear(&mut self) {
+        self.records.clear();
+        self.order.clear();
+        self.index.clear();
+        self.id_index.clear();
+        self.fenwick = Fenwick::empty();
+    }
+
+    /// Insert a record at its `(ts, msg_id)` position. Returns false —
+    /// leaving the buffer untouched — when a record with the same
+    /// composite key is already loaded (dedup).
+    ///
+    /// A newest-position insert (the live-arrival hot path) appends in
+    /// O(log n); any other position is structural and rebuilds the
+    /// Fenwick in O(n) — batch those through [`MsgBuffer::insert_batch`].
+    pub fn insert(&mut self, rec: MsgRecord) -> bool {
+        let Some(slot) = self.insert_nofen(rec) else { return false };
+        let pos = self.order.iter().position(|s| *s == slot).expect("freshly inserted slot");
+
+        if self.order.last() == Some(&slot) {
+            // Appended at the newest end: a plain Fenwick push.
+            let height = self.records.get(slot).unwrap().height;
+            self.fenwick.push(height);
+        } else {
+            self.rebuild_fenwick();
+        }
+
+        // Derived-record invariant: day-run heads get their separator.
+        self.sync_separator_for_insert(pos);
+
+        true
+    }
+
+    /// Batch insert with a single O(n) Fenwick rebuild covering the
+    /// whole batch, for structural edits (loader backfill, filter
+    /// reloads). Returns how many records were inserted (duplicates in
+    /// the batch or against loaded records are rejected).
+    pub fn insert_batch(&mut self, batch: impl IntoIterator<Item = MsgRecord>) -> usize {
+        let mut count = 0;
+        for rec in batch {
+            if self.insert_nofen(rec).is_some() {
+                count += 1;
+            }
+        }
+        self.resync_separators();
+        self.rebuild_fenwick();
+        count
+    }
+
+    /// The ordering/index/dedup half of an insert, without touching the
+    /// Fenwick tree. Returns the record's arena slot on success.
+    fn insert_nofen(&mut self, rec: MsgRecord) -> Option<SlotKey> {
+        let key = rec.key();
+        if self.index.contains_key(&key) {
+            t!("insert dedup rejected ts={} id={}", key.0, key.1);
+            return None
+        }
+
+        // Ascending by key: the insertion position is the count of
+        // records with a strictly smaller key.
+        let pos = self.order.partition_point(|slot| {
+            self.records.get(*slot).expect("dangling slot in order").key() < key
+        });
+
+        let derived = rec.msg_type.is_derived();
+        let id = rec.id;
+        let slot = self.records.insert(rec);
+        self.order.insert(pos, slot);
+        self.index.insert(key, slot);
+        if !derived {
+            self.id_index.insert(id, slot);
+        }
+
+        t!("insert ts={} id={} at pos {pos}", key.0, id);
+        Some(slot)
+    }
+
+    /// Re-measure every loaded record (visible-first ordering is the
+    /// caller's choice through `order_fn`) and rebuild the Fenwick once.
+    /// The reflow protocol's bulk path.
+    pub fn remeasure_all(&mut self, mut measure: impl FnMut(&MsgRecord) -> f32) {
+        for slot in self.order.clone() {
+            if let Some(rec) = self.records.get_mut(slot) {
+                let h = measure(&*rec);
+                rec.height = h;
+            }
+        }
+        self.rebuild_fenwick();
+    }
+
+    /// One O(n) Fenwick pass from the current `order` heights.
+    pub(crate) fn rebuild_fenwick(&mut self) {
+        let mut heights = Vec::with_capacity(self.order.len());
+        for slot in &self.order {
+            heights.push(self.records.get(*slot).unwrap().height);
+        }
+        self.fenwick.rebuild(&heights);
+        t!("fenwick rebuild over {} records", self.order.len());
+    }
+
+    /// Remove the record with this id (derived records are not in the
+    /// id index; they are removed internally by slot). Returns false if
+    /// no such record is loaded.
+    pub fn remove(&mut self, id: &MessageId) -> bool {
+        let Some(slot) = self.id_index.get(id).copied() else { return false };
+        self.remove_slot(slot).is_some()
+    }
+
+    /// Structural removal by arena slot; also the derived-record path.
+    fn remove_slot(&mut self, slot: SlotKey) -> Option<MsgRecord> {
+        let rec = self.records.remove(slot)?;
+        self.index.remove(&rec.key());
+        if !rec.msg_type.is_derived() {
+            self.id_index.remove(&rec.id);
+        }
+        self.order.retain(|s| *s != slot);
+        if self.separators && !rec.msg_type.is_derived() {
+            self.cleanup_separator_for_removal(rec.ts);
+        }
+        self.rebuild_fenwick();
+        t!("removed ts={} id={}", rec.ts, rec.id);
+        Some(rec)
+    }
+
+    /// Whether a record with this id is loaded.
+    pub fn contains(&self, id: &MessageId) -> bool {
+        self.id_index.contains_key(id)
+    }
+
+    /// The record with this id, if loaded.
+    pub fn record(&self, id: &MessageId) -> Option<&MsgRecord> {
+        self.records.get(*self.id_index.get(id)?)
+    }
+
+    /// Mutable access to the record with this id, if loaded.
+    pub fn record_mut(&mut self, id: &MessageId) -> Option<&mut MsgRecord> {
+        self.records.get_mut(*self.id_index.get(id)?)
+    }
+
+    /// The record at display position `idx` (0 = newest).
+    pub fn record_at(&self, idx: usize) -> Option<&MsgRecord> {
+        let f = self.order.len().checked_sub(1 + idx)?;
+        self.records.get(*self.order.get(f)?)
+    }
+
+    /// Loaded records in display order (newest first).
+    pub fn iter_display_order(&self) -> impl Iterator<Item = &MsgRecord> {
+        self.order.iter().rev().filter_map(|slot| self.records.get(*slot))
+    }
+
+    /// The oldest loaded timestamp: the loader's resume point. None
+    /// when nothing is loaded.
+    pub fn oldest_ts(&self) -> Option<Timestamp> {
+        let first = self.order.first()?;
+        Some(self.records.get(*first).expect("dangling slot in order").ts)
+    }
+
+    /// Total px of loaded content.
+    pub fn total_height(&self) -> f32 {
+        self.fenwick.prefix(self.order.len())
+    }
+
+    /// Px from the content bottom up to the top of the record with
+    /// this id (id-index backed; derived records share synthetic ids
+    /// and are excluded — use [`MsgBuffer::pos_of_key`]).
+    pub fn pos_of(&self, id: &MessageId) -> Option<f32> {
+        let f = self.fenwick_idx(id)?;
+        Some(self.total_height() - self.fenwick.prefix(f))
+    }
+
+    /// Px from the content bottom up to the top of the record with
+    /// this composite key. Works for every record, derived ones
+    /// included.
+    pub fn pos_of_key(&self, key: &(Timestamp, MessageId)) -> Option<f32> {
+        let f = self.fenwick_idx_key(key)?;
+        Some(self.total_height() - self.fenwick.prefix(f))
+    }
+
+    /// Update a record's height; returns the delta (new − old) for
+    /// scroll compensation, or None if the id is not loaded. O(log n)
+    /// point update.
+    pub fn set_height(&mut self, id: &MessageId, h: f32) -> Option<f32> {
+        let slot = *self.id_index.get(id)?;
+        self.set_height_slot(slot, h)
+    }
+
+    /// Composite-key height update (derived records included).
+    pub fn set_height_key(&mut self, key: &(Timestamp, MessageId), h: f32) -> Option<f32> {
+        let slot = *self.index.get(key)?;
+        self.set_height_slot(slot, h)
+    }
+
+    /// The stored height of the record with this composite key.
+    pub fn index_get_height(&self, key: &(Timestamp, MessageId)) -> Option<f32> {
+        let slot = *self.index.get(key)?;
+        self.records.get(slot).map(|rec| rec.height)
+    }
+
+    fn set_height_slot(&mut self, slot: SlotKey, h: f32) -> Option<f32> {
+        let rec = self.records.get_mut(slot)?;
+        let f = self.order.iter().position(|s| *s == slot).expect("record slot not in order");
+        let delta = h - rec.height;
+        if delta != 0. {
+            self.fenwick.set(f, h);
+        }
+        rec.height = h;
+        Some(delta)
+    }
+
+    /// Display-order range intersecting the viewport
+    /// `[scroll, scroll + view_h)` in px from the content bottom.
+    /// Half-open; display indices (0 = newest).
+    pub fn visible_range(&self, scroll: f32, view_h: f32) -> Range<usize> {
+        let n = self.order.len();
+        // The viewport in top-based offsets (measured from the content
+        // top): a is the top edge, b the bottom edge.
+        let b = self.total_height() - scroll;
+        let a = b - view_h;
+
+        // Oldest-first index of the first record whose top edge is
+        // above the viewport's top edge; records before it are older
+        // and out of sight.
+        let start_f = self.fenwick.lower_bound(a);
+        // Even that record starts at/above the viewport's bottom edge:
+        // the viewport sits below all loaded content from here on.
+        if start_f >= n || self.fenwick.prefix(start_f) >= b {
+            return n..n
+        }
+
+        // One past the oldest-first index of the last record starting
+        // below the viewport's bottom edge, converted to display
+        // positions.
+        let end_f = self.fenwick.lower_bound_prefix(b);
+        (n - end_f)..(n - start_f)
+    }
+
+    /// The record containing content px `content_y` measured from the
+    /// content bottom (the message spacing belongs to the message it
+    /// trails). None outside loaded content.
+    pub fn record_at_y(&self, content_y: f32) -> Option<&MsgRecord> {
+        let n = self.order.len();
+        if n == 0 || content_y < 0. {
+            return None
+        }
+        // The Fenwick accumulates from the top (oldest-first), so the
+        // query converts to a top-based offset first — the same
+        // conversion visible_range does.
+        let total = self.total_height();
+        if content_y >= total {
+            return None
+        }
+        let f = self.fenwick.lower_bound(total - content_y);
+        if f >= n {
+            return None
+        }
+        self.record_at(n - 1 - f)
+    }
+
+    /// The local-midnight timestamp of a record's day. Falls back to
+    /// the naive UTC conversion in zones/times where local midnight is
+    /// nonexistent or ambiguous (DST transitions at 00:00) instead of
+    /// panicking; ordering only needs a monotone day boundary.
+    fn midnight_of(ts: Timestamp) -> Timestamp {
+        let Some(dt) = Local.timestamp_millis_opt(ts as i64).single() else {
+            return ts.saturating_sub(ts % 86_400_000)
+        };
+        let Some(naive) = dt.date_naive().and_hms_opt(0, 0, 0) else {
+            return ts.saturating_sub(ts % 86_400_000)
+        };
+        match Local.from_local_datetime(&naive).single() {
+            Some(local) => local.timestamp_millis() as u64,
+            // Ambiguous/nonexistent midnight: pick the earliest mapping.
+            None => match Local.from_local_datetime(&naive).earliest() {
+                Some(local) => local.timestamp_millis() as u64,
+                None => naive.and_utc().timestamp_millis() as u64,
+            },
+        }
+    }
+
+    /// A separator record for a day: synthetic `(midnight, zero id)`
+    /// key. Because every message of the day has `ts >= midnight` and
+    /// every older message has `ts < midnight`, the ordinary composite
+    /// ordering places the separator exactly at the boundary of the
+    /// day's run; the zero id only breaks the exact-midnight tie.
+    fn separator_for(midnight: Timestamp) -> MsgRecord {
+        MsgRecord {
+            ts: midnight,
+            id: MessageId([0; 32]),
+            msg_type: MsgType::DateMsg,
+            payload: codec::encode_datemsg_payload(midnight),
+            height: 0.,
+        }
+    }
+
+    fn has_separator(&self, midnight: Timestamp) -> bool {
+        self.index.contains_key(&(midnight, MessageId([0; 32])))
+    }
+
+    /// Separator maintenance after a single record insert: a record
+    /// whose older neighbor lives on another day starts a new day-run
+    /// and gets its separator next to it.
+    fn sync_separator_for_insert(&mut self, pos: usize) {
+        let Some(slot) = self.order.get(pos).copied() else { return };
+        let rec = self.records.get(slot).expect("dangling slot in order");
+        if rec.msg_type.is_derived() {
+            return
+        }
+        let midnight = Self::midnight_of(rec.ts);
+
+        let starts_run = match self.order.get(pos.wrapping_sub(1)) {
+            Some(&older_slot) if pos > 0 => {
+                let older = self.records.get(older_slot).expect("dangling slot in order");
+                !older.msg_type.is_derived() && Self::midnight_of(older.ts) != midnight
+            }
+            _ => true,
+        };
+        if self.separators && starts_run && !self.has_separator(midnight) {
+            let sep = Self::separator_for(midnight);
+            t!("separator for day {midnight}");
+            self.insert(sep);
+        }
+    }
+
+    /// Orphan cleanup after a removal: a day that lost its last message
+    /// leaves its separator behind.
+    fn cleanup_separator_for_removal(&mut self, ts: Timestamp) {
+        let midnight = Self::midnight_of(ts);
+        let day_still_populated = self.order.iter().any(|slot| {
+            let rec = self.records.get(*slot).expect("dangling slot in order");
+            !rec.msg_type.is_derived() && Self::midnight_of(rec.ts) == midnight
+        });
+        if !day_still_populated && self.has_separator(midnight) {
+            let sep_slot = self.index[&(midnight, MessageId([0; 32]))];
+            t!("orphan separator removed for day {midnight}");
+            self.remove_slot(sep_slot);
+        }
+    }
+
+    /// Full separator resync after structural batches: every maximal
+    /// same-day run gets exactly one separator; separators of days with
+    /// no messages are removed. O(n) alongside the batch rebuild.
+    fn resync_separators(&mut self) {
+        if !self.separators {
+            return
+        }
+        let mut days = std::collections::HashSet::new();
+        let mut seps = std::collections::HashSet::new();
+        for slot in &self.order {
+            let rec = self.records.get(*slot).expect("dangling slot in order");
+            if rec.msg_type.is_derived() {
+                seps.insert(rec.ts);
+            } else {
+                days.insert(Self::midnight_of(rec.ts));
+            }
+        }
+
+        for midnight in days.iter() {
+            if !seps.contains(midnight) {
+                let sep = Self::separator_for(*midnight);
+                t!("separator for day {midnight}");
+                self.insert_nofen(sep);
+            }
+        }
+        for midnight in seps {
+            if !days.contains(&midnight) {
+                let slot = self.index[&(midnight, MessageId([0; 32]))];
+                self.remove_slot(slot);
+                t!("orphan separator removed for day {midnight}");
+            }
+        }
+    }
+
+    /// Fenwick (ascending) index of the record with this id.
+    fn fenwick_idx(&self, id: &MessageId) -> Option<usize> {
+        let slot = *self.id_index.get(id)?;
+        self.order.iter().position(|s| *s == slot)
+    }
+
+    /// Fenwick (ascending) index of the record with this composite key.
+    fn fenwick_idx_key(&self, key: &(Timestamp, MessageId)) -> Option<usize> {
+        let slot = *self.index.get(key)?;
+        self.order.iter().position(|s| *s == slot)
+    }
+
+    /// Display-order position of the record with this id.
+    pub(crate) fn position_of(&self, id: &MessageId) -> Option<usize> {
+        Some(self.order.len() - 1 - self.fenwick_idx(id)?)
+    }
+}
+
+/// The height-change scroll compensation rule. When a message entirely
+/// below the viewport bottom (its top edge `msg_top` at or below
+/// `scroll`) changes height by `delta`, the content the user is looking
+/// at must not move: since scroll measures from the content bottom,
+/// keeping the same content in view means adding `delta`. At `scroll ==
+/// 0` (bottom pinned) there is nothing to hold — content grows upward
+/// and the view auto-follows. Changes overlapping or above the viewport
+/// are visible growth by design and are also left alone.
+pub fn below_viewport_compensation(delta: f32, msg_top: f32, scroll: f32) -> f32 {
+    if scroll > 0. && msg_top <= scroll {
+        delta
+    } else {
+        0.
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn rec(ts: Timestamp, id_byte: u8) -> MsgRecord {
+        MsgRecord::new(ts, MessageId([id_byte; 32]), MsgType::PrivMsg)
+    }
+
+    fn ids_in_order(buf: &MsgBuffer) -> Vec<u8> {
+        let mut ids = vec![];
+        for rec in buf.iter_display_order() {
+            ids.push(rec.id.0[0]);
+        }
+        ids
+    }
+
+    #[test]
+    fn insert_orders_descending() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        assert!(buf.insert(rec(300, b'a')));
+        assert!(buf.insert(rec(100, b'b')));
+        assert!(buf.insert(rec(200, b'c')));
+        assert_eq!(ids_in_order(&buf), vec![b'a', b'c', b'b']);
+        assert_eq!(buf.oldest_ts(), Some(100));
+    }
+
+    #[test]
+    fn insert_newest_goes_to_front() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(rec(100, b'a'));
+        buf.insert(rec(200, b'b'));
+        buf.insert(rec(500, b'c'));
+        assert_eq!(ids_in_order(&buf), vec![b'c', b'b', b'a']);
+    }
+
+    #[test]
+    fn insert_older_goes_to_back() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(rec(200, b'b'));
+        buf.insert(rec(300, b'c'));
+        buf.insert(rec(50, b'a'));
+        assert_eq!(ids_in_order(&buf), vec![b'c', b'b', b'a']);
+        assert_eq!(buf.oldest_ts(), Some(50));
+    }
+
+    #[test]
+    fn duplicate_insert_is_ignored() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        assert!(buf.insert(rec(100, b'a')));
+        assert!(!buf.insert(rec(100, b'a')));
+        assert_eq!(buf.len(), 1);
+        assert_eq!(ids_in_order(&buf), vec![b'a']);
+    }
+
+    #[test]
+    fn same_millisecond_coexists_ordered_by_id() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        assert!(buf.insert(rec(100, b'x')));
+        assert!(buf.insert(rec(100, b'b')));
+        assert!(buf.insert(rec(100, b'q')));
+        assert_eq!(buf.len(), 3);
+        assert_eq!(ids_in_order(&buf), vec![b'x', b'q', b'b']);
+    }
+
+    #[test]
+    fn removal_updates_ordering() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(rec(100, b'a'));
+        buf.insert(rec(200, b'b'));
+        buf.insert(rec(300, b'c'));
+
+        assert!(buf.remove(&MessageId([b'b'; 32])));
+        assert_eq!(ids_in_order(&buf), vec![b'c', b'a']);
+        assert!(!buf.contains(&MessageId([b'b'; 32])));
+        assert!(!buf.remove(&MessageId([b'b'; 32])));
+
+        assert!(buf.remove(&MessageId([b'c'; 32])));
+        assert_eq!(ids_in_order(&buf), vec![b'a']);
+        assert_eq!(buf.oldest_ts(), Some(100));
+    }
+
+    #[test]
+    fn reinsert_after_removal_works() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(rec(100, b'a'));
+        buf.insert(rec(200, b'b'));
+        assert!(buf.remove(&MessageId([b'b'; 32])));
+        assert!(buf.insert(rec(200, b'b')));
+        assert_eq!(ids_in_order(&buf), vec![b'b', b'a']);
+        assert_eq!(buf.position_of(&MessageId([b'b'; 32])), Some(0));
+    }
+
+    #[test]
+    fn record_access_by_id() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(rec(100, b'a'));
+        buf.record_mut(&MessageId([b'a'; 32])).unwrap().height = 42.;
+        assert_eq!(buf.record(&MessageId([b'a'; 32])).unwrap().height, 42.);
+        assert!(buf.record(&MessageId([b'z'; 32])).is_none());
+    }
+
+    #[test]
+    fn clear_resets_everything() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(rec(100, b'a'));
+        buf.insert(rec(200, b'b'));
+        buf.clear();
+        assert!(buf.is_empty());
+        assert_eq!(buf.oldest_ts(), None);
+        assert!(buf.insert(rec(100, b'a')));
+        assert_eq!(ids_in_order(&buf), vec![b'a']);
+    }
+
+    #[test]
+    fn derived_records_share_synthetic_ids() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        let sep1 = MsgRecord::new(1000, MessageId([0; 32]), MsgType::DateMsg);
+        let sep2 = MsgRecord::new(2000, MessageId([0; 32]), MsgType::DateMsg);
+        assert!(buf.insert(sep1));
+        assert!(buf.insert(sep2));
+        assert_eq!(buf.len(), 2);
+        // The zero id is not in the id index: derived lookups and
+        // public removal go through real message ids only.
+        assert!(!buf.contains(&MessageId([0; 32])));
+        assert!(!buf.remove(&MessageId([0; 32])));
+        assert_eq!(buf.oldest_ts(), Some(1000));
+    }
+
+    #[test]
+    fn randomized_inserts_match_sorted_reference() {
+        use rand::{rngs::StdRng, Rng, SeedableRng};
+        let mut rng = StdRng::seed_from_u64(0xBEEF);
+        for _ in 0..50 {
+            let n = rng.gen_range(0..200);
+            let mut keys = vec![];
+            for _ in 0..n {
+                let key = (rng.gen_range(0..10_000u64), rng.gen_range(0..255u8));
+                keys.push(key);
+            }
+            let mut buf = MsgBuffer::new();
+            buf.disable_separators();
+            for (ts, id) in &keys {
+                buf.insert(rec(*ts, *id));
+            }
+
+            keys.sort_unstable();
+            keys.dedup();
+            keys.reverse();
+            assert_eq!(buf.len(), keys.len(), "loaded count");
+            let mut expect = vec![];
+            for (_, id) in &keys {
+                expect.push(*id);
+            }
+            assert_eq!(ids_in_order(&buf), expect, "display order");
+            assert_eq!(buf.oldest_ts(), keys.last().map(|(ts, _)| *ts));
+        }
+    }
+
+    fn id(b: u8) -> MessageId {
+        MessageId([b; 32])
+    }
+
+    /// A unique id from a counter, for randomized tests.
+    fn num_id(n: u64) -> MessageId {
+        let mut bytes = [0u8; 32];
+        bytes[..8].copy_from_slice(&n.to_be_bytes());
+        MessageId(bytes)
+    }
+
+    fn hrec(ts: Timestamp, id_byte: u8, height: f32) -> MsgRecord {
+        let mut r = rec(ts, id_byte);
+        r.height = height;
+        r
+    }
+
+    fn hrec_num(ts: Timestamp, id_num: u64, height: f32) -> MsgRecord {
+        let mut r = MsgRecord::new(ts, num_id(id_num), MsgType::PrivMsg);
+        r.height = height;
+        r
+    }
+
+    /// Brute-force visible range over display-order heights: display
+    /// record i occupies content px [cum(i) - h_i, cum(i)) from the
+    /// bottom; visible iff it intersects [scroll, scroll + vh).
+    fn brute_visible(heights: &[f32], scroll: f32, vh: f32) -> Range<usize> {
+        let mut start = heights.len();
+        let mut end = heights.len();
+        let mut cum = 0.;
+        for (i, h) in heights.iter().enumerate() {
+            let bottom = cum;
+            cum += h;
+            if start == heights.len() && cum > scroll {
+                start = i;
+            }
+            if bottom >= scroll + vh {
+                end = i;
+                break
+            }
+        }
+        start..end
+    }
+
+    #[test]
+    fn geometry_basics() {
+        // Display order (newest first): c(30) b(20) a(10); total 60.
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(hrec(100, b'a', 10.));
+        buf.insert(hrec(200, b'b', 20.));
+        buf.insert(hrec(300, b'c', 30.));
+
+        assert_eq!(buf.total_height(), 60.);
+        assert_eq!(buf.pos_of(&id(b'a')), Some(60.));
+        assert_eq!(buf.pos_of(&id(b'b')), Some(50.));
+        assert_eq!(buf.pos_of(&id(b'c')), Some(30.));
+        assert_eq!(buf.pos_of(&id(b'z')), None);
+
+        assert_eq!(buf.visible_range(0., 60.), 0..3);
+        assert_eq!(buf.visible_range(0., 35.), 0..2);
+        assert_eq!(buf.visible_range(10., 20.), 0..1);
+        assert_eq!(buf.visible_range(30., 30.), 1..3);
+        assert_eq!(buf.visible_range(60., 10.), 3..3);
+
+        // record_at agrees with display iteration.
+        assert_eq!(buf.record_at(0).unwrap().id, id(b'c'));
+        assert_eq!(buf.record_at(2).unwrap().id, id(b'a'));
+        assert!(buf.record_at(3).is_none());
+    }
+
+    #[test]
+    fn set_height_reports_delta_and_updates_geometry() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(hrec(100, b'a', 10.));
+        buf.insert(hrec(200, b'b', 20.));
+        buf.insert(hrec(300, b'c', 30.));
+
+        assert_eq!(buf.set_height(&id(b'b'), 35.), Some(15.));
+        assert_eq!(buf.total_height(), 75.);
+        assert_eq!(buf.pos_of(&id(b'a')), Some(75.));
+        assert_eq!(buf.pos_of(&id(b'b')), Some(65.));
+        assert_eq!(buf.pos_of(&id(b'c')), Some(30.));
+
+        assert_eq!(buf.set_height(&id(b'b'), 35.), Some(0.));
+        assert_eq!(buf.set_height(&id(b'z'), 5.), None);
+    }
+
+    #[test]
+    fn insert_batch_matches_single_inserts() {
+        let batch: Vec<MsgRecord> = vec![
+            hrec(300, b'c', 30.),
+            hrec(100, b'a', 10.),
+            hrec(500, b'e', 50.),
+            hrec(200, b'b', 20.),
+        ];
+        let mut batched = MsgBuffer::new();
+        batched.disable_separators();
+        assert_eq!(batched.insert_batch(batch), 4);
+        let mut single = MsgBuffer::new();
+        single.disable_separators();
+        single.insert(hrec(300, b'c', 30.));
+        single.insert(hrec(100, b'a', 10.));
+        single.insert(hrec(500, b'e', 50.));
+        single.insert(hrec(200, b'b', 20.));
+
+        for buf in [&batched, &single] {
+            assert_eq!(buf.total_height(), 110.);
+            assert_eq!(ids_in_order(buf), vec![b'e', b'c', b'b', b'a']);
+            assert_eq!(buf.visible_range(0., 60.), 0..2);
+        }
+
+        // Duplicates against loaded records are rejected by the batch too.
+        assert_eq!(batched.insert_batch(vec![hrec(200, b'b', 99.)]), 0);
+        assert_eq!(batched.record(&id(b'b')).unwrap().height, 20.);
+    }
+
+    #[test]
+    fn randomized_geometry_against_linear_scan() {
+        use rand::{rngs::StdRng, seq::SliceRandom, Rng, SeedableRng};
+        let mut rng = StdRng::seed_from_u64(0xD1CE);
+
+        for _ in 0..30 {
+            let mut buf = MsgBuffer::new();
+            buf.disable_separators();
+            // Reference model: (ts, id) -> height, kept sorted ascending.
+            let mut model: Vec<(Timestamp, u64, f32)> = vec![];
+            let mut next_id = 0u64;
+
+            for _ in 0..400 {
+                match rng.gen_range(0..6) {
+                    0 | 1 | 2 => {
+                        let ts = rng.gen_range(0..5000u64);
+                        let id_num = next_id;
+                        next_id += 1;
+                        let h = rng.gen_range(0..80) as f32;
+                        buf.insert(hrec_num(ts, id_num, h));
+                        if !model.iter().any(|(t, i, _)| *t == ts && *i == id_num) {
+                            model.push((ts, id_num, h));
+                            model.sort_unstable_by_key(|(t, i, _)| (*t, *i));
+                        }
+                    }
+                    3 => {
+                        // Batch of older records (backfill-shaped).
+                        let mut batch = vec![];
+                        for _ in 0..rng.gen_range(1..8) {
+                            let ts = rng.gen_range(0..5000u64);
+                            let id_num = next_id;
+                            next_id += 1;
+                            let h = rng.gen_range(0..80) as f32;
+                            batch.push(hrec_num(ts, id_num, h));
+                            model.push((ts, id_num, h));
+                        }
+                        model.sort_unstable_by_key(|(t, i, _)| (*t, *i));
+                        model.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
+                        buf.insert_batch(batch);
+                    }
+                    4 => {
+                        if let Some(&(_, id_num, _)) = model.choose(&mut rng) {
+                            let new_h = rng.gen_range(0..120) as f32;
+                            buf.set_height(&num_id(id_num), new_h);
+                            for (_, i, h) in model.iter_mut() {
+                                if *i == id_num {
+                                    *h = new_h;
+                                }
+                            }
+                        }
+                    }
+                    _ => {
+                        if !model.is_empty() {
+                            let idx = rng.gen_range(0..model.len());
+                            let (_, id_num, _) = model[idx];
+                            model.remove(idx);
+                            assert!(buf.remove(&num_id(id_num)));
+                        }
+                    }
+                }
+
+                // Display-order heights from the ascending model.
+                let mut disp = vec![];
+                for (_, _, h) in model.iter().rev() {
+                    disp.push(*h);
+                }
+                let mut total = 0.;
+                for h in &disp {
+                    total += h;
+                }
+                assert!((buf.total_height() - total).abs() < 1e-2, "total_height");
+                assert_eq!(buf.len(), disp.len(), "loaded count");
+
+                let mut cum = 0.;
+                for (d, h) in disp.iter().enumerate() {
+                    let record = buf.record_at(d).unwrap();
+                    assert!((buf.pos_of(&record.id).unwrap() - (cum + h)).abs() < 1e-2, "pos_of");
+                    cum += h;
+                }
+
+                let mut scrolls = vec![0., total / 2., total];
+                for _ in 0..6 {
+                    scrolls.push(rng.gen_range(0f32..total + 1.));
+                }
+                for scroll in scrolls {
+                    let vh = rng.gen_range(0f32..600.);
+                    let expect = brute_visible(&disp, scroll, vh);
+                    let got = buf.visible_range(scroll, vh);
+                    assert_eq!(got, expect, "visible_range(scroll={scroll}, vh={vh})");
+                }
+            }
+        }
+    }
+
+    /// Timestamps on distinct local days, in unix ms.
+    fn day_ts(day_offset: i64, hour: u32, min: u32) -> Timestamp {
+        use chrono::NaiveDate;
+        let date =
+            NaiveDate::from_ymd_opt(2026, 8, 29).unwrap() + chrono::Duration::days(day_offset);
+        let dt = date.and_hms_opt(hour, min, 0).unwrap();
+        Local.from_local_datetime(&dt).unwrap().timestamp_millis() as u64
+    }
+
+    #[test]
+    fn separators_appear_at_day_boundaries() {
+        let mut buf = MsgBuffer::new();
+        // Two messages on day 0, one on day 1.
+        buf.insert(rec(day_ts(0, 10, 0), b'a'));
+        buf.insert(rec(day_ts(0, 11, 0), b'b'));
+        buf.insert(rec(day_ts(1, 9, 0), b'c'));
+
+        let kinds: Vec<(u8, bool)> =
+            buf.iter_display_order().map(|r| (r.id.0[0], r.msg_type.is_derived())).collect();
+        // Display order (newest first): c, [sep day1], b, a, [sep day0].
+        assert_eq!(kinds, vec![(b'c', false), (0, true), (b'b', false), (b'a', false), (0, true)]);
+
+        // The separator keys are the local midnights.
+        let seps: Vec<Timestamp> =
+            buf.iter_display_order().filter(|r| r.msg_type.is_derived()).map(|r| r.ts).collect();
+        assert_eq!(
+            seps,
+            vec![MsgBuffer::midnight_of(day_ts(1, 9, 0)), MsgBuffer::midnight_of(day_ts(0, 10, 0))]
+        );
+    }
+
+    #[test]
+    fn record_at_y_covers_derived_separators() {
+        // A day-1 message, its separator, and a day-0 message; the
+        // separator must be selectable like any record.
+        let mut buf = MsgBuffer::new();
+        buf.insert(hrec(day_ts(1, 9, 0), b'c', 30.));
+        buf.insert(hrec(day_ts(0, 10, 0), b'a', 20.));
+
+        let sep = buf
+            .iter_display_order()
+            .find(|r| r.msg_type.is_derived())
+            .expect("separator present")
+            .clone();
+        assert!(buf.set_height_key(&(sep.ts, sep.id), 34.).is_some());
+
+        // Walk the whole content span; the separator must be resolved
+        // somewhere.
+        let total = buf.total_height();
+        let mut found = false;
+        let mut y = 0.;
+        while y < total {
+            if let Some(rec) = buf.record_at_y(y) {
+                if rec.id == sep.id {
+                    found = true;
+                    break
+                }
+            }
+            y += 1.;
+        }
+        assert!(found, "separator never resolved by record_at_y");
+    }
+
+    #[test]
+    fn record_at_y_resolves_the_on_screen_line() {
+        // Ascending [a(10), b(20), c(30)], total 60: c occupies
+        // bottom-based [0,30], b [30,50], a [50,60].
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(hrec(100, b'a', 10.));
+        buf.insert(hrec(200, b'b', 20.));
+        buf.insert(hrec(300, b'c', 30.));
+
+        assert_eq!(buf.record_at_y(5.).unwrap().id.0[0], b'c');
+        assert_eq!(buf.record_at_y(29.).unwrap().id.0[0], b'c');
+        assert_eq!(buf.record_at_y(31.).unwrap().id.0[0], b'b');
+        assert_eq!(buf.record_at_y(49.).unwrap().id.0[0], b'b');
+        assert_eq!(buf.record_at_y(55.).unwrap().id.0[0], b'a');
+        assert!(buf.record_at_y(61.).is_none());
+        assert!(buf.record_at_y(-1.).is_none());
+    }
+
+    #[test]
+    fn remeasure_all_rebuilds_geometry_from_new_heights() {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf.insert(hrec(100, b'a', 10.));
+        buf.insert(hrec(200, b'b', 20.));
+        buf.insert(hrec(300, b'c', 30.));
+        assert_eq!(buf.total_height(), 60.);
+
+        // The reflow protocol's bulk path: every height replaced, one
+        // Fenwick rebuild, geometry consistent.
+        buf.remeasure_all(|rec| rec.height * 2.);
+        assert_eq!(buf.total_height(), 120.);
+        assert_eq!(buf.pos_of(&MessageId([b'a'; 32])), Some(120.));
+        assert_eq!(buf.pos_of(&MessageId([b'b'; 32])), Some(100.));
+        assert_eq!(buf.pos_of(&MessageId([b'c'; 32])), Some(60.));
+        // Viewport [0, 60) holds only c (b's bottom edge sits exactly
+        // at 60, outside the half-open window).
+        assert_eq!(buf.visible_range(0., 60.), 0..1);
+    }
+
+    #[test]
+    fn same_day_insert_adds_no_second_separator() {
+        let mut buf = MsgBuffer::new();
+        for i in 0..5 {
+            assert!(buf.insert(rec(day_ts(0, 10, i), b'a' + i as u8)));
+        }
+        let sep_count = buf.iter_display_order().filter(|r| r.msg_type.is_derived()).count();
+        assert_eq!(sep_count, 1);
+    }
+
+    #[test]
+    fn deleting_a_days_only_message_removes_its_separator() {
+        let mut buf = MsgBuffer::new();
+        buf.insert(rec(day_ts(0, 10, 0), b'a'));
+        buf.insert(rec(day_ts(1, 9, 0), b'b'));
+        buf.insert(rec(day_ts(1, 10, 0), b'c'));
+        assert_eq!(buf.iter_display_order().filter(|r| r.msg_type.is_derived()).count(), 2);
+
+        // Remove day 0's only message: its separator must go too.
+        assert!(buf.remove(&MessageId([b'a'; 32])));
+        let kinds: Vec<(u8, bool)> =
+            buf.iter_display_order().map(|r| (r.id.0[0], r.msg_type.is_derived())).collect();
+        assert_eq!(kinds, vec![(b'c', false), (b'b', false), (0, true)]);
+
+        // Removing one of day 1's messages keeps its separator.
+        assert!(buf.remove(&MessageId([b'c'; 32])));
+        let kinds: Vec<(u8, bool)> =
+            buf.iter_display_order().map(|r| (r.id.0[0], r.msg_type.is_derived())).collect();
+        assert_eq!(kinds, vec![(b'b', false), (0, true)]);
+    }
+
+    #[test]
+    fn batch_insert_syncs_separators() {
+        let mut buf = MsgBuffer::new();
+        let batch = vec![
+            rec(day_ts(2, 23, 0), b'x'),
+            rec(day_ts(1, 8, 0), b'y'),
+            rec(day_ts(0, 12, 0), b'z'),
+            rec(day_ts(2, 1, 0), b'w'),
+        ];
+        assert_eq!(buf.insert_batch(batch), 4);
+        assert_eq!(buf.iter_display_order().filter(|r| r.msg_type.is_derived()).count(), 3);
+
+        // Display order: x w [sep d2] y [sep d1] z [sep d0].
+        let kinds: Vec<u8> = buf
+            .iter_display_order()
+            .map(|r| if r.msg_type.is_derived() { b'|' } else { r.id.0[0] })
+            .collect();
+        assert_eq!(kinds, vec![b'x', b'w', b'|', b'y', b'|', b'z', b'|']);
+    }
+
+    #[test]
+    fn clear_drops_separators() {
+        let mut buf = MsgBuffer::new();
+        buf.insert(rec(day_ts(0, 10, 0), b'a'));
+        buf.insert(rec(day_ts(1, 9, 0), b'b'));
+        buf.clear();
+        assert!(buf.is_empty());
+        buf.insert(rec(day_ts(1, 9, 0), b'b'));
+        let sep_count = buf.iter_display_order().filter(|r| r.msg_type.is_derived()).count();
+        assert_eq!(sep_count, 1, "separator re-derives after clear");
+    }
+
+    #[test]
+    fn compensation_below_inside_above_and_pinned() {
+        // Viewport [50, 80) in content px from the bottom.
+        let scroll = 50.;
+
+        // Message entirely below the viewport bottom: top <= scroll.
+        assert_eq!(below_viewport_compensation(10., 40., scroll), 10.);
+        assert_eq!(below_viewport_compensation(10., 50., scroll), 10.);
+        // Message overlapping the viewport: top > scroll.
+        assert_eq!(below_viewport_compensation(10., 60., scroll), 0.);
+        // Message entirely above the viewport.
+        assert_eq!(below_viewport_compensation(10., 90., scroll), 0.);
+        // Bottom pinned: no adjustment even for below-viewport growth.
+        assert_eq!(below_viewport_compensation(10., 40., 0.), 0.);
+        // Shrinking below the viewport also compensates (negative delta).
+        assert_eq!(below_viewport_compensation(-4., 40., scroll), -4.);
+    }
+}

+ 271 - 0
bin/app/src/ui/chatview/codec.rs

@@ -0,0 +1,271 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! The chatview storage wire format.
+//!
+//! kv key:   `[u64 BE ts][32-byte msg id]`
+//! kv value: `[u8 tag][type-owned bytes]` — the tag is the `MsgType`
+//! discriminant, the rest is owned by the message type.
+//!
+//! privmsg payload: `[nick: String][text: String][confirmed: bool]`
+//! datemsg payload: `[local-midnight ts: u64]` (derived; never stored)
+//!
+//! This is a clean break from the previous chatview: values it cannot
+//! decode are corrupt data and fail explicitly (panic) with a message
+//! identifying the entry — never a silent skip or a misread.
+
+use std::io::Cursor;
+
+use darkfi_serial::{Decodable, Encodable};
+
+use super::{MessageId, MsgRecord, MsgType, Timestamp};
+
+/// Serialized kv key length: 8-byte timestamp + 32-byte msg id.
+pub const KEY_LEN: usize = 8 + 32;
+
+/// Decode a wire tag. Unknown tags are corrupt data and panic — errors
+/// are always explicit, never a silent skip.
+pub fn msg_type_from_u8(tag: u8) -> MsgType {
+    match tag {
+        0 => MsgType::PrivMsg,
+        1 => MsgType::FileMsg,
+        2 => MsgType::DateMsg,
+        _ => panic!("unknown msg type tag {tag}"),
+    }
+}
+
+/// Encode an entry key: `[u64 BE ts][msg id]`.
+pub fn encode_key(ts: Timestamp, id: &MessageId) -> [u8; KEY_LEN] {
+    let mut key = [0u8; KEY_LEN];
+    key[..8].copy_from_slice(&ts.to_be_bytes());
+    key[8..].copy_from_slice(&id.0);
+    key
+}
+
+/// Decode an entry key back into its `(ts, msg_id)` composite.
+///
+/// ## Panics
+///
+/// If the key is not [`KEY_LEN`] bytes.
+pub fn decode_key(key: &[u8]) -> (Timestamp, MessageId) {
+    assert_eq!(key.len(), KEY_LEN, "corrupt chat entry key: {key:?} (expected {KEY_LEN} bytes)");
+    let ts_bytes: [u8; 8] = key[..8].try_into().unwrap();
+    let id: [u8; 32] = key[8..].try_into().unwrap();
+    (Timestamp::from_be_bytes(ts_bytes), MessageId(id))
+}
+
+/// Encode an entry value: `[tag][type-owned payload]`.
+pub fn encode_value(msg_type: MsgType, payload: &[u8]) -> Vec<u8> {
+    let mut val = Vec::with_capacity(1 + payload.len());
+    val.push(msg_type as u8);
+    val.extend_from_slice(payload);
+    val
+}
+
+/// Decode an entry value into a record (height starts at zero; the
+/// owning type node measures once materialized).
+///
+/// ## Panics
+///
+/// On an empty value, an unknown type tag, or an undecodable payload —
+/// identifying the entry by ts/id and the failure. Corrupt data is
+/// never silently skipped.
+pub fn decode_value(val: &[u8], ts: Timestamp, id: &MessageId) -> MsgRecord {
+    let Some((&tag, payload)) = val.split_first() else {
+        panic!("corrupt chat entry: empty value [ts={ts} id={id}]")
+    };
+    let msg_type = msg_type_from_u8(tag);
+
+    match msg_type {
+        MsgType::PrivMsg => {
+            let (_nick, _text, _confirmed) = decode_privmsg_payload(payload, ts, id);
+        }
+        MsgType::DateMsg => {
+            let _midnight = decode_datemsg_payload(payload, ts, id);
+        }
+        MsgType::FileMsg => {
+            panic!("corrupt chat entry: filemsg is derived and never stored [ts={ts} id={id}]")
+        }
+    }
+
+    MsgRecord { ts, id: *id, msg_type, payload: payload.to_vec(), height: 0. }
+}
+
+/// Encode the privmsg payload: `[nick][text][confirmed]`.
+pub fn encode_privmsg_payload(nick: &str, text: &str, confirmed: bool) -> Vec<u8> {
+    let mut payload = vec![];
+    nick.encode(&mut payload).unwrap();
+    text.encode(&mut payload).unwrap();
+    confirmed.encode(&mut payload).unwrap();
+    payload
+}
+
+/// Decode the privmsg payload back into `(nick, text, confirmed)`.
+///
+/// ## Panics
+///
+/// If the payload does not decode, identifying the entry.
+pub fn decode_privmsg_payload(
+    payload: &[u8],
+    ts: Timestamp,
+    id: &MessageId,
+) -> (String, String, bool) {
+    let mut cur = Cursor::new(payload);
+    let ctx = |what: &str| format!("corrupt chat entry: {what} [ts={ts} id={id}]");
+    let nick =
+        String::decode(&mut cur).unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg nick")));
+    let text =
+        String::decode(&mut cur).unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg text")));
+    let confirmed = bool::decode(&mut cur)
+        .unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg confirmed flag")));
+    (nick, text, confirmed)
+}
+
+/// Encode the datemsg payload: the separator day's local-midnight ts.
+pub fn encode_datemsg_payload(midnight: Timestamp) -> Vec<u8> {
+    let mut payload = vec![];
+    midnight.encode(&mut payload).unwrap();
+    payload
+}
+
+/// Decode the datemsg payload back into its midnight timestamp.
+///
+/// ## Panics
+///
+/// If the payload does not decode, identifying the entry.
+pub fn decode_datemsg_payload(payload: &[u8], ts: Timestamp, id: &MessageId) -> Timestamp {
+    let mut cur = Cursor::new(payload);
+    Timestamp::decode(&mut cur).unwrap_or_else(|e| {
+        panic!("corrupt chat entry: bad datemsg midnight [ts={ts} id={id}]: {e}")
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn rid(b: u8) -> MessageId {
+        MessageId([b; 32])
+    }
+
+    #[test]
+    fn key_round_trip() {
+        let key = encode_key(1_756_000_000_000, &rid(7));
+        assert_eq!(key.len(), KEY_LEN);
+        assert_eq!(decode_key(&key), (1_756_000_000_000, rid(7)));
+        // BE ordering: the ts leads.
+        assert_eq!(&key[..8], &1_756_000_000_000u64.to_be_bytes());
+    }
+
+    #[test]
+    fn privmsg_value_round_trip() {
+        let id = rid(1);
+        let payload = encode_privmsg_payload("alice", "hello world", false);
+        let val = encode_value(MsgType::PrivMsg, &payload);
+        assert_eq!(val[0], MsgType::PrivMsg as u8);
+
+        let rec = decode_value(&val, 123, &id);
+        assert_eq!(rec.ts, 123);
+        assert_eq!(rec.id, id);
+        assert_eq!(rec.msg_type, MsgType::PrivMsg);
+        assert_eq!(rec.height, 0.);
+        assert_eq!(
+            decode_privmsg_payload(&rec.payload, 123, &id),
+            ("alice".to_string(), "hello world".to_string(), false)
+        );
+
+        let payload = encode_privmsg_payload("NOTICE", "\u{1}ACTION waves\u{1}", true);
+        let val = encode_value(MsgType::PrivMsg, &payload);
+        let rec = decode_value(&val, 124, &id);
+        assert_eq!(
+            decode_privmsg_payload(&rec.payload, 124, &id),
+            ("NOTICE".to_string(), "\u{1}ACTION waves\u{1}".to_string(), true)
+        );
+    }
+
+    #[test]
+    fn datemsg_value_round_trip() {
+        let payload = encode_datemsg_payload(1_755_936_000_000);
+        let val = encode_value(MsgType::DateMsg, &payload);
+        let rec = decode_value(&val, 1_755_936_000_000, &rid(0));
+        assert_eq!(rec.msg_type, MsgType::DateMsg);
+        assert_eq!(decode_datemsg_payload(&rec.payload, 0, &rid(0)), 1_755_936_000_000);
+    }
+
+    #[test]
+    fn discriminants_are_the_wire_tags() {
+        assert_eq!(MsgType::PrivMsg as u8, 0);
+        assert_eq!(MsgType::FileMsg as u8, 1);
+        assert_eq!(MsgType::DateMsg as u8, 2);
+        assert_eq!(msg_type_from_u8(0), MsgType::PrivMsg);
+        assert_eq!(msg_type_from_u8(1), MsgType::FileMsg);
+        assert_eq!(msg_type_from_u8(2), MsgType::DateMsg);
+    }
+
+    #[test]
+    #[should_panic(expected = "unknown msg type tag 9")]
+    fn unknown_tag_panics() {
+        decode_value(&[9, 0, 0], 1, &rid(1));
+    }
+
+    #[test]
+    #[should_panic(expected = "empty value")]
+    fn empty_value_panics() {
+        decode_value(&[], 1, &rid(1));
+    }
+
+    #[test]
+    #[should_panic(expected = "bad privmsg text")]
+    fn truncated_privmsg_payload_panics() {
+        let mut payload = encode_privmsg_payload("alice", "hello", true);
+        payload.truncate(6);
+        decode_privmsg_payload(&payload, 5, &rid(2));
+    }
+
+    #[test]
+    #[should_panic(expected = "bad privmsg confirmed flag")]
+    fn missing_confirmed_flag_panics() {
+        let mut payload = encode_privmsg_payload("alice", "hello", true);
+        payload.pop();
+        decode_privmsg_payload(&payload, 5, &rid(2));
+    }
+
+    #[test]
+    #[should_panic(expected = "unknown msg type tag")]
+    fn legacy_untagged_value_panics() {
+        // The old chatview stored `nick, text` with no tag byte; the
+        // first byte (the nick's varint length) reads as a tag and must
+        // fail loudly, never misread.
+        let mut legacy = vec![];
+        "alice".encode(&mut legacy).unwrap();
+        "hello".encode(&mut legacy).unwrap();
+        decode_value(&legacy, 1, &rid(1));
+    }
+
+    #[test]
+    #[should_panic(expected = "filemsg is derived and never stored")]
+    fn stored_filemsg_panics() {
+        decode_value(&[1, 0, 0], 1, &rid(1));
+    }
+
+    #[test]
+    #[should_panic(expected = "expected 40 bytes")]
+    fn bad_key_length_panics() {
+        decode_key(&[0u8; 12]);
+    }
+}

+ 510 - 0
bin/app/src/ui/chatview/loader.rs

@@ -0,0 +1,510 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! The chatview background loader: the single kvdb-owning pipeline.
+//!
+//! One async task owns all kvdb access for its chatview and maintains
+//! the coverage invariant: the loaded region always includes the live
+//! bottom and extends far enough above the viewport to cover
+//! `viewport + preload margin`. Wakes coalesce through a bitset of
+//! reasons; the pump always just restores the invariant, whatever the
+//! trigger — except ChannelSwitch/FilterChange, which clear the buffer
+//! first. Records are decoded with the tagged codec (corrupt entries
+//! panic loudly), filtered here (kvdb → filter → buffer), and inserted
+//! as one batch per pump with a single Fenwick rebuild.
+
+use async_lock::Mutex as AsyncMutex;
+use darkfi::system::CondVar;
+use kvdb_overlay::Tree;
+use parking_lot::Mutex as SyncMutex;
+use std::sync::{
+    atomic::{AtomicU8, Ordering},
+    Arc,
+};
+
+use super::{codec, MessageId, MsgBuffer, MsgRecord, MsgType, RedrawTrigger, Timestamp};
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::loader", $($arg)*); } }
+macro_rules! i { ($($arg:tt)*) => { info!(target: "ui::chatview::loader", $($arg)*); } }
+
+/// Preload margin above the viewport, in viewport heights.
+const PRELOAD_MARGIN_FRAC: f32 = 1.;
+/// Records per pump batch at most. Until heights are measured by the
+/// type framework the height-based shortfall cannot bound a batch, so
+/// the count keeps every pump bounded.
+const LOAD_BATCH_RECORDS: usize = 100;
+
+/// Why the loader was woken. Reasons are advisory bookkeeping: the
+/// pump always just restores the coverage invariant, whatever the
+/// trigger — except ChannelSwitch and FilterChange, which clear the
+/// buffer first.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Wakeup {
+    ChannelSwitch,
+    NearTop,
+    Insert,
+    FilterChange,
+    RectChange,
+}
+
+impl Wakeup {
+    fn bit(self) -> u8 {
+        match self {
+            Self::ChannelSwitch => 1 << 0,
+            Self::NearTop => 1 << 1,
+            Self::Insert => 1 << 2,
+            Self::FilterChange => 1 << 3,
+            Self::RectChange => 1 << 4,
+        }
+    }
+}
+
+/// Runtime message filter, applied in the load pipeline. Filtered-out
+/// messages remain stored; they just never enter the buffer.
+pub type FilterFn = Arc<SyncMutex<Box<dyn Fn(&MsgRecord) -> bool + Send>>>;
+
+/// The loader state shared with the chatview. All kvdb access for this
+/// chatview happens inside the pump task.
+pub struct Loader {
+    /// The bound channel's tree; None until the first `set_channel`.
+    tree: SyncMutex<Option<(String, Tree)>>,
+    /// Shared with the chatview and type nodes.
+    buffer: Arc<AsyncMutex<MsgBuffer>>,
+    filter: FilterFn,
+    /// Wakers call wake(); the run loop drains the accumulated bits.
+    cv: Arc<CondVar>,
+    /// Wake reasons accumulated since the last pump took them, so
+    /// wakes arriving while the pump runs are not lost.
+    pending: AtomicU8,
+    /// `(scroll, view_h)` as last seen by the chatview's draw path.
+    viewport: SyncMutex<(f32, f32)>,
+    /// Measures records during load (materializes type-node instances).
+    measure: SyncMutex<Option<Arc<dyn Fn(&MsgRecord) -> f32 + Send + Sync>>>,
+    /// Derives file messages from loaded privmsgs (fud URLs).
+    derive: SyncMutex<Option<Arc<dyn Fn(&MsgRecord) -> Option<MsgRecord> + Send + Sync>>>,
+    redraw: RedrawTrigger,
+}
+
+impl Loader {
+    pub fn new(buffer: Arc<AsyncMutex<MsgBuffer>>, redraw: RedrawTrigger) -> Arc<Self> {
+        Arc::new(Self {
+            tree: SyncMutex::new(None),
+            buffer,
+            filter: Arc::new(SyncMutex::new(Box::new(|_| true))),
+            cv: Arc::new(CondVar::new()),
+            pending: AtomicU8::new(0),
+            viewport: SyncMutex::new((0., 0.)),
+            measure: SyncMutex::new(None),
+            derive: SyncMutex::new(None),
+            redraw,
+        })
+    }
+
+    /// Wake the pump, recording the reason (bits coalesce).
+    pub fn wake(&self, reason: Wakeup) {
+        self.pending.fetch_or(reason.bit(), Ordering::SeqCst);
+        self.cv.notify();
+    }
+
+    /// Take and clear the accumulated wake reasons.
+    fn take_pending(&self) -> u8 {
+        self.pending.swap(0, Ordering::SeqCst)
+    }
+
+    /// The chatview's draw/scroll path reports the current viewport.
+    pub fn update_viewport(&self, scroll: f32, view_h: f32) {
+        *self.viewport.lock() = (scroll, view_h);
+    }
+
+    /// Bind a channel tree and reload. Called from `set_channel`.
+    pub fn bind(&self, name: String, tree: Tree) {
+        t!("binding channel tree {name}");
+        *self.tree.lock() = Some((name, tree));
+        self.wake(Wakeup::ChannelSwitch);
+    }
+
+    /// Replace the runtime filter and rebuild the buffer through the
+    /// load pipeline.
+    pub fn set_filter(&self, f: Box<dyn Fn(&MsgRecord) -> bool + Send>) {
+        *self.filter.lock() = f;
+        self.wake(Wakeup::FilterChange);
+    }
+
+    /// Overwrite the stored entry for this composite key in place
+    /// (confirmation rewrites the payload; never creates a duplicate).
+    pub fn update(&self, ts: Timestamp, id: &MessageId, msg_type: MsgType, payload: &[u8]) {
+        let tree = self.tree.lock();
+        let Some((_, tree)) = tree.as_ref() else { return };
+        let key = codec::encode_key(ts, id);
+        let val = codec::encode_value(msg_type, payload);
+        tree.insert(&key, &val).expect("cannot update chat entry");
+    }
+
+    /// Install the record-measuring callback the pump uses to give
+    /// loaded records their heights (type nodes lay out text; the
+    /// loader stays geometry-free).
+    pub fn set_measure(&self, f: Arc<dyn Fn(&MsgRecord) -> f32 + Send + Sync>) {
+        *self.measure.lock() = Some(f);
+    }
+
+    /// Install the derivation callback the pump uses to derive file
+    /// messages from loaded privmsg text.
+    pub fn set_derive(&self, f: Arc<dyn Fn(&MsgRecord) -> Option<MsgRecord> + Send + Sync>) {
+        *self.derive.lock() = Some(f);
+    }
+
+    /// Persist a live message into the bound channel tree. Dedup by
+    /// the composite key; returns false when the entry already exists.
+    pub fn store(&self, ts: Timestamp, id: &MessageId, msg_type: MsgType, payload: &[u8]) -> bool {
+        let tree = self.tree.lock();
+        let Some((_, tree)) = tree.as_ref() else { return false };
+        let key = codec::encode_key(ts, id);
+        match tree.contains_key(&key) {
+            Ok(true) => false,
+            Err(_) => false,
+            Ok(false) => {
+                let val = codec::encode_value(msg_type, payload);
+                tree.insert(&key, &val).expect("cannot persist chat entry");
+                true
+            }
+        }
+    }
+
+    /// The background task: wait for wakes, drain the reason bits,
+    /// pump. The reset-before-drain ordering makes wakes arriving
+    /// during a pump observable on the next `wait`.
+    pub async fn run(self: Arc<Self>) {
+        loop {
+            self.cv.wait().await;
+            self.cv.reset();
+            loop {
+                let bits = self.take_pending();
+                if bits == 0 {
+                    break
+                }
+                self.pump(bits).await;
+            }
+        }
+    }
+
+    /// Restore the coverage invariant. `reasons` are the drained wake
+    /// bits; ChannelSwitch/FilterChange reload the buffer from empty.
+    async fn pump(&self, reasons: u8) {
+        let reload = reasons & (Wakeup::ChannelSwitch.bit() | Wakeup::FilterChange.bit()) != 0;
+        let (scroll, view_h) = *self.viewport.lock();
+
+        // The viewport is only known once the draw path has evaluated
+        // the rect (it reports (0, 0) before the first draw pass).
+        // Loading before that would measure every layout at a nonsense
+        // wrap width. The draw path wakes NearTop once real geometry
+        // exists, and this pump runs then.
+        if view_h <= 0. {
+            t!("pump deferred: viewport not yet known");
+            return
+        }
+        let margin = PRELOAD_MARGIN_FRAC * view_h;
+
+        let mut buffer = self.buffer.lock().await;
+        if reload {
+            buffer.clear();
+        }
+
+        let covered = buffer.total_height();
+        let need = scroll + view_h + margin;
+        if !reload && covered >= need {
+            // Advisory fast path: e.g. NearTop when already covered.
+            return
+        }
+        let shortfall = need - covered;
+
+        let tree_guard = self.tree.lock();
+        let Some((tree_name, tree)) = tree_guard.as_ref() else { return };
+        let tree_name = tree_name.clone();
+        t!("pump reasons={reasons:#04x} tree={tree_name} covered={covered} need={need}");
+
+        let filter = self.filter.lock();
+        let mut batch = vec![];
+        let mut batch_height = 0.;
+        let touched_viewport = covered < scroll + view_h;
+
+        // Iterate newest -> older, resuming below the oldest loaded
+        // composite key.
+        let iter = match buffer.oldest_ts() {
+            Some(oldest) => {
+                let key = codec::encode_key(oldest.saturating_sub(1), &MessageId([0xff; 32]));
+                tree.range(..key).rev()
+            }
+            None => tree.iter().rev(),
+        };
+
+        for entry in iter {
+            let (k, v) = entry.expect("kvdb iteration failed");
+            let (ts, id) = codec::decode_key(&k);
+            let mut rec = codec::decode_value(&v, ts, &id);
+            if !filter(&rec) {
+                continue
+            }
+            if let Some(measure) = self.measure.lock().as_ref() {
+                rec.height = measure(&rec);
+            }
+            batch_height += rec.height;
+            let rec_height = rec.height;
+            let is_privmsg = rec.msg_type == MsgType::PrivMsg;
+            batch.push(rec);
+            if is_privmsg {
+                if let Some(derive) = self.derive.lock().as_ref() {
+                    if let Some(file_rec) = derive(batch.last().unwrap()) {
+                        let mut file_rec = file_rec;
+                        if let Some(measure) = self.measure.lock().as_ref() {
+                            file_rec.height = measure(&file_rec);
+                        }
+                        batch_height += file_rec.height;
+                        let _ = rec_height;
+                        batch.push(file_rec);
+                    }
+                }
+            }
+            // Stop once the batch covers the shortfall (or, until
+            // heights are measured, the count cap keeps us bounded).
+            if batch_height >= shortfall || batch.len() >= LOAD_BATCH_RECORDS {
+                break
+            }
+        }
+        drop(filter);
+
+        let inserted = buffer.insert_batch(batch);
+
+        // Separators derived by the batch arrive unmeasured (they are
+        // not in the tree); give them heights through the type nodes.
+        if let Some(measure) = self.measure.lock().as_ref() {
+            let mut ids = vec![];
+            for rec in buffer.iter_display_order() {
+                if rec.msg_type.is_derived() && rec.height == 0. {
+                    ids.push(rec.id);
+                }
+            }
+            for id in ids {
+                if let Some(rec) = buffer.record(&id).cloned() {
+                    let h = measure(&rec);
+                    buffer.set_height_key(&(rec.ts, rec.id), h);
+                }
+            }
+        }
+        drop(buffer);
+        drop(tree_guard);
+
+        t!("pump loaded {inserted} records for tree {tree_name}");
+        if inserted > 0 && touched_viewport {
+            self.redraw.trigger();
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::ui::chatview::{codec, MessageId, MsgType};
+
+    fn fixture_db(tag: &str, lines: &[(u64, u8)]) -> Tree {
+        let path = std::env::temp_dir()
+            .join(format!("darkfi-chatview-loader-{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();
+        for (ts, idb) in lines {
+            let mut id = [0u8; 32];
+            id[0] = *idb;
+            let id = MessageId(id);
+            let payload = codec::encode_privmsg_payload("nick", "text", true);
+            let val = codec::encode_value(MsgType::PrivMsg, &payload);
+            let key = codec::encode_key(*ts, &id);
+            tree.insert(&key, &val).unwrap();
+        }
+        tree
+    }
+
+    /// A buffer with separator maintenance off (these tests model
+    /// stored records only; the buffer's separator tests cover the
+    /// derived-record invariant).
+    fn raw_buffer() -> MsgBuffer {
+        let mut buf = MsgBuffer::new();
+        buf.disable_separators();
+        buf
+    }
+
+    fn ids_loaded(buffer: &MsgBuffer) -> Vec<u8> {
+        let mut ids = vec![];
+        for rec in buffer.iter_display_order() {
+            ids.push(rec.id.0[0]);
+        }
+        ids
+    }
+
+    /// The full id matching `fixture_db`'s first-byte ids.
+    fn fid(b: u8) -> MessageId {
+        let mut id = [0u8; 32];
+        id[0] = b;
+        MessageId(id)
+    }
+
+    #[test]
+    fn pump_loads_newest_first_in_display_order() {
+        let buffer = Arc::new(AsyncMutex::new(raw_buffer()));
+
+        let (redraw, _rx) = RedrawTrigger::new();
+        let loader = Loader::new(buffer.clone(), redraw);
+        loader.update_viewport(0., 500.);
+        loader.bind(
+            "test".to_string(),
+            fixture_db("order", &[(100, b'a'), (300, b'c'), (200, b'b')]),
+        );
+
+        smol::block_on(loader.pump(Wakeup::ChannelSwitch.bit()));
+
+        let buffer = smol::block_on(buffer.lock());
+        assert_eq!(ids_loaded(&buffer), vec![b'c', b'b', b'a']);
+        assert_eq!(buffer.oldest_ts(), Some(100));
+    }
+
+    #[test]
+    fn pump_resumes_below_oldest_and_dedups() {
+        let buffer = Arc::new(AsyncMutex::new(raw_buffer()));
+
+        let (redraw, _rx) = RedrawTrigger::new();
+        let loader = Loader::new(buffer.clone(), redraw);
+        loader.update_viewport(0., 500.);
+        loader.bind(
+            "test".to_string(),
+            fixture_db("resume", &[(100, b'a'), (200, b'b'), (300, b'c'), (400, b'd')]),
+        );
+
+        smol::block_on(loader.pump(Wakeup::ChannelSwitch.bit()));
+        {
+            let buffer = smol::block_on(buffer.lock());
+            assert_eq!(ids_loaded(&buffer), vec![b'd', b'c', b'b', b'a']);
+        }
+
+        // A second pump (e.g. NearTop) resumes from below the oldest
+        // loaded key and finds nothing new — no duplicates, no reload.
+        loader.update_viewport(10_000., 500.);
+        smol::block_on(loader.pump(Wakeup::NearTop.bit()));
+        let buffer = smol::block_on(buffer.lock());
+        assert_eq!(ids_loaded(&buffer), vec![b'd', b'c', b'b', b'a']);
+    }
+
+    #[test]
+    fn filter_change_reloads_without_filtered_records() {
+        let buffer = Arc::new(AsyncMutex::new(raw_buffer()));
+
+        let (redraw, _rx) = RedrawTrigger::new();
+        let loader = Loader::new(buffer.clone(), redraw);
+        loader.update_viewport(0., 500.);
+        loader.bind(
+            "test".to_string(),
+            fixture_db("filter", &[(100, b'a'), (200, b'b'), (300, b'c')]),
+        );
+
+        loader.set_filter(Box::new(|rec| rec.id.0[0] != b'b'));
+
+        smol::block_on(loader.pump(Wakeup::ChannelSwitch.bit() | Wakeup::FilterChange.bit()));
+        let buffer = smol::block_on(buffer.lock());
+        assert_eq!(ids_loaded(&buffer), vec![b'c', b'a']);
+    }
+
+    #[test]
+    fn deleted_records_stay_gone_after_reload() {
+        let buffer = Arc::new(AsyncMutex::new(raw_buffer()));
+
+        let (redraw, _rx) = RedrawTrigger::new();
+        let loader = Loader::new(buffer.clone(), redraw);
+        loader.update_viewport(0., 500.);
+        let tree = fixture_db("delete", &[(100, b'a'), (200, b'b'), (300, b'c')]);
+        loader.bind("test".to_string(), tree.clone());
+
+        smol::block_on(loader.pump(Wakeup::ChannelSwitch.bit()));
+
+        // Remove from buffer and tree (what the chatview's delete_line
+        // does), then force a full reload.
+        {
+            let mut buffer = smol::block_on(buffer.lock());
+            assert!(buffer.remove(&fid(b'b')));
+        }
+        let key = codec::encode_key(200, &fid(b'b'));
+        tree.remove(&key).unwrap();
+
+        loader.set_filter(Box::new(|_| true));
+        smol::block_on(loader.pump(Wakeup::FilterChange.bit()));
+        let buffer = smol::block_on(buffer.lock());
+        assert_eq!(ids_loaded(&buffer), vec![b'c', b'a']);
+    }
+
+    #[test]
+    fn pump_derives_separators_for_loaded_days() {
+        let buffer = Arc::new(AsyncMutex::new(MsgBuffer::new()));
+        let (redraw, _rx) = RedrawTrigger::new();
+        let loader = Loader::new(buffer.clone(), redraw);
+        loader.update_viewport(0., 500.);
+
+        // Two days of stored messages (2026-08-29/30, unix ms).
+        use chrono::{Local, TimeZone};
+        let ts = |day: i64, h: u32| {
+            let date =
+                chrono::NaiveDate::from_ymd_opt(2026, 8, 29).unwrap() + chrono::Duration::days(day);
+            let dt = date.and_hms_opt(h, 0, 0).unwrap();
+            Local.from_local_datetime(&dt).unwrap().timestamp_millis() as u64
+        };
+        loader.bind(
+            "test".to_string(),
+            fixture_db("seps", &[(ts(0, 10), b'a'), (ts(0, 11), b'b'), (ts(1, 9), b'c')]),
+        );
+
+        smol::block_on(loader.pump(Wakeup::ChannelSwitch.bit()));
+
+        let buffer = smol::block_on(buffer.lock());
+        let kinds: Vec<u8> = buffer
+            .iter_display_order()
+            .map(|r| if r.msg_type.is_derived() { b'|' } else { r.id.0[0] })
+            .collect();
+        assert_eq!(kinds, vec![b'c', b'|', b'b', b'a', b'|']);
+    }
+
+    #[test]
+    fn corrupt_entries_panic_loudly() {
+        let buffer = Arc::new(AsyncMutex::new(MsgBuffer::new()));
+        let (redraw, _rx) = RedrawTrigger::new();
+        let loader = Loader::new(buffer.clone(), redraw);
+        loader.update_viewport(0., 500.);
+
+        let path = std::env::temp_dir()
+            .join(format!("darkfi-chatview-loader-corrupt-{}.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();
+        // Old untagged format: must fail, never misread.
+        let mut legacy = vec![];
+        darkfi_serial::Encodable::encode(&"alice".to_string(), &mut legacy).unwrap();
+        let key = codec::encode_key(100, &MessageId([b'a'; 32]));
+        tree.insert(&key, &legacy).unwrap();
+        loader.bind("test".to_string(), tree);
+
+        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+            smol::block_on(loader.pump(Wakeup::ChannelSwitch.bit()));
+        }));
+        assert!(result.is_err(), "corrupt entry must panic");
+    }
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 882 - 777
bin/app/src/ui/chatview/mod.rs


+ 329 - 0
bin/app/src/ui/chatview/msg/datemsg.rs

@@ -0,0 +1,329 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! The date-separator type node. Separators are derived records owned
+//! by the buffer — synthetic `(local-midnight, zero id)` keys, never
+//! persisted — materialized through this node like any other message
+//! type. No methods, no signals; copy text is the date label.
+
+use async_trait::async_trait;
+use chrono::{Local, TimeZone};
+use parking_lot::Mutex as SyncMutex;
+use std::{collections::HashMap, sync::Arc};
+
+use crate::{
+    gfx::{DrawInstruction, Point, Renderer},
+    mesh::Color,
+    prop::{Property, PropertyColor, PropertyFloat32, PropertySubType, PropertyType, Role},
+    scene::{CallArgType, Pimpl, SceneNode, SceneNodeType, SceneNodeWeak},
+    text,
+    ui::UIObject,
+};
+
+use super::{privmsg::InstKey, Hit, SharedProps};
+use crate::ui::chatview::{codec, MessageId, MsgRecord, Timestamp};
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::datemsg", $($arg)*); } }
+
+/// The rendered label for a separator day: "Sat 29 Aug 2026".
+pub fn datestr(midnight: Timestamp) -> String {
+    let Some(dt) = Local.timestamp_millis_opt(midnight as i64).single() else {
+        return String::new()
+    };
+    dt.format("%a %-d %b %Y").to_string()
+}
+
+/// One materialized separator instance.
+pub struct DateMsg {
+    label: String,
+    sig: DateSig,
+    layout: text::TextLayout,
+    instrs: Option<Vec<DrawInstruction>>,
+    height: f32,
+}
+
+#[derive(PartialEq)]
+struct DateSig {
+    font_size: f32,
+    line_height: f32,
+    window_scale: f32,
+    color: Color,
+}
+
+struct DateInner {
+    instances: HashMap<InstKey, DateMsg>,
+    /// Last-access counter per instance, for LRU eviction.
+    touches: HashMap<InstKey, u64>,
+    /// Monotonic access counter driving `touches`.
+    access: u64,
+    layout_builds: usize,
+}
+
+pub type DateMsgNodePtr = Arc<DateMsgNode>;
+
+/// The date-separator type node.
+pub struct DateMsgNode {
+    node: SceneNodeWeak,
+    shared: SharedProps,
+    /// Type-local font size (null = inherit the chatview's).
+    font_size: crate::prop::PropertyPtr,
+    color: PropertyColor,
+    inner: SyncMutex<DateInner>,
+}
+
+impl DateMsgNode {
+    pub async fn new(node: SceneNodeWeak, shared: SharedProps) -> Pimpl {
+        let node_ref = &node.upgrade().unwrap();
+        let color = PropertyColor::wrap(node_ref, Role::Internal, "color").expect("datemsg color");
+        let font_size = node_ref.get_property("font_size").expect("datemsg font_size");
+
+        let self_ = Arc::new(Self {
+            node: node.clone(),
+            shared,
+            font_size,
+            color,
+            inner: SyncMutex::new(DateInner {
+                instances: HashMap::new(),
+                touches: HashMap::new(),
+                access: 0,
+                layout_builds: 0,
+            }),
+        });
+        Pimpl::DateMsgNode(self_)
+    }
+
+    /// The effective font size: the node's own when set, else the
+    /// chatview's (null default).
+    fn effective_font_size(&self) -> f32 {
+        match self.font_size.get_f32_opt(0) {
+            Ok(Some(v)) if v > 0. => v,
+            _ => self.shared.font_size.get(),
+        }
+    }
+
+    fn current_sig(&self) -> DateSig {
+        DateSig {
+            font_size: self.effective_font_size(),
+            line_height: self.shared.line_height.get(),
+            window_scale: self.shared.window_scale.get(),
+            color: self.color.get(),
+        }
+    }
+
+    /// The instance, (re)materialized as needed; returns its height.
+    fn ensure_materialized(&self, rec: &MsgRecord) -> f32 {
+        let key = (rec.ts, rec.id);
+        let sig = self.current_sig();
+        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(&key) {
+            if inst.sig == sig {
+                return inst.height
+            }
+        }
+
+        let midnight = codec::decode_datemsg_payload(&rec.payload, rec.ts, &rec.id);
+        let label = datestr(midnight);
+        // The line box scales with the effective font size, keeping the
+        // chatview's line-height ratio; a smaller date font gives a
+        // proportionally smaller row.
+        let font_size = self.effective_font_size();
+        let line_ratio = self.shared.line_height.get() / self.shared.font_size.get();
+        let line_height = font_size * line_ratio;
+        let layout = text::make_layout(
+            &label,
+            sig.color,
+            font_size,
+            line_ratio,
+            sig.window_scale,
+            None,
+            &[],
+        );
+        let height = line_height + self.shared.message_spacing.get();
+        let inst = DateMsg { label, sig, layout, instrs: None, height };
+        let height = inst.height;
+        inner.instances.insert(key, inst);
+        inner.layout_builds += 1;
+        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))
+    }
+
+    /// Drop an instance's rendered state.
+    pub fn release(&self, key: &InstKey) {
+        let mut inner = self.inner.lock();
+        inner.instances.remove(key);
+        inner.touches.remove(key);
+    }
+
+    /// Drop every instance's rendered state.
+    pub fn release_all(&self) {
+        let mut inner = self.inner.lock();
+        inner.instances.clear();
+        inner.touches.clear();
+    }
+
+    /// Rebuild rendered state from live props + current data.
+    pub fn regen(&self, key: &InstKey) {
+        self.release(key);
+    }
+
+    /// Rebuild every instance's rendered state.
+    pub fn regen_all(&self) {
+        self.release_all();
+    }
+
+    /// Release the out-of-window instances beyond the LRU budget.
+    pub fn sweep(&self, keep: &std::collections::HashSet<InstKey>, budget: usize) {
+        let releases = {
+            let inner = self.inner.lock();
+            super::evict_beyond(keep, &inner.touches, budget)
+        };
+        for key in releases {
+            self.release(&key);
+        }
+    }
+
+    /// Renderer-bound draw instructions in message-local coordinates.
+    pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> Vec<DrawInstruction> {
+        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 Some(inst) = inner.instances.get_mut(&key) else { return vec![] };
+        if inst.instrs.is_none() {
+            let instrs = text::render_layout(
+                &inst.layout,
+                renderer,
+                crate::gfx::gfxtag!("chatview_datemsg"),
+            );
+            inst.instrs = Some(instrs);
+        }
+        inst.instrs.clone().unwrap_or_default()
+    }
+
+    /// Clipboard contribution when selected: the date label.
+    pub fn copy_text(&self, rec: &MsgRecord) -> Option<String> {
+        let inner = self.inner.lock();
+        inner.instances.get(&(rec.ts, rec.id)).map(|inst| inst.label.clone())
+    }
+
+    /// Separators carry no interactive content.
+    pub fn hit_test(&self, _rec: &MsgRecord, _pos: Point) -> Option<Hit> {
+        None
+    }
+
+    /// The scene node handle.
+    pub fn node(&self) -> &SceneNodeWeak {
+        &self.node
+    }
+}
+
+/// Scene node factory for the date-separator type node.
+
+#[async_trait]
+impl UIObject for DateMsgNode {
+    fn priority(&self) -> u32 {
+        0
+    }
+}
+
+impl std::fmt::Debug for DateMsgNode {
+    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_datemsg_node,
+        prop::PropertyAtomicGuard,
+        scene::SceneNodePtr,
+        ui::chatview::{buffer::MsgBuffer, MsgRecord, MsgType},
+    };
+
+    async fn make_node() -> (SceneNodePtr, DateMsgNodePtr) {
+        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", 20.).unwrap();
+        chat.set_property_f32(atom, Role::App, "line_height", 30.).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 mut wscale = crate::scene::SceneNode::new("w", crate::scene::SceneNodeType::Object);
+        wscale
+            .add_property(Property::new("scale", PropertyType::Float32, PropertySubType::Null))
+            .unwrap();
+        let wscale = wscale.setup_null();
+        wscale.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
+        let window_scale = PropertyFloat32::wrap(&wscale, Role::Internal, "scale", 0).unwrap();
+
+        let shared = super::super::SharedProps::wrap(&chat, window_scale);
+        let node = create_datemsg_node("datemsg");
+        let shared2 = shared.clone();
+        let node = node.setup(|me| async move { DateMsgNode::new(me, shared2).await }).await;
+        chat.link(node.clone());
+        let Pimpl::DateMsgNode(ptr) = node.pimpl() else { panic!() };
+        (chat, ptr.clone())
+    }
+
+    fn sep_rec(ts: Timestamp) -> MsgRecord {
+        let payload = crate::ui::chatview::codec::encode_datemsg_payload(ts);
+        MsgRecord { ts, id: MessageId([0; 32]), msg_type: MsgType::DateMsg, payload, height: 0. }
+    }
+
+    #[test]
+    fn font_size_overrides_and_invalidates() {
+        let (chat, node) = smol::block_on(make_node());
+        let rec = sep_rec(1_756_000_000_000);
+        let h1 = node.measure(&rec);
+        // Inherits the chatview font size (20) with its 1.5 line ratio:
+        // 30 + 4 spacing.
+        assert!((h1 - 34.).abs() < 0.01, "{h1}");
+        let builds = node.inner.lock().layout_builds;
+
+        // A type-local font size is picked up, rebuilds the layout, and
+        // shrinks the row box proportionally: 12 * 1.5 + 4.
+        let atom = &mut PropertyAtomicGuard::none();
+        let node2 = node.node().upgrade().unwrap();
+        node2.set_property_f32(atom, Role::App, "font_size", 12.).unwrap();
+        let h2 = node.measure(&rec);
+        assert!(node.inner.lock().layout_builds > builds, "signature change rebuilds");
+        assert!((h2 - 22.).abs() < 0.01, "shrunk line box: {h2}");
+
+        let _ = chat;
+    }
+}

+ 758 - 0
bin/app/src/ui/chatview/msg/filemsg.rs

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

+ 368 - 0
bin/app/src/ui/chatview/msg/mod.rs

@@ -0,0 +1,368 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! The chatview message-type contract and registry.
+//!
+//! Every message type is a fixed variant of [`MsgType`] (no factories,
+//! no placeholders; unknown type ids panic at decode). Each variant is
+//! served by exactly one type node — a scene sub-node of the chatview
+//! carrying the type's styling properties, signals, and lifecycle
+//! methods — plus per-id message instances owned by that node. The
+//! instances split into CPU-only state (layouts, measured heights,
+//! hit rects — unit-testable without a GPU) and renderer-bound work
+//! (mesh/texture caches), which stays at the draw edge.
+
+use std::{
+    collections::{HashMap, HashSet},
+    sync::{Arc, Weak},
+};
+
+use async_lock::Mutex as AsyncMutex;
+use url::Url;
+
+use crate::{
+    app::node::{create_datemsg_node, create_filemsg_node, create_privmsg_node},
+    gfx::{DrawInstruction, Point, Renderer},
+    prop::{PropertyColor, PropertyFloat32, PropertyPtr, PropertyRect, Role},
+    scene::{Pimpl, SceneNodePtr, SceneNodeWeak},
+    util::i18n::I18nBabelFish,
+};
+
+use super::{
+    buffer::MsgBuffer, loader::Loader, ChatView, MessageId, MsgRecord, MsgType, Timestamp,
+};
+
+pub mod datemsg;
+pub mod filemsg;
+pub mod privmsg;
+pub use datemsg::{DateMsgNode, DateMsgNodePtr};
+pub use filemsg::{FileMsgNode, FileMsgNodePtr};
+pub use privmsg::{PrivMsgNode, PrivMsgNodePtr};
+
+/// Live property handles for the styling shared by every message type,
+/// defined once on the chatview node and handed to each type node's
+/// constructor. A type node overrides anything it defines itself.
+#[derive(Clone)]
+pub struct SharedProps {
+    pub font_size: PropertyFloat32,
+    pub timestamp_font_size: PropertyFloat32,
+    pub timestamp_width: PropertyFloat32,
+    pub line_height: PropertyFloat32,
+    pub message_spacing: PropertyFloat32,
+    pub baseline: PropertyFloat32,
+    pub timestamp_color: PropertyColor,
+    pub text_color: PropertyColor,
+    pub hi_bg_color: PropertyColor,
+    pub window_scale: PropertyFloat32,
+    /// The chatview's rect; `.w` is the wrap width.
+    pub rect: PropertyRect,
+}
+
+impl SharedProps {
+    /// Wrap the shared styling properties off the chatview scene node.
+    pub fn wrap(chatview_node: &SceneNodePtr, window_scale: PropertyFloat32) -> Self {
+        let font_size = PropertyFloat32::wrap(chatview_node, Role::Internal, "font_size", 0)
+            .expect("chatview font_size");
+        let timestamp_font_size =
+            PropertyFloat32::wrap(chatview_node, Role::Internal, "timestamp_font_size", 0)
+                .expect("chatview timestamp_font_size");
+        let timestamp_width =
+            PropertyFloat32::wrap(chatview_node, Role::Internal, "timestamp_width", 0)
+                .expect("chatview timestamp_width");
+        let line_height = PropertyFloat32::wrap(chatview_node, Role::Internal, "line_height", 0)
+            .expect("chatview line_height");
+        let message_spacing =
+            PropertyFloat32::wrap(chatview_node, Role::Internal, "message_spacing", 0)
+                .expect("chatview message_spacing");
+        let baseline = PropertyFloat32::wrap(chatview_node, Role::Internal, "baseline", 0)
+            .expect("chatview baseline");
+        let timestamp_color = PropertyColor::wrap(chatview_node, Role::Internal, "timestamp_color")
+            .expect("chatview timestamp_color");
+        let text_color = PropertyColor::wrap(chatview_node, Role::Internal, "text_color")
+            .expect("chatview text_color");
+        let hi_bg_color = PropertyColor::wrap(chatview_node, Role::Internal, "hi_bg_color")
+            .expect("chatview hi_bg_color");
+        let rect =
+            PropertyRect::wrap(chatview_node, Role::Internal, "rect").expect("chatview rect");
+
+        Self {
+            font_size,
+            timestamp_font_size,
+            timestamp_width,
+            line_height,
+            message_spacing,
+            baseline,
+            timestamp_color,
+            text_color,
+            hi_bg_color,
+            window_scale,
+            rect,
+        }
+    }
+}
+
+/// What a hit inside a message resolved to, in message-local
+/// coordinates.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Hit {
+    Url(String),
+    Nick(String),
+    /// The collapsed/expanded toggle affordance of a capped message.
+    Expand,
+    /// A file message's activation (download request) target.
+    File(Url),
+}
+
+/// How a record's draw instructions must be emitted: inline in the
+/// chatview's call, or as a sibling draw call clipped to `clip_h`
+/// (collapsed long messages).
+pub enum DrawOutcome {
+    Inline(Vec<DrawInstruction>),
+    Clipped { instrs: Vec<DrawInstruction>, clip_h: f32 },
+}
+
+/// The per-type node registry: hardcoded enum dispatch, one stable
+/// node per type, created with the chatview and never recreated by
+/// buffer changes (channel switch, load, eviction).
+pub struct TypeNodes {
+    pub privmsg: PrivMsgNodePtr,
+    pub datemsg: DateMsgNodePtr,
+    pub filemsg: FileMsgNodePtr,
+}
+
+impl TypeNodes {
+    /// Create the type sub-nodes as children of the chatview node.
+    pub async fn new(
+        chatview_node: &SceneNodePtr,
+        shared: SharedProps,
+        i18n: I18nBabelFish,
+        loader: Arc<Loader>,
+        buffer: Arc<AsyncMutex<MsgBuffer>>,
+        chat: Weak<ChatView>,
+    ) -> Self {
+        let node = create_privmsg_node("privmsg");
+        let shared2 = shared.clone();
+        let loader2 = loader.clone();
+        let buffer2 = buffer.clone();
+        let chat2 = chat.clone();
+        let node = node
+            .setup(|me| async move { PrivMsgNode::new(me, shared2, loader2, buffer2, chat2).await })
+            .await;
+        let privmsg = node_ref_privmsg(&node);
+        chatview_node.link(node);
+
+        let node = create_datemsg_node("datemsg");
+        let shared2 = shared.clone();
+        let node = node.setup(|me| async move { DateMsgNode::new(me, shared2).await }).await;
+        let datemsg = node_ref_datemsg(&node);
+        chatview_node.link(node);
+
+        let node = create_filemsg_node("filemsg");
+        let shared2 = shared.clone();
+        let i18n2 = i18n.clone();
+        let loader2 = loader.clone();
+        let buffer2 = buffer.clone();
+        let node = node
+            .setup(|me| async move {
+                FileMsgNode::new(me, shared2, i18n2, loader2, buffer2, chat).await
+            })
+            .await;
+        let filemsg = node_ref_filemsg(&node);
+        chatview_node.link(node);
+
+        Self { privmsg, datemsg, filemsg }
+    }
+
+    /// Measure a record: materialize its instance if needed (CPU-only
+    /// layout work) and return its height. Used by the loader while
+    /// collecting a batch, so heights exist before geometry is built.
+    pub fn measure(&self, rec: &MsgRecord) -> f32 {
+        match rec.msg_type {
+            MsgType::PrivMsg => self.privmsg.measure(rec),
+            MsgType::DateMsg => self.datemsg.measure(rec),
+            MsgType::FileMsg => self.filemsg.measure(rec),
+        }
+    }
+
+    /// Whether the record's instance currently holds rendered state.
+    pub fn is_materialized(&self, rec: &MsgRecord) -> bool {
+        match rec.msg_type {
+            MsgType::PrivMsg => self.privmsg.is_materialized(rec),
+            MsgType::DateMsg => self.datemsg.is_materialized(rec),
+            MsgType::FileMsg => self.filemsg.is_materialized(rec),
+        }
+    }
+
+    /// Ensure the instance exists (e.g. rematerialized after release).
+    /// Returns the measured height, which the caller flows back into
+    /// the buffer when it differs from the record's stored height.
+    pub fn ensure_materialized(&self, rec: &MsgRecord) -> f32 {
+        match rec.msg_type {
+            MsgType::PrivMsg => self.privmsg.measure(rec),
+            MsgType::DateMsg => self.datemsg.measure(rec),
+            MsgType::FileMsg => self.filemsg.measure(rec),
+        }
+    }
+
+    /// Drop every instance's rendered state (channel switch, reflow).
+    pub fn release_all(&self) {
+        self.privmsg.release_all();
+        self.datemsg.release_all();
+        self.filemsg.release_all();
+    }
+
+    /// Rebuild rendered state for every instance from live props.
+    pub fn regen_all(&self) {
+        self.privmsg.regen_all();
+        self.datemsg.regen_all();
+        self.filemsg.regen_all();
+    }
+
+    /// Renderer-bound draw instructions for a materialized record, in
+    /// message-local coordinates (y grows downward from its top edge).
+    pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> DrawOutcome {
+        match rec.msg_type {
+            MsgType::PrivMsg => self.privmsg.draw(rec, renderer),
+            MsgType::DateMsg => DrawOutcome::Inline(self.datemsg.draw(rec, renderer)),
+            MsgType::FileMsg => self.filemsg.draw(rec, renderer),
+        }
+    }
+
+    /// Clipboard contribution when selected (None = nothing copied).
+    pub fn copy_text(&self, rec: &MsgRecord) -> Option<String> {
+        match rec.msg_type {
+            MsgType::PrivMsg => self.privmsg.copy_text(rec),
+            MsgType::DateMsg => self.datemsg.copy_text(rec),
+            MsgType::FileMsg => self.filemsg.copy_text(rec),
+        }
+    }
+
+    /// Hit dispatch (urls, nicks, buttons) in message-local coordinates.
+    pub fn hit_test(&self, rec: &MsgRecord, pos: Point) -> Option<Hit> {
+        match rec.msg_type {
+            MsgType::PrivMsg => self.privmsg.hit_test(rec, pos),
+            MsgType::FileMsg => self.filemsg.hit_test(rec, pos),
+            _ => None,
+        }
+    }
+
+    /// Release out-of-window instances beyond the LRU budget (the
+    /// virtualization sweep; called from the draw path).
+    pub fn sweep(&self, keep: &HashSet<privmsg::InstKey>, budget: usize) {
+        self.privmsg.sweep(keep, budget);
+        self.datemsg.sweep(keep, budget);
+        self.filemsg.sweep(keep, budget);
+    }
+}
+
+/// The soft-window + LRU budget policy: window members are always
+/// kept; of the rest, the `budget` most recently touched survive and
+/// older instances are released. Pure — unit-tested standalone.
+pub fn evict_beyond<K: std::hash::Hash + Eq + Clone>(
+    keep: &std::collections::HashSet<K>,
+    touches: &HashMap<K, u64>,
+    budget: usize,
+) -> Vec<K> {
+    let mut candidates: Vec<(K, u64)> = vec![];
+    for (key, touch) in touches {
+        if !keep.contains(key) {
+            candidates.push((key.clone(), *touch));
+        }
+    }
+    if candidates.len() <= budget {
+        return vec![]
+    }
+    // Oldest first; release all but the newest `budget`.
+    candidates.sort_unstable_by_key(|(_, touch)| *touch);
+    let excess = candidates.len() - budget;
+    let mut releases = vec![];
+    for (key, _) in candidates.into_iter().take(excess) {
+        releases.push(key);
+    }
+    releases
+}
+
+/// Pull the `PrivMsgNodePtr` back out of the set-up scene node.
+fn node_ref_privmsg(node: &SceneNodePtr) -> PrivMsgNodePtr {
+    let Pimpl::PrivMsgNode(ptr) = node.pimpl() else { panic!("privmsg node pimpl") };
+    ptr.clone()
+}
+
+/// Pull the `DateMsgNodePtr` back out of the set-up scene node.
+fn node_ref_datemsg(node: &SceneNodePtr) -> DateMsgNodePtr {
+    let Pimpl::DateMsgNode(ptr) = node.pimpl() else { panic!("datemsg node pimpl") };
+    ptr.clone()
+}
+
+/// Pull the `FileMsgNodePtr` back out of the set-up scene node.
+fn node_ref_filemsg(node: &SceneNodePtr) -> FileMsgNodePtr {
+    let Pimpl::FileMsgNode(ptr) = node.pimpl() else { panic!("filemsg node pimpl") };
+    ptr.clone()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn key(n: u64) -> privmsg::InstKey {
+        (n, MessageId([n as u8; 32]))
+    }
+
+    #[test]
+    fn evict_beyond_keeps_window_members() {
+        let mut keep = HashSet::new();
+        keep.insert(key(1));
+        keep.insert(key(2));
+        let mut touches = HashMap::new();
+        for n in 0..10u64 {
+            touches.insert(key(n), n);
+        }
+        // Window members are never released, whatever the budget.
+        assert!(evict_beyond(&keep, &touches, 0).iter().all(|k| !keep.contains(k)));
+        let releases = evict_beyond(&keep, &touches, 0);
+        assert_eq!(releases.len(), 8);
+    }
+
+    #[test]
+    fn evict_beyond_respects_budget() {
+        let keep = HashSet::new();
+        let mut touches = HashMap::new();
+        for n in 0..10u64 {
+            touches.insert(key(n), n);
+        }
+        // Under budget: nothing released.
+        assert!(evict_beyond(&keep, &touches, 12).is_empty());
+        // Budget 3 keeps the 3 most recently touched (7, 8, 9).
+        let mut releases = evict_beyond(&keep, &touches, 3);
+        releases.sort_unstable_by_key(|(n, _)| *n);
+        assert_eq!(releases, vec![key(0), key(1), key(2), key(3), key(4), key(5), key(6)]);
+    }
+
+    #[test]
+    fn evict_beyond_releases_oldest_first() {
+        let keep = HashSet::new();
+        let mut touches = HashMap::new();
+        // Insertion order is not access order.
+        touches.insert(key(0), 90);
+        touches.insert(key(1), 10);
+        touches.insert(key(2), 50);
+        let releases = evict_beyond(&keep, &touches, 1);
+        // The two oldest go oldest-first; key 0 (touch 90) fills the budget.
+        assert_eq!(releases, vec![key(1), key(2)]);
+    }
+}

+ 1445 - 0
bin/app/src/ui/chatview/msg/privmsg.rs

@@ -0,0 +1,1445 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! 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, PropertyColor, PropertyFloat32, 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<Color> {
+    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<u8>,
+    /// Signature the rendered state was built with.
+    sig: LayoutSig,
+    txt_layout: text::TextLayout,
+    ts_layout: text::TextLayout,
+    instrs: Option<Vec<DrawInstruction>>,
+    /// 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<Rectangle>,
+    /// 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 "<nick> <body>", actions
+    /// "* <nick> <body>".
+    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<InstKey, PrivMsg>,
+    /// Last-access counter per instance, for LRU eviction.
+    touches: HashMap<InstKey, u64>,
+    /// Monotonic access counter driving `touches`.
+    access: u64,
+    /// Epoch-scoped mesh caches die with the epoch.
+    epoch_tracker: Option<EpochTracker>,
+    /// How many layouts have been (re)built; cache-behavior test hook.
+    layout_builds: usize,
+}
+
+pub type PrivMsgNodePtr = Arc<PrivMsgNode>;
+
+/// The privmsg type node.
+pub struct PrivMsgNode {
+    node: SceneNodeWeak,
+    shared: SharedProps,
+    own: PrivOwnProps,
+    loader: Arc<Loader>,
+    buffer: Arc<AsyncMutex<MsgBuffer>>,
+    chat: Weak<ChatView>,
+    inner: SyncMutex<PrivInner>,
+    /// Messages the user expanded (over-cap ones collapse by default);
+    /// survives rendered-state regens.
+    expanded: SyncMutex<HashSet<InstKey>>,
+    /// "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<Loader>,
+        buffer: Arc<AsyncMutex<MsgBuffer>>,
+        chat: Weak<ChatView>,
+    ) -> 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(
+            &timestr,
+            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<f32> {
+        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<InstKey>, 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.
+    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<String> {
+        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<Hit> {
+        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<usize>],
+        payload_fn: impl Fn(&str) -> Option<String>,
+    ) -> 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<regex::Regex> = 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<String> {
+    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<std::ops::Range<usize>> {
+    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<usize>, 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<u8> {
+    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);
+        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<Loader>, 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<AsyncMutex<MsgBuffer>>) {
+        // 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<ChatView> = 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);
+    }
+}

+ 0 - 1858
bin/app/src/ui/chatview/page.rs

@@ -1,1858 +0,0 @@
-/* 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 <https://www.gnu.org/licenses/>.
- */
-
-use async_gen::{gen as async_gen, AsyncIter};
-use async_trait::async_trait;
-use chrono::{Local, NaiveDate, TimeZone};
-use darkfi_serial::{Encodable, FutAsyncWriteExt, SerialDecodable, SerialEncodable};
-use futures::stream::{Stream, StreamExt};
-use image::{ImageBuffer, ImageReader, Rgba};
-use miniquad::{MouseButton, TextureFormat, TouchPhase};
-use parking_lot::Mutex as SyncMutex;
-use regex::Regex;
-use std::{
-    collections::HashMap,
-    hash::{DefaultHasher, Hash, Hasher},
-    io::Cursor,
-    ops::Range,
-    pin::pin,
-    sync::{
-        atomic::{AtomicBool, Ordering},
-        Arc, LazyLock,
-    },
-};
-use url::Url;
-
-use super::{MessageId, Timestamp};
-use crate::{
-    gfx::{
-        gfxtag, DrawInstruction, EpochTracker, ManagedTexturePtr, Point, Rectangle, RenderApi,
-        Renderer,
-    },
-    mesh::{Color, MeshBuilder, COLOR_CYAN, COLOR_GREEN, COLOR_RED, COLOR_WHITE},
-    prop::{PropertyColor, PropertyFloat32, PropertyPtr},
-    scene::SceneNodeWeak,
-    text,
-    ui::UIObject,
-    util::enumerate_mut,
-};
-
-macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::message_buffer", $($arg)*); } }
-
-//const PAGE_SIZE: usize = 10;
-//const PRELOAD_PAGES: usize = 10;
-
-const UNCONF_COLOR: [f32; 4] = [0.4, 0.4, 0.4, 1.];
-
-static URL_REGEX: LazyLock<Regex> =
-    LazyLock::new(|| Regex::new(r"https?://[^\s]+|fud://[^\s]+|www\.[^\s]+").unwrap());
-
-fn url_color_ranges(text: &str, offset: usize, color: Color) -> Vec<(Range<usize>, Color)> {
-    URL_REGEX.find_iter(text).map(|m| (m.start() + offset..m.end() + offset, color)).collect()
-}
-
-/// 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)
-}
-
-#[derive(Clone)]
-pub struct PrivMessage {
-    font_size: f32,
-    timestamp_font_size: f32,
-    window_scale: f32,
-
-    timestamp: Timestamp,
-    id: MessageId,
-    nick: String,
-    text: String,
-    pub confirmed: bool,
-
-    /// Whether this is an IRC-style CTCP ACTION message (`/me`).
-    /// Detected in `PrivMessage::new`; `text` holds the stripped action text.
-    is_action: bool,
-
-    is_selected: bool,
-
-    mesh_cache: Option<Vec<DrawInstruction>>,
-    txt_layout: Option<text::TextLayout>,
-
-    /// Bounding rects of this message's URL runs in message-local coordinates,
-    /// each tagged with its URL string. Populated in `gen_mesh`, used by
-    /// `handle_mouse_btn_up` for click hit-testing. Cleared in `clear_mesh`.
-    url_click_rects: Vec<(String, Rectangle)>,
-}
-
-impl PrivMessage {
-    pub fn new(
-        mut font_size: f32,
-        timestamp_font_size: f32,
-        window_scale: f32,
-
-        timestamp: Timestamp,
-        id: MessageId,
-        nick: String,
-        text: String,
-    ) -> Message {
-        if nick == "NOTICE" {
-            font_size *= 0.8;
-        }
-
-        let (is_action, text) = match parse_ctcp_action(&text) {
-            Some(action) => (true, action.to_string()),
-            None => (false, text),
-        };
-
-        Message::Priv(Self {
-            font_size,
-            timestamp_font_size,
-            window_scale,
-            timestamp,
-            id,
-            nick,
-            text,
-            confirmed: true,
-            is_action,
-            is_selected: false,
-            mesh_cache: None,
-            txt_layout: None,
-            url_click_rects: vec![],
-        })
-    }
-
-    fn gen_timestr(timestamp: Timestamp) -> String {
-        let dt = Local.timestamp_millis_opt(timestamp as i64).unwrap();
-        let timestr = dt.format("%H:%M").to_string();
-        timestr
-    }
-
-    fn height(&self, _line_height: f32) -> f32 {
-        self.txt_layout.as_ref().unwrap().height()
-    }
-
-    /// The full rendered line text: NOTICE renders the body alone, normal
-    /// messages render "<nick> <body>", and actions render "* <nick> <body>".
-    fn line_text(&self) -> String {
-        if self.nick == "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.
-    fn body_offset(&self) -> usize {
-        if self.nick == "NOTICE" {
-            return 0
-        }
-        if self.is_action {
-            // "* " + nick + " "
-            return self.nick.len() + 3
-        }
-        self.nick.len() + 1
-    }
-
-    fn cache_txt_layout(
-        &mut self,
-        clip: &Rectangle,
-        line_height: f32,
-        timestamp_width: f32,
-        nick_colors: &[Color],
-        text_color: Color,
-        action_text_color: Color,
-        url_text_color: Color,
-    ) {
-        if self.txt_layout.is_some() {
-            return
-        }
-
-        let linetext = self.line_text();
-
-        let nick_color = select_nick_color(&self.nick, nick_colors);
-
-        let is_notice = self.nick == "NOTICE";
-        let body_offset = self.body_offset();
-        let url_ranges = url_color_ranges(&self.text, body_offset, url_text_color);
-
-        let txt_layout = if is_notice {
-            text::make_layout2(
-                &linetext,
-                text_color,
-                self.font_size,
-                line_height / self.font_size,
-                self.window_scale,
-                Some(clip.w - timestamp_width),
-                &[],
-                &url_ranges,
-                parley::Alignment::Start,
-                parley::OverflowWrap::Normal,
-            )
-        } else {
-            let body_color = if self.is_action {
-                if self.confirmed {
-                    action_text_color
-                } else {
-                    UNCONF_COLOR
-                }
-            } else if self.confirmed {
-                text_color
-            } else {
-                UNCONF_COLOR
-            };
-            let mut foreground_colors = vec![(0..body_offset, nick_color)];
-            foreground_colors.extend(url_ranges);
-            text::make_layout2(
-                &linetext,
-                body_color,
-                self.font_size,
-                line_height / self.font_size,
-                self.window_scale,
-                Some(clip.w - timestamp_width),
-                &[],
-                &foreground_colors,
-                parley::Alignment::Start,
-                parley::OverflowWrap::Normal,
-            )
-        };
-        self.txt_layout = Some(txt_layout);
-    }
-
-    async fn gen_mesh(
-        &mut self,
-        clip: &Rectangle,
-        line_height: f32,
-        msg_spacing: f32,
-        timestamp_width: f32,
-        nick_colors: &[Color],
-        timestamp_color: Color,
-        text_color: Color,
-        action_text_color: Color,
-        url_text_color: Color,
-        url_bg_color: Color,
-        url_bg_border_size: f32,
-        url_bg_border_color: Color,
-        hi_bg_color: Color,
-        renderer: &Renderer,
-    ) -> Vec<DrawInstruction> {
-        if let Some(instrs) = &self.mesh_cache {
-            assert!(self.txt_layout.is_some());
-            return instrs.clone()
-        }
-
-        // Timestamp layout
-        let timestr = Self::gen_timestr(self.timestamp);
-        let timestamp_layout = text::make_layout(
-            &timestr,
-            timestamp_color,
-            self.timestamp_font_size,
-            line_height / self.timestamp_font_size,
-            self.window_scale,
-            None,
-            &[],
-        );
-
-        self.cache_txt_layout(
-            clip,
-            line_height,
-            timestamp_width,
-            nick_colors,
-            text_color,
-            action_text_color,
-            url_text_color,
-        );
-
-        let mut all_instrs = vec![];
-
-        // Draw selection background if selected
-        if self.is_selected {
-            let height = self.height(line_height) + msg_spacing;
-            let mut mesh = MeshBuilder::new(gfxtag!("chatview_privmsg_sel"));
-            mesh.draw_filled_box(&Rectangle { x: 0., y: 0., w: clip.w, h: height }, hi_bg_color);
-            all_instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
-        }
-
-        // Render timestamp
-        let timestamp_instrs =
-            text::render_layout(&timestamp_layout, renderer, gfxtag!("chatview_privmsg_ts"));
-        all_instrs.extend(timestamp_instrs);
-
-        // Render message text offset by timestamp_width
-        all_instrs.push(DrawInstruction::Move(Point::new(timestamp_width, 0.)));
-
-        // Draw URL background (and optional border) behind the URL runs, under the
-        // glyphs. render_backgrounds matches glyph runs by their style brush, so
-        // only the URL-colored runs (brush == url_text_color) get a box — never the
-        // nick or surrounding body text.
-        if URL_REGEX.is_match(&self.text) {
-            let bg_instrs = text::render_backgrounds(
-                self.txt_layout.as_ref().unwrap(),
-                url_text_color,
-                url_bg_color,
-                url_bg_border_color,
-                url_bg_border_size,
-                renderer,
-                gfxtag!("chatview_privmsg_urlbg"),
-            );
-            all_instrs.extend(bg_instrs);
-        }
-
-        // Record this message's URL hit-rectangles for click detection.
-        self.url_click_rects = self.compute_url_click_rects(timestamp_width, url_text_color);
-
-        let text_instrs = text::render_layout(
-            self.txt_layout.as_ref().unwrap(),
-            renderer,
-            gfxtag!("chatview_privmsg_text"),
-        );
-        all_instrs.extend(text_instrs);
-
-        self.mesh_cache = Some(all_instrs.clone());
-        all_instrs
-    }
-
-    fn adjust_params(&mut self, font_size: f32, timestamp_font_size: f32, window_scale: f32) {
-        let font_size = if self.nick == "NOTICE" { font_size * 0.8 } else { font_size };
-        self.font_size = font_size;
-        self.timestamp_font_size = timestamp_font_size;
-        self.window_scale = window_scale;
-    }
-
-    fn clear_mesh(&mut self) {
-        // Auto-deletes when refs are dropped
-        self.mesh_cache = None;
-        self.txt_layout = None;
-        self.url_click_rects.clear();
-    }
-
-    fn select(&mut self) {
-        self.is_selected = true;
-    }
-
-    fn deselect(&mut self) {
-        self.is_selected = false;
-    }
-
-    fn is_selected(&self) -> bool {
-        self.is_selected
-    }
-
-    /// Build the URL hit-rectangles for this message, in message-local
-    /// virtual coordinates. Each URL-colored glyph run (`style().brush ==
-    /// url_text_color`) becomes a rect `(timestamp_width + run.offset,
-    /// run.baseline - ascent, run.advance, ascent + descent)`. The run is
-    /// tagged with its URL string by intersecting its (coarse) font-run
-    /// `text_range()` with the message's URL byte ranges in `linetext`,
-    /// so wrapped URLs and multiple URLs are handled correctly.
-    fn compute_url_click_rects(
-        &self,
-        timestamp_width: f32,
-        url_text_color: Color,
-    ) -> Vec<(String, Rectangle)> {
-        let mut rects = vec![];
-        let Some(layout) = self.txt_layout.as_ref() else { return rects };
-
-        let linetext = self.line_text();
-        let body_offset = self.body_offset();
-
-        // URL byte ranges within linetext (the color value is unused here).
-        let url_ranges: Vec<Range<usize>> =
-            url_color_ranges(&self.text, body_offset, url_text_color)
-                .into_iter()
-                .map(|(r, _)| r)
-                .collect();
-        if url_ranges.is_empty() {
-            return rects
-        }
-
-        // Layout coordinates are physical so divide by the scale to get
-        // the virtual units the hit test positions use.
-        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 != url_text_color {
-                    continue
-                }
-
-                // Map this run to its URL via the (coarse) font-run range intersected
-                // with the URL byte ranges.
-                let font_range = glyph_run.run().text_range();
-                let Some(url_range) = url_ranges
-                    .iter()
-                    .find(|r| r.start < font_range.end && r.end > font_range.start)
-                else {
-                    continue
-                };
-                let url_str = linetext[url_range.clone()].to_string();
-
-                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((url_str, Rectangle::new(x, y, w, h)));
-            }
-        }
-
-        rects
-    }
-
-    fn url_at_local(&self, pos: Point) -> Option<&str> {
-        for (url, rect) in &self.url_click_rects {
-            if rect.contains(pos) {
-                return Some(url.as_str())
-            }
-        }
-        None
-    }
-
-    async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
-        if btn != MouseButton::Left {
-            return false
-        }
-        let Some(url) = self.url_at_local(mouse_pos) else { return false };
-        info!(target: "ui::chatview", "URL clicked: {url}");
-
-        #[cfg(target_os = "android")]
-        crate::android::open_url(url);
-
-        #[cfg(not(target_os = "android"))]
-        let _ = open::that(url);
-
-        true
-    }
-
-    async fn handle_touch(&self, phase: TouchPhase, touch_pos: Point) -> bool {
-        if phase != TouchPhase::Ended {
-            return false
-        }
-        let Some(url) = self.url_at_local(touch_pos) else { return false };
-        info!(target: "ui::chatview", "URL tapped: {url}");
-
-        #[cfg(target_os = "android")]
-        crate::android::open_url(url);
-
-        #[cfg(not(target_os = "android"))]
-        let _ = open::that(url);
-
-        true
-    }
-}
-
-impl std::fmt::Debug for PrivMessage {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let dt = Local.timestamp_millis_opt(self.timestamp as i64).unwrap();
-        let timestr = dt.format("%H:%M").to_string();
-        write!(f, "{} <{}> {}", timestr, self.nick, self.text)
-    }
-}
-
-#[derive(Clone)]
-pub struct DateMessage {
-    font_size: f32,
-    window_scale: f32,
-    timestamp: Timestamp,
-    mesh_cache: Option<Vec<DrawInstruction>>,
-}
-
-impl DateMessage {
-    pub fn new(font_size: f32, window_scale: f32, timestamp: Timestamp) -> Message {
-        let timestamp = Self::timest_to_midnight(timestamp);
-        Message::Date(Self { font_size, window_scale, timestamp, mesh_cache: None })
-    }
-
-    fn datestr(timestamp: Timestamp) -> String {
-        let dt = Local.timestamp_millis_opt(timestamp as i64).unwrap();
-        let datestr = dt.format("%a %-d %b %Y").to_string();
-        datestr
-    }
-
-    fn timest_to_midnight(timestamp: Timestamp) -> Timestamp {
-        let dt = Local.timestamp_millis_opt(timestamp as i64).unwrap();
-        let dt2 = dt.date_naive().and_hms_opt(0, 0, 0).unwrap();
-        assert_eq!(dt.date_naive(), dt2.date());
-        let timestamp = Local.from_local_datetime(&dt2).unwrap().timestamp_millis() as u64;
-        timestamp
-    }
-
-    fn adjust_params(&mut self, font_size: f32, window_scale: f32) {
-        self.font_size = font_size;
-        self.window_scale = window_scale;
-        self.mesh_cache = None;
-    }
-
-    fn clear_mesh(&mut self) {
-        self.mesh_cache = None;
-    }
-
-    async fn gen_mesh(
-        &mut self,
-        line_height: f32,
-        timestamp_color: Color,
-        renderer: &Renderer,
-    ) -> Vec<DrawInstruction> {
-        // Return cached mesh if available
-        if let Some(cache) = &self.mesh_cache {
-            return cache.clone()
-        }
-
-        let datestr = Self::datestr(self.timestamp);
-
-        let layout = text::make_layout(
-            &datestr,
-            timestamp_color,
-            self.font_size,
-            line_height / self.font_size,
-            self.window_scale,
-            None,
-            &[],
-        );
-
-        let instrs = text::render_layout(&layout, renderer, gfxtag!("chatview_datemsg"));
-        // Cache the instructions
-        self.mesh_cache = Some(instrs.clone());
-        instrs
-    }
-}
-
-impl std::fmt::Debug for DateMessage {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let dt = Local.timestamp_millis_opt(self.timestamp as i64).unwrap();
-        let datestr = dt.format("%a %-d %b %Y").to_string();
-        write!(f, "{}", datestr)
-    }
-}
-
-#[derive(Clone, Debug, SerialEncodable, SerialDecodable)]
-pub enum FileMessageStatus {
-    Initializing,
-    Idle,
-    Downloading { progress: f32 },
-    Downloaded { path: String },
-    Error { msg: String, progress: f32 },
-}
-
-type GenericImageBuffer = ImageBuffer<Rgba<u8>, Vec<u8>>;
-
-pub struct FileMessage {
-    chatview_node: SceneNodeWeak,
-
-    font_size: f32,
-    window_scale: f32,
-    max_width: f32,
-
-    file_url: Url,
-    pub status: FileMessageStatus,
-    imgbuf: Arc<SyncMutex<Option<GenericImageBuffer>>>,
-    timestamp: Timestamp,
-
-    active_rect: Option<Rectangle>,
-    mouse_btn_held: AtomicBool,
-
-    mesh_cache: Option<Vec<DrawInstruction>>,
-}
-
-impl FileMessage {
-    // This is not portable across devices and will break
-    const GLOW_SIZE: f32 = 20.;
-    const MARGIN_TOP: f32 = 4.;
-    const MARGIN_BOTTOM: f32 = 10.;
-    const BOX_PADDING_Y: f32 = 12.;
-    const BOX_PADDING_X: f32 = 15.;
-    const IMG_MAX_HEIGHT: f32 = 500.;
-
-    pub fn new(
-        chatview_node: SceneNodeWeak,
-
-        font_size: f32,
-        window_scale: f32,
-
-        file_url: Url,
-        status: FileMessageStatus,
-        timestamp: Timestamp,
-    ) -> Message {
-        Message::File(Self {
-            chatview_node,
-            font_size,
-            window_scale,
-            max_width: 0.,
-            file_url,
-            status,
-            imgbuf: Arc::new(SyncMutex::new(None)),
-            timestamp,
-            active_rect: None,
-            mouse_btn_held: AtomicBool::new(false),
-            mesh_cache: None,
-        })
-    }
-
-    fn filestr(file_url: &Url, status: &FileMessageStatus) -> Vec<String> {
-        let status_str = match status {
-            FileMessageStatus::Initializing => "starting fud".to_string(),
-            FileMessageStatus::Idle => "tap to download".to_string(),
-            FileMessageStatus::Downloading { progress } => format!("downloading [{progress:.1}%]"),
-            FileMessageStatus::Downloaded { .. } => "downloaded".to_string(),
-            FileMessageStatus::Error { msg, progress } => {
-                if *progress > 0. {
-                    format!("{} [{progress:.1}%]", msg.to_lowercase())
-                } else {
-                    msg.to_lowercase()
-                }
-            }
-        };
-
-        vec![
-            file_url
-                .host_str()
-                .map(|file_hash| {
-                    if file_hash.len() >= 12 {
-                        let first_part = &file_hash[..4];
-                        let last_part = &file_hash[file_hash.len() - 4..];
-                        format!("{}...{}", first_part, last_part)
-                    } else {
-                        file_hash.to_string()
-                    }
-                })
-                .unwrap_or("???".to_string()),
-            status_str,
-        ]
-    }
-
-    pub fn set_status(&mut self, status: &FileMessageStatus) {
-        self.status = status.clone();
-
-        if let FileMessageStatus::Downloaded { .. } = status {
-            let mut imgbuf = self.imgbuf.lock();
-            *imgbuf = self.load_img();
-        }
-    }
-
-    fn adjust_params(&mut self, font_size: f32, window_scale: f32) {
-        self.font_size = font_size;
-        self.window_scale = window_scale;
-        self.mesh_cache = None;
-    }
-
-    fn clear_mesh(&mut self) {
-        self.mesh_cache = None;
-    }
-
-    fn get_img_size(&self, imgbuf: &ImageBuffer<Rgba<u8>, Vec<u8>>) -> (f32, f32) {
-        let img_w = imgbuf.width() as f32;
-        let img_h = imgbuf.height() as f32;
-
-        let width_scale = self.max_width / img_w;
-        let height_scale = Self::IMG_MAX_HEIGHT / img_h;
-
-        let scale = width_scale.min(height_scale);
-        (img_w * scale, img_h * scale)
-    }
-
-    async fn gen_mesh(
-        &mut self,
-        clip: &Rectangle,
-        line_height: f32,
-        timestamp_width: f32,
-        timestamp_color: Color,
-        renderer: &Renderer,
-    ) -> Vec<DrawInstruction> {
-        if let Some(instrs) = &self.mesh_cache {
-            return instrs.clone()
-        }
-
-        self.max_width = clip.w - timestamp_width - Self::GLOW_SIZE;
-
-        // Extract image size while holding lock, then drop it
-        let mut img_size = None;
-        if let Some(img) = &*self.imgbuf.lock() {
-            img_size = Some(self.get_img_size(img));
-        }
-
-        // Lock is dropped here, safe to await now
-        if let Some((img_w, img_h)) = img_size {
-            let mesh_rect = Rectangle::from([timestamp_width, Self::MARGIN_TOP, img_w, img_h]);
-            let texture = self.load_texture(renderer);
-            let mut mesh_gradient = MeshBuilder::new(gfxtag!("file_gradient"));
-            let glow_color = [timestamp_color[0], timestamp_color[1], timestamp_color[2], 0.5];
-            mesh_gradient.draw_box_shadow(&mesh_rect, glow_color, Self::GLOW_SIZE);
-            self.active_rect = Some(mesh_rect);
-
-            let mesh_gradient = mesh_gradient.alloc(renderer);
-            let mut instrs = vec![DrawInstruction::Draw(mesh_gradient.draw_untextured())];
-
-            let mut mesh_img = MeshBuilder::new(gfxtag!("file_img"));
-            let uv_rect = Rectangle::from([0., 0., 1., 1.]);
-            mesh_img.draw_box(&mesh_rect, COLOR_WHITE, &uv_rect);
-            let mesh_img = mesh_img.alloc(renderer);
-            instrs.push(DrawInstruction::Draw(mesh_img.draw_with_textures(vec![texture])));
-
-            self.mesh_cache = Some(instrs.clone());
-            // Image is downloaded so return
-            return instrs;
-        }
-
-        // File is not an image, or the image is not downloaded yet
-
-        let mut all_instrs = vec![];
-
-        let color = match self.status {
-            FileMessageStatus::Initializing => timestamp_color,
-            FileMessageStatus::Idle => timestamp_color,
-            FileMessageStatus::Downloading { .. } => COLOR_CYAN,
-            FileMessageStatus::Downloaded { .. } => COLOR_GREEN,
-            FileMessageStatus::Error { .. } => COLOR_RED,
-        };
-
-        // Compute text
-
-        let file_strs = Self::filestr(&self.file_url, &self.status);
-        let mut layouts = Vec::with_capacity(file_strs.len());
-        let mut text_width = 0.;
-        for file_str in &file_strs {
-            let layout = text::make_layout(
-                file_str,
-                color,
-                self.font_size,
-                line_height / self.font_size,
-                self.window_scale,
-                Some(self.max_width),
-                &[],
-            );
-            if layout.width() > text_width {
-                text_width = layout.width();
-            }
-            layouts.push(layout);
-        }
-
-        // Draw background box
-
-        let box_height = 2. * line_height + Self::BOX_PADDING_Y * 2.;
-
-        let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg_box"));
-        let box_width = if text_width > self.max_width { self.max_width } else { text_width } +
-            Self::BOX_PADDING_X * 2.;
-        let mesh_rect = Rectangle::new(timestamp_width, Self::MARGIN_TOP, box_width, box_height);
-        mesh.draw_outline(&mesh_rect, color, 1.);
-        self.active_rect = Some(mesh_rect);
-
-        let glow_color = [color[0], color[1], color[2], 0.3];
-        mesh.draw_box_shadow(&mesh_rect, glow_color, Self::GLOW_SIZE);
-        let mesh = mesh.alloc(renderer);
-
-        all_instrs.push(DrawInstruction::Draw(mesh.draw_untextured()));
-
-        // Draw text
-
-        all_instrs.push(DrawInstruction::Move(Point::new(
-            timestamp_width + Self::BOX_PADDING_X,
-            Self::MARGIN_TOP + Self::BOX_PADDING_Y,
-        )));
-        for layout in layouts {
-            let instrs = text::render_layout(&layout, renderer, gfxtag!("chatview_filemsg_text"));
-            all_instrs.extend(instrs);
-            all_instrs.push(DrawInstruction::Move(Point::new(0., line_height)));
-        }
-
-        self.mesh_cache = Some(all_instrs.clone());
-        all_instrs
-    }
-
-    fn load_img(&self) -> Option<ImageBuffer<Rgba<u8>, Vec<u8>>> {
-        if let FileMessageStatus::Downloaded { path } = &self.status {
-            let data = Arc::new(SyncMutex::new(vec![]));
-            let data2 = data.clone();
-            miniquad::fs::load_file(path.as_str(), move |res| match res {
-                Ok(res) => *data2.lock() = res,
-                Err(_) => {}
-            });
-            let data = std::mem::take(&mut *data.lock());
-            let Ok(img) =
-                ImageReader::new(Cursor::new(data)).with_guessed_format().unwrap().decode()
-            else {
-                return None;
-            };
-            return Some(img.to_rgba8());
-        }
-
-        None
-    }
-
-    fn load_texture(&self, renderer: &Renderer) -> ManagedTexturePtr {
-        let imgbuf = self.imgbuf.lock();
-        let img = imgbuf.as_ref().unwrap();
-
-        let width = img.width() as u16;
-        let height = img.height() as u16;
-        let bmp = img.as_raw().clone();
-        drop(imgbuf);
-
-        renderer.new_texture(width, height, bmp, TextureFormat::RGBA8, gfxtag!("file_img_texture"))
-    }
-
-    pub fn height(&self, line_height: f32) -> f32 {
-        let imgbuf = self.imgbuf.lock();
-        // If image is downloaded, return image height plus margins
-        if let Some(buf) = &*imgbuf {
-            let img_height = self.get_img_size(buf).1;
-            return img_height + Self::MARGIN_TOP + Self::MARGIN_BOTTOM;
-        }
-        drop(imgbuf);
-
-        // No image yet, so calculate height for text box
-        // filestr() always returns 2 lines: [file_hash, status_string]
-        2. * line_height + Self::BOX_PADDING_Y * 2. + Self::MARGIN_TOP + Self::MARGIN_BOTTOM
-    }
-
-    fn select(&mut self) {}
-
-    async fn download(&self) {
-        let node_ref = self.chatview_node.upgrade().unwrap();
-        let mut data = vec![];
-        self.file_url.encode(&mut data).unwrap();
-        let _ = node_ref.trigger("file_download_request", data).await;
-    }
-}
-
-#[async_trait]
-impl UIObject for FileMessage {
-    fn priority(&self) -> u32 {
-        1
-    }
-
-    async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
-        if btn != MouseButton::Left {
-            return false
-        }
-        if self.active_rect.is_none() {
-            return false
-        }
-        let rect = self.active_rect.unwrap();
-        if !rect.contains(mouse_pos) {
-            return false
-        }
-
-        self.mouse_btn_held.store(true, Ordering::Relaxed);
-        true
-    }
-
-    async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
-        if btn != MouseButton::Left {
-            return false
-        }
-
-        // Did we start the click inside this FileMessage?
-        let btn_held = self.mouse_btn_held.swap(false, Ordering::Relaxed);
-        if !btn_held {
-            return false
-        }
-
-        if self.active_rect.is_none() {
-            return false
-        }
-        let rect = self.active_rect.unwrap();
-        if !rect.contains(mouse_pos) {
-            return false
-        }
-
-        match self.status {
-            FileMessageStatus::Idle | FileMessageStatus::Error { .. } => {
-                self.download().await;
-            }
-            _ => {}
-        }
-        true
-    }
-}
-
-impl FileMessage {
-    /// Tap on the file area downloads the file (idle/error states).
-    pub(super) async fn handle_touch(&self, phase: TouchPhase, _id: u64, touch_pos: Point) -> bool {
-        if phase != TouchPhase::Ended {
-            return false
-        }
-        if self.active_rect.is_none() {
-            return false
-        }
-        let rect = self.active_rect.unwrap();
-        if !rect.contains(touch_pos) {
-            return false
-        }
-
-        match self.status {
-            FileMessageStatus::Idle | FileMessageStatus::Error { .. } => {
-                self.download().await;
-            }
-            _ => {}
-        }
-        true
-    }
-}
-
-impl std::fmt::Debug for FileMessage {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "file: {}", self.file_url)
-    }
-}
-
-/// Easier than fucking around with traits nonsense
-#[derive(Debug)]
-pub enum Message {
-    Priv(PrivMessage),
-    Date(DateMessage),
-    File(FileMessage),
-}
-
-impl Message {
-    fn timestamp(&self) -> u64 {
-        match self {
-            Self::Priv(m) => m.timestamp,
-            Self::Date(m) => m.timestamp,
-            Self::File(m) => m.timestamp,
-        }
-    }
-
-    fn height(&self, line_height: f32) -> f32 {
-        match self {
-            Self::Priv(m) => m.height(line_height),
-            Self::Date(_) => line_height,
-            Self::File(m) => m.height(line_height),
-        }
-    }
-
-    fn adjust_params(&mut self, font_size: f32, timestamp_font_size: f32, window_scale: f32) {
-        match self {
-            Self::Priv(m) => m.adjust_params(font_size, timestamp_font_size, window_scale),
-            Self::Date(m) => m.adjust_params(font_size, window_scale),
-            Self::File(m) => m.adjust_params(font_size, window_scale),
-        }
-    }
-
-    fn clear_mesh(&mut self) {
-        match self {
-            Self::Priv(m) => m.clear_mesh(),
-            Self::Date(m) => m.clear_mesh(),
-            Self::File(m) => m.clear_mesh(),
-        }
-    }
-
-    /// If `local_pos` (message-local coords) is on a URL, return it.
-    fn url_hit(&self, local_pos: Point) -> Option<String> {
-        match self {
-            Self::Priv(m) => m.url_at_local(local_pos).map(|s| s.to_string()),
-            _ => None,
-        }
-    }
-
-    fn cache_txt_layout(
-        &mut self,
-        clip: &Rectangle,
-        line_height: f32,
-        timestamp_width: f32,
-        nick_colors: &[Color],
-        text_color: Color,
-        action_text_color: Color,
-        url_text_color: Color,
-    ) {
-        match self {
-            Self::Priv(m) => {
-                m.cache_txt_layout(
-                    clip,
-                    line_height,
-                    timestamp_width,
-                    nick_colors,
-                    text_color,
-                    action_text_color,
-                    url_text_color,
-                );
-            }
-            Self::Date(_) => {}
-            Self::File(_) => {}
-        }
-    }
-
-    async fn gen_mesh(
-        &mut self,
-        clip: &Rectangle,
-        line_height: f32,
-        msg_spacing: f32,
-        timestamp_width: f32,
-        nick_colors: &[Color],
-        timestamp_color: Color,
-        text_color: Color,
-        action_text_color: Color,
-        url_text_color: Color,
-        url_bg_color: Color,
-        url_bg_border_size: f32,
-        url_bg_border_color: Color,
-        hi_bg_color: Color,
-        renderer: &Renderer,
-    ) -> Vec<DrawInstruction> {
-        match self {
-            Self::Priv(m) => {
-                m.gen_mesh(
-                    clip,
-                    line_height,
-                    msg_spacing,
-                    timestamp_width,
-                    nick_colors,
-                    timestamp_color,
-                    text_color,
-                    action_text_color,
-                    url_text_color,
-                    url_bg_color,
-                    url_bg_border_size,
-                    url_bg_border_color,
-                    hi_bg_color,
-                    renderer,
-                )
-                .await
-            }
-            Self::Date(m) => m.gen_mesh(line_height, timestamp_color, renderer).await,
-            Self::File(m) => {
-                m.gen_mesh(clip, line_height, timestamp_width, timestamp_color, renderer).await
-            }
-        }
-    }
-
-    fn is_date(&self) -> bool {
-        match self {
-            Self::Priv(_) => false,
-            Self::Date(_) => true,
-            Self::File(_) => false,
-        }
-    }
-
-    fn select(&mut self) {
-        match self {
-            Self::Priv(m) => m.select(),
-            Self::Date(_) => {}
-            Self::File(m) => m.select(),
-        }
-    }
-
-    fn deselect(&mut self) {
-        match self {
-            Self::Priv(m) => m.deselect(),
-            Self::Date(_) => {}
-            Self::File(_) => {}
-        }
-    }
-
-    fn is_selected(&self) -> bool {
-        match self {
-            Self::Priv(m) => m.is_selected(),
-            _ => false,
-        }
-    }
-
-    fn get_privmsg_mut(&mut self) -> Option<&mut PrivMessage> {
-        match self {
-            Message::Priv(msg) => Some(msg),
-            _ => None,
-        }
-    }
-
-    fn get_filemsg_mut(&mut self) -> Option<&mut FileMessage> {
-        match self {
-            Message::File(msg) => Some(msg),
-            _ => None,
-        }
-    }
-}
-
-#[async_trait]
-impl UIObject for Message {
-    fn priority(&self) -> u32 {
-        1
-    }
-    async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
-        match self {
-            Self::Priv(_) => false,
-            Self::Date(_) => false,
-            Self::File(m) => m.handle_mouse_btn_down(btn, mouse_pos).await,
-        }
-    }
-    async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
-        match self {
-            Self::Priv(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
-            Self::Date(_) => false,
-            Self::File(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
-        }
-    }
-}
-
-impl Message {
-    /// Tap forwarding into the message content: opens URLs, downloads
-    /// files. Returns true if the message consumed the tap.
-    pub(super) async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        match self {
-            Self::Priv(m) => m.handle_touch(phase, touch_pos).await,
-            Self::Date(_) => false,
-            Self::File(m) => m.handle_touch(phase, id, touch_pos).await,
-        }
-    }
-}
-
-fn select_nick_color(nick: &str, nick_colors: &[Color]) -> Color {
-    let mut hasher = DefaultHasher::new();
-    nick.hash(&mut hasher);
-    let i = hasher.finish() as usize;
-    let color = nick_colors[i % nick_colors.len()];
-    color
-}
-
-pub struct MessageBuffer {
-    /// From most recent to older
-    msgs: Vec<Message>,
-    date_msgs: HashMap<NaiveDate, Message>,
-
-    font_size: PropertyFloat32,
-    timestamp_font_size: PropertyFloat32,
-    timestamp_width: PropertyFloat32,
-    line_height: PropertyFloat32,
-    msg_spacing: PropertyFloat32,
-    baseline: PropertyFloat32,
-    timestamp_color: PropertyColor,
-    text_color: PropertyColor,
-    action_text_color: PropertyColor,
-    url_text_color: PropertyColor,
-    url_bg_color: PropertyColor,
-    url_bg_border_size: PropertyFloat32,
-    url_bg_border_color: PropertyColor,
-    nick_colors: PropertyPtr,
-    hi_bg_color: PropertyColor,
-
-    window_scale: PropertyFloat32,
-    /// Used to detect if the window scale was changed when drawing.
-    /// If it does then we must reload the glyphs too.
-    old_window_scale: f32,
-
-    renderer: Renderer,
-    epoch_tracker: EpochTracker,
-}
-
-impl MessageBuffer {
-    pub fn new(
-        font_size: PropertyFloat32,
-        timestamp_font_size: PropertyFloat32,
-        timestamp_width: PropertyFloat32,
-        line_height: PropertyFloat32,
-        msg_spacing: PropertyFloat32,
-        baseline: PropertyFloat32,
-        timestamp_color: PropertyColor,
-        text_color: PropertyColor,
-        action_text_color: PropertyColor,
-        url_text_color: PropertyColor,
-        url_bg_color: PropertyColor,
-        url_bg_border_size: PropertyFloat32,
-        url_bg_border_color: PropertyColor,
-        nick_colors: PropertyPtr,
-        hi_bg_color: PropertyColor,
-        window_scale: PropertyFloat32,
-        renderer: Renderer,
-    ) -> Self {
-        let old_window_scale = window_scale.get();
-        let epoch_tracker = EpochTracker::new(&renderer);
-        Self {
-            msgs: vec![],
-            date_msgs: HashMap::new(),
-
-            font_size,
-            timestamp_font_size,
-            timestamp_width,
-            line_height,
-            msg_spacing,
-            baseline,
-            timestamp_color,
-            text_color,
-            action_text_color,
-            url_text_color,
-            url_bg_color,
-            url_bg_border_size,
-            url_bg_border_color,
-            nick_colors,
-            hi_bg_color,
-
-            window_scale,
-            old_window_scale,
-
-            renderer,
-            epoch_tracker,
-        }
-    }
-
-    pub fn clear(&mut self) {
-        self.msgs.clear();
-        self.date_msgs.clear();
-    }
-
-    /// Returns whether the scale changed (and params were re-adjusted).
-    pub fn adjust_window_scale(&mut self) -> bool {
-        let window_scale = self.window_scale.get();
-        if self.old_window_scale == window_scale {
-            return false
-        }
-
-        self.adjust_params();
-        true
-    }
-
-    /// Returns whether the gfx epoch changed since the last check,
-    /// meaning every message mesh cache holds dead GPU resources.
-    pub fn epoch_changed(&mut self) -> bool {
-        self.epoch_tracker.changed()
-    }
-
-    /// This will force a reload of everything
-    pub fn adjust_params(&mut self) {
-        let window_scale = self.window_scale.get();
-        let font_size = self.font_size.get();
-        let timestamp_font_size = self.timestamp_font_size.get();
-
-        for msg in &mut self.msgs {
-            msg.adjust_params(font_size, timestamp_font_size, window_scale);
-        }
-    }
-
-    /// Clear all meshes and caches.
-    pub fn clear_meshes(&mut self) {
-        for msg in &mut self.msgs {
-            msg.clear_mesh();
-        }
-    }
-
-    pub async fn calc_total_height(&mut self, rect: &Rectangle) -> f32 {
-        let line_height = self.line_height.get();
-        let baseline = self.baseline.get();
-        let timestamp_width = self.timestamp_width.get();
-        let msg_spacing = self.msg_spacing.get();
-        let text_color = self.text_color.get();
-        let action_text_color = self.action_text_color.get();
-        let url_text_color = self.url_text_color.get();
-        let nick_colors = self.read_nick_colors();
-        let mut height = 0.;
-
-        let msgs = self.msgs_with_date();
-        let mut msgs = pin!(msgs);
-
-        let mut is_first = true;
-
-        while let Some(msg) = msgs.next().await {
-            if is_first {
-                is_first = false;
-            } else {
-                height += msg_spacing;
-            }
-
-            msg.cache_txt_layout(
-                &rect,
-                line_height,
-                timestamp_width,
-                &nick_colors,
-                text_color,
-                action_text_color,
-                url_text_color,
-            );
-
-            height += msg.height(line_height);
-        }
-
-        // For the very top item. This is the ascent
-        if !is_first {
-            height += line_height - baseline;
-        }
-
-        height
-    }
-
-    fn find_privmsg_mut(&mut self, msg_id: &MessageId) -> Option<&mut PrivMessage> {
-        for msg in &mut self.msgs {
-            let Some(privmsg) = msg.get_privmsg_mut() else { continue };
-            if privmsg.id == *msg_id {
-                return Some(privmsg)
-            }
-        }
-        None
-    }
-    pub fn mark_confirmed(&mut self, msg_id: &MessageId) -> bool {
-        let Some(privmsg) = self.find_privmsg_mut(msg_id) else { return false };
-
-        assert_eq!(privmsg.confirmed, false);
-        privmsg.confirmed = true;
-        privmsg.clear_mesh();
-
-        return true
-    }
-
-    pub async fn insert_privmsg(
-        &mut self,
-        timest: Timestamp,
-        msg_id: MessageId,
-        nick: String,
-        text: String,
-        rect: Rectangle,
-    ) -> Option<&mut PrivMessage> {
-        t!("insert_privmsg({timest}, {msg_id}, {nick}, {text})");
-        let line_height = self.line_height.get();
-        let font_size = self.font_size.get();
-        let timestamp_font_size = self.timestamp_font_size.get();
-        let timestamp_width = self.timestamp_width.get();
-        let window_scale = self.window_scale.get();
-        let text_color = self.text_color.get();
-        let action_text_color = self.action_text_color.get();
-        let url_text_color = self.url_text_color.get();
-        let nick_colors = self.read_nick_colors();
-
-        let mut msg = PrivMessage::new(
-            font_size,
-            timestamp_font_size,
-            window_scale,
-            timest,
-            msg_id,
-            nick,
-            text,
-        );
-
-        msg.cache_txt_layout(
-            &rect,
-            line_height,
-            timestamp_width,
-            &nick_colors,
-            text_color,
-            action_text_color,
-            url_text_color,
-        );
-
-        if self.msgs.is_empty() {
-            self.msgs.push(msg);
-            return self.msgs.last_mut().unwrap().get_privmsg_mut()
-        }
-
-        // We only add lines inside pages.
-        // Calling the appropriate draw() function after should preload any missing pages.
-        // When a line is before the first page, it will get preloaded as a new page.
-        let oldest_timest = self.oldest_timestamp().unwrap();
-        if timest < oldest_timest {
-            return None
-        }
-
-        // Timestamps go from most recent backwards
-
-        let mut idx = None;
-        for (i, msg) in enumerate_mut(&mut self.msgs) {
-            if timest >= msg.timestamp() {
-                idx = Some(i);
-                break
-            }
-        }
-
-        let idx = match idx {
-            Some(idx) => idx,
-            None => {
-                let last_page_idx = 0;
-                last_page_idx
-            }
-        };
-
-        self.msgs.insert(idx, msg);
-        return self.msgs[idx].get_privmsg_mut()
-    }
-
-    pub async fn push_privmsg(
-        &mut self,
-        timest: Timestamp,
-        msg_id: MessageId,
-        nick: String,
-        text: String,
-        rect: &Rectangle,
-    ) -> f32 {
-        //t!("push_privmsg({timest}, {msg_id}, {nick}, {text})");
-        let line_height = self.line_height.get();
-        let font_size = self.font_size.get();
-        let timestamp_font_size = self.timestamp_font_size.get();
-        let timestamp_width = self.timestamp_width.get();
-        let window_scale = self.window_scale.get();
-        let text_color = self.text_color.get();
-        let action_text_color = self.action_text_color.get();
-        let url_text_color = self.url_text_color.get();
-        let nick_colors = self.read_nick_colors();
-
-        let mut msg = PrivMessage::new(
-            font_size,
-            timestamp_font_size,
-            window_scale,
-            timest,
-            msg_id,
-            nick,
-            text,
-        );
-
-        msg.cache_txt_layout(
-            rect,
-            line_height,
-            timestamp_width,
-            &nick_colors,
-            text_color,
-            action_text_color,
-            url_text_color,
-        );
-
-        let msg_height = msg.height(self.line_height.get());
-        self.msgs.push(msg);
-        msg_height
-    }
-
-    /// Generate caches and return draw instructions
-    pub async fn gen_meshes(
-        &mut self,
-        rect: &Rectangle,
-        scroll: f32,
-    ) -> Vec<(f32, Vec<DrawInstruction>)> {
-        let line_height = self.line_height.get();
-        let msg_spacing = self.msg_spacing.get();
-        let timestamp_width = self.timestamp_width.get();
-
-        let timest_color = self.timestamp_color.get();
-        let text_color = self.text_color.get();
-        let action_text_color = self.action_text_color.get();
-        let url_text_color = self.url_text_color.get();
-        let url_bg_color = self.url_bg_color.get();
-        let url_bg_border_size = self.url_bg_border_size.get();
-        let url_bg_border_color = self.url_bg_border_color.get();
-        let nick_colors = self.read_nick_colors();
-        let hi_bg_color = self.hi_bg_color.get();
-
-        let renderer = self.renderer.clone();
-
-        let msgs = self.msgs_with_date();
-        let mut msgs = pin!(msgs);
-
-        let mut meshes = vec![];
-        let mut current_pos = 0.;
-        while let Some(msg) = msgs.next().await {
-            let instrs = msg
-                .gen_mesh(
-                    rect,
-                    line_height,
-                    msg_spacing,
-                    timestamp_width,
-                    &nick_colors,
-                    timest_color,
-                    text_color,
-                    action_text_color,
-                    url_text_color,
-                    url_bg_color,
-                    url_bg_border_size,
-                    url_bg_border_color,
-                    hi_bg_color,
-                    &renderer,
-                )
-                .await;
-
-            let mesh_height = msg.height(line_height);
-            current_pos += msg_spacing + mesh_height;
-
-            let msg_top = current_pos;
-            let msg_bottom = current_pos - mesh_height;
-
-            if msg_bottom > scroll + rect.h {
-                break
-            }
-            if msg_top < scroll {
-                continue
-            }
-
-            meshes.push((current_pos, instrs));
-        }
-        meshes
-    }
-
-    pub fn insert_filemsg(
-        &mut self,
-        chatview_node: SceneNodeWeak,
-        timest: Timestamp,
-        msg_id: MessageId,
-        nick: String,
-        file_url: Url,
-    ) -> Option<&mut FileMessage> {
-        t!("insert_filemsg({timest}, {msg_id}, {nick}, {file_url})");
-        let font_size = self.font_size.get();
-        let window_scale = self.window_scale.get();
-
-        let msg = FileMessage::new(
-            chatview_node,
-            font_size,
-            window_scale,
-            file_url,
-            FileMessageStatus::Initializing,
-            timest,
-        );
-
-        // Timestamps go from most recent backwards
-        let mut idx = None;
-        for (i, msg) in enumerate_mut(&mut self.msgs) {
-            if timest >= msg.timestamp() {
-                idx = Some(i);
-                break
-            }
-        }
-
-        let idx = idx.unwrap_or_default();
-
-        self.msgs.insert(idx, msg);
-        self.msgs[idx].get_filemsg_mut()
-    }
-
-    /// Gets around borrow checker with unsafe
-    fn msgs_with_date(&mut self) -> impl Stream<Item = &mut Message> {
-        let font_size = self.font_size.get();
-        let window_scale = self.window_scale.get();
-        AsyncIter::from(async_gen! {
-            let mut last_date = None;
-
-            for idx in 0..self.msgs.len() {
-                let msg = &mut self.msgs[idx] as *mut Message;
-                let msg = unsafe { &mut *msg };
-                let timest = msg.timestamp();
-
-                let older_date = Local.timestamp_millis_opt(timest as i64).unwrap().date_naive();
-
-                if let Some(newer_date) = last_date {
-                    if newer_date != older_date {
-                        let datemsg = self.get_date_msg(newer_date, font_size, window_scale);
-                        let datemsg = unsafe { &mut *(datemsg as *mut Message) };
-                        //t!("Adding date: {idx} {datemsg:?}");
-                        yield datemsg;
-                    }
-                }
-                last_date = Some(older_date);
-
-                //t!("{idx} {msg:?}");
-                yield msg;
-            }
-
-            if let Some(date) = last_date {
-                let datemsg = self.get_date_msg(date, font_size, window_scale);
-                let datemsg = unsafe { &mut *(datemsg as *mut Message) };
-                yield datemsg;
-            }
-        })
-    }
-
-    fn get_date_msg(&mut self, date: NaiveDate, font_size: f32, window_scale: f32) -> &mut Message {
-        let dt = date.and_hms_opt(0, 0, 0).unwrap();
-        let timest = Local.from_local_datetime(&dt).unwrap().timestamp_millis() as u64;
-
-        if !self.date_msgs.contains_key(&date) {
-            let datemsg = DateMessage::new(font_size, window_scale, timest);
-            self.date_msgs.insert(date, datemsg);
-        }
-
-        self.date_msgs.get_mut(&date).unwrap()
-    }
-
-    pub fn oldest_timestamp(&self) -> Option<Timestamp> {
-        let last_msg = &self.msgs.last()?;
-        Some(last_msg.timestamp())
-    }
-
-    fn read_nick_colors(&self) -> Vec<Color> {
-        let mut colors = vec![];
-        let mut color = [0f32; 4];
-        for i in 0..self.nick_colors.get_len() {
-            color[i % 4] = self.nick_colors.get_f32(i).expect("prop logic err");
-
-            if i > 0 && i % 4 == 0 {
-                let color = std::mem::take(&mut color);
-                colors.push(color);
-            }
-        }
-        colors
-    }
-
-    pub async fn get_line(&mut self, rect: &Rectangle, y: f32) -> Option<(&mut Message, f32)> {
-        let line_height = self.line_height.get();
-        let msg_spacing = self.msg_spacing.get();
-        let timestamp_width = self.timestamp_width.get();
-        let text_color = self.text_color.get();
-        let action_text_color = self.action_text_color.get();
-        let url_text_color = self.url_text_color.get();
-        let nick_colors = self.read_nick_colors();
-
-        let msgs = self.msgs_with_date();
-        let mut msgs = pin!(msgs);
-
-        let mut current_pos = 0.;
-        while let Some(msg) = msgs.next().await {
-            // Messages can have their layout cache cleared at any time
-            // (e.g. by select/deselect), so make sure it exists before
-            // measuring, same as in calc_total_height().
-            msg.cache_txt_layout(
-                rect,
-                line_height,
-                timestamp_width,
-                &nick_colors,
-                text_color,
-                action_text_color,
-                url_text_color,
-            );
-            let mesh_height = msg.height(line_height);
-            let msg_bottom = current_pos;
-            let msg_top = current_pos + mesh_height + msg_spacing;
-
-            if msg_bottom <= y && y <= msg_top {
-                return Some((msg, msg_top))
-            }
-
-            current_pos += msg_spacing;
-            current_pos += mesh_height;
-        }
-
-        None
-    }
-
-    pub async fn url_at(&mut self, rect: &Rectangle, x: f32, y: f32) -> Option<String> {
-        let (msg, msg_top) = self.get_line(rect, y).await?;
-        msg.url_hit(Point::new(x, msg_top - y))
-    }
-
-    pub async fn select_line(&mut self, rect: &Rectangle, y: f32) {
-        if let Some((msg, _)) = self.get_line(rect, y).await {
-            // Do nothing
-            if msg.is_date() {
-                return
-            }
-
-            msg.select();
-
-            msg.clear_mesh();
-        }
-    }
-
-    pub async fn deselect_line(&mut self, rect: &Rectangle, y: f32) {
-        if let Some((msg, _)) = self.get_line(rect, y).await {
-            if msg.is_date() {
-                return
-            }
-
-            msg.deselect();
-
-            msg.clear_mesh();
-        }
-    }
-
-    pub async fn is_line_selected(&mut self, rect: &Rectangle, y: f32) -> bool {
-        if let Some((msg, _)) = self.get_line(rect, y).await {
-            if msg.is_date() {
-                return false
-            }
-            return msg.is_selected()
-        }
-        false
-    }
-
-    /// Whether any message is currently selected.
-    pub fn has_selection(&self) -> bool {
-        self.msgs.iter().any(|msg| msg.is_selected())
-    }
-
-    /// Deselect every selected message.
-    pub fn unselect_all(&mut self) {
-        for msg in &mut self.msgs {
-            if msg.is_selected() {
-                msg.deselect();
-                msg.clear_mesh();
-            }
-        }
-    }
-
-    /// Concatenated text of all selected messages, joined by newlines, in
-    /// display order. NOTICE messages contribute their body; privmsgs
-    /// contribute "<nick> <text>".
-    pub fn selected_text(&self) -> String {
-        let mut lines = vec![];
-        for msg in &self.msgs {
-            if let Message::Priv(p) = msg {
-                if p.is_selected {
-                    if p.nick == "NOTICE" {
-                        lines.push(p.text.clone());
-                    } else {
-                        lines.push(format!("{} {}", p.nick, p.text));
-                    }
-                }
-            }
-        }
-        lines.join("\n")
-    }
-
-    pub fn update_file_status(&mut self, url: &Url, status: &FileMessageStatus) {
-        for msg in &mut self.msgs {
-            if let Some(filemsg) = msg.get_filemsg_mut() {
-                if filemsg.file_url == *url {
-                    filemsg.set_status(status);
-                    filemsg.clear_mesh();
-                }
-            }
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    const TEXT_COLOR: Color = [1., 1., 1., 1.];
-    const ACTION_COLOR: Color = [0.5, 0.25, 0.75, 1.];
-    const URL_COLOR: Color = [0., 0.94, 1., 1.];
-    const NICK_COLORS: &[Color] = &[[1., 0., 0., 1.]];
-
-    fn make_priv(text: &str) -> Message {
-        PrivMessage::new(14., 10., 1., 0, MessageId([0; 32]), "alice".to_string(), text.to_string())
-    }
-
-    #[test]
-    fn ctcp_action_fully_framed() {
-        assert_eq!(parse_ctcp_action("\u{1}ACTION waves\u{1}"), Some("waves"));
-    }
-
-    #[test]
-    fn ctcp_action_missing_trailing_delimiter() {
-        assert_eq!(parse_ctcp_action("\u{1}ACTION waves"), Some("waves"));
-    }
-
-    #[test]
-    fn ctcp_action_empty_body() {
-        assert_eq!(parse_ctcp_action("\u{1}ACTION \u{1}"), Some(""));
-        assert_eq!(parse_ctcp_action("\u{1}ACTION "), Some(""));
-    }
-
-    #[test]
-    fn ctcp_action_not_an_action() {
-        assert_eq!(parse_ctcp_action("waves"), None);
-        assert_eq!(parse_ctcp_action("waves\u{1}"), None);
-        assert_eq!(parse_ctcp_action("see \u{1}ACTION waves\u{1} here"), None);
-        assert_eq!(parse_ctcp_action("\u{1}action waves\u{1}"), None);
-        assert_eq!(parse_ctcp_action("\u{1}PING\u{1}"), None);
-    }
-
-    #[test]
-    fn privmsg_action_detection() {
-        let Message::Priv(m) = make_priv("\u{1}ACTION waves\u{1}") else { panic!() };
-        assert!(m.is_action);
-        assert_eq!(m.text, "waves");
-
-        let Message::Priv(m) = make_priv("\u{1}ACTION waves") else { panic!() };
-        assert!(m.is_action);
-        assert_eq!(m.text, "waves");
-
-        let Message::Priv(m) = make_priv("/me waves") else { panic!() };
-        assert!(!m.is_action);
-        assert_eq!(m.text, "/me waves");
-    }
-
-    #[test]
-    fn action_line_text_and_body_offset() {
-        let Message::Priv(m) = make_priv("\u{1}ACTION waves\u{1}") else { panic!() };
-        assert_eq!(m.line_text(), "* alice waves");
-        assert_eq!(m.body_offset(), "alice".len() + 3);
-
-        let Message::Priv(m) = make_priv("waves") else { panic!() };
-        assert!(!m.is_action);
-        assert_eq!(m.line_text(), "alice waves");
-        assert_eq!(m.body_offset(), "alice".len() + 1);
-    }
-
-    #[test]
-    fn action_layout_colors() {
-        let mut msg = make_priv("\u{1}ACTION waves\u{1}");
-        let Message::Priv(m) = &mut msg else { panic!() };
-        m.cache_txt_layout(
-            &Rectangle::new(0., 0., 1000., 100.),
-            20.,
-            50.,
-            NICK_COLORS,
-            TEXT_COLOR,
-            ACTION_COLOR,
-            URL_COLOR,
-        );
-
-        // GlyphRun items are split per style, so brushes identify the
-        // colored ranges exactly: the "* <nick> " prefix uses the nick
-        // color and the action text uses action_text_color. No other
-        // brush may appear in a confirmed action line.
-        let layout = m.txt_layout.as_ref().unwrap();
-        let mut nick_brushes = 0;
-        let mut action_brushes = 0;
-        for line in layout.lines() {
-            for item in line.items() {
-                let parley::PositionedLayoutItem::GlyphRun(run) = item else { continue };
-                let brush = run.style().brush;
-                if brush == NICK_COLORS[0] {
-                    nick_brushes += 1;
-                } else if brush == ACTION_COLOR {
-                    action_brushes += 1;
-                } else {
-                    panic!("unexpected brush {brush:?}");
-                }
-            }
-        }
-        assert!(nick_brushes > 0);
-        assert!(action_brushes > 0);
-    }
-
-    #[test]
-    fn action_layout_url_color() {
-        let mut msg = make_priv("\u{1}ACTION see https://example.com now\u{1}");
-        let Message::Priv(m) = &mut msg else { panic!() };
-        m.cache_txt_layout(
-            &Rectangle::new(0., 0., 1000., 100.),
-            20.,
-            50.,
-            NICK_COLORS,
-            TEXT_COLOR,
-            ACTION_COLOR,
-            URL_COLOR,
-        );
-
-        let layout = m.txt_layout.as_ref().unwrap();
-        let mut url_brushes = 0;
-        let mut action_brushes = 0;
-        for line in layout.lines() {
-            for item in line.items() {
-                let parley::PositionedLayoutItem::GlyphRun(run) = item else { continue };
-                if run.style().brush == URL_COLOR {
-                    url_brushes += 1;
-                } else if run.style().brush == ACTION_COLOR {
-                    action_brushes += 1;
-                }
-            }
-        }
-        assert!(url_brushes > 0);
-        assert!(action_brushes > 0);
-    }
-}

+ 550 - 0
bin/app/src/ui/chatview/scroll.rs

@@ -0,0 +1,550 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! The chatview scroll controller: physics only.
+//!
+//! The position is internal state — a plain `f32` of px from the live
+//! content bottom (0 = bottom, growing up into history) — never a scene
+//! property. Recognition, slop, long-press timers, and velocity
+//! sampling belong to the gesture session; this controller consumes
+//! intents (drag lifecycle, `DragEnd` velocity, page ticks) and owns
+//! the motion: 1:1 drags, flick glide with exponential decay, eased
+//! page animations, clamping, height-change compensation, and
+//! anchor-based save/restore.
+
+use std::time::{Duration, Instant};
+
+use super::MessageId;
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview::scroll", $($arg)*); } }
+
+/// Release velocity below this (px/sec) is not a flick.
+const FLICK_MIN_VEL: f32 = 50.;
+/// Exponential glide decay rate (1/sec), matching the old chatview's
+/// 0.9-per-10ms resistance.
+const GLIDE_DECEL: f32 = 10.5;
+/// Glide stops when the velocity decays below this (px/sec).
+const GLIDE_STOP_VEL: f32 = 20.;
+/// Page animation duration (wheel ticks, PageUp/PageDown).
+const ANIM_MS: u64 = 250;
+/// scroll within this many px of 0 counts as at the live bottom.
+const BOTTOM_EPSILON: f32 = 0.5;
+/// Animator wake cadence for motion frames (glide and animation).
+const GLIDE_FRAME: Duration = Duration::from_millis(16);
+
+/// What is moving the content right now.
+#[derive(Debug, Clone, PartialEq)]
+pub enum ScrollState {
+    Idle,
+    /// 1:1 finger tracking; `scroll0` is the position at grab time.
+    Drag {
+        start_y: f32,
+        scroll0: f32,
+    },
+    /// Inertial glide, decaying from the release velocity (px/sec).
+    Glide {
+        velocity: f32,
+    },
+    /// Eased animation toward `to` (wheel/PageUp/PageDown).
+    Anim {
+        from: f32,
+        to: f32,
+        started: Instant,
+    },
+}
+
+/// Serialized "what the user is looking at", used only at
+/// save/restore boundaries (channel exit/entry, reflow). Runtime
+/// stability is compensation's job, never the anchor's.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Anchor {
+    /// The oldest visible message (top edge at/above the viewport
+    /// top). None = bottom (scroll == 0) or nothing visible.
+    pub msg: Option<MessageId>,
+    /// How far the viewport top sits below the anchor message's top,
+    /// in px: `dy = (scroll + view_h) - pos_of(msg)`. 0 = the message's
+    /// top is exactly at the viewport top.
+    pub dy: f32,
+}
+
+impl Anchor {
+    /// The bottom-pinned anchor.
+    pub fn bottom() -> Self {
+        Self { msg: None, dy: 0. }
+    }
+}
+
+/// The scroll controller. Geometry inputs (`view_h`, content height)
+/// are fed in by the chatview; the controller never reads the scene.
+#[derive(Debug, Clone)]
+pub struct ScrollController {
+    state: ScrollState,
+    /// Px from the live content bottom; 0 = live bottom.
+    scroll: f32,
+    /// `total_height - view_h`, maintained via [`Self::set_content`].
+    max_scroll: f32,
+    /// Timestamp of the last `tick`, for glide deltas.
+    last_tick: Instant,
+}
+
+impl Default for ScrollController {
+    fn default() -> Self {
+        Self { state: ScrollState::Idle, scroll: 0., max_scroll: 0., last_tick: Instant::now() }
+    }
+}
+
+impl ScrollController {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// The current position: px from the live content bottom.
+    pub fn scroll(&self) -> f32 {
+        self.scroll
+    }
+
+    /// The current motion state.
+    pub fn state(&self) -> &ScrollState {
+        &self.state
+    }
+
+    /// Whether the view sits at the live bottom (drives the
+    /// `is_at_bottom` scene property).
+    pub fn is_at_bottom(&self) -> bool {
+        self.scroll <= BOTTOM_EPSILON
+    }
+
+    /// Feed the content geometry: `total` is the loaded content height,
+    /// `view_h` the viewport height. Updates the clamp range and
+    /// re-clamps the current position (and any animation target).
+    pub fn set_content(&mut self, total: f32, view_h: f32) {
+        self.max_scroll = (total - view_h).max(0.);
+        self.scroll = self.clamp(self.scroll);
+        if let ScrollState::Anim { to, .. } = &mut self.state {
+            let clamped = self.max_scroll.min(*to).max(0.);
+            *to = clamped;
+        }
+    }
+
+    /// Clamp a requested position into `[0, max_scroll]`.
+    pub fn clamp(&self, scroll: f32) -> f32 {
+        scroll.clamp(0., self.max_scroll)
+    }
+
+    /// A touch began over the view (gesture session `Down`): cancel any
+    /// in-flight glide or animation immediately, before the touch even
+    /// travels past the slop. No drag state is entered — that needs
+    /// `drag_start`.
+    pub fn touch_down(&mut self) {
+        if self.state != ScrollState::Idle {
+            t!("touch_down cancels {:?}", self.state);
+        }
+        self.state = ScrollState::Idle;
+        self.last_tick = Instant::now();
+    }
+
+    /// The drag travelled past the slop: grab the content 1:1, killing
+    /// any in-flight motion.
+    pub fn drag_start(&mut self, y: f32) {
+        t!("drag_start(y={y}) from scroll={}", self.scroll);
+        self.state = ScrollState::Drag { start_y: y, scroll0: self.scroll };
+        self.last_tick = Instant::now();
+    }
+
+    /// 1:1 drag tracking. Chat scroll grows back into history, so the
+    /// finger offset adds: `scroll = scroll0 + dy`. Returns the applied
+    /// (clamped) position.
+    pub fn drag_move(&mut self, y: f32) -> f32 {
+        let ScrollState::Drag { start_y, scroll0 } = self.state else { return self.scroll };
+        self.scroll = self.clamp(scroll0 + (y - start_y));
+        self.scroll
+    }
+
+    /// The drag ended. `velocity` is the session's `DragEnd` velocity
+    /// (px/sec on the chat axis); above the flick threshold it becomes
+    /// a decaying glide, otherwise the content just stays put.
+    pub fn drag_end(&mut self, velocity: f32) {
+        if matches!(self.state, ScrollState::Drag { .. }) {
+            self.state = ScrollState::Idle;
+        }
+        if velocity.abs() >= FLICK_MIN_VEL {
+            t!("drag_end flick velocity={velocity}");
+            self.state = ScrollState::Glide { velocity };
+            self.last_tick = Instant::now();
+        }
+    }
+
+    /// Wheel tick / PageUp / PageDown: animate `page` px in `dir`
+    /// (+1 into history, -1 toward the bottom) with easing. Repeated
+    /// ticks coalesce — the target extends from the in-flight target,
+    /// never accumulating velocity.
+    pub fn page_tick(&mut self, dir: f32, page: f32) {
+        let base = match self.state {
+            ScrollState::Anim { to, .. } => to,
+            _ => self.scroll,
+        };
+        let to = self.clamp(base + dir * page);
+        t!("page_tick(dir={dir}) target {to}");
+        self.state = ScrollState::Anim { from: self.scroll, to, started: Instant::now() };
+    }
+
+    /// The down-arrow: teleport to the live bottom, cancel all motion.
+    pub fn scroll_to_bottom(&mut self) {
+        t!("scroll_to_bottom from {}", self.scroll);
+        self.state = ScrollState::Idle;
+        self.scroll = 0.;
+    }
+
+    /// Height-change compensation, applied by the chatview after the
+    /// buffer reports a delta. When a message entirely below the
+    /// viewport changed height and the view is not bottom-pinned, the
+    /// position shifts by the delta so the viewed content stays put.
+    pub fn compensate(&mut self, delta: f32, msg_below_viewport: bool) {
+        if msg_below_viewport && self.scroll > 0. {
+            self.scroll = self.clamp(self.scroll + delta);
+        }
+    }
+
+    /// Animator advance: step Glide/Anim up to `now`, applying the
+    /// frame's scroll internally. Returns the position when motion
+    /// advanced this frame (including the frame that finishes it), and
+    /// None when there was nothing to animate.
+    pub fn tick(&mut self, now: Instant) -> Option<f32> {
+        let dt = now.saturating_duration_since(self.last_tick).as_secs_f32();
+        self.last_tick = now;
+
+        match self.state.clone() {
+            ScrollState::Glide { mut velocity } => {
+                if dt <= 0. {
+                    return None
+                }
+                velocity *= (-GLIDE_DECEL * dt).exp();
+                self.scroll = self.clamp(self.scroll + velocity * dt);
+                let hit_edge = self.scroll <= 0. || self.scroll >= self.max_scroll;
+                if velocity.abs() < GLIDE_STOP_VEL || hit_edge {
+                    t!("glide stopped at {}", self.scroll);
+                    self.state = ScrollState::Idle;
+                } else {
+                    self.state = ScrollState::Glide { velocity };
+                }
+                Some(self.scroll)
+            }
+            ScrollState::Anim { from, to, started } => {
+                let anim_secs = ANIM_MS as f32 / 1000.;
+                let t = (now.saturating_duration_since(started).as_secs_f32() / anim_secs).min(1.);
+                if t >= 1. {
+                    self.scroll = to;
+                    self.state = ScrollState::Idle;
+                } else {
+                    // Ease-out cubic.
+                    let eased = 1. - (1. - t).powi(3);
+                    self.scroll = from + (to - from) * eased;
+                }
+                Some(self.scroll)
+            }
+            ScrollState::Idle | ScrollState::Drag { .. } => None,
+        }
+    }
+
+    /// When the animator task should wake next: on the next frame of
+    /// the motion cadence. The deadline is a frame step (not the
+    /// animation's end) so eased intermediate positions actually render;
+    /// None when nothing moves.
+    pub fn next_deadline(&self, now: Instant) -> Option<Instant> {
+        match &self.state {
+            ScrollState::Idle | ScrollState::Drag { .. } => None,
+            ScrollState::Glide { .. } | ScrollState::Anim { .. } => Some(now + GLIDE_FRAME),
+        }
+    }
+
+    /// Snapshot the current view position. `anchor_msg` is the oldest
+    /// visible message (the chatview derives it from the buffer's
+    /// visible window); `pos_of` resolves a message id to the px offset
+    /// of its top edge from the content bottom.
+    pub fn anchor(
+        &self,
+        view_h: f32,
+        anchor_msg: Option<&MessageId>,
+        mut pos_of: impl FnMut(&MessageId) -> Option<f32>,
+    ) -> Anchor {
+        if self.scroll <= BOTTOM_EPSILON {
+            return Anchor::bottom()
+        }
+        let Some(msg) = anchor_msg else { return Anchor::bottom() };
+        let Some(pos) = pos_of(msg) else { return Anchor::bottom() };
+        Anchor { msg: Some(*msg), dy: (self.scroll + view_h) - pos }
+    }
+
+    /// Resolve an anchor against current geometry: the same content
+    /// reappears at the same place. A message that no longer resolves
+    /// falls back to the current position clamped — explicit, logged,
+    /// never a crash. Returns the applied position.
+    pub fn restore(
+        &mut self,
+        anchor: &Anchor,
+        view_h: f32,
+        mut pos_of: impl FnMut(&MessageId) -> Option<f32>,
+    ) -> f32 {
+        self.state = ScrollState::Idle;
+        self.scroll = match &anchor.msg {
+            None => 0.,
+            Some(msg) => match pos_of(msg) {
+                Some(pos) => self.clamp(pos + anchor.dy - view_h),
+                None => {
+                    t!("restore: anchor message {msg} not resolvable, clamping current position");
+                    self.clamp(self.scroll)
+                }
+            },
+        };
+        self.scroll
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn stepped_ticks(c: &mut ScrollController, step_ms: u64, frames: usize) {
+        let mut now = Instant::now();
+        for _ in 0..frames {
+            now += Duration::from_millis(step_ms);
+            c.tick(now);
+        }
+    }
+
+    #[test]
+    fn drag_tracks_one_to_one() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        c.drag_start(100.);
+        assert_eq!(c.drag_move(150.), 50.);
+        assert_eq!(c.drag_move(90.), 0.);
+        // Finger down the screen drags the content back into history.
+        c.drag_start(100.);
+        assert_eq!(c.drag_move(300.), 200.);
+        assert_eq!(c.drag_move(5_000.), 4_900.);
+        // Clamped at the top of loaded content.
+        assert_eq!(c.drag_move(20_000.), 9_500.);
+    }
+
+    #[test]
+    fn grab_cancels_motion() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        c.page_tick(1., 200.);
+        assert!(matches!(c.state(), ScrollState::Anim { .. }));
+        c.drag_start(0.);
+        assert_eq!(*c.state(), ScrollState::Drag { start_y: 0., scroll0: 0. });
+
+        // A bare touch (no slop yet) also stops glides.
+        c.drag_end(2_000.);
+        assert!(matches!(c.state(), ScrollState::Glide { .. }));
+        c.touch_down();
+        assert_eq!(*c.state(), ScrollState::Idle);
+    }
+
+    #[test]
+    fn wheel_coalescing_extends_target() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+
+        c.page_tick(1., 100.);
+        let ScrollState::Anim { to, .. } = c.state() else { panic!() };
+        assert_eq!(*to, 100.);
+
+        // A second tick before finishing extends from the target.
+        c.page_tick(1., 100.);
+        let ScrollState::Anim { to, .. } = c.state() else { panic!() };
+        assert_eq!(*to, 200.);
+
+        // Downward ticks reverse from the current target.
+        c.page_tick(-1., 150.);
+        let ScrollState::Anim { to, .. } = c.state() else { panic!() };
+        assert_eq!(*to, 50.);
+
+        // Targets stay clamped.
+        c.page_tick(1., 100_000.);
+        let ScrollState::Anim { to, .. } = c.state() else { panic!() };
+        assert_eq!(*to, 9_500.);
+    }
+
+    #[test]
+    fn anim_completes_at_target() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        c.page_tick(1., 300.);
+        let start = Instant::now();
+        let mid = start + Duration::from_millis(ANIM_MS / 2);
+        let half = c.tick(mid).expect("mid-anim frame");
+        assert!(half > 0. && half < 300., "eased halfway: {half}");
+
+        let end = start + Duration::from_millis(ANIM_MS + 20);
+        let done = c.tick(end).expect("final frame");
+        assert_eq!(done, 300.);
+        assert_eq!(*c.state(), ScrollState::Idle);
+        assert_eq!(c.tick(end + Duration::from_millis(50)), None);
+    }
+
+    #[test]
+    fn flick_decays_to_stop_within_range() {
+        let mut c = ScrollController::new();
+        c.set_content(100_000., 500.);
+        c.drag_start(0.);
+        c.drag_move(0.);
+        c.drag_end(1_500.);
+        stepped_ticks(&mut c, 16, 200);
+        assert_eq!(*c.state(), ScrollState::Idle, "decayed to a stop");
+        assert!(c.scroll() > 0., "glided into history: {}", c.scroll());
+        assert!(c.scroll() <= c.max_scroll);
+
+        // Downward flick from near the bottom clamps hard at 0.
+        let mut c = ScrollController::new();
+        c.set_content(100_000., 500.);
+        c.drag_start(0.);
+        c.drag_move(300.);
+        c.drag_end(-5_000.);
+        stepped_ticks(&mut c, 16, 200);
+        assert_eq!(c.scroll(), 0.);
+        assert_eq!(*c.state(), ScrollState::Idle);
+        assert!(c.is_at_bottom());
+    }
+
+    #[test]
+    fn small_release_velocity_is_not_a_flick() {
+        let mut c = ScrollController::new();
+        c.set_content(100_000., 500.);
+        c.drag_start(0.);
+        c.drag_move(800.);
+        c.drag_end(30.);
+        assert_eq!(*c.state(), ScrollState::Idle);
+        assert_eq!(c.scroll(), 800.);
+    }
+
+    #[test]
+    fn scroll_to_bottom_cancels_everything() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        c.page_tick(1., 2_000.);
+        c.scroll_to_bottom();
+        assert_eq!(c.scroll(), 0.);
+        assert_eq!(*c.state(), ScrollState::Idle);
+        assert!(c.is_at_bottom());
+        assert_eq!(c.tick(Instant::now() + Duration::from_millis(100)), None);
+    }
+
+    #[test]
+    fn animator_wakes_at_frame_cadence_during_anim() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        assert_eq!(c.next_deadline(Instant::now()), None);
+
+        // The deadline is a frame step, not the animation's end, so
+        // intermediate eased positions actually render.
+        c.page_tick(1., 100.);
+        let now = Instant::now();
+        let deadline = c.next_deadline(now).unwrap();
+        assert!(deadline <= now + Duration::from_millis(20), "deadline {deadline:?}");
+    }
+
+    #[test]
+    fn set_content_reclamps() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        c.drag_start(0.);
+        c.drag_move(5_000.);
+        // Content shrank (e.g. filter reload).
+        c.set_content(2_000., 500.);
+        assert_eq!(c.scroll(), 1_500.);
+        assert_eq!(c.max_scroll, 1_500.);
+        // Content shorter than the viewport pins to 0.
+        c.set_content(200., 500.);
+        assert_eq!(c.scroll(), 0.);
+        assert!(c.is_at_bottom());
+    }
+
+    #[test]
+    fn compensation_applies_only_below_viewport_unpinned() {
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        c.drag_start(0.);
+        c.drag_move(1_000.);
+
+        c.compensate(50., true);
+        assert_eq!(c.scroll(), 1_050.);
+        c.compensate(-20., true);
+        assert_eq!(c.scroll(), 1_030.);
+        // In-viewport changes never move the position.
+        c.compensate(500., false);
+        assert_eq!(c.scroll(), 1_030.);
+        // Bottom-pinned views auto-follow instead.
+        c.scroll_to_bottom();
+        c.compensate(50., true);
+        assert_eq!(c.scroll(), 0.);
+    }
+
+    #[test]
+    fn anchor_round_trip_survives_shifts() {
+        // Message M's top sits at px 700; viewport [400, 900).
+        fn pos_of(positions: &[(MessageId, f32)]) -> impl FnMut(&MessageId) -> Option<f32> + '_ {
+            move |id: &MessageId| positions.iter().find(|(i, _)| i == id).map(|(_, p)| *p)
+        }
+        let mid = MessageId([7; 32]);
+        let mut positions = vec![(mid, 700.)];
+
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        c.drag_start(0.);
+        c.drag_move(400.);
+        assert_eq!(c.scroll(), 400.);
+
+        let anchor = c.anchor(500., Some(&mid), pos_of(&positions));
+        assert_eq!(anchor.msg, Some(mid));
+        assert_eq!(anchor.dy, 200.);
+
+        // Older messages backfilled above M shift its position up.
+        positions[0].1 = 750.;
+        c.restore(&anchor, 500., pos_of(&positions));
+        assert_eq!(c.scroll(), 450.);
+
+        // Newer messages arriving below shift it further.
+        positions[0].1 = 850.;
+        c.restore(&anchor, 500., pos_of(&positions));
+        assert_eq!(c.scroll(), 550.);
+    }
+
+    #[test]
+    fn anchor_bottom_and_missing_fallbacks() {
+        let pos_of = |id: &MessageId| (id.0[0] == 7).then_some(700_f32);
+
+        let mut c = ScrollController::new();
+        c.set_content(10_000., 500.);
+        // Bottom-pinned: the anchor is the bottom shortcut.
+        let anchor = c.anchor(500., Some(&MessageId([7; 32])), pos_of);
+        assert_eq!(anchor, Anchor::bottom());
+        c.restore(&anchor, 500., pos_of);
+        assert_eq!(c.scroll(), 0.);
+        assert!(c.is_at_bottom());
+
+        // An anchor whose message vanished clamps, never panics.
+        c.drag_start(0.);
+        c.drag_move(2_000.);
+        let gone = Anchor { msg: Some(MessageId([9; 32])), dy: 10. };
+        c.restore(&gone, 500., pos_of);
+        assert_eq!(c.scroll(), 2_000.);
+    }
+}

+ 6 - 1
bin/app/src/ui/edit/mod.rs

@@ -1617,12 +1617,17 @@ impl UIObject for BaseEdit {
         async fn redraw(self_: Arc<BaseEdit>, _batch: BatchGuardPtr) {
             self_.redraw.trigger();
         }
+        async fn rect_changed(self_: Arc<BaseEdit>, _batch: BatchGuardPtr) {
+            // A width change can crop scrolled content; re-clamp.
+            self_.reset_scroll();
+            self_.redraw.trigger();
+        }
         async fn set_text(self_: Arc<BaseEdit>, _batch: BatchGuardPtr) {
             self_.editor.lock().on_text_prop_changed();
             self_.redraw.trigger();
         }
 
-        on_modify.when_change_external(self.rect.prop(), redraw);
+        on_modify.when_change_external(self.rect.prop(), rect_changed);
         on_modify.when_change_external(self.baseline.prop(), redraw);
         on_modify.when_change_external(self.lineheight.prop(), redraw);
         on_modify.when_change_external(self.select_ascent.prop(), redraw);

+ 8 - 2
bin/app/src/ui/mod.rs

@@ -351,7 +351,6 @@ pub fn get_ui_object_ptr(node: &SceneNode3) -> Arc<dyn UIObject + Send> {
         Pimpl::Text(obj) => obj.clone(),
         Pimpl::TextScramble(obj) => obj.clone(),
         Pimpl::Edit(obj) => obj.clone(),
-        Pimpl::ChatView(obj) => obj.clone(),
         Pimpl::Image(obj) => obj.clone(),
         Pimpl::Video(obj) => obj.clone(),
         Pimpl::Button(obj) => obj.clone(),
@@ -359,6 +358,10 @@ pub fn get_ui_object_ptr(node: &SceneNode3) -> Arc<dyn UIObject + Send> {
         Pimpl::Shortcut(obj) => obj.clone(),
         Pimpl::Menu(obj) => obj.clone(),
         Pimpl::TokenTable(obj) => obj.clone(),
+        Pimpl::ChatView(obj) => obj.clone(),
+        Pimpl::PrivMsgNode(obj) => obj.clone(),
+        Pimpl::DateMsgNode(obj) => obj.clone(),
+        Pimpl::FileMsgNode(obj) => obj.clone(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }
@@ -370,7 +373,6 @@ pub fn get_ui_object3<'a>(node: &'a SceneNode3) -> &'a dyn UIObject {
         Pimpl::Text(obj) => obj.as_ref(),
         Pimpl::TextScramble(obj) => obj.as_ref(),
         Pimpl::Edit(obj) => obj.as_ref(),
-        Pimpl::ChatView(obj) => obj.as_ref(),
         Pimpl::Image(obj) => obj.as_ref(),
         Pimpl::Video(obj) => obj.as_ref(),
         Pimpl::Button(obj) => obj.as_ref(),
@@ -378,6 +380,10 @@ pub fn get_ui_object3<'a>(node: &'a SceneNode3) -> &'a dyn UIObject {
         Pimpl::Shortcut(obj) => obj.as_ref(),
         Pimpl::Menu(obj) => obj.as_ref(),
         Pimpl::TokenTable(obj) => obj.as_ref(),
+        Pimpl::ChatView(obj) => obj.as_ref(),
+        Pimpl::PrivMsgNode(obj) => obj.as_ref(),
+        Pimpl::DateMsgNode(obj) => obj.as_ref(),
+        Pimpl::FileMsgNode(obj) => obj.as_ref(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }

+ 449 - 0
bin/app/src/util/fenwick.rs

@@ -0,0 +1,449 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+//! A Fenwick tree (binary indexed tree) over `f32` values.
+//!
+//! Answers prefix-sum questions over mutable heights in O(log n):
+//! total sums, the item containing a given cumulative offset
+//! (`lower_bound`), and point updates. The internal layout is the
+//! classic implicit one: a flat 1-indexed `Vec<f32>` where node `i`
+//! holds the partial sum of the `i & -i` items ending at `i`.
+
+/// Fenwick tree over non-negative `f32` summands.
+///
+/// All public indices are 0-based item positions.
+#[derive(Debug)]
+pub struct Fenwick {
+    /// Partial sums, 1-indexed; node `i` covers the `(i & -i)` items ending at `i`.
+    /// Slot 0 is unused padding.
+    tree: Vec<f32>,
+    /// Number of items.
+    len: usize,
+}
+
+impl Fenwick {
+    /// Build from item values in display order (O(n)).
+    pub fn new(vals: &[f32]) -> Self {
+        let mut fenwick = Self { tree: Vec::with_capacity(vals.len() + 1), len: vals.len() };
+        fenwick.build(vals);
+        fenwick
+    }
+
+    /// The empty tree.
+    pub fn empty() -> Self {
+        Self { tree: vec![0.], len: 0 }
+    }
+
+    /// Append an item (O(log n)) — the live-arrival hot path.
+    pub fn push(&mut self, val: f32) {
+        let i = self.len + 1;
+        self.tree.push(val);
+        let lowbit = i & i.wrapping_neg();
+        // The new node must hold the sum of the lowbit items ending at
+        // i: the appended value plus the items already covered by the
+        // nodes directly below it.
+        let below = self.prefix(i - 1) - self.prefix(i - lowbit);
+        self.tree[i] = val + below;
+        self.len = i;
+    }
+
+    /// Current value at item `idx` (O(log n)).
+    ///
+    /// ## Panics
+    ///
+    /// If `idx` is out of bounds.
+    pub fn get(&self, idx: usize) -> f32 {
+        self.assert_index(idx);
+        self.prefix(idx + 1) - self.prefix(idx)
+    }
+
+    /// Add `delta` to the item at `idx` (O(log n)) — height changes.
+    ///
+    /// ## Panics
+    ///
+    /// If `idx` is out of bounds.
+    pub fn add(&mut self, idx: usize, delta: f32) {
+        self.assert_index(idx);
+        let mut i = idx + 1;
+        while i <= self.len {
+            self.tree[i] += delta;
+            i += i & i.wrapping_neg();
+        }
+    }
+
+    /// Overwrite the item at `idx` with `val` (O(log n)).
+    ///
+    /// ## Panics
+    ///
+    /// If `idx` is out of bounds.
+    pub fn set(&mut self, idx: usize, val: f32) {
+        let delta = val - self.get(idx);
+        self.add(idx, delta);
+    }
+
+    /// Sum of items `[0, idx)` (O(log n)) — `total_height`, `pos_of`.
+    pub fn prefix(&self, idx: usize) -> f32 {
+        let mut i = idx.min(self.len);
+        let mut sum = 0.;
+        while i > 0 {
+            sum += self.tree[i];
+            i -= i & i.wrapping_neg();
+        }
+        sum
+    }
+
+    /// Sum of items `[from, to)` (O(log n)).
+    pub fn range(&self, from: usize, to: usize) -> f32 {
+        let from = from.min(self.len);
+        let to = to.min(self.len);
+        if from >= to {
+            return 0.
+        }
+        self.prefix(to) - self.prefix(from)
+    }
+
+    /// The first item index whose cumulative sum (items `0..=idx`)
+    /// exceeds `target` (O(log n)) — px-from-bottom position → item
+    /// lookup. Returns `len` when `target` is at or past the total sum.
+    pub fn lower_bound(&self, target: f32) -> usize {
+        if self.len == 0 || target < 0. {
+            return 0
+        }
+
+        let mut pw = 1usize << self.len.ilog2();
+        let mut pos = 0usize;
+        let mut rem = target;
+        while pw != 0 {
+            let next = pos + pw;
+            if next <= self.len && self.tree[next] <= rem {
+                pos = next;
+                rem -= self.tree[next];
+            }
+            pw >>= 1;
+        }
+
+        // pos is the largest 1-based index with prefix(pos) <= target,
+        // so item `pos` (0-based) is the first whose cumulative sum
+        // exceeds the target.
+        pos
+    }
+
+    /// The first item index whose cumulative sum of the items BEFORE it
+    /// (`prefix(idx)`) is at least `target` (O(log n)) — the exclusive
+    /// end of the item window intersecting a given cumulative offset.
+    /// Returns `len` when `target` is at or past the total sum.
+    pub fn lower_bound_prefix(&self, target: f32) -> usize {
+        if self.len == 0 || target <= 0. {
+            return 0
+        }
+
+        let mut pw = 1usize << self.len.ilog2();
+        let mut pos = 0usize;
+        let mut rem = target;
+        while pw != 0 {
+            let next = pos + pw;
+            if next <= self.len && self.tree[next] < rem {
+                pos = next;
+                rem -= self.tree[next];
+            }
+            pw >>= 1;
+        }
+
+        // pos is the largest index with prefix(pos) < target; the first
+        // index at-or-after the target follows. Clamped to len when the
+        // target exceeds the total sum (no such index exists).
+        let res = pos + 1;
+        if res > self.len {
+            self.len
+        } else {
+            res
+        }
+    }
+
+    /// Full rebuild from new item values (O(n)) — structural batches.
+    pub fn rebuild(&mut self, vals: &[f32]) {
+        self.len = vals.len();
+        self.build(vals);
+    }
+
+    /// Number of items.
+    pub fn len(&self) -> usize {
+        self.len
+    }
+
+    /// Whether the tree holds no items.
+    pub fn is_empty(&self) -> bool {
+        self.len == 0
+    }
+
+    /// O(n) in-place construction from item values.
+    fn build(&mut self, vals: &[f32]) {
+        self.tree.clear();
+        self.tree.resize(vals.len() + 1, 0.);
+        for (i, val) in vals.iter().enumerate() {
+            let i = i + 1;
+            self.tree[i] += val;
+            let parent = i + (i & i.wrapping_neg());
+            if parent <= vals.len() {
+                self.tree[parent] += self.tree[i];
+            }
+        }
+    }
+
+    fn assert_index(&self, idx: usize) {
+        assert!(idx < self.len, "fenwick index {idx} out of bounds (len {})", self.len);
+    }
+}
+
+impl Default for Fenwick {
+    fn default() -> Self {
+        Self::empty()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use rand::{rngs::StdRng, Rng, SeedableRng};
+
+    /// Brute-force reference: the first idx whose cumulative sum exceeds target.
+    fn brute_lower_bound(vals: &[f32], target: f32) -> usize {
+        let mut sum = 0.;
+        for (idx, val) in vals.iter().enumerate() {
+            sum += val;
+            if sum > target {
+                return idx
+            }
+        }
+        vals.len()
+    }
+
+    /// Brute-force reference: the first idx whose before-it sum
+    /// (prefix) is at least the target.
+    fn brute_lower_bound_prefix(vals: &[f32], target: f32) -> usize {
+        let mut sum = 0.;
+        for idx in 0..=vals.len() {
+            if sum >= target {
+                return idx
+            }
+            if idx < vals.len() {
+                sum += vals[idx];
+            }
+        }
+        vals.len()
+    }
+
+    /// Random item values are integer-valued floats: their sums are
+    /// exact in f32 at these magnitudes, so tree accumulations in any
+    /// order match brute force exactly and lower_bound boundaries are
+    /// stable. (Fractional drift is a separate, accepted concern noted
+    /// in the design; it is not observable with exact arithmetic.)
+    fn gen_val(rng: &mut StdRng) -> f32 {
+        rng.gen_range(0..1000) as f32
+    }
+
+    /// Check every Fenwick query against the brute-force reference.
+    fn check_all(fenwick: &Fenwick, vals: &[f32]) {
+        assert_eq!(fenwick.len(), vals.len());
+        assert_eq!(fenwick.is_empty(), vals.is_empty());
+
+        let mut brute = 0.;
+        for i in 0..=vals.len() {
+            assert!((fenwick.prefix(i) - brute).abs() < 1e-3, "prefix({i})");
+            brute += vals.get(i).copied().unwrap_or(0.);
+        }
+
+        for i in 0..vals.len() {
+            assert!((fenwick.get(i) - vals[i]).abs() < 1e-3, "get({i})");
+        }
+
+        for from in 0..=vals.len() {
+            for to in from..=vals.len() {
+                let mut brute = 0.;
+                for val in &vals[from..to] {
+                    brute += val;
+                }
+                assert!((fenwick.range(from, to) - brute).abs() < 1e-3, "range({from},{to})");
+            }
+        }
+
+        let mut total = 0.;
+        for val in vals {
+            total += val;
+        }
+        let mut targets = vec![0., total, total - 0.5, total + 1., -1.];
+        let mut acc = 0.;
+        for val in vals {
+            targets.push(acc);
+            targets.push(acc + val / 2.);
+            targets.push(acc + val);
+            acc += val;
+        }
+        for target in targets {
+            assert_eq!(
+                fenwick.lower_bound(target),
+                brute_lower_bound(vals, target),
+                "lower_bound({target})"
+            );
+            assert_eq!(
+                fenwick.lower_bound_prefix(target),
+                brute_lower_bound_prefix(vals, target),
+                "lower_bound_prefix({target})"
+            );
+        }
+    }
+
+    #[test]
+    fn empty_tree() {
+        let fenwick = Fenwick::new(&[]);
+        assert!(fenwick.is_empty());
+        assert_eq!(fenwick.prefix(0), 0.);
+        assert_eq!(fenwick.prefix(10), 0.);
+        assert_eq!(fenwick.lower_bound(0.), 0);
+        assert_eq!(fenwick.range(0, 0), 0.);
+    }
+
+    #[test]
+    fn single_item() {
+        let mut fenwick = Fenwick::new(&[42.]);
+        assert_eq!(fenwick.len(), 1);
+        assert_eq!(fenwick.prefix(0), 0.);
+        assert_eq!(fenwick.prefix(1), 42.);
+        assert_eq!(fenwick.get(0), 42.);
+        assert_eq!(fenwick.lower_bound(0.), 0);
+        assert_eq!(fenwick.lower_bound(41.9), 0);
+        assert_eq!(fenwick.lower_bound(42.), 1);
+        fenwick.set(0, 7.);
+        assert_eq!(fenwick.prefix(1), 7.);
+        check_all(&fenwick, &[7.]);
+    }
+
+    #[test]
+    fn zero_height_items() {
+        let vals = [0., 0., 5., 0., 3.];
+        let fenwick = Fenwick::new(&vals);
+        check_all(&fenwick, &vals);
+        assert_eq!(fenwick.lower_bound(0.), 2);
+        assert_eq!(fenwick.lower_bound(4.9), 2);
+        assert_eq!(fenwick.lower_bound(5.), 4);
+    }
+
+    #[test]
+    fn push_matches_build() {
+        let mut rng = StdRng::seed_from_u64(0xC0FFEE);
+        for _ in 0..20 {
+            let n = rng.gen_range(1..200);
+            let mut vals = vec![];
+            for _ in 0..n {
+                vals.push(gen_val(&mut rng));
+            }
+            let built = Fenwick::new(&vals);
+            let mut pushed = Fenwick::empty();
+            for val in &vals {
+                pushed.push(*val);
+            }
+            assert_eq!(built.len(), pushed.len());
+            for i in 0..=n {
+                assert!((built.prefix(i) - pushed.prefix(i)).abs() < 1e-3, "prefix({i})");
+            }
+            check_all(&pushed, &vals);
+        }
+    }
+
+    #[test]
+    fn randomized_operations() {
+        let mut rng = StdRng::seed_from_u64(0xBADF00D);
+        for _ in 0..30 {
+            let n = rng.gen_range(0..300);
+            let mut vals = vec![];
+            for _ in 0..n {
+                vals.push(gen_val(&mut rng));
+            }
+            let mut fenwick = Fenwick::new(&vals);
+            check_all(&fenwick, &vals);
+
+            for _ in 0..500 {
+                match rng.gen_range(0..5) {
+                    0 => {
+                        let val = gen_val(&mut rng);
+                        vals.push(val);
+                        fenwick.push(val);
+                    }
+                    1 => {
+                        if vals.is_empty() {
+                            continue
+                        }
+                        let idx = rng.gen_range(0..vals.len());
+                        let delta = rng.gen_range(-500..500) as f32;
+                        // Heights stay non-negative, so clamp shrinking deltas.
+                        let applied = delta.max(-vals[idx]);
+                        vals[idx] += applied;
+                        fenwick.add(idx, applied);
+                    }
+                    2 => {
+                        if vals.is_empty() {
+                            continue
+                        }
+                        let idx = rng.gen_range(0..vals.len());
+                        let val = gen_val(&mut rng);
+                        vals[idx] = val;
+                        fenwick.set(idx, val);
+                    }
+                    3 => {
+                        let n = rng.gen_range(0..100);
+                        vals.clear();
+                        for _ in 0..n {
+                            vals.push(gen_val(&mut rng));
+                        }
+                        fenwick.rebuild(&vals);
+                    }
+                    _ => {
+                        check_all(&fenwick, &vals);
+                    }
+                }
+            }
+
+            check_all(&fenwick, &vals);
+        }
+    }
+
+    #[test]
+    fn rebuild_replaces_everything() {
+        let mut fenwick = Fenwick::new(&[100., 200., 300.]);
+        fenwick.rebuild(&[1., 2.]);
+        assert_eq!(fenwick.len(), 2);
+        check_all(&fenwick, &[1., 2.]);
+        fenwick.rebuild(&[]);
+        assert!(fenwick.is_empty());
+        check_all(&fenwick, &[]);
+    }
+
+    #[test]
+    #[should_panic(expected = "out of bounds")]
+    fn get_out_of_bounds_panics() {
+        let fenwick = Fenwick::new(&[1.]);
+        fenwick.get(1);
+    }
+
+    #[test]
+    #[should_panic(expected = "out of bounds")]
+    fn add_out_of_bounds_panics() {
+        let mut fenwick = Fenwick::new(&[1.]);
+        fenwick.add(5, 1.);
+    }
+}

+ 1 - 0
bin/app/src/util/mod.rs

@@ -21,6 +21,7 @@ use colored::Colorize;
 use std::time::{SystemTime, UNIX_EPOCH};
 
 pub mod clipboard;
+pub mod fenwick;
 pub mod i18n;
 mod rt;
 pub use rt::{AsyncRuntime, ExecutorPtr};

+ 0 - 0
openspec/changes/app-chatview/.openspec.yaml → openspec/changes/archive/2026-09-04-app-chatview/.openspec.yaml


+ 0 - 0
openspec/changes/app-chatview/design.md → openspec/changes/archive/2026-09-04-app-chatview/design.md


+ 0 - 0
openspec/changes/app-chatview/proposal.md → openspec/changes/archive/2026-09-04-app-chatview/proposal.md


+ 0 - 0
openspec/changes/app-chatview/specs/chatview/spec.md → openspec/changes/archive/2026-09-04-app-chatview/specs/chatview/spec.md


+ 40 - 40
openspec/changes/app-chatview/tasks.md → openspec/changes/archive/2026-09-04-app-chatview/tasks.md

@@ -10,46 +10,46 @@ design.md → Development Protocol.
 
 ## 1. Fenwick tree util
 
-- [ ] 1.1 Implement `src/util/fenwick.rs` (new, get, add, set, push,
+- [x] 1.1 Implement `src/util/fenwick.rs` (new, get, add, set, push,
   prefix, range, lower_bound, rebuild) with randomized unit tests
   comparing every operation against brute-force sums over a reference
   `Vec<f32>`; verify `cargo test` in bin/app passes
-- [ ] 1.2 Gate: review tests + implementation with the owner, apply
+- [x] 1.2 Gate: review tests + implementation with the owner, apply
   amendments, commit as one atomic unit
 
 ## 2. Buffer: records and ordering
 
-- [ ] 2.1 Implement `src/ui/chatview2/buffer.rs` record store: slotmap
+- [x] 2.1 Implement `src/ui/chatview2/buffer.rs` record store: slotmap
   arena, order index sorted by `(timestamp, msg_id)`, dedup set, removal
   by id; verify unit tests pass: insert at any position, duplicate
   insert ignored, same-millisecond coexistence, removal, ordered
   iteration
-- [ ] 2.2 Gate: review + amendments + atomic commit
+- [x] 2.2 Gate: review + amendments + atomic commit
 
 ## 3. Buffer: geometry
 
-- [ ] 3.1 Wire the Fenwick tree into the buffer: `total_height`,
+- [x] 3.1 Wire the Fenwick tree into the buffer: `total_height`,
   `visible_range(scroll, view_h)`, `pos_of(msg_id)`, `set_height`
   point-updates, `insert_batch` single-rebuild, plus the
   below-viewport compensation math helper; verify randomized unit tests
   pass comparing geometry against a linear scan and compensation
   below/inside/above viewport and at scroll==0
-- [ ] 3.2 Gate: review + amendments + atomic commit
+- [x] 3.2 Gate: review + amendments + atomic commit
 
 ## 4. Wire codec and legacy decode
 
-- [ ] 4.1 Implement the tagged value codec (`[u8 tag][type bytes]`,
+- [x] 4.1 Implement the tagged value codec (`[u8 tag][type bytes]`,
   privmsg payload = `nick, text` encoding + confirmed flag): a fixed
   `#[repr(u8)]` `MsgType` enum whose discriminants are the wire tags
   (encode via `as u8`, decode via a `from_u8` match, no factories);
   verify unit tests pass for encode/decode round-trip and that an
   unknown tag or undecodable payload panics with an identifying
   message (corrupt data is never silently skipped)
-- [ ] 4.2 Gate: review + amendments + atomic commit
+- [x] 4.2 Gate: review + amendments + atomic commit
 
 ## 5. Scroll controller
 
-- [ ] 5.1 Implement `src/ui/chatview2/scroll.rs`: internal
+- [x] 5.1 Implement `src/ui/chatview2/scroll.rs`: internal
   pixels-from-bottom scroll (no scene property), Idle/Drag/Glide/Anim
   state machine with intents fed from the gesture subsystem's drag
   lifecycle (drag start/move/end; flick = threshold on the session's
@@ -61,11 +61,11 @@ design.md → Development Protocol.
   target, flick fed a `DragEnd`-style velocity decays to stop, clamps
   at 0 and top, scroll_to_bottom, anchor round-trip with inserts above
   and below)
-- [ ] 5.2 Gate: review + amendments + atomic commit
+- [x] 5.2 Gate: review + amendments + atomic commit
 
 ## 6. Chatview2 skeleton + dev schema screen
 
-- [ ] 6.1 Create the `src/ui/chatview2/` module skeleton and the
+- [x] 6.1 Create the `src/ui/chatview2/` module skeleton and the
   `ChatView2` UIObject: properties (rect, shared styling, is_at_bottom
   bool), view-wide method stubs (`set_channel`, `set_filter`,
   `copy_select`, `unselect`, `scroll_to_bottom`, `get_line_ids`,
@@ -74,7 +74,7 @@ design.md → Development Protocol.
   `handle_gesture` stub), empty-buffer draw running the visible-window
   loop, and the node factory; verify `make compile-dev` succeeds and
   the dev screen renders an empty view
-- [ ] 6.2 Create the `src/app/schema/test_chatview.rs` dev schema
+- [x] 6.2 Create the `src/app/schema/test_chatview.rs` dev schema
   (modeled on `schema/test.rs`) hosting the chatview2 node for
   development, gated behind a new `schema-test-chatview` cargo feature
   following the existing `schema-test-*` convention (mutually exclusive
@@ -82,11 +82,11 @@ design.md → Development Protocol.
   running the app with the feature enabled: screen shows, and a
   netdebug scene dump lists the chatview2 node with its properties and
   methods
-- [ ] 6.3 Gate: review + amendments + atomic commit
+- [x] 6.3 Gate: review + amendments + atomic commit
 
 ## 7. Loader pipeline + live-testing methods
 
-- [ ] 7.1 Implement `src/ui/chatview2/loader.rs`: single kvdb-owning
+- [x] 7.1 Implement `src/ui/chatview2/loader.rs`: single kvdb-owning
   bg task, coverage invariant (live bottom + viewport + preload
   margin), wake-ups (set_channel, near-top scroll, insert, filter, rect
   change), filter application at load, plus working `get_line_ids` and
@@ -95,11 +95,11 @@ design.md → Development Protocol.
   (ts, id) list in display order, `delete_line` removes a record and it
   stays gone after re-`set_channel`; trace logs show load batches and
   Fenwick rebuilds as designed
-- [ ] 7.2 Gate: review + amendments + atomic commit
+- [x] 7.2 Gate: review + amendments + atomic commit
 
 ## 8. Message type framework
 
-- [ ] 8.1 Implement `src/ui/chatview2/msg/mod.rs`: `MessageType`
+- [x] 8.1 Implement `src/ui/chatview2/msg/mod.rs`: `MessageType`
   trait (materialize/release/regen/height/draw/hit_test/copy_text) and
   the hardcoded `MsgType` enum dispatch (no factories, no placeholder —
   unknown type ids panic at decode), height reporting into the buffer;
@@ -108,11 +108,11 @@ design.md → Development Protocol.
   draws, invalidated by width/styling/data changes); visually confirm
   records render on the dev screen and trace logs show
   materialize/release as the window moves
-- [ ] 8.2 Gate: review + amendments + atomic commit
+- [x] 8.2 Gate: review + amendments + atomic commit
 
 ## 9. Privmsg I: insertion and basic rendering
 
-- [ ] 9.1 Implement `msg/privmsg.rs` part 1: the type's
+- [x] 9.1 Implement `msg/privmsg.rs` part 1: the type's
   `insert_line`/`insert_unconf_line`/`confirm` methods (persist via the
   loader, dedup, buffer insert, confirm rewrites the payload in place
   and regens, materialize if visible) and basic rendering (nick colors,
@@ -124,22 +124,22 @@ design.md → Development Protocol.
   `confirm` on one: trace logs show dedup, fenwick pushes, layout cache
   hit/miss behavior; visual inspection against the old chatview for
   basic lines and unconfirmed→confirmed restyling
-- [ ] 9.2 Gate: review + amendments + atomic commit
+- [x] 9.2 Gate: review + amendments + atomic commit
 
 ## 10. Privmsg II: URLs, signals, variants
 
-- [ ] 10.1 Implement privmsg part 2: URL spans with backgrounds,
+- [x] 10.1 Implement privmsg part 2: URL spans with backgrounds,
   click/tap open, right-click/long-press copy with the toast overlay,
   `nick_clicked` signal carrying msg id + nick, CTCP ACTION rendering,
   NOTICE styling, unconfirmed gray; verify netdebug-driven inserts of
   urls/actions/notices render correctly (visual), the `nick_clicked`
   signal is observable on the netdebug pub socket, and toast behavior
   matches the old chatview
-- [ ] 10.2 Gate: review + amendments + atomic commit
+- [x] 10.2 Gate: review + amendments + atomic commit
 
 ## 11. Selection across types
 
-- [ ] 11.1 Implement selection: chatview-owned selected set,
+- [x] 11.1 Implement selection: chatview-owned selected set,
   chatview-drawn highlight (no per-type cache invalidation), mouse
   click toggle, `Tap` toggle, long-press entering selection mode with
   subsequent `DragMove` extending the selection instead of scrolling,
@@ -148,21 +148,21 @@ design.md → Development Protocol.
   test for mixed-type copy ordering passes, netdebug
   `copy_select`/`unselect` behave per spec, and manual
   drag/toggle/long-press selection works visually on the dev screen
-- [ ] 11.2 Gate: review + amendments + atomic commit
+- [x] 11.2 Gate: review + amendments + atomic commit
 
 ## 12. Date separators
 
-- [ ] 12.1 Implement `msg/datemsg.rs` and derived records: synthetic
+- [x] 12.1 Implement `msg/datemsg.rs` and derived records: synthetic
   `(midnight, [0;32])` keys, `sync_separators` on insert/load/delete,
   orphan cleanup, selectable with date-label copy text; verify unit
   tests pass for separator sync and orphan removal, netdebug
   `delete_line` of a day's only message removes its separator, and
   visual day-boundary rendering matches the old chatview
-- [ ] 12.2 Gate: review + amendments + atomic commit
+- [x] 12.2 Gate: review + amendments + atomic commit
 
 ## 13. Scroll input integration
 
-- [ ] 13.1 Wire input on the dev screen: touch via `handle_gesture`
+- [x] 13.1 Wire input on the dev screen: touch via `handle_gesture`
   mapping to the scroll controller per design's gesture integration
   table (`Down` pauses inertia and resets long-press mode, `DragStart`
   grabs (kills motion), `DragMove` 1:1 on the chat axis (scroll =
@@ -178,11 +178,11 @@ design.md → Development Protocol.
   expected, the overlaid arrow receives its taps (arbitration), and
   visual inspection confirms pixel-exact drag, smooth wheel animation,
   correct stops at both clamps, and the arrow toggling with position
-- [ ] 13.2 Gate: review + amendments + atomic commit
+- [x] 13.2 Gate: review + amendments + atomic commit
 
 ## 14. Materialization lifecycle and eviction
 
-- [ ] 14.1 Implement the virtualization window with soft margin and
+- [x] 14.1 Implement the virtualization window with soft margin and
   LRU eviction budget: materialize on window enter, release on exit
   (render-scoped tasks cancelled), bounded render resources; verify a
   unit test for the LRU/budget policy passes (eviction order under
@@ -191,31 +191,31 @@ design.md → Development Protocol.
   bounded memory/GPU resources after long scrolls, and visual
   inspection shows no ghost lines or missing lines around the window
   edges
-- [ ] 14.2 Gate: review + amendments + atomic commit
+- [x] 14.2 Gate: review + amendments + atomic commit
 
 ## 15. Cap/expand for long messages
 
-- [ ] 15.1 Implement the collapsed default height with expand
+- [x] 15.1 Implement the collapsed default height with expand
   affordance and toggle: height change flows through regen +
   compensation; verify a unit test for capped measurement and expand
   height reporting passes, and visual toggle on very long messages
   keeps surrounding content stable per the compensation rules
-- [ ] 15.2 Gate: review + amendments + atomic commit
+- [x] 15.2 Gate: review + amendments + atomic commit
 
 ## 16. Reflow
 
-- [ ] 16.1 Implement the reflow protocol (anchor snapshot → invalidate
+- [x] 16.1 Implement the reflow protocol (anchor snapshot → invalidate
   rendered state → re-wrap visible-first → single Fenwick rebuild →
   anchor restore; height-only rect changes just re-clamp); verify via
   netdebug `SetPropertyValue` on font_size and by resizing the window:
   trace logs show visible-first regen order and one rebuild, visual
   inspection confirms the anchored message stays put and bottom stays
   pinned
-- [ ] 16.2 Gate: review + amendments + atomic commit
+- [x] 16.2 Gate: review + amendments + atomic commit
 
 ## 17. Filemsg, content-scoped tasks, i18n
 
-- [ ] 17.1 Implement `msg/filemsg.rs`: `set_file_status` method, status
+- [x] 17.1 Implement `msg/filemsg.rs`: `set_file_status` method, status
   lifecycle rendering, fud URL derivation, `download_request`/
   `fileurl_detected`/`status_changed` signals, downloaded image display
   with fit bounds, and the content-scoped `key → Task` map surviving
@@ -224,11 +224,11 @@ design.md → Development Protocol.
   (visual image + statuses), trace logs show task dedup/attach on
   re-materialization, eviction mid-download continues the task, and a
   language switch translates the status text
-- [ ] 17.2 Gate: review + amendments + atomic commit
+- [x] 17.2 Gate: review + amendments + atomic commit
 
 ## 18. Single-screen cutover
 
-- [ ] 18.1 Rework `src/app/schema/chat.rs` to a single chat screen
+- [x] 18.1 Rework `src/app/schema/chat.rs` to a single chat screen
   with one chatview2: `set_channel` on channel selection, channel
   label binding, scroll-to-bottom arrow driven by `is_at_bottom` +
   `scroll_to_bottom`; remove the per-channel screen loop in
@@ -241,18 +241,18 @@ design.md → Development Protocol.
   updates the active channel, switching channels clears/reloads and
   restores each channel's position, and unread indication works via
   signals
-- [ ] 18.2 Gate: review + amendments + atomic commit
+- [x] 18.2 Gate: review + amendments + atomic commit
 
 ## 19. Old module removal + full validation
 
-- [ ] 19.1 Delete `src/ui/chatview/` and all old-chatview references
+- [x] 19.1 Delete `src/ui/chatview/` and all old-chatview references
   (`ui/mod.rs` re-exports, test schemas using `create_chatview`); verify
   `make compile-dev` and `make compile-apk` both succeed
-- [ ] 19.2 Full validation: parity checklist from `specs/chatview/spec.md`
+- [x] 19.2 Full validation: parity checklist from `specs/chatview/spec.md`
   (URLs, selection/copy incl. separators, file messages, actions/notices,
   unconfirmed, date separators, keys, touch incl. slop dead-zone,
   grab-to-stop, long-press select mode, wheel, restore, reflow) on
   desktop; performance pass scrolling a large history (no slowdown,
   bounded memory via renderer debug stats); android device smoke test
   (drag, flick, grab-stop, long-press select/copy, channel switching)
-- [ ] 19.3 Gate: final review + atomic commit closing the change
+- [x] 19.3 Gate: final review + atomic commit closing the change

+ 32 - 0
openspec/config.yaml

@@ -0,0 +1,32 @@
+schema: spec-driven
+
+# Project context (optional)
+# This is shown to AI when creating artifacts.
+# Add your tech stack, conventions, style guides, domain knowledge, etc.
+# Example:
+#   context: |
+#     Tech stack: TypeScript, React, Node.js
+#     We use conventional commits
+#     Domain: e-commerce platform
+
+# Per-artifact rules (optional)
+# Add custom rules for specific artifacts.
+# Example:
+#   rules:
+#     proposal:
+#       - Keep proposals under 500 words
+#       - Always include a "Non-goals" section
+#     tasks:
+#       - Break tasks into chunks of max 2 hours
+
+# Per-operation guidance (optional)
+# Add advisory guidance for how apply and archive work should be conducted.
+# This is separate from artifact rules above.
+# Example:
+#   operations:
+#     apply:
+#       guidance:
+#         - Keep test summaries concise
+#     archive:
+#       guidance:
+#         - Summarize the archive outcome before finishing

+ 557 - 0
openspec/specs/chatview/spec.md

@@ -0,0 +1,557 @@
+## Purpose
+
+The chatview2 widget renders a channel's message history as a virtualized,
+scrollable chat log with typed interactive messages, replacing the current
+`bin/app` chatview. This spec defines its observable behavior: scene API,
+buffer semantics, scrolling, storage, loading, filtering, resource
+management, and feature parity with the current implementation.
+
+## Requirements
+
+### Requirement: Single chat screen with channel retargeting
+
+The chatview SHALL support rebinding to a different channel's message
+store at runtime via a `set_channel` method. On exit from a channel it
+SHALL release that channel's in-memory buffer; on entry it SHALL reload
+the target channel's messages through the background loading pipeline.
+Message-type sub-nodes and their signal wirings SHALL remain attached and
+functional across channel switches.
+
+#### Scenario: Switching channels clears and reloads
+
+- **WHEN** `set_channel` is called with a channel that has stored history
+- **THEN** the previously displayed messages are no longer rendered and
+  the target channel's newest messages load in the background
+
+#### Scenario: Signal wirings survive channel switches
+
+- **WHEN** the UI has subscribed to a message-type sub-node signal and the
+  channel is switched
+- **THEN** the subscription remains active and receives signals from
+  messages of the newly bound channel
+
+#### Scenario: Entering a channel with no history
+
+- **WHEN** `set_channel` targets a channel with an empty store
+- **THEN** the view renders empty and remains interactive
+
+### Requirement: Scroll position restore on re-entry
+
+The chatview SHALL remember, per channel, where the user left off and
+restore that position on re-entry. The remembered state SHALL identify the
+message being viewed (anchor id plus pixel offset) so restoration is stable
+when messages arrive while the channel is not open. A user who left the
+channel at the bottom SHALL return to the bottom.
+
+#### Scenario: Re-entry restores the same content
+
+- **WHEN** the user exits a channel while scrolled into history and new
+  messages arrive before re-entry
+- **THEN** the restored view shows the same anchor message at the same
+  offset within the viewport
+
+#### Scenario: Re-entry at the bottom
+
+- **WHEN** the user exits a channel while at the live bottom
+- **THEN** re-entry restores the bottom position and newly arrived
+  messages are visible
+
+#### Scenario: Anchor no longer available
+
+- **WHEN** the anchored message cannot be found on re-entry
+- **THEN** the scroll position is clamped to a valid position without
+  crashing or blocking
+
+### Requirement: Render resources released when out of view
+
+The chatview SHALL release render resources (meshes, glyphs, textures,
+layouts) for messages that leave the visible region, using a soft window
+plus LRU budget rather than strict window-bound eviction. Scrolling
+through a large history SHALL NOT cause unbounded growth of memory or GPU
+resource usage. Render-scoped async tasks SHALL be cancelled when their
+message is released.
+
+#### Scenario: Long scroll does not accumulate resources
+
+- **WHEN** the user scrolls through many screens of history
+- **THEN** resources held for messages far outside the viewport are
+  released, and total resource usage stays bounded
+
+### Requirement: Buffer-size independent interaction
+
+Geometry queries (total content height, the set of messages visible at a
+scroll position, the position of a given message) and per-frame scrolling
+work SHALL NOT scale with the number of buffered messages. UI interaction
+responsiveness SHALL NOT degrade as the buffer grows.
+
+#### Scenario: Scrolling a large buffer costs like a small one
+
+- **WHEN** the same viewport is scrolled by the same delta with a small
+  and then a very large loaded buffer
+- **THEN** per-frame cost and responsiveness are comparable
+
+### Requirement: Scroll semantics in pixels from the bottom
+
+The scroll position SHALL be measured in pixels from the live bottom of
+the content (`0` = bottom, increasing = further up in history) and
+SHALL be clamped to the valid range. The position SHALL be internal
+view state, not a settable scene property: externally, the view SHALL
+expose a `scroll_to_bottom` method and an at-bottom indication that
+distinguishes "at the live bottom" from "scrolled into history".
+
+#### Scenario: Scroll zero pins to live bottom
+
+- **WHEN** the view is at the bottom and a new message arrives
+- **THEN** the view stays at the bottom and the new message is visible
+
+#### Scenario: Clamping
+
+- **WHEN** a gesture or animation requests a scroll position beyond the
+  valid range
+- **THEN** the resulting position is clamped and no error occurs
+
+#### Scenario: Scroll to bottom
+
+- **WHEN** `scroll_to_bottom` is invoked (e.g. the down-arrow button)
+- **THEN** any in-flight motion stops and the view returns to the live
+  bottom; the at-bottom indication reflects the position
+
+### Requirement: Direct-drag scrolling
+
+Touch-drag scrolling, delivered by the app gesture subsystem as a drag
+lifecycle (`DragStart`/`DragMove`/`DragEnd`), SHALL move content 1:1
+with the pointer in pixels, without animation or smoothing on top. The
+slop dead-zone before the drag starts and the move delivery cadence
+SHALL come from the gesture session, not the view. Starting a drag —
+or touching down over the view — SHALL cancel any in-flight glide or
+scroll animation.
+
+#### Scenario: Finger tracking is pixel-exact
+
+- **WHEN** the finger moves up by N pixels during a drag
+- **THEN** the content scrolls exactly N pixels, delivered at the
+  gesture session's move cadence, with no acceleration or smoothing
+  added by the view
+
+#### Scenario: Slop dead-zone precedes scroll
+
+- **WHEN** a touch travels less than the session's touch slop
+- **THEN** no scroll occurs and the touch remains eligible for
+  tap/long-press recognition
+
+#### Scenario: Grabbing stops motion
+
+- **WHEN** a touch begins while an animated scroll or glide is in progress
+- **THEN** the animation/glide stops immediately and the drag takes over
+
+### Requirement: Animated page scrolling for wheel and keys
+
+Mouse wheel ticks and PageUp/PageDown SHALL scroll half a page, animated
+with easing. Repeated ticks while an animation is in flight SHALL
+retarget/coalesce the animation rather than accumulate velocity.
+
+#### Scenario: Single wheel tick
+
+- **WHEN** the mouse wheel is scrolled one tick
+- **THEN** the view animates half a page in the wheel direction
+
+#### Scenario: Repeated ticks coalesce
+
+- **WHEN** several wheel ticks occur in quick succession
+- **THEN** the animation target extends by half a page per tick and the
+  motion remains smooth, without a velocity runaway
+
+### Requirement: Flick inertia
+
+Releasing a drag with sufficient velocity SHALL produce an inertial glide
+that decays over time and stops within the clamped range. Release
+velocity SHALL be taken from the gesture session's `DragEnd` velocity
+(the view does not sample its own). Touching down over the view during a
+glide SHALL stop it, including before the touch travels past the touch
+slop.
+
+#### Scenario: Flick decays and stops
+
+- **WHEN** the finger is released with upward velocity
+- **THEN** the content glides in the same direction, decaying, and comes
+  to rest at or within the valid scroll range
+
+#### Scenario: Touchdown stops a glide
+
+- **WHEN** the user touches the view while an inertial glide is in motion
+- **THEN** the glide stops immediately, even if the touch never travels
+  past the touch slop
+
+### Requirement: Scroll compensation for height changes
+
+When a message below the viewport bottom changes height, the scroll
+position SHALL be adjusted by the height delta so the viewed content stays
+stable, unless scroll is `0` (bottom pinned). Height changes inside the
+viewport SHALL grow or shrink the content around the current view without
+jumping. Total height and the maximum scroll SHALL reflect height changes
+immediately.
+
+#### Scenario: Image loads below the reading position
+
+- **WHEN** the user is scrolled into history and a message below the
+  viewport grows as its image loads
+- **THEN** the viewed content does not move
+
+#### Scenario: Expansion inside the viewport
+
+- **WHEN** the user expands a collapsed message that is on screen
+- **THEN** the message expands in place without the surrounding content
+  jumping out of view
+
+### Requirement: Message type sub-nodes
+
+Each message type SHALL be represented by exactly one sub-node of the
+chatview, exposing type-specific styling properties, signals whose
+payloads identify the message (msg id) plus type-specific data, and
+methods. Message lifecycle operations defined by each type's own
+semantics (e.g. inserting messages of that type, file status updates)
+SHALL be methods and signals of the type's sub-node; the chatview node
+SHALL expose only view-wide methods and signals (channel switching,
+filtering, selection). Sub-nodes SHALL NOT be created or destroyed when
+the buffer changes (channel switch, load, eviction). Registering a new
+message type SHALL NOT require modifying existing types.
+
+#### Scenario: Nick click emits an identified signal
+
+- **WHEN** the user clicks a nick inside a privmsg
+- **THEN** the privmsg type sub-node emits a signal carrying the msg id
+  and nick, which the UI can use (e.g. inserting the nick into the chat
+  editor)
+
+#### Scenario: Lifecycle operations go through type nodes
+
+- **WHEN** a privmsg is inserted, confirmed, or a file status changes
+- **THEN** the operation is invoked as a method on the corresponding
+  type sub-node (privmsg insert/confirm, filemsg status), not on the
+  chatview node
+
+#### Scenario: New message type is additive
+
+- **WHEN** a new message type is registered with its sub-node and payload
+  decoder
+- **THEN** existing types and stored messages continue to work unchanged
+
+### Requirement: Debug introspection and deletion
+
+The chatview SHALL provide methods to support live testing:
+enumerating the ids (with timestamps) of currently loaded messages in
+display order, and deleting a loaded message by id. Deletion SHALL
+remove the message from the buffer (updating ordering, geometry, and
+rendered state correctly) and from the channel's storage. These methods
+are testing affordances, not user-facing features.
+
+#### Scenario: Enumerate loaded messages
+
+- **WHEN** the id-enumeration method is called
+- **THEN** it returns the ids and timestamps of all currently loaded
+  messages in display order
+
+#### Scenario: Delete by id updates everything
+
+- **WHEN** a loaded message is deleted by id via the deletion method
+- **THEN** it disappears from the view, geometry (total height, scroll
+  range) updates correctly, and it is absent after the channel is
+  re-entered
+
+### Requirement: Styling inheritance and regen
+
+Styling properties shared across message types (e.g. font size, line
+height, timestamp styling, selection color) SHALL be defined once on the
+chatview node. A type sub-node SHALL only define properties specific to
+it and MAY override an inherited property by defining its own. Message
+types SHALL receive live property handles when created; changing a styling
+property SHALL cause affected messages' rendered state to be rebuilt
+(regen) with re-measured heights.
+
+#### Scenario: Font size change re-renders everything
+
+- **WHEN** the chatview font size property changes
+- **THEN** all rendered messages are re-laid-out at the new size, heights
+  are re-measured, and the scroll position remains valid (compensated)
+
+#### Scenario: Type-specific override
+
+- **WHEN** a type sub-node defines its own value for an otherwise
+  inherited property
+- **THEN** messages of that type render using the override while other
+  types use the inherited value
+
+### Requirement: Async message content updates
+
+Message types MAY run async tasks that update their own persistent data
+(e.g. download progress, decoded image buffers) and then rebuild their
+rendered state, including height. Tasks tied to rendering SHALL be
+cancelled on release. Tasks that must outlive eviction (e.g. background
+downloads) SHALL be hosted on the type sub-node, keyed by msg id or
+content address so duplicates are not spawned, and surviving tasks SHALL
+keep updating state; re-materializing a message SHALL attach to running
+tasks or current state rather than restart from scratch.
+
+#### Scenario: Download continues while evicted
+
+- **WHEN** a file message's download task is running and the message
+  scrolls out of view
+- **THEN** the download continues, and scrolling back shows current
+  progress without a duplicate task
+
+#### Scenario: Content update changes height
+
+- **WHEN** an image finishes loading and replaces a progress placeholder
+- **THEN** the message re-renders at its new height and scroll
+  compensation keeps the view stable per the height-change rules
+
+### Requirement: Random insertion at any timestamp
+
+Messages SHALL be insertable at any timestamp position (including
+backfill of older messages during sync) with the view updating correctly.
+Buffer ordering SHALL use the (timestamp, msg_id) composite key.
+Inserting a message whose (timestamp, msg_id) already exists SHALL be
+ignored (deduplication).
+
+#### Scenario: Backfilled message appears in order
+
+- **WHEN** an older message arrives while the user views history that
+  includes its timestamp position
+- **THEN** it appears at the correct chronological position without
+  duplicating or displacing other messages
+
+#### Scenario: Same-millisecond messages coexist
+
+- **WHEN** two messages share a timestamp but have different msg ids
+- **THEN** both are stored, ordered, and rendered
+
+#### Scenario: Duplicate insert is ignored
+
+- **WHEN** the same (timestamp, msg_id) is inserted twice
+- **THEN** the second insert has no visible effect
+
+### Requirement: Persisted message storage format
+
+Messages SHALL be persisted in the per-channel kvdb tree with key
+`(timestamp big-endian, msg_id)` and value `[type_id][type-owned
+bytes]`; the type decides how to interpret its bytes. Unconfirmed
+messages SHALL be persisted with a confirmed flag (type-owned payload
+state), and their later confirmation SHALL update the existing entry in
+place rather than create a duplicate. The format is a clean break from
+the previous chatview: values it cannot decode are corrupt data and
+SHALL fail explicitly (panic) rather than be skipped or misread.
+
+#### Scenario: Unconfirmed then confirmed
+
+- **WHEN** a message is sent unconfirmed and later confirmed via the
+  privmsg type node's confirm method
+- **THEN** exactly one stored entry exists, rendered with confirmed
+  styling, and it survives app restart
+
+#### Scenario: Corrupt entry fails explicitly
+
+- **WHEN** a stored value carries an unknown type id or undecodable
+  payload
+- **THEN** loading fails loudly with an explicit error identifying the
+  entry, never a silent skip
+
+### Requirement: Background loading pipeline
+
+All message loading — channel entry, live receive, scrolling toward
+history, and filter changes — SHALL happen in an async background pipeline
+that never blocks UI interaction. The loader SHALL maintain coverage of
+the visible region plus a preload margin, waking on demand.
+
+#### Scenario: Entering a large channel is non-blocking
+
+- **WHEN** `set_channel` targets a channel with a large history
+- **THEN** the UI is immediately interactive while messages stream in
+
+### Requirement: Runtime message filter
+
+A filter callback SHALL be settable and replaceable at runtime. The filter
+SHALL decide which stored messages enter the buffer during loading;
+filtered-out messages SHALL still be persisted. Changing the filter SHALL
+rebuild the visible set through the background pipeline.
+
+#### Scenario: Filter narrows the view
+
+- **WHEN** a filter that excludes some messages is set and the buffer
+  reloads
+- **THEN** excluded messages are not rendered but remain in storage
+
+#### Scenario: Filter replaced at runtime
+
+- **WHEN** the filter callback is swapped
+- **THEN** the view rebuilds in the background using the new filter
+
+### Requirement: Privmsg rendering parity
+
+Privmsgs SHALL render with the current chatview's feature set: nick
+coloring (stable per nick), CTCP ACTION rendering, NOTICE styling with
+reduced font size, unconfirmed gray styling, timestamps, URL detection
+with colored/backgrounded spans, URL click/tap opening, and URL
+right-click or long-press copying with the "copied link" toast overlay.
+
+#### Scenario: URL interaction parity
+
+- **WHEN** a message containing a URL is clicked, or long-pressed on
+  touch
+- **THEN** the URL opens (click) or is copied with the toast overlay
+  (long-press/right-click), matching current behavior
+
+### Requirement: Selection across message types
+
+Any displayed message, regardless of type, SHALL be selectable (mouse
+click toggle, `Tap` toggle, drag sweep, long-press entering selection
+mode with subsequent drag extending the selection, tap toggling in
+selection mode). `copy_select`
+SHALL copy the selected messages' text in display order joined by
+newlines, where each selected message contributes copy text defined by
+its type; a type MAY contribute nothing. `unselect` SHALL clear all
+selection. `select_changed` SHALL fire on transitions between having
+and not having any selection.
+
+#### Scenario: Mixed-type selection copies per-type text
+
+- **WHEN** a privmsg, a file message, and a date separator are all
+  selected and `copy_select` is invoked
+- **THEN** the clipboard contains each message's type-defined copy
+  text (privmsg its rendered line, file message its file URL, date
+  separator its date label) joined by newlines in display order, and
+  selection is cleared
+
+#### Scenario: Every type toggles
+
+- **WHEN** a date separator line is clicked
+- **THEN** it becomes selected and shows the selection highlight,
+  unlike the current chatview where separators are unselectable
+
+#### Scenario: Long-press selects and drag extends
+
+- **WHEN** a long-press lands on a message line and the finger then
+  drags without lifting
+- **THEN** the pressed line becomes selected, selection mode is entered,
+  and the drag extends the selection instead of scrolling
+
+#### Scenario: Selection transitions signal
+
+- **WHEN** the first line becomes selected, or the last selection is
+  cleared
+- **THEN** `select_changed` fires with true and false respectively
+
+### Requirement: Expandable message height
+
+Long messages SHALL be capped to a default height with an affordance to
+expand to full height; expansion changes the message height and follows
+the height-change scroll rules. In v1 the privmsg body is plain text with
+nicks and URLs; the payload format and APIs SHALL accommodate a richer
+span/block body (quotes, styling, code, math) added later without
+breaking stored messages or the buffer/scroll machinery.
+
+#### Scenario: Long message collapses and expands
+
+- **WHEN** a message taller than the cap is rendered and the user toggles
+  expansion
+- **THEN** the message renders capped, then expands in place following
+  the height-change rules, and collapsing restores the capped height
+
+### Requirement: File message parity
+
+fud file messages SHALL be derived from privmsg text containing fud URLs,
+render the file status lifecycle (initializing, idle, downloading with
+progress, downloaded, error), request downloads on click/tap via a signal,
+update rendering when status changes, and display downloaded images
+scaled to fit width and height bounds.
+
+#### Scenario: File download lifecycle
+
+- **WHEN** a file message is tapped while idle and the download progresses
+- **THEN** a download request signal fires, status renders with progress,
+  and on completion the image (or completed state) is displayed
+
+### Requirement: Reflow on viewport changes
+
+When the viewport width or window scale changes, the chatview SHALL
+re-wrap affected messages, re-measure heights, and keep the user's
+reading position stable: the anchored content remains in view after
+reflow, and a bottom-pinned view stays bottom-pinned. Height-only
+viewport changes (e.g. the input editor growing) SHALL NOT trigger
+re-wrapping. Reflow SHALL complete without losing messages.
+
+#### Scenario: Width change keeps reading position
+
+- **WHEN** the window is resized while the user is reading history
+- **THEN** messages re-wrap at the new width and the message under the
+  viewport anchor remains in view at the same offset
+
+#### Scenario: Bottom stays bottom across reflow
+
+- **WHEN** a reflow occurs while the view is pinned at the live bottom
+- **THEN** the view remains pinned at the bottom
+
+#### Scenario: Height-only change does not rewrap
+
+- **WHEN** the chat editor grows and only the viewport height changes
+- **THEN** no re-wrapping occurs and the scroll position remains valid
+
+### Requirement: Date separators and derived messages
+
+Date separator messages SHALL be derived from the stored messages' dates,
+never persisted, and inserted into the display order at day boundaries.
+
+#### Scenario: Day boundary renders a separator
+
+- **WHEN** consecutive messages span midnight
+- **THEN** a date separator renders between them showing the new date
+
+### Requirement: i18n support
+
+Message types SHALL accept the i18n translation fish and use it for
+translatable user-facing strings (e.g. file status labels).
+
+#### Scenario: File status string is translated
+
+- **WHEN** the active language differs and a file message shows "tap to
+  download"
+- **THEN** the string is rendered translated
+
+### Requirement: Keyboard scrolling
+
+PageUp/PageDown (and wheel equivalents) SHALL scroll via the animated page
+scrolling behavior, and keyboard input SHALL be consumable so it does not
+leak to other UI elements while interacting with the chatview.
+
+#### Scenario: PageUp animates half a page
+
+- **WHEN** PageUp is pressed
+- **THEN** the view animates half a page up and the key event is consumed
+
+### Requirement: Gesture subsystem integration
+
+The chatview SHALL receive touch input exclusively through the app
+gesture subsystem: it SHALL declare its accepted gestures via
+`gesture_set` (tap, long-press, vertical drag), pass an exact
+`gesture_hit_test` matching its rect, and consume the `GestureAction`
+stream via `handle_gesture`. It SHALL NOT implement its own recognition
+thresholds, long-press timers, or velocity sampling; touch slop, tap
+bounds, long-press firing, move throttling, and release velocity SHALL
+come from the session. Interactive layers floating over the chatview
+(e.g. the scroll-to-bottom arrow) SHALL carry a higher node priority
+than the chatview so the session's priority-ordered target resolution
+delivers their gestures to them.
+
+#### Scenario: Overlaying arrow receives its taps
+
+- **WHEN** the scroll-to-bottom arrow is visible over the chatview and
+  tapped
+- **THEN** the arrow receives the tap (triggering scroll-to-bottom) and
+  the chatview does not treat it as selection or content activation
+
+#### Scenario: No local recognition
+
+- **WHEN** a touch stream is delivered to the chatview
+- **THEN** slop gating, tap/long-press discrimination, long-press
+  timing, and release-velocity sampling are those of the gesture
+  session; the chatview applies only scroll physics and content
+  dispatch

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio