Explorar el 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 hace 2 semanas
padre
commit
2ae24ea767
Se han modificado 42 ficheros con 8244 adiciones y 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 = []
 schema-test-edit = []
 schema-test-edit = []
 schema-test-scroll-layer = []
 schema-test-scroll-layer = []
+schema-test-chatview = []
 
 
 [patch.crates-io]
 [patch.crates-io]
 halo2_proofs = { git="https://github.com/parazyd/halo2", branch="v050" }
 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
 # 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:
 data/font/ibm-plex-mono-regular.otf:
 	mkdir -p data/font/
 	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")]
         #[cfg(feature = "schema-test-scroll-layer")]
         schema::test_scroll_layer::make(&self, window.clone(), &i18n_fish).await;
         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"))]
         #[cfg(all(feature = "schema-app", feature = "schema-test"))]
         compile_error!("Only one schema can be selected");
         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");
         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 {
 pub fn create_chatview(name: &str) -> SceneNode {
     let mut node = SceneNode::new(name, SceneNodeType::ChatView);
     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);
     let mut prop = Property::new("rect", PropertyType::Float32, PropertySubType::Pixel);
     prop.set_array_len(4);
     prop.set_array_len(4);
     prop.allow_exprs();
     prop.allow_exprs();
     node.add_property(prop).unwrap();
     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);
     let prop = Property::new("font_size", PropertyType::Float32, PropertySubType::Pixel);
     node.add_property(prop).unwrap();
     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);
     let prop = Property::new("message_spacing", PropertyType::Float32, PropertySubType::Pixel);
     node.add_property(prop).unwrap();
     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);
     let mut prop = Property::new("timestamp_color", PropertyType::Float32, PropertySubType::Color);
     prop.set_array_len(4);
     prop.set_array_len(4);
     prop.set_range_f32(0., 1.);
     prop.set_range_f32(0., 1.);
@@ -600,6 +601,77 @@ pub fn create_chatview(name: &str) -> SceneNode {
     prop.set_range_f32(0., 1.);
     prop.set_range_f32(0., 1.);
     node.add_property(prop).unwrap();
     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 =
     let mut prop =
         Property::new("action_text_color", PropertyType::Float32, PropertySubType::Color);
         Property::new("action_text_color", PropertyType::Float32, PropertySubType::Color);
     prop.set_array_len(4);
     prop.set_array_len(4);
@@ -627,6 +699,10 @@ pub fn create_chatview(name: &str) -> SceneNode {
     prop.set_range_f32(0., 1.);
     prop.set_range_f32(0., 1.);
     node.add_property(prop).unwrap();
     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)
     // "Copied link" overlay (right-click / long-hold on a URL)
     let mut prop = Property::new("url_copy_text", PropertyType::Str, PropertySubType::Null);
     let mut prop = Property::new("url_copy_text", PropertyType::Str, PropertySubType::Null);
     prop.set_defaults_str(vec!["Copied link".to_string()]).unwrap();
     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();
     prop.set_defaults_f32(vec![2.]).unwrap();
     node.add_property(prop).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(
     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();
     .unwrap();
 
 
     node.add_signal(
     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();
     .unwrap();
 
 
@@ -743,17 +773,68 @@ pub fn create_chatview(name: &str) -> SceneNode {
     )
     )
     .unwrap();
     .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(
     node.add_method(
         "set_file_status",
         "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,
         None,
     )
     )
     .unwrap();
     .unwrap();
 
 
-    node.add_method("copy_select", vec![], None).unwrap();
-
-    node.add_method("unselect", vec![], None).unwrap();
-
     node
     node
 }
 }
 
 

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

@@ -42,13 +42,17 @@ use crate::{
     scene::{Pimpl, SceneNodePtr, Slot},
     scene::{Pimpl, SceneNodePtr, Slot},
     shape,
     shape,
     ui::{
     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},
     util::{i18n::I18nBabelFish, unixtime},
     ExecutorPtr,
     ExecutorPtr,
 };
 };
 
 
+use std::io::Cursor;
+
+use url::Url;
+
 use super::{ColorScheme, COLOR_SCHEME};
 use super::{ColorScheme, COLOR_SCHEME};
 
 
 #[cfg(any(target_os = "android", feature = "emulate-android"))]
 #[cfg(any(target_os = "android", feature = "emulate-android"))]
@@ -87,6 +91,7 @@ mod android_ui_consts {
     pub const MESSAGE_SPACING: f32 = 15.;
     pub const MESSAGE_SPACING: f32 = 15.;
     pub const LINE_HEIGHT: f32 = 58.;
     pub const LINE_HEIGHT: f32 = 58.;
     pub const CHATVIEW_BASELINE: f32 = 36.;
     pub const CHATVIEW_BASELINE: f32 = 36.;
+    pub const CHATVIEW_DATE_FONTSIZE: f32 = 32.;
 
 
     pub const CMD_HELP_HEIGHT: f32 = 110.;
     pub const CMD_HELP_HEIGHT: f32 = 110.;
     pub const CMD_HELP_GAP: f32 = 10.;
     pub const CMD_HELP_GAP: f32 = 10.;
@@ -170,6 +175,7 @@ mod ui_consts {
     pub const MESSAGE_SPACING: f32 = 5.;
     pub const MESSAGE_SPACING: f32 = 5.;
     pub const LINE_HEIGHT: f32 = 30.;
     pub const LINE_HEIGHT: f32 = 30.;
     pub const CHATVIEW_BASELINE: f32 = 20.;
     pub const CHATVIEW_BASELINE: f32 = 20.;
+    pub const CHATVIEW_DATE_FONTSIZE: f32 = 16.;
 
 
     pub const CMD_HELP_HEIGHT: f32 = 55.;
     pub const CMD_HELP_HEIGHT: f32 = 55.;
     pub const CMD_HELP_GAP: f32 = 5.;
     pub const CMD_HELP_GAP: f32 = 5.;
@@ -232,7 +238,6 @@ pub async fn make(
     renderer: &Renderer,
     renderer: &Renderer,
     ex: &ExecutorPtr,
     ex: &ExecutorPtr,
     content: SceneNodePtr,
     content: SceneNodePtr,
-    channel: &str,
     kv_db: &KvDb,
     kv_db: &KvDb,
     i18n_fish: &I18nBabelFish,
     i18n_fish: &I18nBabelFish,
     emoji_meshes: emoji_picker::EmojiMeshesPtr,
     emoji_meshes: emoji_picker::EmojiMeshesPtr,
@@ -263,7 +268,7 @@ pub async fn make(
     cc.add_const_f32("NETSTATUS_ICON_SIZE", super::NETSTATUS_ICON_SIZE);
     cc.add_const_f32("NETSTATUS_ICON_SIZE", super::NETSTATUS_ICON_SIZE);
 
 
     // Main view
     // 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();
     let prop = layer_node.get_property("rect").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.).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, 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_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
     prop.set_f32(atom, Role::App, 3, CHATEDIT_HEIGHT).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_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();
     let prop = node.get_property("text_color").unwrap();
     if COLOR_SCHEME == ColorScheme::DarkMode {
     if COLOR_SCHEME == ColorScheme::DarkMode {
         prop.set_f32(atom, Role::App, 0, 1.).unwrap();
         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();
     node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
 
 
-    let node = node
+    let label_node = node
         .setup(|me| {
         .setup(|me| {
             Text::new(me, window_scale.clone(), renderer.clone(), i18n_fish.clone(), redraw.clone())
             Text::new(me, window_scale.clone(), renderer.clone(), i18n_fish.clone(), redraw.clone())
         })
         })
         .await;
         .await;
-    layer_node.link(node);
+    layer_node.link(label_node.clone());
 
 
     // Create the emoji picker
     // Create the emoji picker
     let mut node = create_emoji_picker("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, "timestamp_width", TIMESTAMP_WIDTH).unwrap();
     node.set_property_f32(atom, Role::App, "line_height", LINE_HEIGHT).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, "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_f32(atom, Role::App, "baseline", CHATVIEW_BASELINE).unwrap();
     node.set_property_u32(atom, Role::App, "z_index", 2).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();
     let prop = node.get_property("timestamp_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.407).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();
         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 {
     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();
         prop.set_f32(atom, Role::App, 3, 1.).unwrap();
     } else if COLOR_SCHEME == ColorScheme::DarkMode {
     } 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();
         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, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.94).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, 2, 1.).unwrap();
     prop.set_f32(atom, Role::App, 3, 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, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.13).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, 2, 0.08).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).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, 0, 0.11).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.6).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, 2, 0.63).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
     prop.set_f32(atom, Role::App, 3, 1.).unwrap();
 
 
     // "Copied link" overlay styling (mirrors the edit action menu)
     // "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, 0, 0.).unwrap();
     prop.set_f32(atom, Role::App, 1, 0.94).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, 2, 1.).unwrap();
     prop.set_f32(atom, Role::App, 3, 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, 0, 0.1).unwrap();
     prop.set_f32(atom, Role::App, 1, 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, 2, 0.1).unwrap();
     prop.set_f32(atom, Role::App, 3, 0.9).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]
     #[rustfmt::skip]
     let nick_colors = [
     let nick_colors = [
         0.00, 0.94, 1.00, 1.,
         0.00, 0.94, 1.00, 1.,
@@ -659,41 +698,21 @@ pub async fn make(
         prop.push_f32(atom, Role::App, c).unwrap();
         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");
     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 sg_root2 = sg_root.clone();
     let listen_fileurl = ex.spawn(async move {
     let listen_fileurl = ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
         while let Ok(data) = recvr.recv().await {
@@ -704,13 +723,18 @@ pub async fn make(
     });
     });
     layer_node.push_task(listen_fileurl);
     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 sg_root2 = sg_root.clone();
     let listen_file_download = ex.spawn(async move {
     let listen_file_download = ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
         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") {
             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 =
     let down_layer_is_visible =
         PropertyBool::wrap(&down_layer, Role::App, "is_visible", 0).unwrap();
         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 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 {
     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"));
             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);
     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();
     prop.set_f32(atom, Role::App, 3, DOWNARROW_H).unwrap();
     let (slot, recvr) = Slot::new("scroll_bottom");
     let (slot, recvr) = Slot::new("scroll_bottom");
     node.register("click", slot).unwrap();
     node.register("click", slot).unwrap();
-    let redraw2 = redraw.clone();
+    let chatview_node2 = chatview_node.clone();
     let listen_click = ex.spawn(async move {
     let listen_click = ex.spawn(async move {
         while let Ok(_) = recvr.recv().await {
         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);
     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
     // the netstatus layer, so its single background box draws over the netstatus
     // icons and its buttons win click hit-testing. It carries `unselect_btn`
     // icons and its buttons win click hit-testing. It carries `unselect_btn`
     // (over `back_btn`) and `copy_btn` (over the reconnect button).
     // (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();
     let prop = select_layer.get_property("rect").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.).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, 1, 0.).unwrap();
@@ -1122,6 +1142,7 @@ pub async fn make(
     //node.set_property_bool(atom, Role::App, "debug", true).unwrap();
     //node.set_property_bool(atom, Role::App, "debug", true).unwrap();
 
 
     let editz_text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
     let editz_text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
+
     let editz_select_text = node.get_property("select_text").unwrap();
     let editz_select_text = node.get_property("select_text").unwrap();
 
 
     //let editbox_focus = PropertyBool::wrap(node, Role::App, "is_focused", 0).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();
     let chatedit_node = node.clone();
     layer_node.link(node);
     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");
     let (slot, recvr) = Slot::new("emoji_selected");
     emoji_picker_node.register("emoji_select", slot).unwrap();
     emoji_picker_node.register("emoji_select", slot).unwrap();
     let chatedit_node2 = chatedit_node.clone();
     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();
     prop.set_f32(atom, Role::App, 3, SENDBTN_BOX[3]).unwrap();
 
 
     let editz_text2 = editz_text.clone();
     let editz_text2 = editz_text.clone();
-    let channel2 = channel.to_string();
     let sg_root2 = sg_root.clone();
     let sg_root2 = sg_root.clone();
     let redraw2 = redraw.clone();
     let redraw2 = redraw.clone();
     let sendmsg = move || {
     let sendmsg = move || {
         let editz_text = editz_text2.clone();
         let editz_text = editz_text2.clone();
-        let channel = channel2.clone();
         let sg_root = sg_root2.clone();
         let sg_root = sg_root2.clone();
         let chatview_node = chatview_node.clone();
         let chatview_node = chatview_node.clone();
         let redraw = redraw2.clone();
         let redraw = redraw2.clone();
         async move {
         async move {
             let mut text = editz_text.get();
             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"));
                 let atom = &mut redraw.make_guard(gfxtag!("sendmsg clear edit"));
                 editz_text.set(atom, "");
                 editz_text.set(atom, "");
@@ -1257,7 +1297,7 @@ pub async fn make(
                 id.encode(&mut data).unwrap();
                 id.encode(&mut data).unwrap();
                 "NOTICE".encode(&mut data).unwrap();
                 "NOTICE".encode(&mut data).unwrap();
                 msg.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
                 return
             }
             }
@@ -1799,9 +1839,11 @@ pub async fn make(
 // Just for testing
 // Just for testing
 #[allow(dead_code)]
 #[allow(dead_code)]
 pub(super) fn populate_tree(tree: &Tree) {
 pub(super) fn populate_tree(tree: &Tree) {
+    use crate::ui::chatview::{codec, MessageId, MsgType};
     use chrono::{NaiveDate, NaiveDateTime};
     use chrono::{NaiveDate, NaiveDateTime};
+
     let chat_txt = include_str!("../../../data/chat.txt");
     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();
         let parts: Vec<&str> = line.splitn(3, ' ').collect();
         assert_eq!(parts.len(), 3);
         assert_eq!(parts.len(), 3);
         let time_parts: Vec<&str> = parts[0].splitn(2, ':').collect();
         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 nick = parts[1].to_string();
         let text = parts[2].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();
         tree.insert(&key, &val).unwrap();
     }
     }
     // O(n)
     // 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");
             debug!(target: "app::menu", "secret paste button clicked");
             match miniquad::window::clipboard_get() {
             match miniquad::window::clipboard_get() {
                 Some(clipboard_text) => {
                 Some(clipboard_text) => {
-                    let text_prop = secedit_node2.get_property("text").unwrap();
                     let atom = &mut redraw2.make_guard(gfxtag!("secret paste"));
                     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)"),
                 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");
             debug!(target: "app::menu", "gen secret button clicked");
             let secret_bytes: [u8; 32] = OsRng.gen();
             let secret_bytes: [u8; 32] = OsRng.gen();
             let secret = bs58::encode(secret_bytes).into_string();
             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"));
             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);
     app.tasks.lock().unwrap().push(listen_click);
@@ -1615,70 +1608,37 @@ pub async fn make(
     menu_node.register("select", slot).unwrap();
     menu_node.register("select", slot).unwrap();
 
 
     let sg_root = app.sg_root.clone();
     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 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 redraw2 = app.redraw_trigger.clone();
 
 
     let listen_select = app.ex.spawn(async move {
     let listen_select = app.ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
         while let Ok(data) = recvr.recv().await {
             let channel: String = deserialize(&data).unwrap();
             let channel: String = deserialize(&data).unwrap();
             i!("Selected channel: {channel}");
             i!("Selected channel: {channel}");
-            let path = format!("/window/content/chat/{}_chat_layer", &channel);
 
 
             let atom = &mut redraw2.make_guard(gfxtag!("channel_selected"));
             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 =
             let main_menu =
                 sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
                 sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
             let items_prop = main_menu.get_property("items").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);
             append_joined_channel(&channel);
 
 
             // Hide channel screen
             // Hide channel screen
             channel_vis.set(atom, false);
             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();
             redraw2.trigger();
 
 
             // Trigger rescan for this channel
             // 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");
             debug!(target: "app::menu", "secret paste button clicked");
             match clipboard::get() {
             match clipboard::get() {
                 Some(clipboard_text) => {
                 Some(clipboard_text) => {
-                    let text_prop = secedit_node2.get_property("text").unwrap();
                     let atom = &mut redraw_clone.make_guard(gfxtag!("secret paste"));
                     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)"),
                 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 path = format!("/window/content/chat/{}_chat_layer", &contact);
             let atom = &mut redraw2.make_guard(gfxtag!("contact_selected"));
             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();
                 node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
                 contact_vis.set(atom, false);
                 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;
                 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 =
             let main_menu =
                 sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
                 sg_root.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
             let items_prop = main_menu.get_property("items").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/>.
  * 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 kvdb_overlay::Database as KvDb;
 use std::io::Write;
 use std::io::Write;
 use ui_consts::*;
 use ui_consts::*;
@@ -439,18 +439,24 @@ pub async fn make(
     let redraw = app.redraw_trigger.clone();
     let redraw = app.redraw_trigger.clone();
     let role1_group = node.get_property("role1_group").unwrap();
     let role1_group = node.get_property("role1_group").unwrap();
     let role2_group = node.get_property("role2_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 {
     let listen_click = app.ex.spawn(async move {
         while let Ok(data) = recvr.recv().await {
         while let Ok(data) = recvr.recv().await {
             let channel: String = deserialize(&data).unwrap();
             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"));
                 let atom = &mut redraw.make_guard(gfxtag!("channel_clicked"));
                 info!(target: "app::menu", "clicked: {channel}!");
                 info!(target: "app::menu", "clicked: {channel}!");
                 sfx::play_click();
                 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();
                 node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
                 menu_is_visible.set(atom, false);
                 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
     // 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");
     let (edit_done_slot, edit_done_recvr) = Slot::new("edit_done");
     menu_node.register("edit_done", edit_done_slot).unwrap();
     menu_node.register("edit_done", edit_done_slot).unwrap();
-    let sg_root = app.sg_root.clone();
     let menu_node2 = menu_node.clone();
     let menu_node2 = menu_node.clone();
     let redraw = app.redraw_trigger.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 {
     let edit_done_listen = app.ex.spawn(async move {
         while let Ok(data) = edit_done_recvr.recv().await {
         while let Ok(data) = edit_done_recvr.recv().await {
             let deleted_items: Vec<String> = deserialize(&data).unwrap();
             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();
                 menu_node2.get_property("items").unwrap().get_str_vec().unwrap();
             write_joined_channels(&current);
             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 {
             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:
             // 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;
 pub mod menu;
 use menu::channel::Channel;
 use menu::channel::Channel;
 pub mod test;
 pub mod test;
+pub mod test_chatview;
 pub mod test_edit;
 pub mod test_edit;
 pub mod test_scroll_layer;
 pub mod test_scroll_layer;
 mod wallet;
 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())
     menu::make(app, chat_layer.clone(), i18n_fish, app_db.clone(), &kv_db, emoji_meshes.clone())
         .await;
         .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;
     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},
     expr::{self, Compiler},
     mesh::COLOR_PURPLE,
     mesh::COLOR_PURPLE,
     prop::{PropertyAtomicGuard, PropertyFloat32, Role},
     prop::{PropertyAtomicGuard, PropertyFloat32, Role},
-    scene::SceneNodePtr,
+    scene::{Pimpl, SceneNodePtr},
     ui::{ChatView, Layer, Text, VectorArt, VectorShape, Video},
     ui::{ChatView, Layer, Text, VectorArt, VectorShape, Video},
     util::i18n::I18nBabelFish,
     util::i18n::I18nBabelFish,
 };
 };
+use darkfi_serial::Encodable;
 use kvdb_overlay::Database as KvDb;
 use kvdb_overlay::Database as KvDb;
 
 
 const LIGHTMODE: bool = false;
 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, "line_height", 30.).unwrap();
     node.set_property_f32(atom, Role::App, "baseline", 20.).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_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();
     let prop = node.get_property("timestamp_color").unwrap();
     prop.set_f32(atom, Role::App, 0, 0.5).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();
         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]
     #[rustfmt::skip]
     let nick_colors = [
     let nick_colors = [
         0.00, 0.94, 1.00, 1.,
         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();
         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
     // Text edit
     let node = create_singleline_edit("editz");
     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::{
 use crate::{
     app::{
     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,
         App,
     },
     },
     expr::{self, Compiler},
     expr::{self, Compiler},
     mesh::COLOR_PURPLE,
     mesh::COLOR_PURPLE,
     prop::{PropertyAtomicGuard, PropertyFloat32, PropertyStr, Role},
     prop::{PropertyAtomicGuard, PropertyFloat32, PropertyStr, Role},
     scene::{SceneNodePtr, Slot},
     scene::{SceneNodePtr, Slot},
-    ui::{BaseEdit, BaseEditType, ChatView, Layer, Text, VectorArt, VectorShape, Video},
+    ui::{BaseEdit, BaseEditType, Layer, Text, VectorArt, VectorShape, Video},
     util::i18n::I18nBabelFish,
     util::i18n::I18nBabelFish,
 };
 };
 
 

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

@@ -20,14 +20,14 @@
 
 
 use crate::{
 use crate::{
     app::{
     app::{
-        node::{create_chatview, create_layer, create_text, create_vector_art, create_video},
+        node::{create_layer, create_text, create_vector_art, create_video},
         App,
         App,
     },
     },
     expr::{self, Compiler},
     expr::{self, Compiler},
     mesh::COLOR_PURPLE,
     mesh::COLOR_PURPLE,
     prop::{PropertyAtomicGuard, PropertyFloat32, Role},
     prop::{PropertyAtomicGuard, PropertyFloat32, Role},
     scene::SceneNodePtr,
     scene::SceneNodePtr,
-    ui::{ChatView, Layer, Text, VectorArt, VectorShape, Video},
+    ui::{Layer, Text, VectorArt, VectorShape, Video},
     util::i18n::I18nBabelFish,
     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 {
     let listen_click = app.ex.spawn(async move {
         while let Ok(_) = recvr.recv().await {
         while let Ok(_) = recvr.recv().await {
             if let Some(clipboard_text) = clipboard::get() {
             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"));
                 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
                 // 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") {
                 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 {
             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();
     let amount_rect = amount_input_node.get_property("rect").unwrap();
     amount_rect.set_expr(atom, Role::App, 2, width_code).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
     // Update token symbol position
     let token_rect = token_node.get_property("rect").unwrap();
     let token_rect = token_node.get_property("rect").unwrap();
     token_rect
     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);
         let overlays = std::mem::take(&mut self.overlays);
         for overlay in 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.view = Rectangle::new(0., 0., screen_w, screen_h);
             self.scale = overlay.scale;
             self.scale = overlay.scale;
             self.view.w /= self.scale;
             self.view.w /= self.scale;
@@ -568,7 +572,12 @@ impl<'a> RenderContext<'a> {
                     }
                     }
                 }
                 }
                 GfxDrawInstruction::Overlay(instrs) => {
                 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 {
                     self.overlays.push(OverlayDefer {
                         scale: self.scale,
                         scale: self.scale,
                         pos,
                         pos,

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

@@ -325,30 +325,29 @@ async fn load_plugins(
             let nick = String::decode(&mut cur).unwrap();
             let nick = String::decode(&mut cur).unwrap();
             let msg = 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}");
             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![];
             let mut data = vec![];
+            channel.encode(&mut data).unwrap();
             timestamp.encode(&mut data).unwrap();
             timestamp.encode(&mut data).unwrap();
             id.encode(&mut data).unwrap();
             id.encode(&mut data).unwrap();
             nick.encode(&mut data).unwrap();
             nick.encode(&mut data).unwrap();
             msg.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
                 continue
             }
             }
 
 
@@ -436,16 +435,17 @@ async fn load_plugins(
         let sg_root2 = sg_root.clone();
         let sg_root2 = sg_root.clone();
         let listen_file_status = ex.spawn(async move {
         let listen_file_status = ex.spawn(async move {
             while let Ok(data) = recv.recv().await {
             while let Ok(data) = recv.recv().await {
-                let window = sg_root2.lookup_node("/window/content").unwrap();
                 let mut cur = Cursor::new(&data);
                 let mut cur = Cursor::new(&data);
                 let url = Url::decode(&mut cur).unwrap();
                 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![];
                         let mut data = vec![];
                         url.encode(&mut data).unwrap();
                         url.encode(&mut data).unwrap();
                         status.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::Text(_) |
             Pimpl::TextScramble(_) |
             Pimpl::TextScramble(_) |
             Pimpl::Edit(_) |
             Pimpl::Edit(_) |
-            Pimpl::ChatView(_) |
             Pimpl::Image(_) |
             Pimpl::Image(_) |
             Pimpl::Video(_) |
             Pimpl::Video(_) |
             Pimpl::Button(_) |
             Pimpl::Button(_) |

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

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

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

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

La diferencia del archivo ha sido suprimido porque es 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) {
         async fn redraw(self_: Arc<BaseEdit>, _batch: BatchGuardPtr) {
             self_.redraw.trigger();
             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) {
         async fn set_text(self_: Arc<BaseEdit>, _batch: BatchGuardPtr) {
             self_.editor.lock().on_text_prop_changed();
             self_.editor.lock().on_text_prop_changed();
             self_.redraw.trigger();
             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.baseline.prop(), redraw);
         on_modify.when_change_external(self.lineheight.prop(), redraw);
         on_modify.when_change_external(self.lineheight.prop(), redraw);
         on_modify.when_change_external(self.select_ascent.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::Text(obj) => obj.clone(),
         Pimpl::TextScramble(obj) => obj.clone(),
         Pimpl::TextScramble(obj) => obj.clone(),
         Pimpl::Edit(obj) => obj.clone(),
         Pimpl::Edit(obj) => obj.clone(),
-        Pimpl::ChatView(obj) => obj.clone(),
         Pimpl::Image(obj) => obj.clone(),
         Pimpl::Image(obj) => obj.clone(),
         Pimpl::Video(obj) => obj.clone(),
         Pimpl::Video(obj) => obj.clone(),
         Pimpl::Button(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::Shortcut(obj) => obj.clone(),
         Pimpl::Menu(obj) => obj.clone(),
         Pimpl::Menu(obj) => obj.clone(),
         Pimpl::TokenTable(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:?}"),
         _ => 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::Text(obj) => obj.as_ref(),
         Pimpl::TextScramble(obj) => obj.as_ref(),
         Pimpl::TextScramble(obj) => obj.as_ref(),
         Pimpl::Edit(obj) => obj.as_ref(),
         Pimpl::Edit(obj) => obj.as_ref(),
-        Pimpl::ChatView(obj) => obj.as_ref(),
         Pimpl::Image(obj) => obj.as_ref(),
         Pimpl::Image(obj) => obj.as_ref(),
         Pimpl::Video(obj) => obj.as_ref(),
         Pimpl::Video(obj) => obj.as_ref(),
         Pimpl::Button(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::Shortcut(obj) => obj.as_ref(),
         Pimpl::Menu(obj) => obj.as_ref(),
         Pimpl::Menu(obj) => obj.as_ref(),
         Pimpl::TokenTable(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:?}"),
         _ => 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};
 use std::time::{SystemTime, UNIX_EPOCH};
 
 
 pub mod clipboard;
 pub mod clipboard;
+pub mod fenwick;
 pub mod i18n;
 pub mod i18n;
 mod rt;
 mod rt;
 pub use rt::{AsyncRuntime, ExecutorPtr};
 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. 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
   prefix, range, lower_bound, rebuild) with randomized unit tests
   comparing every operation against brute-force sums over a reference
   comparing every operation against brute-force sums over a reference
   `Vec<f32>`; verify `cargo test` in bin/app passes
   `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
   amendments, commit as one atomic unit
 
 
 ## 2. Buffer: records and ordering
 ## 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
   arena, order index sorted by `(timestamp, msg_id)`, dedup set, removal
   by id; verify unit tests pass: insert at any position, duplicate
   by id; verify unit tests pass: insert at any position, duplicate
   insert ignored, same-millisecond coexistence, removal, ordered
   insert ignored, same-millisecond coexistence, removal, ordered
   iteration
   iteration
-- [ ] 2.2 Gate: review + amendments + atomic commit
+- [x] 2.2 Gate: review + amendments + atomic commit
 
 
 ## 3. Buffer: geometry
 ## 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`
   `visible_range(scroll, view_h)`, `pos_of(msg_id)`, `set_height`
   point-updates, `insert_batch` single-rebuild, plus the
   point-updates, `insert_batch` single-rebuild, plus the
   below-viewport compensation math helper; verify randomized unit tests
   below-viewport compensation math helper; verify randomized unit tests
   pass comparing geometry against a linear scan and compensation
   pass comparing geometry against a linear scan and compensation
   below/inside/above viewport and at scroll==0
   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. 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
   privmsg payload = `nick, text` encoding + confirmed flag): a fixed
   `#[repr(u8)]` `MsgType` enum whose discriminants are the wire tags
   `#[repr(u8)]` `MsgType` enum whose discriminants are the wire tags
   (encode via `as u8`, decode via a `from_u8` match, no factories);
   (encode via `as u8`, decode via a `from_u8` match, no factories);
   verify unit tests pass for encode/decode round-trip and that an
   verify unit tests pass for encode/decode round-trip and that an
   unknown tag or undecodable payload panics with an identifying
   unknown tag or undecodable payload panics with an identifying
   message (corrupt data is never silently skipped)
   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. 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
   pixels-from-bottom scroll (no scene property), Idle/Drag/Glide/Anim
   state machine with intents fed from the gesture subsystem's drag
   state machine with intents fed from the gesture subsystem's drag
   lifecycle (drag start/move/end; flick = threshold on the session's
   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
   target, flick fed a `DragEnd`-style velocity decays to stop, clamps
   at 0 and top, scroll_to_bottom, anchor round-trip with inserts above
   at 0 and top, scroll_to_bottom, anchor round-trip with inserts above
   and below)
   and below)
-- [ ] 5.2 Gate: review + amendments + atomic commit
+- [x] 5.2 Gate: review + amendments + atomic commit
 
 
 ## 6. Chatview2 skeleton + dev schema screen
 ## 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
   `ChatView2` UIObject: properties (rect, shared styling, is_at_bottom
   bool), view-wide method stubs (`set_channel`, `set_filter`,
   bool), view-wide method stubs (`set_channel`, `set_filter`,
   `copy_select`, `unselect`, `scroll_to_bottom`, `get_line_ids`,
   `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
   `handle_gesture` stub), empty-buffer draw running the visible-window
   loop, and the node factory; verify `make compile-dev` succeeds and
   loop, and the node factory; verify `make compile-dev` succeeds and
   the dev screen renders an empty view
   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
   (modeled on `schema/test.rs`) hosting the chatview2 node for
   development, gated behind a new `schema-test-chatview` cargo feature
   development, gated behind a new `schema-test-chatview` cargo feature
   following the existing `schema-test-*` convention (mutually exclusive
   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
   running the app with the feature enabled: screen shows, and a
   netdebug scene dump lists the chatview2 node with its properties and
   netdebug scene dump lists the chatview2 node with its properties and
   methods
   methods
-- [ ] 6.3 Gate: review + amendments + atomic commit
+- [x] 6.3 Gate: review + amendments + atomic commit
 
 
 ## 7. Loader pipeline + live-testing methods
 ## 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
   bg task, coverage invariant (live bottom + viewport + preload
   margin), wake-ups (set_channel, near-top scroll, insert, filter, rect
   margin), wake-ups (set_channel, near-top scroll, insert, filter, rect
   change), filter application at load, plus working `get_line_ids` and
   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
   (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
   stays gone after re-`set_channel`; trace logs show load batches and
   Fenwick rebuilds as designed
   Fenwick rebuilds as designed
-- [ ] 7.2 Gate: review + amendments + atomic commit
+- [x] 7.2 Gate: review + amendments + atomic commit
 
 
 ## 8. Message type framework
 ## 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
   trait (materialize/release/regen/height/draw/hit_test/copy_text) and
   the hardcoded `MsgType` enum dispatch (no factories, no placeholder —
   the hardcoded `MsgType` enum dispatch (no factories, no placeholder —
   unknown type ids panic at decode), height reporting into the buffer;
   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
   draws, invalidated by width/styling/data changes); visually confirm
   records render on the dev screen and trace logs show
   records render on the dev screen and trace logs show
   materialize/release as the window moves
   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. 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
   `insert_line`/`insert_unconf_line`/`confirm` methods (persist via the
   loader, dedup, buffer insert, confirm rewrites the payload in place
   loader, dedup, buffer insert, confirm rewrites the payload in place
   and regens, materialize if visible) and basic rendering (nick colors,
   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
   `confirm` on one: trace logs show dedup, fenwick pushes, layout cache
   hit/miss behavior; visual inspection against the old chatview for
   hit/miss behavior; visual inspection against the old chatview for
   basic lines and unconfirmed→confirmed restyling
   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. 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,
   click/tap open, right-click/long-press copy with the toast overlay,
   `nick_clicked` signal carrying msg id + nick, CTCP ACTION rendering,
   `nick_clicked` signal carrying msg id + nick, CTCP ACTION rendering,
   NOTICE styling, unconfirmed gray; verify netdebug-driven inserts of
   NOTICE styling, unconfirmed gray; verify netdebug-driven inserts of
   urls/actions/notices render correctly (visual), the `nick_clicked`
   urls/actions/notices render correctly (visual), the `nick_clicked`
   signal is observable on the netdebug pub socket, and toast behavior
   signal is observable on the netdebug pub socket, and toast behavior
   matches the old chatview
   matches the old chatview
-- [ ] 10.2 Gate: review + amendments + atomic commit
+- [x] 10.2 Gate: review + amendments + atomic commit
 
 
 ## 11. Selection across types
 ## 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
   chatview-drawn highlight (no per-type cache invalidation), mouse
   click toggle, `Tap` toggle, long-press entering selection mode with
   click toggle, `Tap` toggle, long-press entering selection mode with
   subsequent `DragMove` extending the selection instead of scrolling,
   subsequent `DragMove` extending the selection instead of scrolling,
@@ -148,21 +148,21 @@ design.md → Development Protocol.
   test for mixed-type copy ordering passes, netdebug
   test for mixed-type copy ordering passes, netdebug
   `copy_select`/`unselect` behave per spec, and manual
   `copy_select`/`unselect` behave per spec, and manual
   drag/toggle/long-press selection works visually on the dev screen
   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. 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,
   `(midnight, [0;32])` keys, `sync_separators` on insert/load/delete,
   orphan cleanup, selectable with date-label copy text; verify unit
   orphan cleanup, selectable with date-label copy text; verify unit
   tests pass for separator sync and orphan removal, netdebug
   tests pass for separator sync and orphan removal, netdebug
   `delete_line` of a day's only message removes its separator, and
   `delete_line` of a day's only message removes its separator, and
   visual day-boundary rendering matches the old chatview
   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. 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
   mapping to the scroll controller per design's gesture integration
   table (`Down` pauses inertia and resets long-press mode, `DragStart`
   table (`Down` pauses inertia and resets long-press mode, `DragStart`
   grabs (kills motion), `DragMove` 1:1 on the chat axis (scroll =
   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
   expected, the overlaid arrow receives its taps (arbitration), and
   visual inspection confirms pixel-exact drag, smooth wheel animation,
   visual inspection confirms pixel-exact drag, smooth wheel animation,
   correct stops at both clamps, and the arrow toggling with position
   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. 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
   LRU eviction budget: materialize on window enter, release on exit
   (render-scoped tasks cancelled), bounded render resources; verify a
   (render-scoped tasks cancelled), bounded render resources; verify a
   unit test for the LRU/budget policy passes (eviction order under
   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
   bounded memory/GPU resources after long scrolls, and visual
   inspection shows no ghost lines or missing lines around the window
   inspection shows no ghost lines or missing lines around the window
   edges
   edges
-- [ ] 14.2 Gate: review + amendments + atomic commit
+- [x] 14.2 Gate: review + amendments + atomic commit
 
 
 ## 15. Cap/expand for long messages
 ## 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 +
   affordance and toggle: height change flows through regen +
   compensation; verify a unit test for capped measurement and expand
   compensation; verify a unit test for capped measurement and expand
   height reporting passes, and visual toggle on very long messages
   height reporting passes, and visual toggle on very long messages
   keeps surrounding content stable per the compensation rules
   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. 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 →
   rendered state → re-wrap visible-first → single Fenwick rebuild →
   anchor restore; height-only rect changes just re-clamp); verify via
   anchor restore; height-only rect changes just re-clamp); verify via
   netdebug `SetPropertyValue` on font_size and by resizing the window:
   netdebug `SetPropertyValue` on font_size and by resizing the window:
   trace logs show visible-first regen order and one rebuild, visual
   trace logs show visible-first regen order and one rebuild, visual
   inspection confirms the anchored message stays put and bottom stays
   inspection confirms the anchored message stays put and bottom stays
   pinned
   pinned
-- [ ] 16.2 Gate: review + amendments + atomic commit
+- [x] 16.2 Gate: review + amendments + atomic commit
 
 
 ## 17. Filemsg, content-scoped tasks, i18n
 ## 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`/
   lifecycle rendering, fud URL derivation, `download_request`/
   `fileurl_detected`/`status_changed` signals, downloaded image display
   `fileurl_detected`/`status_changed` signals, downloaded image display
   with fit bounds, and the content-scoped `key → Task` map surviving
   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
   (visual image + statuses), trace logs show task dedup/attach on
   re-materialization, eviction mid-download continues the task, and a
   re-materialization, eviction mid-download continues the task, and a
   language switch translates the status text
   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. 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
   with one chatview2: `set_channel` on channel selection, channel
   label binding, scroll-to-bottom arrow driven by `is_at_bottom` +
   label binding, scroll-to-bottom arrow driven by `is_at_bottom` +
   `scroll_to_bottom`; remove the per-channel screen loop in
   `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
   updates the active channel, switching channels clears/reloads and
   restores each channel's position, and unread indication works via
   restores each channel's position, and unread indication works via
   signals
   signals
-- [ ] 18.2 Gate: review + amendments + atomic commit
+- [x] 18.2 Gate: review + amendments + atomic commit
 
 
 ## 19. Old module removal + full validation
 ## 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
   (`ui/mod.rs` re-exports, test schemas using `create_chatview`); verify
   `make compile-dev` and `make compile-apk` both succeed
   `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,
   (URLs, selection/copy incl. separators, file messages, actions/notices,
   unconfirmed, date separators, keys, touch incl. slop dead-zone,
   unconfirmed, date separators, keys, touch incl. slop dead-zone,
   grab-to-stop, long-press select mode, wheel, restore, reflow) on
   grab-to-stop, long-press select mode, wheel, restore, reflow) on
   desktop; performance pass scrolling a large history (no slowdown,
   desktop; performance pass scrolling a large history (no slowdown,
   bounded memory via renderer debug stats); android device smoke test
   bounded memory via renderer debug stats); android device smoke test
   (drag, flick, grab-stop, long-press select/copy, channel switching)
   (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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio