Browse Source

app: add a feed to UI schema. we also fix a scroll bug in layer.

darkfi 6 days ago
parent
commit
c03be8c544

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

@@ -75,6 +75,9 @@ pub struct App {
     /// the minimal baseline is active. The active theme name lives in
     /// the minimal baseline is active. The active theme name lives in
     /// `/setting/theme`.
     /// `/setting/theme`.
     pub theme_ctx: SyncMutex<Option<theme::ThemeCtx>>,
     pub theme_ctx: SyncMutex<Option<theme::ThemeCtx>>,
+    /// The app kvdb, set once in `setup()`; widgets that persist to it
+    /// (e.g. the wallet screen's feed chatview) read it from here.
+    pub kv_db: std::sync::OnceLock<KvDb>,
 }
 }
 
 
 impl App {
 impl App {
@@ -89,6 +92,7 @@ impl App {
             redraw_rx,
             redraw_rx,
             is_first_time: AtomicBool::new(false),
             is_first_time: AtomicBool::new(false),
             theme_ctx: SyncMutex::new(None),
             theme_ctx: SyncMutex::new(None),
+            kv_db: std::sync::OnceLock::new(),
         })
         })
     }
     }
 
 
@@ -96,6 +100,7 @@ impl App {
     /// the objects.
     /// the objects.
     pub async fn setup(self: &Arc<Self>, kv_db: KvDb, app_db: AppDbPtr) {
     pub async fn setup(self: &Arc<Self>, kv_db: KvDb, app_db: AppDbPtr) {
         t!("App::setup()");
         t!("App::setup()");
+        let _ = self.kv_db.set(kv_db.clone());
 
 
         let app_version = env!("CARGO_PKG_VERSION");
         let app_version = env!("CARGO_PKG_VERSION");
         let is_first_time = app_db.app_version_get().await.unwrap().as_deref() != Some(app_version);
         let is_first_time = app_db.app_version_get().await.unwrap().as_deref() != Some(app_version);

+ 75 - 0
bin/app/src/app/node.rs

@@ -997,6 +997,12 @@ pub fn create_chatview(name: &str) -> SceneNode {
     prop.allow_exprs();
     prop.allow_exprs();
     node.add_property(prop).unwrap();
     node.add_property(prop).unwrap();
 
 
+    let mut prop =
+        Property::new("show_channel", PropertyType::Bool, PropertySubType::Null, PERM_APP);
+    prop.set_ui_text("Show channel", "Render each message's channel name beside the timestamp");
+    prop.set_defaults_bool(vec![false]).unwrap();
+    node.add_property(prop).unwrap();
+
     let mut prop =
     let mut prop =
         Property::new("text_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
         Property::new("text_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
     prop.set_array_len(4);
     prop.set_array_len(4);
@@ -1130,6 +1136,65 @@ pub fn create_privmsg_node(name: &str) -> SceneNode {
     prop.allow_exprs();
     prop.allow_exprs();
     node.add_property(prop).unwrap();
     node.add_property(prop).unwrap();
 
 
+    // Channel label (the room-name span shown when channel display is
+    // active). Type-specific styling: lives here, on the privmsg node.
+    let mut prop =
+        Property::new("channel_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_array_len(4);
+    prop.set_range_f32(0., 1.);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new(
+        "channel_font_size",
+        PropertyType::Float32,
+        PropertySubType::Pixel,
+        PERM_STYLE,
+    );
+    prop.set_defaults_f32(vec![12.]).unwrap();
+    prop.set_range_f32(0.01, f32::MAX);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("channel_spacing", PropertyType::Float32, PropertySubType::Pixel, PERM_STYLE);
+    prop.set_defaults_f32(vec![4.]).unwrap();
+    prop.set_range_f32(0.01, f32::MAX);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new(
+        "channel_bg_color",
+        PropertyType::Float32,
+        PropertySubType::Color,
+        PERM_STYLE,
+    );
+    prop.set_array_len(4);
+    prop.set_range_f32(0., 1.);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new(
+        "channel_border_size",
+        PropertyType::Float32,
+        PropertySubType::Pixel,
+        PERM_STYLE,
+    );
+    prop.set_range_f32(0., f32::MAX);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new(
+        "channel_border_color",
+        PropertyType::Float32,
+        PropertySubType::Color,
+        PERM_STYLE,
+    );
+    prop.set_array_len(4);
+    prop.set_range_f32(0., 1.);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
     let mut prop =
     let mut prop =
         Property::new("cap_max_height", PropertyType::Float32, PropertySubType::Pixel, PERM_APP);
         Property::new("cap_max_height", PropertyType::Float32, PropertySubType::Pixel, PERM_APP);
     prop.set_range_f32(0., f32::MAX);
     prop.set_range_f32(0., f32::MAX);
@@ -1199,6 +1264,16 @@ pub fn create_privmsg_node(name: &str) -> SceneNode {
     )
     )
     .unwrap();
     .unwrap();
 
 
+    node.add_signal(
+        "channel_clicked",
+        "A channel name was clicked",
+        vec![
+            ("id", "Message ID", CallArgType::Hash),
+            ("channel", "Channel name", CallArgType::Str),
+        ],
+    )
+    .unwrap();
+
     node.add_signal(
     node.add_signal(
         "url_clicked",
         "url_clicked",
         "A URL was clicked",
         "A URL was clicked",

+ 93 - 8
bin/app/src/app/schema/chat.rs

@@ -43,8 +43,9 @@ use crate::{
     shape,
     shape,
     theme::wire_color,
     theme::wire_color,
     ui::{
     ui::{
-        chatview::MessageId, emoji_picker, BaseEdit, BaseEditType, Button, ChatView, EmojiPicker,
-        Layer, RedrawTrigger, Shortcut, Text, VectorArt, VectorShape,
+        chatview::{MessageId, FEED_CHANNEL},
+        emoji_picker, BaseEdit, BaseEditType, Button, ChatView, EmojiPicker, Layer, RedrawTrigger,
+        Shortcut, Text, VectorArt, VectorShape,
     },
     },
     util::{i18n::I18nBabelFish, unixtime},
     util::{i18n::I18nBabelFish, unixtime},
     ExecutorPtr,
     ExecutorPtr,
@@ -87,6 +88,8 @@ mod android_ui_consts {
     pub const FONTSIZE: f32 = 50.;
     pub const FONTSIZE: f32 = 50.;
     pub const TIMESTAMP_FONTSIZE: f32 = 30.;
     pub const TIMESTAMP_FONTSIZE: f32 = 30.;
     pub const TIMESTAMP_WIDTH: f32 = 135.;
     pub const TIMESTAMP_WIDTH: f32 = 135.;
+    /// Default timestamp→line / chip spacing (the shipped look).
+    pub const CHANNEL_SPACING: f32 = 45.;
     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.;
@@ -170,6 +173,8 @@ pub mod ui_consts {
     pub const FONTSIZE: f32 = 25.;
     pub const FONTSIZE: f32 = 25.;
     pub const TIMESTAMP_FONTSIZE: f32 = 12.;
     pub const TIMESTAMP_FONTSIZE: f32 = 12.;
     pub const TIMESTAMP_WIDTH: f32 = 60.;
     pub const TIMESTAMP_WIDTH: f32 = 60.;
+    /// Default timestamp→line / chip spacing (the shipped look).
+    pub const CHANNEL_SPACING: f32 = 24.;
     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.;
@@ -516,22 +521,38 @@ pub async fn make(
         .await;
         .await;
     layer_node.link(chatview_node.clone());
     layer_node.link(chatview_node.clone());
 
 
-    let tree_name = "#dev__chat_tree_v2";
+    let tree_name = "#dev__chat_tree_v3";
     let tree = kv_db.open_tree_default(&tree_name).unwrap();
     let tree = kv_db.open_tree_default(&tree_name).unwrap();
     if tree.is_empty().expect("cannot read dev chat tree") {
     if tree.is_empty().expect("cannot read dev chat tree") {
         populate_tree(&tree);
         populate_tree(&tree);
     }
     }
 
 
-    // The label follows the bound channel.
+    // The feed tree mirrors the fixture (interleaved across a few
+    // channels) so the `*` view has labeled, mixed-room content to
+    // test with before any live traffic arrives.
+    let feed_tree = kv_db.open_tree_default(&ChatView::tree_name(FEED_CHANNEL)).unwrap();
+    if feed_tree.is_empty().expect("cannot read feed chat tree") {
+        populate_feed_tree(&feed_tree);
+    }
+
+    // The label follows the bound channel; the feed binding also
+    // forces channel names on (the feed is unread without them).
     {
     {
         let channel_prop = PropertyStr::wrap(&chatview_node, Role::App, "channel", 0).unwrap();
         let channel_prop = PropertyStr::wrap(&chatview_node, Role::App, "channel", 0).unwrap();
         let channel_sub = channel_prop.prop().subscribe_modify();
         let channel_sub = channel_prop.prop().subscribe_modify();
         let label_text = PropertyStr::wrap(&label_node, Role::App, "text", 0).unwrap();
         let label_text = PropertyStr::wrap(&label_node, Role::App, "text", 0).unwrap();
+        let show_channel_prop =
+            PropertyBool::wrap(&chatview_node, Role::App, "show_channel", 0).unwrap();
         let redraw2 = redraw.clone();
         let redraw2 = redraw.clone();
         let label_task = ex.spawn(async move {
         let label_task = ex.spawn(async move {
             while let Ok(_) = channel_sub.receive().await {
             while let Ok(_) = channel_sub.receive().await {
                 let atom = &mut redraw2.make_guard(gfxtag!("channel label"));
                 let atom = &mut redraw2.make_guard(gfxtag!("channel label"));
-                label_text.set(atom, channel_prop.get());
+                let channel = channel_prop.get();
+                label_text.set(atom, channel.clone());
+                let shown = channel == FEED_CHANNEL;
+                if show_channel_prop.get() != shown {
+                    show_channel_prop.set(atom, shown);
+                }
             }
             }
         });
         });
         chatview_node.push_task(label_task);
         chatview_node.push_task(label_task);
@@ -542,6 +563,28 @@ pub async fn make(
     privmsg_node.set_property_f32(atom, Role::App, "cap_max_height", 1000.).unwrap();
     privmsg_node.set_property_f32(atom, Role::App, "cap_max_height", 1000.).unwrap();
     wire_color(&privmsg_node, "action_text_color", &theme, "chatview.action_text_color").unwrap();
     wire_color(&privmsg_node, "action_text_color", &theme, "chatview.action_text_color").unwrap();
     wire_color(&privmsg_node, "url_text_color", &theme, "chatview.url_text_color").unwrap();
     wire_color(&privmsg_node, "url_text_color", &theme, "chatview.url_text_color").unwrap();
+
+    // Channel label styling: text color follows the shared token; the
+    // chip gets a visible bg + border. Font size and spacing match the
+    // timestamp size / the shipped timestamp→line gap now that the
+    // fixed gutter no longer drives the body indent.
+    wire_color(&privmsg_node, "channel_color", &theme, "chatview.channel_color").unwrap();
+    let prop = privmsg_node.get_property("channel_bg_color").unwrap();
+    prop.set_default_f32_multi(&rgba!(0x263B3Cff)).unwrap();
+    let prop = privmsg_node.get_property("channel_border_color").unwrap();
+    prop.set_default_f32_multi(&rgba!(0x689AA5ff)).unwrap();
+    privmsg_node.get_property("channel_border_size").unwrap().set_default_f32(0, 1.).unwrap();
+    privmsg_node
+        .get_property("channel_font_size")
+        .unwrap()
+        .set_default_f32(0, TIMESTAMP_FONTSIZE)
+        .unwrap();
+    privmsg_node
+        .get_property("channel_spacing")
+        .unwrap()
+        .set_default_f32(0, CHANNEL_SPACING)
+        .unwrap();
+
     let prop = privmsg_node.get_property("url_bg_color").unwrap();
     let prop = privmsg_node.get_property("url_bg_color").unwrap();
     prop.set_default_f32_multi(&[0., 0.13, 0.08, 1.]).unwrap();
     prop.set_default_f32_multi(&[0., 0.13, 0.08, 1.]).unwrap();
     privmsg_node.get_property("url_bg_border_size").unwrap().set_default_f32(0, 1.).unwrap();
     privmsg_node.get_property("url_bg_border_size").unwrap().set_default_f32(0, 1.).unwrap();
@@ -576,6 +619,28 @@ pub async fn make(
     ];
     ];
     prop.set_default_f32_multi(&nick_colors).unwrap();
     prop.set_default_f32_multi(&nick_colors).unwrap();
 
 
+    // Clicking a rendered channel name navigates to that channel
+    // (one chat screen, retargeted).
+    {
+        let (slot, recvr) = Slot::new("channel_nav");
+        privmsg_node.register("channel_clicked", slot).unwrap();
+        let chatview_node2 = chatview_node.clone();
+        let listen_channel = 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(channel) = String::decode(&mut cur) else { continue };
+                if channel == FEED_CHANNEL {
+                    continue
+                }
+                let mut chan_data = vec![];
+                channel.encode(&mut chan_data).unwrap();
+                let _ = chatview_node2.call_method("set_channel", chan_data).await;
+            }
+        });
+        layer_node.push_task(listen_channel);
+    }
+
     // Date separator styling.
     // Date separator styling.
     let datemsg_node = chatview_node.lookup_node("/datemsg").expect("datemsg type node");
     let datemsg_node = chatview_node.lookup_node("/datemsg").expect("datemsg type node");
     datemsg_node
     datemsg_node
@@ -1100,7 +1165,6 @@ pub async fn make(
         async move {
         async move {
             let mut text = editz_text.get();
             let mut text = editz_text.get();
             let channel = chatview_node.get_property_str("channel").unwrap_or_default();
             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}");
             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"));
@@ -1123,12 +1187,16 @@ pub async fn make(
                 let msg = format!("You are now known as <{nick}>");
                 let msg = format!("You are now known as <{nick}>");
                 let id: [u8; 32] = rand::random();
                 let id: [u8; 32] = rand::random();
 
 
+                // Route the local echo through the chatview's receive:
+                // it persists (channel tree + feed mirror) and shows it
+                // in the bound view — insert_line is display-only.
                 let mut data = vec![];
                 let mut data = vec![];
+                channel.encode(&mut data).unwrap();
                 unixtime().encode(&mut data).unwrap();
                 unixtime().encode(&mut data).unwrap();
                 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();
-                privmsg_node.call_method("insert_line", data).await.unwrap();
+                chatview_node.call_method("receive", data).await.unwrap();
 
 
                 return
                 return
             }
             }
@@ -1668,6 +1736,22 @@ 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) {
+    populate_tree_chan(tree, |_| "#dev".to_string());
+}
+
+/// Feed fixture: the same chat.txt lines re-stamped across a few
+/// channels (stable per nick, so conversations stay coherent) — the
+/// `*` view gets labeled, mixed-room content to test with.
+#[allow(dead_code)]
+pub(super) fn populate_feed_tree(tree: &Tree) {
+    const FEED_CHANNELS: [&str; 3] = ["#dev", "#ramen", "#darkfi"];
+    populate_tree_chan(tree, |nick| {
+        let hash = nick.bytes().map(|b| b as usize).sum::<usize>();
+        FEED_CHANNELS[hash % FEED_CHANNELS.len()].to_string()
+    });
+}
+
+fn populate_tree_chan(tree: &Tree, channel_of: impl Fn(&str) -> String) {
     use crate::ui::chatview::{codec, MessageId, MsgType};
     use crate::ui::chatview::{codec, MessageId, MsgType};
     use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
     use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
 
 
@@ -1697,13 +1781,14 @@ pub(super) fn populate_tree(tree: &Tree) {
 
 
         let nick = nick.to_string();
         let nick = nick.to_string();
         let text = text.to_string();
         let text = text.to_string();
+        let channel = channel_of(&nick);
 
 
         // Unique id per line: the minute timestamp alone can repeat.
         // Unique id per line: the minute timestamp alone can repeat.
         let mut id_bytes = [0u8; 32];
         let mut id_bytes = [0u8; 32];
         id_bytes[..8].copy_from_slice(&(idx as u64).to_be_bytes());
         id_bytes[..8].copy_from_slice(&(idx as u64).to_be_bytes());
         let id = MessageId(id_bytes);
         let id = MessageId(id_bytes);
 
 
-        let payload = codec::encode_privmsg_payload(&nick, &text, true);
+        let payload = codec::encode_privmsg_payload(&nick, &text, true, &channel);
         let val = codec::encode_value(MsgType::PrivMsg, &payload);
         let val = codec::encode_value(MsgType::PrivMsg, &payload);
         let key = codec::encode_key(timest, &id);
         let key = codec::encode_key(timest, &id);
         tree.insert(&key, &val).unwrap();
         tree.insert(&key, &val).unwrap();

+ 6 - 1
bin/app/src/app/schema/settings.rs

@@ -205,7 +205,12 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
     prop.set_default_f32(1, 0.).unwrap();
     prop.set_default_f32(1, 0.).unwrap();
     prop.set_default_expr(2, expr::load_var("w")).unwrap();
     prop.set_default_expr(2, expr::load_var("w")).unwrap();
     prop.set_default_expr(3, expr::load_var("h")).unwrap();
     prop.set_default_expr(3, expr::load_var("h")).unwrap();
-    layer_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    // Hidden until the settings button opens it. A visible-by-default
+    // full-screen layer would draw under the screens but still sit
+    // above them in the input dispatch order (priority-0 siblings
+    // iterate newest-linked first), its hovered edits swallowing
+    // wheel events meant for the screens behind.
+    layer_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
     layer_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
     layer_node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
     let layer_node = layer_node
     let layer_node = layer_node
         .setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
         .setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone()))

+ 1 - 1
bin/app/src/app/schema/test_chatview.rs

@@ -366,7 +366,7 @@ fn populate_tree(tree: &Tree) {
         id_bytes[..8].copy_from_slice(&(idx as u64).to_be_bytes());
         id_bytes[..8].copy_from_slice(&(idx as u64).to_be_bytes());
         let id = MessageId(id_bytes);
         let id = MessageId(id_bytes);
 
 
-        let payload = codec::encode_privmsg_payload(&nick, &text, true);
+        let payload = codec::encode_privmsg_payload(&nick, &text, true, DEV_CHANNEL);
         let val = codec::encode_value(MsgType::PrivMsg, &payload);
         let val = codec::encode_value(MsgType::PrivMsg, &payload);
         let key = codec::encode_key(timest, &id);
         let key = codec::encode_key(timest, &id);
         tree.insert(&key, &val).unwrap();
         tree.insert(&key, &val).unwrap();

+ 28 - 0
bin/app/src/app/schema/wallet/data.rs

@@ -39,6 +39,20 @@ mod android_ui_consts {
     pub const TOKEN_NAME_OFFSET: f32 = 200.;
     pub const TOKEN_NAME_OFFSET: f32 = 200.;
     pub const PADDING_X: f32 = 40.;
     pub const PADDING_X: f32 = 40.;
     pub const PADDING_Y: f32 = 30.;
     pub const PADDING_Y: f32 = 30.;
+    /// Padding around the feed layer on the main wallet screen.
+    pub const FEED_LAYER_PADDING: f32 = 30.;
+    /// Header band inside the feed layer (the feed chatview starts
+    /// below it).
+    pub const FEED_HEADER_HEIGHT: f32 = 60.;
+    // Feed chatview styling (mirrors the chat screen's values).
+    pub const FEED_FONTSIZE: f32 = 50.;
+    pub const FEED_TIMESTAMP_FONTSIZE: f32 = 24.;
+    pub const FEED_TIMESTAMP_WIDTH: f32 = 135.;
+    pub const FEED_LINE_HEIGHT: f32 = 58.;
+    pub const FEED_MESSAGE_SPACING: f32 = 15.;
+    pub const FEED_BASELINE: f32 = 36.;
+    pub const FEED_DATE_FONTSIZE: f32 = 32.;
+    pub const FEED_CHANNEL_SPACING: f32 = 45.;
     pub const RECIPIENT_INPUT_MARGIN: f32 = 30.;
     pub const RECIPIENT_INPUT_MARGIN: f32 = 30.;
     pub const RECIPIENT_INPUT_PADDING_X: f32 = 40.;
     pub const RECIPIENT_INPUT_PADDING_X: f32 = 40.;
     pub const RECIPIENT_INPUT_HEIGHT: f32 = 120.;
     pub const RECIPIENT_INPUT_HEIGHT: f32 = 120.;
@@ -96,6 +110,20 @@ mod ui_consts {
     pub const TOKEN_NAME_OFFSET: f32 = 90.;
     pub const TOKEN_NAME_OFFSET: f32 = 90.;
     pub const PADDING_X: f32 = 20.;
     pub const PADDING_X: f32 = 20.;
     pub const PADDING_Y: f32 = 15.;
     pub const PADDING_Y: f32 = 15.;
+    /// Padding around the feed layer on the main wallet screen.
+    pub const FEED_LAYER_PADDING: f32 = 15.;
+    /// Header band inside the feed layer (the feed chatview starts
+    /// below it).
+    pub const FEED_HEADER_HEIGHT: f32 = 30.;
+    // Feed chatview styling (mirrors the chat screen's values).
+    pub const FEED_FONTSIZE: f32 = 25.;
+    pub const FEED_TIMESTAMP_FONTSIZE: f32 = 12.;
+    pub const FEED_TIMESTAMP_WIDTH: f32 = 60.;
+    pub const FEED_LINE_HEIGHT: f32 = 30.;
+    pub const FEED_MESSAGE_SPACING: f32 = 5.;
+    pub const FEED_BASELINE: f32 = 20.;
+    pub const FEED_DATE_FONTSIZE: f32 = 16.;
+    pub const FEED_CHANNEL_SPACING: f32 = 24.;
     pub const RECIPIENT_INPUT_MARGIN: f32 = 15.;
     pub const RECIPIENT_INPUT_MARGIN: f32 = 15.;
     pub const RECIPIENT_INPUT_PADDING_X: f32 = 20.;
     pub const RECIPIENT_INPUT_PADDING_X: f32 = 20.;
     pub const RECIPIENT_INPUT_HEIGHT: f32 = 60.;
     pub const RECIPIENT_INPUT_HEIGHT: f32 = 60.;

+ 193 - 5
bin/app/src/app/schema/wallet/main.rs

@@ -16,21 +16,27 @@
  * 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::Decodable;
+use darkfi_serial::{Decodable, Encodable};
 
 
 use crate::{
 use crate::{
     app::{
     app::{
-        node::{create_button, create_layer, create_text, create_tokentable, create_vector_art},
+        node::{
+            create_button, create_chatview, create_layer, create_text, create_tokentable,
+            create_vector_art,
+        },
         App,
         App,
     },
     },
     expr,
     expr,
     gfx::gfxtag,
     gfx::gfxtag,
-    mesh::{COLOR_CYAN, COLOR_TEAL},
+    mesh::{rgba, COLOR_CYAN, COLOR_TEAL},
     prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
     prop::{PropertyAtomicGuard, PropertyBool, PropertyFloat32, Role},
-    scene::{SceneNodePtr, Slot},
+    scene::{Pimpl, SceneNodePtr, Slot},
     shape,
     shape,
     theme::wire_color,
     theme::wire_color,
-    ui::{Button, Layer, Text, TokenTable, VectorArt, VectorShape},
+    ui::{
+        chatview::{MessageId, FEED_CHANNEL},
+        Button, ChatView, Layer, Text, TokenTable, VectorArt, VectorShape,
+    },
     util::i18n::I18nBabelFish,
     util::i18n::I18nBabelFish,
 };
 };
 
 
@@ -72,6 +78,188 @@ pub async fn make(
 
 
     let main_is_visible = PropertyBool::wrap(&main_layer, Role::App, "is_visible", 0).unwrap();
     let main_is_visible = PropertyBool::wrap(&main_layer, Role::App, "is_visible", 0).unwrap();
 
 
+    // ============================================
+    // Feed Layer (bottom half of the screen)
+    // ============================================
+    cc.add_const_f32("FEED_LAYER_PADDING", FEED_LAYER_PADDING);
+    cc.add_const_f32("FEED_HEADER_HEIGHT", FEED_HEADER_HEIGHT);
+    let feed_layer = create_layer("feed_layer");
+    let prop = feed_layer.get_property("rect").unwrap();
+    prop.set_default_f32(0, FEED_LAYER_PADDING).unwrap();
+    let code = cc.compile("h / 2 + FEED_LAYER_PADDING").unwrap();
+    prop.set_default_expr(1, code).unwrap();
+    let code = cc.compile("w - 2 * FEED_LAYER_PADDING").unwrap();
+    prop.set_default_expr(2, code).unwrap();
+    let code = cc.compile("h / 2 - 2 * FEED_LAYER_PADDING").unwrap();
+    prop.set_default_expr(3, code).unwrap();
+    feed_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+    feed_layer.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    let feed_layer = feed_layer
+        .setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
+        .await;
+    // Child of the main wallet layer: shows with the main screen and
+    // hides with it when navigating to send/receive screens.
+    main_layer.link(feed_layer.clone());
+
+    // Feed layer background: covers the entire layer. Transparent grey
+    // by default; the active theme may restyle it (scifi recolors it).
+    let node = create_vector_art("bg");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_default_f32(0, 0.).unwrap();
+    prop.set_default_f32(1, 0.).unwrap();
+    prop.set_default_expr(2, expr::load_var("w")).unwrap();
+    prop.set_default_expr(3, expr::load_var("h")).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 0).unwrap();
+    let mut shape = VectorShape::new();
+    shape.add_filled_box(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        [0.5, 0.5, 0.5, 0.5],
+    );
+    node.set_property_shape(atom, Role::App, "shape", shape).unwrap();
+    let node =
+        node.setup(|me| VectorArt::new(me, app.renderer.clone(), app.redraw_trigger.clone())).await;
+    feed_layer.link(node);
+
+    // The global feed chatview, bound to the reserved `*` pseudo-channel.
+    // Starts below the feed header band and fills the rest of the layer.
+    // Channel labels are forced on (the feed is unread without them).
+    let node = create_chatview("feed_chatty");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_default_f32(0, 0.).unwrap();
+    prop.set_default_f32(1, FEED_HEADER_HEIGHT).unwrap();
+    prop.set_default_expr(2, expr::load_var("w")).unwrap();
+    let code = cc.compile("h - FEED_HEADER_HEIGHT").unwrap();
+    prop.set_default_expr(3, code).unwrap();
+    node.get_property("font_size").unwrap().set_default_f32(0, FEED_FONTSIZE).unwrap();
+    node.get_property("timestamp_font_size")
+        .unwrap()
+        .set_default_f32(0, FEED_TIMESTAMP_FONTSIZE)
+        .unwrap();
+    node.get_property("timestamp_width").unwrap().set_default_f32(0, FEED_TIMESTAMP_WIDTH).unwrap();
+    node.get_property("line_height").unwrap().set_default_f32(0, FEED_LINE_HEIGHT).unwrap();
+    node.get_property("message_spacing").unwrap().set_default_f32(0, FEED_MESSAGE_SPACING).unwrap();
+    node.get_property("baseline").unwrap().set_default_f32(0, FEED_BASELINE).unwrap();
+    node.set_property_f32(atom, Role::App, "wheel_page_frac", 0.2).unwrap();
+    node.set_property_bool(atom, Role::App, "show_channel", true).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
+
+    // Class wiring onto the shared /theme tokens.
+    let theme = app.sg_root.lookup_node("/theme").unwrap();
+    wire_color(&node, "timestamp_color", &theme, "chatview.timestamp_color").unwrap();
+    wire_color(&node, "text_color", &theme, "chatview.text_color").unwrap();
+    wire_color(&node, "hi_bg_color", &theme, "chatview.hi_bg_color").unwrap();
+
+    let kv_db = app.kv_db.get().expect("app kvdb set in setup()").clone();
+    let feed_chatty = 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;
+    feed_layer.link(feed_chatty.clone());
+
+    // Type-node styling: privmsg + date separator.
+    let privmsg_node = feed_chatty.lookup_node("/privmsg").expect("privmsg type node");
+    privmsg_node.set_property_f32(atom, Role::App, "cap_max_height", 1000.).unwrap();
+    wire_color(&privmsg_node, "action_text_color", &theme, "chatview.action_text_color").unwrap();
+    wire_color(&privmsg_node, "url_text_color", &theme, "chatview.url_text_color").unwrap();
+    wire_color(&privmsg_node, "channel_color", &theme, "chatview.channel_color").unwrap();
+    let prop = privmsg_node.get_property("url_bg_color").unwrap();
+    prop.set_default_f32_multi(&[0., 0.13, 0.08, 1.]).unwrap();
+    privmsg_node.get_property("url_bg_border_size").unwrap().set_default_f32(0, 1.).unwrap();
+    let prop = privmsg_node.get_property("url_bg_border_color").unwrap();
+    prop.set_default_f32_multi(&[0.11, 0.6, 0.63, 1.]).unwrap();
+    // Channel label chip: same values as the chat screen.
+    let prop = privmsg_node.get_property("channel_bg_color").unwrap();
+    prop.set_default_f32_multi(&rgba!(0x263B3Cff)).unwrap();
+    let prop = privmsg_node.get_property("channel_border_color").unwrap();
+    prop.set_default_f32_multi(&rgba!(0x689AA5ff)).unwrap();
+    privmsg_node.get_property("channel_border_size").unwrap().set_default_f32(0, 1.).unwrap();
+    privmsg_node
+        .get_property("channel_font_size")
+        .unwrap()
+        .set_default_f32(0, FEED_TIMESTAMP_FONTSIZE)
+        .unwrap();
+    privmsg_node
+        .get_property("channel_spacing")
+        .unwrap()
+        .set_default_f32(0, FEED_CHANNEL_SPACING)
+        .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.,
+    ];
+    prop.set_default_f32_multi(&nick_colors).unwrap();
+
+    let datemsg_node = feed_chatty.lookup_node("/datemsg").expect("datemsg type node");
+    datemsg_node.get_property("font_size").unwrap().set_default_f32(0, FEED_DATE_FONTSIZE).unwrap();
+    let prop = datemsg_node.get_property("color").unwrap();
+    prop.set_default_f32_multi(&[0.5, 0.5, 0.5, 1.]).unwrap();
+
+    // Clicking a channel label navigates to the chat screen and opens
+    // that channel (mirrors the channels-menu click path).
+    {
+        let (slot, recvr) = Slot::new("feed_channel_nav");
+        privmsg_node.register("channel_clicked", slot).unwrap();
+        let sg_root = app.sg_root.clone();
+        let redraw = app.redraw_trigger.clone();
+        let listen_channel = app.ex.spawn(async move {
+            while let Ok(data) = recvr.recv().await {
+                let mut cur = std::io::Cursor::new(&data);
+                let Ok(_id) = MessageId::decode(&mut cur) else { continue };
+                let Ok(channel) = String::decode(&mut cur) else { continue };
+                if channel == FEED_CHANNEL {
+                    continue
+                }
+                let atom = &mut redraw.make_guard(gfxtag!("feed channel nav"));
+                let Some(chat_screen) = sg_root.lookup_node("/window/content/chat") else {
+                    continue
+                };
+                chat_screen.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                let wallet_screen = sg_root.lookup_node("/window/content/wallet").unwrap();
+                wallet_screen.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+                let Some(node) = sg_root.lookup_node("/window/content/chat/main_chat_layer") else {
+                    continue
+                };
+                node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                let menu_node = sg_root.lookup_node("/window/content/chat/menu_layer").unwrap();
+                menu_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+                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;
+            }
+        });
+        app.tasks.lock().push(listen_channel);
+    }
+
+    // Bind to the feed. Direct pimpl call (not the method bus): the
+    // bus has no buffering, so a call before the node's start()
+    // subscribes would be silently dropped. The loader's wake flag
+    // persists, so binding before start() spawns the pump is safe.
+    let Pimpl::ChatView(feed_view) = feed_chatty.pimpl() else { unreachable!() };
+    feed_view.handle_set_channel(FEED_CHANNEL.to_string()).await;
+
     // Back arrow
     // Back arrow
     let node = create_vector_art("wallet_back_btn_bg");
     let node = create_vector_art("wallet_back_btn_bg");
     let prop = node.get_property("rect").unwrap();
     let prop = node.get_property("rect").unwrap();

+ 21 - 1
bin/app/src/main.rs

@@ -338,10 +338,22 @@ async fn load_plugins(
             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("receive", data).await {
+            if let Err(err) = chatview.call_method("receive", data.clone()).await {
                 error!(target: "app", "Call method {node_path}::receive({channel}, {timestamp}, {id}): {err:?}");
                 error!(target: "app", "Call method {node_path}::receive({channel}, {timestamp}, {id}): {err:?}");
             }
             }
 
 
+            // Fan out to the wallet screen's feed view: bound to the
+            // reserved `*` pseudo-channel, it live-inserts every
+            // channel's messages so the feed stays current. Persistence
+            // dedups (composite key) against the chat screen chatty's
+            // writes; the insert keeps the feed buffer fresh even while
+            // the wallet screen is hidden.
+            if let Some(feed_chatty) =
+                sg_root2.lookup_node("/window/content/wallet/main_layer/feed_layer/feed_chatty")
+            {
+                let _ = feed_chatty.call_method("receive", data).await;
+            }
+
             // Apply coloring when the message is not being viewed:
             // Apply coloring when the message is not being viewed:
             // either another channel is open, or the user is not in the
             // either another channel is open, or the user is not in the
             // chat screen at all.
             // chat screen at all.
@@ -354,6 +366,14 @@ async fn load_plugins(
 
 
             let menu_node =
             let menu_node =
                 sg_root2.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
                 sg_root2.lookup_node("/window/content/chat/menu_layer/main_menu").unwrap();
+            // Unread coloring only for joined channels: the feed now
+            // relays every public room on the network, and role-group
+            // entries without a menu item are inert — don't let them
+            // accumulate.
+            let items = menu_node.get_property("items").unwrap();
+            if !items.contains_str(&channel) {
+                continue
+            }
             let group_name = if msg.contains(&darkirc_nick.get()) { "role2_group" } else { "role1_group" };
             let group_name = if msg.contains(&darkirc_nick.get()) { "role2_group" } else { "role1_group" };
             let group = menu_node.get_property(group_name).unwrap();
             let group = menu_node.get_property(group_name).unwrap();
             if !group.get_str_vec().unwrap().contains(&channel) {
             if !group.get_str_vec().unwrap().contains(&channel) {

+ 9 - 3
bin/app/src/plugin/darkirc.rs

@@ -534,11 +534,17 @@ impl DarkIrc {
             // Route the message. An already-decrypted (plaintext) message names a
             // Route the message. An already-decrypted (plaintext) message names a
             // channel we hold directly: encrypted channels arrive as base58
             // channel we hold directly: encrypted channels arrive as base58
             // ciphertext, so a channel key we recognise is plaintext by definition
             // ciphertext, so a channel key we recognise is plaintext by definition
-            // and is accepted as-is. Anything else must decrypt as a channel or DM;
-            // undecryptable traffic (base58 garbage in neither map) is silently dropped.
+            // and is accepted as-is. Public channels are plaintext on the network
+            // whether or not we joined them — a `#`-prefixed channel name cannot
+            // be ciphertext (base58 has no `#`), so every public room is relayed
+            // and the global feed carries it. Anything else must decrypt as a
+            // channel or DM; undecryptable traffic (base58 garbage in neither
+            // map) is silently dropped.
             let mut privmsg = privmsg;
             let mut privmsg = privmsg;
             // Is this a plaintext channel?
             // Is this a plaintext channel?
-            let is_plaintext = self.channels.read().await.contains_key(&privmsg.channel);
+            let is_public = privmsg.channel.starts_with('#');
+            let is_plaintext =
+                is_public || self.channels.read().await.contains_key(&privmsg.channel);
             if !is_plaintext && !self.try_decrypt(&mut privmsg, &self.nick.get()).await {
             if !is_plaintext && !self.try_decrypt(&mut privmsg, &self.nick.get()).await {
                 continue;
                 continue;
             }
             }

+ 2 - 0
bin/app/src/theme/mod.rs

@@ -87,6 +87,7 @@ pub fn shared_token_props() -> Vec<Property> {
         color_prop("menu.role2_color", [0.56, 0.61, 1., 1.]),
         color_prop("menu.role2_color", [0.56, 0.61, 1., 1.]),
         // ChatView
         // ChatView
         color_prop("chatview.timestamp_color", [0.55, 0.55, 0.55, 1.]),
         color_prop("chatview.timestamp_color", [0.55, 0.55, 0.55, 1.]),
+        color_prop("chatview.channel_color", [0.55, 0.55, 0.55, 1.]),
         color_prop("chatview.text_color", [0.92, 0.92, 0.92, 1.]),
         color_prop("chatview.text_color", [0.92, 0.92, 0.92, 1.]),
         color_prop("chatview.hi_bg_color", [0.25, 0.25, 0.25, 1.]),
         color_prop("chatview.hi_bg_color", [0.25, 0.25, 0.25, 1.]),
         color_prop("chatview.action_text_color", [0.80, 0.80, 0.80, 1.]),
         color_prop("chatview.action_text_color", [0.80, 0.80, 0.80, 1.]),
@@ -646,6 +647,7 @@ mod tests {
             "menu.role2_color",
             "menu.role2_color",
             // chatview
             // chatview
             "chatview.timestamp_color",
             "chatview.timestamp_color",
+            "chatview.channel_color",
             "chatview.text_color",
             "chatview.text_color",
             "chatview.hi_bg_color",
             "chatview.hi_bg_color",
             "chatview.action_text_color",
             "chatview.action_text_color",

+ 17 - 0
bin/app/src/theme/scifi.rs

@@ -98,6 +98,7 @@ async fn scifi_apply(ctx: &ThemeCtx) -> Result<()> {
         //("menu.role1_color", [0.36, 1., 0.51, 1.]),
         //("menu.role1_color", [0.36, 1., 0.51, 1.]),
         //("menu.role2_color", [0.56, 0.61, 1., 1.]),
         //("menu.role2_color", [0.56, 0.61, 1., 1.]),
         ("chatview.timestamp_color", [0.407, 0.604, 0.647, 1.]),
         ("chatview.timestamp_color", [0.407, 0.604, 0.647, 1.]),
+        ("chatview.channel_color", [0., 0.941, 1., 1.]),
         ("chatview.text_color", [1., 1., 1., 1.]),
         ("chatview.text_color", [1., 1., 1., 1.]),
         ("chatview.hi_bg_color", [0., 0.2, 0.2, 1.]),
         ("chatview.hi_bg_color", [0., 0.2, 0.2, 1.]),
         ("chatview.action_text_color", [0.5, 0.25, 0.75, 1.]),
         ("chatview.action_text_color", [0.5, 0.25, 0.75, 1.]),
@@ -161,6 +162,22 @@ async fn scifi_apply(ctx: &ThemeCtx) -> Result<()> {
     bg_fade_node(ctx, "/window/content/chat/main_chat_layer", "bg").await?;
     bg_fade_node(ctx, "/window/content/chat/main_chat_layer", "bg").await?;
     bg_fade_node(ctx, "/window/content/wallet/main_layer", "wallet_bg").await?;
     bg_fade_node(ctx, "/window/content/wallet/main_layer", "wallet_bg").await?;
 
 
+    // Feed layer bg on the main wallet screen: restyle the schema's
+    // neutral fill with the scifi dark teal.
+    if let Some(node) = ctx.sg_root().lookup_node("/window/content/wallet/main_layer/feed_layer/bg")
+    {
+        let mut shape = VectorShape::new();
+        shape.add_filled_box(
+            expr::const_f32(0.),
+            expr::const_f32(0.),
+            expr::load_var("w"),
+            expr::load_var("h"),
+            [0., 0.09, 0.106, 1.],
+        );
+        node.set_property_shape(&mut PropertyAtomicGuard::none(), Role::Theme, "shape", shape)
+            .unwrap();
+    }
+
     // Bg for wallet btn to navigate to chat screen
     // Bg for wallet btn to navigate to chat screen
     let node = create_vector_art("chat_btn_bg");
     let node = create_vector_art("chat_btn_bg");
     let prop = node.get_property("rect").unwrap();
     let prop = node.get_property("rect").unwrap();

+ 43 - 14
bin/app/src/ui/chatview/codec.rs

@@ -22,9 +22,13 @@
 //! kv value: `[u8 tag][type-owned bytes]` — the tag is the `MsgType`
 //! kv value: `[u8 tag][type-owned bytes]` — the tag is the `MsgType`
 //! discriminant, the rest is owned by the message type.
 //! discriminant, the rest is owned by the message type.
 //!
 //!
-//! privmsg payload: `[nick: String][text: String][confirmed: bool]`
+//! privmsg payload: `[nick: String][text: String][confirmed: bool][channel: String]`
 //! datemsg payload: `[local-midnight ts: u64]` (derived; never stored)
 //! datemsg payload: `[local-midnight ts: u64]` (derived; never stored)
 //!
 //!
+//! The channel is always present so entries read from any tree — their
+//! own channel's or an aggregate like the global feed — identify their
+//! room.
+//!
 //! This is a clean break from the previous chatview: values it cannot
 //! This is a clean break from the previous chatview: values it cannot
 //! decode are corrupt data and fail explicitly (panic) with a message
 //! decode are corrupt data and fail explicitly (panic) with a message
 //! identifying the entry — never a silent skip or a misread.
 //! identifying the entry — never a silent skip or a misread.
@@ -93,7 +97,7 @@ pub fn decode_value(val: &[u8], ts: Timestamp, id: &MessageId) -> MsgRecord {
 
 
     match msg_type {
     match msg_type {
         MsgType::PrivMsg => {
         MsgType::PrivMsg => {
-            let (_nick, _text, _confirmed) = decode_privmsg_payload(payload, ts, id);
+            let (_nick, _text, _confirmed, _channel) = decode_privmsg_payload(payload, ts, id);
         }
         }
         MsgType::DateMsg => {
         MsgType::DateMsg => {
             let _midnight = decode_datemsg_payload(payload, ts, id);
             let _midnight = decode_datemsg_payload(payload, ts, id);
@@ -106,16 +110,18 @@ pub fn decode_value(val: &[u8], ts: Timestamp, id: &MessageId) -> MsgRecord {
     MsgRecord { ts, id: *id, msg_type, payload: payload.to_vec(), height: 0. }
     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> {
+/// Encode the privmsg payload: `[nick][text][confirmed][channel]`.
+pub fn encode_privmsg_payload(nick: &str, text: &str, confirmed: bool, channel: &str) -> Vec<u8> {
     let mut payload = vec![];
     let mut payload = vec![];
     nick.encode(&mut payload).unwrap();
     nick.encode(&mut payload).unwrap();
     text.encode(&mut payload).unwrap();
     text.encode(&mut payload).unwrap();
     confirmed.encode(&mut payload).unwrap();
     confirmed.encode(&mut payload).unwrap();
+    channel.encode(&mut payload).unwrap();
     payload
     payload
 }
 }
 
 
-/// Decode the privmsg payload back into `(nick, text, confirmed)`.
+/// Decode the privmsg payload back into
+/// `(nick, text, confirmed, channel)`.
 ///
 ///
 /// ## Panics
 /// ## Panics
 ///
 ///
@@ -124,7 +130,7 @@ pub fn decode_privmsg_payload(
     payload: &[u8],
     payload: &[u8],
     ts: Timestamp,
     ts: Timestamp,
     id: &MessageId,
     id: &MessageId,
-) -> (String, String, bool) {
+) -> (String, String, bool, String) {
     let mut cur = Cursor::new(payload);
     let mut cur = Cursor::new(payload);
     let ctx = |what: &str| format!("corrupt chat entry: {what} [ts={ts} id={id}]");
     let ctx = |what: &str| format!("corrupt chat entry: {what} [ts={ts} id={id}]");
     let nick =
     let nick =
@@ -133,7 +139,9 @@ pub fn decode_privmsg_payload(
         String::decode(&mut cur).unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg text")));
         String::decode(&mut cur).unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg text")));
     let confirmed = bool::decode(&mut cur)
     let confirmed = bool::decode(&mut cur)
         .unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg confirmed flag")));
         .unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg confirmed flag")));
-    (nick, text, confirmed)
+    let channel =
+        String::decode(&mut cur).unwrap_or_else(|e| panic!("{}: {e}", ctx("bad privmsg channel")));
+    (nick, text, confirmed, channel)
 }
 }
 
 
 /// Encode the datemsg payload: the separator day's local-midnight ts.
 /// Encode the datemsg payload: the separator day's local-midnight ts.
@@ -175,7 +183,7 @@ mod tests {
     #[test]
     #[test]
     fn privmsg_value_round_trip() {
     fn privmsg_value_round_trip() {
         let id = rid(1);
         let id = rid(1);
-        let payload = encode_privmsg_payload("alice", "hello world", false);
+        let payload = encode_privmsg_payload("alice", "hello world", false, "#dev");
         let val = encode_value(MsgType::PrivMsg, &payload);
         let val = encode_value(MsgType::PrivMsg, &payload);
         assert_eq!(val[0], MsgType::PrivMsg as u8);
         assert_eq!(val[0], MsgType::PrivMsg as u8);
 
 
@@ -186,15 +194,24 @@ mod tests {
         assert_eq!(rec.height, 0.);
         assert_eq!(rec.height, 0.);
         assert_eq!(
         assert_eq!(
             decode_privmsg_payload(&rec.payload, 123, &id),
             decode_privmsg_payload(&rec.payload, 123, &id),
-            ("alice".to_string(), "hello world".to_string(), false)
+            ("alice".to_string(), "hello world".to_string(), false, "#dev".to_string())
         );
         );
 
 
-        let payload = encode_privmsg_payload("NOTICE", "\u{1}ACTION waves\u{1}", true);
+        // A feed-tree entry reads back with its room.
+        let payload = encode_privmsg_payload("bob", "hi", true, "#ramen");
         let val = encode_value(MsgType::PrivMsg, &payload);
         let val = encode_value(MsgType::PrivMsg, &payload);
         let rec = decode_value(&val, 124, &id);
         let rec = decode_value(&val, 124, &id);
         assert_eq!(
         assert_eq!(
             decode_privmsg_payload(&rec.payload, 124, &id),
             decode_privmsg_payload(&rec.payload, 124, &id),
-            ("NOTICE".to_string(), "\u{1}ACTION waves\u{1}".to_string(), true)
+            ("bob".to_string(), "hi".to_string(), true, "#ramen".to_string())
+        );
+
+        let payload = encode_privmsg_payload("NOTICE", "\u{1}ACTION waves\u{1}", true, "#dev");
+        let val = encode_value(MsgType::PrivMsg, &payload);
+        let rec = decode_value(&val, 125, &id);
+        assert_eq!(
+            decode_privmsg_payload(&rec.payload, 125, &id),
+            ("NOTICE".to_string(), "\u{1}ACTION waves\u{1}".to_string(), true, "#dev".to_string())
         );
         );
     }
     }
 
 
@@ -232,16 +249,28 @@ mod tests {
     #[test]
     #[test]
     #[should_panic(expected = "bad privmsg text")]
     #[should_panic(expected = "bad privmsg text")]
     fn truncated_privmsg_payload_panics() {
     fn truncated_privmsg_payload_panics() {
-        let mut payload = encode_privmsg_payload("alice", "hello", true);
+        let mut payload = encode_privmsg_payload("alice", "hello", true, "#dev");
         payload.truncate(6);
         payload.truncate(6);
         decode_privmsg_payload(&payload, 5, &rid(2));
         decode_privmsg_payload(&payload, 5, &rid(2));
     }
     }
 
 
+    #[test]
+    #[should_panic(expected = "bad privmsg channel")]
+    fn missing_channel_panics() {
+        // v2-era payload `[nick][text][confirmed]` — no channel bytes.
+        let mut payload = encode_privmsg_payload("alice", "hello", true, "#dev");
+        // Strip the trailing channel string (its varint length + bytes).
+        let channel_len = "#dev".len() + 1;
+        payload.truncate(payload.len() - channel_len);
+        decode_privmsg_payload(&payload, 5, &rid(2));
+    }
+
     #[test]
     #[test]
     #[should_panic(expected = "bad privmsg confirmed flag")]
     #[should_panic(expected = "bad privmsg confirmed flag")]
     fn missing_confirmed_flag_panics() {
     fn missing_confirmed_flag_panics() {
-        let mut payload = encode_privmsg_payload("alice", "hello", true);
-        payload.pop();
+        let mut payload = encode_privmsg_payload("alice", "hello", true, "#dev");
+        // Strip confirmed flag and channel.
+        payload.truncate(payload.len() - ("#dev".len() + 2));
         decode_privmsg_payload(&payload, 5, &rid(2));
         decode_privmsg_payload(&payload, 5, &rid(2));
     }
     }
 
 

+ 6 - 33
bin/app/src/ui/chatview/loader.rs

@@ -145,16 +145,6 @@ impl Loader {
         self.wake(Wakeup::FilterChange);
         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
     /// Install the record-measuring callback the pump uses to give
     /// loaded records their heights (type nodes lay out text; the
     /// loaded records their heights (type nodes lay out text; the
     /// loader stays geometry-free).
     /// loader stays geometry-free).
@@ -168,23 +158,6 @@ impl Loader {
         *self.derive.lock() = Some(f);
         *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,
     /// The background task: wait for wakes, drain the reason bits,
     /// pump. The reset-before-drain ordering makes wakes arriving
     /// pump. The reset-before-drain ordering makes wakes arriving
     /// during a pump observable on the next `wait`.
     /// during a pump observable on the next `wait`.
@@ -233,7 +206,7 @@ impl Loader {
         let shortfall = need - covered;
         let shortfall = need - covered;
 
 
         let tree_guard = self.tree.lock();
         let tree_guard = self.tree.lock();
-        let Some((tree_name, tree)) = tree_guard.as_ref() else { return };
+        let Some((tree_name, tree)) = &*tree_guard else { return };
         let tree_name = tree_name.clone();
         let tree_name = tree_name.clone();
         t!("pump reasons={reasons:#04x} tree={tree_name} covered={covered} need={need}");
         t!("pump reasons={reasons:#04x} tree={tree_name} covered={covered} need={need}");
 
 
@@ -262,7 +235,7 @@ impl Loader {
             if !filter(&rec) {
             if !filter(&rec) {
                 continue
                 continue
             }
             }
-            if let Some(measure) = self.measure.lock().as_ref() {
+            if let Some(measure) = &*self.measure.lock() {
                 rec.height = measure(&rec);
                 rec.height = measure(&rec);
             }
             }
             batch_height += rec.height;
             batch_height += rec.height;
@@ -270,10 +243,10 @@ impl Loader {
             let is_privmsg = rec.msg_type == MsgType::PrivMsg;
             let is_privmsg = rec.msg_type == MsgType::PrivMsg;
             batch.push(rec);
             batch.push(rec);
             if is_privmsg {
             if is_privmsg {
-                if let Some(derive) = self.derive.lock().as_ref() {
+                if let Some(derive) = &*self.derive.lock() {
                     if let Some(file_rec) = derive(batch.last().unwrap()) {
                     if let Some(file_rec) = derive(batch.last().unwrap()) {
                         let mut file_rec = file_rec;
                         let mut file_rec = file_rec;
-                        if let Some(measure) = self.measure.lock().as_ref() {
+                        if let Some(measure) = &*self.measure.lock() {
                             file_rec.height = measure(&file_rec);
                             file_rec.height = measure(&file_rec);
                         }
                         }
                         batch_height += file_rec.height;
                         batch_height += file_rec.height;
@@ -294,7 +267,7 @@ impl Loader {
 
 
         // Separators derived by the batch arrive unmeasured (they are
         // Separators derived by the batch arrive unmeasured (they are
         // not in the tree); give them heights through the type nodes.
         // not in the tree); give them heights through the type nodes.
-        if let Some(measure) = self.measure.lock().as_ref() {
+        if let Some(measure) = &*self.measure.lock() {
             let mut ids = vec![];
             let mut ids = vec![];
             for rec in buffer.iter_display_order() {
             for rec in buffer.iter_display_order() {
                 if rec.msg_type.is_derived() && rec.height == 0. {
                 if rec.msg_type.is_derived() && rec.height == 0. {
@@ -333,7 +306,7 @@ mod tests {
             let mut id = [0u8; 32];
             let mut id = [0u8; 32];
             id[0] = *idb;
             id[0] = *idb;
             let id = MessageId(id);
             let id = MessageId(id);
-            let payload = codec::encode_privmsg_payload("nick", "text", true);
+            let payload = codec::encode_privmsg_payload("nick", "text", true, "#dev");
             let val = codec::encode_value(MsgType::PrivMsg, &payload);
             let val = codec::encode_value(MsgType::PrivMsg, &payload);
             let key = codec::encode_key(*ts, &id);
             let key = codec::encode_key(*ts, &id);
             tree.insert(&key, &val).unwrap();
             tree.insert(&key, &val).unwrap();

+ 551 - 38
bin/app/src/ui/chatview/mod.rs

@@ -60,7 +60,7 @@ use crate::{
         eval_f32_multi, PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyFloat32,
         eval_f32_multi, PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyFloat32,
         PropertyPermission, PropertyRect, PropertyStr, PropertyUint32, Role,
         PropertyPermission, PropertyRect, PropertyStr, PropertyUint32, Role,
     },
     },
-    scene::{MethodCallSub, Pimpl, SceneNodeWeak},
+    scene::{MethodCallSub, Pimpl, SceneNodePtr, SceneNodeWeak},
     text,
     text,
     util::{clipboard, i18n::I18nBabelFish},
     util::{clipboard, i18n::I18nBabelFish},
     ExecutorPtr,
     ExecutorPtr,
@@ -87,6 +87,71 @@ macro_rules! i { ($($arg:tt)*) => { info!(target: "ui::chatview", $($arg)*); } }
 /// Unix-millisecond message timestamps.
 /// Unix-millisecond message timestamps.
 pub type Timestamp = u64;
 pub type Timestamp = u64;
 
 
+/// The reserved pseudo-channel naming the global feed view. Binding it
+/// via `set_channel` shows all rooms' messages. Real channels and
+/// contacts are always `#`/`@`-prefixed (the darkirc relay asserts
+/// this), so `*` can never collide with a channel or DM — the feed
+/// tree is distinct by construction, no refusal machinery needed.
+pub const FEED_CHANNEL: &str = "*";
+
+/// Whether a relayed message should be shown in this view: true when
+/// the view is bound to the message's own channel, or to the feed
+/// (`*` shows every channel).
+#[inline]
+pub(crate) fn receive_should_show(active: &str, channel: &str) -> bool {
+    active == channel || active == FEED_CHANNEL
+}
+
+/// Write an entry into the feed tree (caller-site dual-write). Same
+/// key/value as the channel write — the bound tree may already be the
+/// feed itself, in which case the dedup makes this a no-op.
+/// Best-effort: the feed is a derived view, a missed mirror line never
+/// costs channel history. `dedup` skips the write when the key already
+/// exists (fresh stores); updates overwrite in place.
+pub(crate) fn mirror_feed(
+    feed: &Tree,
+    ts: Timestamp,
+    id: &MessageId,
+    msg_type: MsgType,
+    payload: &[u8],
+    dedup: bool,
+) {
+    let key = codec::encode_key(ts, id);
+    if dedup && feed.contains_key(&key).expect("cannot read feed tree") {
+        return
+    }
+    let val = codec::encode_value(msg_type, payload);
+    feed.insert(&key, &val).expect("cannot persist feed entry");
+}
+
+/// Persist a received message: its own channel's tree plus the
+/// feed-tree mirror, deduped per tree by the composite key.
+pub(crate) fn persist_side_channel(
+    kv_db: &KvDb,
+    channel: &str,
+    ts: Timestamp,
+    id: &MessageId,
+    nick: &str,
+    text: &str,
+) {
+    let payload = codec::encode_privmsg_payload(nick, text, true, channel);
+    let key = codec::encode_key(ts, id);
+    let val = codec::encode_value(MsgType::PrivMsg, &payload);
+
+    let tree_name = ChatView::tree_name(channel);
+    let tree = kv_db.open_tree_default(&tree_name).expect("cannot open side-channel tree");
+    if !tree.contains_key(&key).expect("cannot read side-channel tree") {
+        tree.insert(&key, &val).expect("cannot persist chat entry");
+        t!("side-channel store {channel} ts={ts}");
+    }
+
+    // The feed mirror shares the key; the live insert may have written
+    // it already (feed bound), so dedup again here.
+    let feed_name = ChatView::tree_name(FEED_CHANNEL);
+    let feed = kv_db.open_tree_default(&feed_name).expect("cannot open feed tree");
+    mirror_feed(&feed, ts, id, MsgType::PrivMsg, &payload, true);
+}
+
 /// A message's unique identity. The 32 bytes come from darkirc's
 /// A message's unique identity. The 32 bytes come from darkirc's
 /// message hash; derived records (date separators) use the zero id with
 /// message hash; derived records (date separators) use the zero id with
 /// a synthetic timestamp instead.
 /// a synthetic timestamp instead.
@@ -360,7 +425,7 @@ impl ChatView {
         let loader_measure = types.clone();
         let loader_measure = types.clone();
         self_.loader.set_measure(Arc::new(move |rec| loader_measure.measure(rec)));
         self_.loader.set_measure(Arc::new(move |rec| loader_measure.measure(rec)));
         self_.loader.set_derive(Arc::new(|rec: &MsgRecord| {
         self_.loader.set_derive(Arc::new(|rec: &MsgRecord| {
-            let (_, text, _) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
+            let (_, text, _, _) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
             msg::filemsg::derive_filemsg(rec, &text)
             msg::filemsg::derive_filemsg(rec, &text)
         }));
         }));
         if self_.types.set(types).is_err() {
         if self_.types.set(types).is_err() {
@@ -398,10 +463,10 @@ impl ChatView {
         t!("derived filemsg for {nick}: {url}");
         t!("derived filemsg for {nick}: {url}");
     }
     }
 
 
-    /// The channel tree name for a channel: `{channel}__chat_tree_v2`.
+    /// The channel tree name for a channel: `{channel}__chat_tree_v3`.
     /// Versioned so the clean-break format never reads old trees.
     /// Versioned so the clean-break format never reads old trees.
     pub fn tree_name(channel: &str) -> String {
     pub fn tree_name(channel: &str) -> String {
-        format!("{channel}__chat_tree_v2")
+        format!("{channel}__chat_tree_v3")
     }
     }
 
 
     /// Update the `is_at_bottom` property when the position crosses the
     /// Update the `is_at_bottom` property when the position crosses the
@@ -554,6 +619,16 @@ impl ChatView {
                 }
                 }
                 true
                 true
             }
             }
+            msg::Hit::Channel(channel) => {
+                t!("channel clicked");
+                if let Some(node) = self.types().privmsg.node().upgrade() {
+                    let mut data = vec![];
+                    darkfi_serial::Encodable::encode(&id, &mut data).unwrap();
+                    darkfi_serial::Encodable::encode(&channel, &mut data).unwrap();
+                    let _ = node.trigger("channel_clicked", data).await;
+                }
+                true
+            }
             msg::Hit::File(url) => {
             msg::Hit::File(url) => {
                 t!("file activated");
                 t!("file activated");
                 self.types().filemsg.request_download(&id, &url).await;
                 self.types().filemsg.request_download(&id, &url).await;
@@ -689,7 +764,7 @@ impl ChatView {
         let view_h = self.rect.get().h;
         let view_h = self.rect.get().h;
 
 
         // Save where the user left the outgoing channel.
         // Save where the user left the outgoing channel.
-        if let Some((old_name, _)) = self.channel.lock().as_ref() {
+        if let Some((old_name, _)) = &*self.channel.lock() {
             let buffer = self.buffer.lock().await;
             let buffer = self.buffer.lock().await;
             let oldest_visible = {
             let oldest_visible = {
                 let scroll = self.controller.lock().scroll();
                 let scroll = self.controller.lock().scroll();
@@ -728,10 +803,13 @@ impl ChatView {
         self.redraw.trigger();
         self.redraw.trigger();
     }
     }
 
 
-    /// Receive a message from the relay: messages for the bound
-    /// channel insert live; anything else is persisted to its own
-    /// channel tree for later (the buffer is not touched — unread
-    /// indication is the relayer's concern).
+    /// Receive a message from the relay. Persistence and display are
+    /// decoupled: every message is persisted (its own channel tree
+    /// plus the feed mirror, deduped per tree), and the buffer insert
+    /// happens only when this view is bound to the message's channel
+    /// or to the feed (unread indication is the relayer's concern).
+    /// The bound-channel guard is dropped before the await (parking_lot
+    /// guards are !Send).
     pub async fn handle_receive(
     pub async fn handle_receive(
         &self,
         &self,
         channel: &str,
         channel: &str,
@@ -740,29 +818,13 @@ impl ChatView {
         nick: String,
         nick: String,
         text: String,
         text: String,
     ) {
     ) {
-        let active = self.channel.lock().as_ref().map(|(name, _)| name.clone());
-        match active {
-            Some(active) if active == channel => {
-                self.types().privmsg.insert_line(ts, id, nick, text).await;
-            }
-            _ => {
-                // Side-channel store: dedup by composite key, codec write.
-                let tree_name = Self::tree_name(channel);
-                let Ok(tree) = self.kv_db.open_tree_default(&tree_name) else {
-                    error!(target: "ui::chatview", "cannot open side-channel tree {tree_name}");
-                    return
-                };
-                let payload = codec::encode_privmsg_payload(&nick, &text, true);
-                let key = codec::encode_key(ts, &id);
-                match tree.contains_key(&key) {
-                    Ok(false) => {
-                        let val = codec::encode_value(MsgType::PrivMsg, &payload);
-                        tree.insert(&key, &val).expect("cannot persist chat entry");
-                        t!("side-channel store {channel} ts={ts}");
-                    }
-                    _ => {}
-                }
-            }
+        persist_side_channel(&self.kv_db, channel, ts, &id, &nick, &text);
+        let mut show = false;
+        if let Some((active, _)) = &*self.channel.lock() {
+            show = receive_should_show(active, channel);
+        }
+        if show {
+            self.types().privmsg.insert_line(ts, id, channel.to_string(), nick, text).await;
         }
         }
     }
     }
 
 
@@ -1032,7 +1094,9 @@ impl ChatView {
         };
         };
         assert!(method_call.send_res.is_none());
         assert!(method_call.send_res.is_none());
 
 
-        let Some((ts, id, nick, text)) = msg::privmsg::decode_insert_data(&method_call.data) else {
+        let Some((ts, id, channel, nick, text)) =
+            msg::privmsg::decode_insert_data(&method_call.data)
+        else {
             t!("insert_line() method invalid arg data");
             t!("insert_line() method invalid arg data");
             return true
             return true
         };
         };
@@ -1042,9 +1106,9 @@ impl ChatView {
         };
         };
         let privmsg = self_.types().privmsg.clone();
         let privmsg = self_.types().privmsg.clone();
         if confirmed {
         if confirmed {
-            privmsg.insert_line(ts, id, nick, text).await;
+            privmsg.insert_line(ts, id, channel, nick, text).await;
         } else {
         } else {
-            privmsg.insert_unconf_line(ts, id, nick, text).await;
+            privmsg.insert_unconf_line(ts, id, channel, nick, text).await;
         }
         }
         true
         true
     }
     }
@@ -1274,7 +1338,11 @@ impl UIObject for ChatView {
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
         on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
             self_.redraw.trigger();
             self_.redraw.trigger();
         });
         });
-        i!("chatview start: {} tasks spawned", tasks.len());
+        i!(
+            "chatview start: {} tasks spawned [node={:?}]",
+            tasks.len(),
+            self.node.upgrade().unwrap()
+        );
 
 
         // Styling changes re-wrap: the reflow protocol. Every shared
         // Styling changes re-wrap: the reflow protocol. Every shared
         // styling property that shapes text (and the privmsg node's
         // styling property that shapes text (and the privmsg node's
@@ -1289,13 +1357,25 @@ impl UIObject for ChatView {
             "baseline",
             "baseline",
             "timestamp_color",
             "timestamp_color",
             "text_color",
             "text_color",
+            "show_channel",
         ] {
         ] {
             let Some(prop) = node_ref.get_property(name) else { continue };
             let Some(prop) = node_ref.get_property(name) else { continue };
             on_modify.when_change_external(prop, |self_, _| async move {
             on_modify.when_change_external(prop, |self_, _| async move {
                 self_.reflow().await;
                 self_.reflow().await;
             });
             });
         }
         }
-        for name in ["cap_max_height", "nick_colors", "action_text_color", "url_text_color"] {
+        for name in [
+            "cap_max_height",
+            "nick_colors",
+            "action_text_color",
+            "url_text_color",
+            "channel_color",
+            "channel_font_size",
+            "channel_spacing",
+            "channel_bg_color",
+            "channel_border_color",
+            "channel_border_size",
+        ] {
             let Some(prop) = self.types().privmsg.node().upgrade().unwrap().get_property(name)
             let Some(prop) = self.types().privmsg.node().upgrade().unwrap().get_property(name)
             else {
             else {
                 continue
                 continue
@@ -1584,6 +1664,7 @@ impl UIObject for ChatView {
         let rect = self.rect.get();
         let rect = self.rect.get();
 
 
         let mouse_pos = self.mouse_pos.lock().clone();
         let mouse_pos = self.mouse_pos.lock().clone();
+        t!("handle_mouse_wheel({wheel_pos:?}) rect={rect:?} mouse_pos={mouse_pos:?}");
         if !rect.contains(mouse_pos) {
         if !rect.contains(mouse_pos) {
             return false
             return false
         }
         }
@@ -1681,6 +1762,392 @@ mod tests {
     use super::*;
     use super::*;
     use crate::app::node::create_chatview;
     use crate::app::node::create_chatview;
 
 
+    /// Replicates the wallet feed's layer chain (window → wallet →
+    /// main → feed → bg/chatview, with a full-screen netstatus layer
+    /// linked after main) and drives the mouse move + wheel dispatch
+    /// through it. Pins the input path that desktop wheel scrolling
+    /// depends on: the chatview must receive the move (tracking the
+    /// position) and then swallow the wheel (containment passing).
+    /// Two chatviews share one kvdb (the chat screen's chatty and the
+    /// wallet's feed view). Every relayed message is received by both
+    /// (`main.rs` fan-out); whichever processes it first dual-writes
+    /// the trees, so the second one's store dedups. The second view
+    /// must still SHOW the message — its buffer, not the tree, is the
+    /// per-view dedup. Covers both fan-out orders.
+    #[test]
+    fn cross_view_receive_shows_in_both_views() {
+        smol::block_on(async {
+            let ex: ExecutorPtr = Arc::new(smol::Executor::new());
+            let (redraw_tx, _redraw_rx) = RedrawTrigger::new();
+            let (method_tx, _method_rx) = async_channel::unbounded();
+            let renderer = Renderer::new(method_tx);
+
+            let window_scale = {
+                let mut w = crate::app::node::create_chatview("w");
+                w.add_property(crate::prop::Property::new(
+                    "scale",
+                    crate::prop::PropertyType::Float32,
+                    crate::prop::PropertySubType::Null,
+                    crate::prop::PropertyPermission::default(),
+                ))
+                .unwrap();
+                let w = w.setup_null();
+                let atom = &mut PropertyAtomicGuard::none();
+                w.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
+                PropertyFloat32::wrap(&w, Role::Internal, "scale", 0).unwrap()
+            };
+            let i18n_fish = crate::util::i18n::I18nBabelFish::new(String::new(), "en-US");
+
+            let make_view = |name: &str, kv_db: KvDb| {
+                let name = name.to_string();
+                let renderer = renderer.clone();
+                let redraw = redraw_tx.clone();
+                let scale = window_scale.clone();
+                let i18n = i18n_fish.clone();
+                let ex = ex.clone();
+                async move {
+                    let node = create_chatview(&name);
+                    let node = node
+                        .setup(move |me| {
+                            ChatView::new(me, kv_db, scale, i18n, renderer, redraw, ex)
+                        })
+                        .await;
+                    let Pimpl::ChatView(ptr) = node.pimpl() else { panic!("chatview pimpl") };
+                    ptr.clone()
+                }
+            };
+
+            // Order A: the chat screen chatty (bound to #dev) processes
+            // first, then the feed view.
+            {
+                let path = std::env::temp_dir()
+                    .join(format!("darkfi-chatview-crossview-a-{}.db", std::process::id()));
+                let _ = std::fs::remove_file(&path);
+                let kv_db = KvDb::open_default(&path).unwrap();
+                let chat_view = make_view("chat_chatty", kv_db.clone()).await;
+                let feed_view = make_view("feed_chatty", kv_db).await;
+                chat_view.handle_set_channel("#dev".to_string()).await;
+                feed_view.handle_set_channel(FEED_CHANNEL.to_string()).await;
+
+                let id = MessageId([7u8; 32]);
+                chat_view
+                    .handle_receive(
+                        &"#ramen".to_string(),
+                        1_756_000_000_000,
+                        id,
+                        "alice".to_string(),
+                        "hi".to_string(),
+                    )
+                    .await;
+                feed_view
+                    .handle_receive(
+                        &"#ramen".to_string(),
+                        1_756_000_000_000,
+                        id,
+                        "alice".to_string(),
+                        "hi".to_string(),
+                    )
+                    .await;
+
+                let feed_buffer = feed_view.buffer.lock().await;
+                assert!(
+                    feed_buffer.record(&id).is_some(),
+                    "feed view missing a message persisted by the chat view first"
+                );
+            }
+
+            // Order B: the feed view processes first, then a chat view
+            // bound to the message's own channel.
+            {
+                let path = std::env::temp_dir()
+                    .join(format!("darkfi-chatview-crossview-b-{}.db", std::process::id()));
+                let _ = std::fs::remove_file(&path);
+                let kv_db = KvDb::open_default(&path).unwrap();
+                let chat_view = make_view("chat_chatty", kv_db.clone()).await;
+                let feed_view = make_view("feed_chatty", kv_db).await;
+                chat_view.handle_set_channel("#ramen".to_string()).await;
+                feed_view.handle_set_channel(FEED_CHANNEL.to_string()).await;
+
+                let id = MessageId([9u8; 32]);
+                feed_view
+                    .handle_receive(
+                        &"#ramen".to_string(),
+                        1_756_000_000_000,
+                        id,
+                        "bob".to_string(),
+                        "yo".to_string(),
+                    )
+                    .await;
+                chat_view
+                    .handle_receive(
+                        &"#ramen".to_string(),
+                        1_756_000_000_000,
+                        id,
+                        "bob".to_string(),
+                        "yo".to_string(),
+                    )
+                    .await;
+
+                let chat_buffer = chat_view.buffer.lock().await;
+                assert!(
+                    chat_buffer.record(&id).is_some(),
+                    "channel view missing a message persisted by the feed view first"
+                );
+            }
+        });
+    }
+
+    #[test]
+    fn wheel_dispatch_through_wallet_feed_layers() {
+        smol::block_on(async {
+            let ex: ExecutorPtr = Arc::new(smol::Executor::new());
+            let (redraw_tx, _redraw_rx) = RedrawTrigger::new();
+            let (method_tx, _method_rx) = async_channel::unbounded();
+            let renderer = Renderer::new(method_tx);
+
+            let root = crate::scene::SceneNode::root();
+
+            let window_scale = {
+                let mut w = crate::app::node::create_chatview("w");
+                w.add_property(crate::prop::Property::new(
+                    "scale",
+                    crate::prop::PropertyType::Float32,
+                    crate::prop::PropertySubType::Null,
+                    crate::prop::PropertyPermission::default(),
+                ))
+                .unwrap();
+                let w = w.setup_null();
+                let atom = &mut PropertyAtomicGuard::none();
+                w.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
+                PropertyFloat32::wrap(&w, Role::Internal, "scale", 0).unwrap()
+            };
+
+            // wallet_layer (0,0,1000,800)
+            let wallet_layer = crate::app::node::create_layer("wallet");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                wallet_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                for (i, v) in [0., 0., 1000., 800.].into_iter().enumerate() {
+                    wallet_layer
+                        .get_property("rect")
+                        .unwrap()
+                        .set_f32(atom, Role::App, i, v)
+                        .unwrap();
+                }
+            }
+            let wallet_layer = wallet_layer
+                .setup(|me| crate::ui::Layer::new(me, renderer.clone(), redraw_tx.clone()))
+                .await;
+            root.link(wallet_layer.clone());
+
+            // main_layer (0,0,1000,800)
+            let main_layer = crate::app::node::create_layer("main_layer");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                main_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                for (i, v) in [0., 0., 1000., 800.].into_iter().enumerate() {
+                    main_layer
+                        .get_property("rect")
+                        .unwrap()
+                        .set_f32(atom, Role::App, i, v)
+                        .unwrap();
+                }
+            }
+            let main_layer = main_layer
+                .setup(|me| crate::ui::Layer::new(me, renderer.clone(), redraw_tx.clone()))
+                .await;
+            wallet_layer.link(main_layer.clone());
+
+            // netstatus_layer linked AFTER main (as in the schema):
+            // full-width, tall, visible — must not swallow input.
+            let netstatus_layer = crate::app::node::create_layer("netstatus_layer");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                netstatus_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                for (i, v) in [0., 0., 1000., 1000.].into_iter().enumerate() {
+                    netstatus_layer
+                        .get_property("rect")
+                        .unwrap()
+                        .set_f32(atom, Role::App, i, v)
+                        .unwrap();
+                }
+            }
+            let netstatus_layer = netstatus_layer
+                .setup(|me| crate::ui::Layer::new(me, renderer.clone(), redraw_tx.clone()))
+                .await;
+            wallet_layer.link(netstatus_layer.clone());
+
+            // feed_layer (15, 415, 970, 370) — the bottom half, inset.
+            let feed_layer = crate::app::node::create_layer("feed_layer");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                feed_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                for (i, v) in [15., 415., 970., 370.].into_iter().enumerate() {
+                    feed_layer
+                        .get_property("rect")
+                        .unwrap()
+                        .set_f32(atom, Role::App, i, v)
+                        .unwrap();
+                }
+            }
+            let feed_layer = feed_layer
+                .setup(|me| crate::ui::Layer::new(me, renderer.clone(), redraw_tx.clone()))
+                .await;
+            main_layer.link(feed_layer.clone());
+
+            // bg vector art covering the whole feed layer, linked first.
+            let bg = crate::app::node::create_vector_art("bg");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                for (i, v) in [0., 0., 970., 370.].into_iter().enumerate() {
+                    bg.get_property("rect").unwrap().set_f32(atom, Role::App, i, v).unwrap();
+                }
+            }
+            let bg = bg
+                .setup(|me| crate::ui::VectorArt::new(me, renderer.clone(), redraw_tx.clone()))
+                .await;
+            feed_layer.link(bg);
+
+            // The feed chatview: (0, 30, 970, 340) feed-layer-local.
+            let path = std::env::temp_dir()
+                .join(format!("darkfi-chatview-wheeltest-{}.db", std::process::id()));
+            let _ = std::fs::remove_file(&path);
+            let kv_db = KvDb::open_default(&path).unwrap();
+            let chatty = create_chatview("feed_chatty");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                for (i, v) in [0., 30., 970., 340.].into_iter().enumerate() {
+                    chatty.get_property("rect").unwrap().set_f32(atom, Role::App, i, v).unwrap();
+                }
+                // Styling defaults (as the schema sets): without them
+                // every record measures ~0px tall and max_scroll
+                // stays 0 — nothing can ever scroll.
+                chatty.set_property_f32(atom, Role::App, "font_size", 14.).unwrap();
+                chatty.set_property_f32(atom, Role::App, "timestamp_font_size", 10.).unwrap();
+                chatty.set_property_f32(atom, Role::App, "timestamp_width", 50.).unwrap();
+                chatty.set_property_f32(atom, Role::App, "line_height", 20.).unwrap();
+                chatty.set_property_f32(atom, Role::App, "message_spacing", 4.).unwrap();
+                let prop = chatty.get_property("text_color").unwrap();
+                for (i, c) in [1., 1., 1., 1.].into_iter().enumerate() {
+                    prop.set_f32(atom, Role::App, i, c).unwrap();
+                }
+                let prop = chatty.get_property("timestamp_color").unwrap();
+                for (i, c) in [0.5, 0.5, 0.5, 1.].into_iter().enumerate() {
+                    prop.set_f32(atom, Role::App, i, c).unwrap();
+                }
+            }
+            let i18n_fish = crate::util::i18n::I18nBabelFish::new(String::new(), "en-US");
+            let chatty = chatty
+                .setup(|me| {
+                    ChatView::new(
+                        me,
+                        kv_db.clone(),
+                        window_scale,
+                        i18n_fish,
+                        renderer.clone(),
+                        redraw_tx.clone(),
+                        ex.clone(),
+                    )
+                })
+                .await;
+            feed_layer.link(chatty.clone());
+
+            // Populate the feed tree so scrolling has range, bind, and
+            // start (spawns the loader pump and the animator).
+            let feed_tree = kv_db.open_tree_default(&ChatView::tree_name(FEED_CHANNEL)).unwrap();
+            for i in 0..300u64 {
+                let payload =
+                    codec::encode_privmsg_payload("alice", "some message text here", true, "#dev");
+                let val = codec::encode_value(MsgType::PrivMsg, &payload);
+                let key = codec::encode_key(1_000_000 + i * 60_000, &MessageId([i as u8; 32]));
+                feed_tree.insert(&key, &val).unwrap();
+            }
+            let Pimpl::ChatView(chatty_ptr) = chatty.pimpl() else { panic!("chatview pimpl") };
+            chatty_ptr.handle_set_channel(FEED_CHANNEL.to_string()).await;
+            chatty_ptr.clone().start(ex.clone()).await;
+
+            // The feed_layer's rect, as the draw path would pass it.
+            let feed_rect = Rectangle::new(15., 415., 970., 370.);
+
+            // Draw passes drive the production loop: the first reports
+            // the viewport (waking the pump's NearTop), the pump loads
+            // the tree, the second sets the content height. The draw
+            // calls enqueue renderer methods into the test channel —
+            // no GPU needed.
+            let atom = &mut PropertyAtomicGuard::none();
+            let draw1 = {
+                let obj = crate::ui::get_ui_object3(&chatty);
+                obj.draw(feed_rect, atom).await
+            };
+            assert!(draw1.is_some(), "first draw produced output");
+
+            // Let the pump load the feed tree.
+            ex.run(async {
+                smol::Timer::after(std::time::Duration::from_millis(100)).await;
+            })
+            .await;
+
+            // Diagnostic: how many records did the pump load? (The
+            // method handler runs on the executor, so the call must
+            // go through it.)
+            let loaded = ex
+                .run(async {
+                    chatty
+                        .call_method("get_line_ids", vec![])
+                        .await
+                        .unwrap()
+                        .map(|data| data.len() / (8 + 32))
+                        .unwrap_or(0)
+                })
+                .await;
+            assert!(loaded > 0, "pump loaded nothing into the buffer");
+
+            let atom = &mut PropertyAtomicGuard::none();
+            let _ = {
+                let obj = crate::ui::get_ui_object3(&chatty);
+                obj.draw(feed_rect, atom).await
+            };
+
+            // Window-space point over the middle of the feed view:
+            // feed_layer-local = (485, 200) → inside (0, 30, 970, 340).
+            let over_feed = Point::new(15. + 485., 415. + 200.);
+
+            let wallet_obj = crate::ui::get_ui_object3(&wallet_layer);
+            wallet_obj.handle_mouse_move(over_feed).await;
+            let swallowed = wallet_obj.handle_mouse_wheel(Point::new(0., 1.)).await;
+            assert!(swallowed, "wheel over the feed chatview was not swallowed by it");
+
+            // A point over the header band (feed-layer-local y < 30)
+            // is outside the chatview rect: the wheel falls through.
+            let over_header = Point::new(15. + 485., 415. + 10.);
+            wallet_obj.handle_mouse_move(over_header).await;
+            let swallowed_header = wallet_obj.handle_mouse_wheel(Point::new(0., 1.)).await;
+            assert!(!swallowed_header, "wheel over the header band must fall through");
+
+            // Wheel over the view again and let the animator run: the
+            // Anim deadline cadence advances the scroll off the bottom,
+            // flipping `is_at_bottom`.
+            wallet_obj.handle_mouse_move(over_feed).await;
+            let swallowed2 = wallet_obj.handle_mouse_wheel(Point::new(0., 1.)).await;
+            assert!(swallowed2, "second wheel tick swallowed");
+
+            let flipped = ex
+                .run(async {
+                    let deadline =
+                        std::time::Instant::now() + std::time::Duration::from_millis(500);
+                    while std::time::Instant::now() < deadline {
+                        smol::future::yield_now().await;
+                        if !chatty.get_property_bool("is_at_bottom").unwrap() {
+                            return true
+                        }
+                    }
+                    false
+                })
+                .await;
+            assert!(flipped, "animated scroll never moved the view off the bottom");
+        });
+    }
+
     /// Every property the chatview wraps must exist on the factory's
     /// Every property the chatview wraps must exist on the factory's
     /// node — catches factory/impl drift that would panic at runtime
     /// node — catches factory/impl drift that would panic at runtime
     /// (a missing prop only explodes when the screen is built).
     /// (a missing prop only explodes when the screen is built).
@@ -1698,6 +2165,7 @@ mod tests {
             "message_spacing",
             "message_spacing",
             "baseline",
             "baseline",
             "timestamp_color",
             "timestamp_color",
+            "show_channel",
             "text_color",
             "text_color",
             "hi_bg_color",
             "hi_bg_color",
             "is_at_bottom",
             "is_at_bottom",
@@ -1709,6 +2177,51 @@ mod tests {
         }
         }
     }
     }
 
 
+    /// Bound to the feed, every channel shows; bound elsewhere, only
+    /// the message's own channel shows.
+    #[test]
+    fn receive_should_show_by_bound_channel() {
+        assert!(receive_should_show("#dev", "#dev"));
+        assert!(!receive_should_show("#ramen", "#dev"));
+        // The feed view shows every channel — including, trivially, a
+        // message "for" the feed itself (by equality).
+        assert!(receive_should_show(FEED_CHANNEL, "#dev"));
+        assert!(receive_should_show(FEED_CHANNEL, "#ramen"));
+        assert!(receive_should_show(FEED_CHANNEL, FEED_CHANNEL));
+    }
+
+    /// Side-channel persistence lands the entry in its own channel's
+    /// tree and the feed tree, carrying the channel, without
+    /// duplicates on repeat relays.
+    #[test]
+    fn side_channel_persist_lands_in_both_trees() {
+        let path = std::env::temp_dir()
+            .join(format!("darkfi-chatview-sidechan-{}.db", std::process::id()));
+        let _ = std::fs::remove_file(&path);
+        let db = KvDb::open_default(&path).unwrap();
+
+        let id = MessageId([b'x'; 32]);
+        persist_side_channel(&db, "#ramen", 1_756_000_000_000, &id, "alice", "hi");
+
+        let key = codec::encode_key(1_756_000_000_000, &id);
+        for name in [ChatView::tree_name("#ramen"), ChatView::tree_name(FEED_CHANNEL)] {
+            let tree = db.open_tree_default(&name).unwrap();
+            assert_eq!(tree.len().unwrap(), 1, "{name} holds the entry");
+            let val = tree.get(&key).unwrap().expect("entry present");
+            let rec = codec::decode_value(&val, 1_756_000_000_000, &id);
+            let (nick, text, _confirmed, channel) =
+                codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
+            assert_eq!((nick.as_str(), text.as_str(), channel.as_str()), ("alice", "hi", "#ramen"));
+        }
+
+        // A duplicate relay changes nothing in either tree.
+        persist_side_channel(&db, "#ramen", 1_756_000_000_000, &id, "alice", "hi");
+        for name in [ChatView::tree_name("#ramen"), ChatView::tree_name(FEED_CHANNEL)] {
+            let tree = db.open_tree_default(&name).unwrap();
+            assert_eq!(tree.len().unwrap(), 1, "{name} still holds one entry");
+        }
+    }
+
     use crate::{
     use crate::{
         prop::{Property, PropertyAtomicGuard, PropertySubType, PropertyType},
         prop::{Property, PropertyAtomicGuard, PropertySubType, PropertyType},
         scene::{SceneNode as TestSceneNode, SceneNodeType as TestSceneNodeType},
         scene::{SceneNode as TestSceneNode, SceneNodeType as TestSceneNodeType},
@@ -1786,7 +2299,7 @@ mod tests {
             id[0] = idb;
             id[0] = idb;
             let id = MessageId(id);
             let id = MessageId(id);
             ids.push(id);
             ids.push(id);
-            let payload = codec::encode_privmsg_payload("alice", text, true);
+            let payload = codec::encode_privmsg_payload("alice", text, true, "#dev");
             let rec = MsgRecord { ts, id, msg_type: MsgType::PrivMsg, payload, height: 0. };
             let rec = MsgRecord { ts, id, msg_type: MsgType::PrivMsg, payload, height: 0. };
             types.measure(&rec);
             types.measure(&rec);
             assert!(buffer.insert(MsgRecord { height: 20., ..rec }));
             assert!(buffer.insert(MsgRecord { height: 20., ..rec }));

+ 9 - 8
bin/app/src/ui/chatview/msg/filemsg.rs

@@ -251,7 +251,9 @@ impl FileMsgNode {
 
 
     fn img_size(&self, imgbuf: &GenericImageBuffer) -> (f32, f32) {
     fn img_size(&self, imgbuf: &GenericImageBuffer) -> (f32, f32) {
         const IMG_MAX_HEIGHT: f32 = 500.;
         const IMG_MAX_HEIGHT: f32 = 500.;
-        let max_width = self.shared.rect.get().w - self.shared.timestamp_width.get();
+        // File messages are continuation content: full view width,
+        // starting at the left edge like wrapped lines.
+        let max_width = self.shared.rect.get().w;
         let img_w = imgbuf.width() as f32;
         let img_w = imgbuf.width() as f32;
         let img_h = imgbuf.height() as f32;
         let img_h = imgbuf.height() as f32;
         let scale = (max_width / img_w).min(IMG_MAX_HEIGHT / img_h);
         let scale = (max_width / img_w).min(IMG_MAX_HEIGHT / img_h);
@@ -381,11 +383,11 @@ impl FileMsgNode {
 
 
         let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
         let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
         let line_height = self.shared.line_height.get();
         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 timestamp_color = self.shared.timestamp_color.get();
         let font_size = self.shared.font_size.get();
         let font_size = self.shared.font_size.get();
         let window_scale = self.shared.window_scale.get();
         let window_scale = self.shared.window_scale.get();
-        let max_width = self.shared.rect.get().w - timestamp_width - GLOW_SIZE;
+        // Continuation content: full width from the left edge.
+        let max_width = self.shared.rect.get().w - GLOW_SIZE;
 
 
         if inst.instrs.is_none() {
         if inst.instrs.is_none() {
             let (status, imgbuf) = {
             let (status, imgbuf) = {
@@ -401,7 +403,7 @@ impl FileMsgNode {
             if let Some(imgbuf) = imgbuf {
             if let Some(imgbuf) = imgbuf {
                 // Downloaded image: fitted to bounds, with a glow.
                 // Downloaded image: fitted to bounds, with a glow.
                 let (img_w, img_h) = self.img_size(&imgbuf);
                 let (img_w, img_h) = self.img_size(&imgbuf);
-                let mesh_rect = Rectangle::from([timestamp_width, Self::MARGIN_TOP, img_w, img_h]);
+                let mesh_rect = Rectangle::from([0., Self::MARGIN_TOP, img_w, img_h]);
                 let width = imgbuf.width() as u16;
                 let width = imgbuf.width() as u16;
                 let height = imgbuf.height() as u16;
                 let height = imgbuf.height() as u16;
                 let bmp = imgbuf.as_raw().clone();
                 let bmp = imgbuf.as_raw().clone();
@@ -449,8 +451,7 @@ impl FileMsgNode {
                 let box_height = 2. * line_height + BOX_PADDING_Y * 2.;
                 let box_height = 2. * line_height + BOX_PADDING_Y * 2.;
                 let box_width = if text_width > max_width { max_width } else { text_width } +
                 let box_width = if text_width > max_width { max_width } else { text_width } +
                     BOX_PADDING_X * 2.;
                     BOX_PADDING_X * 2.;
-                let mesh_rect =
-                    Rectangle::new(timestamp_width, Self::MARGIN_TOP, box_width, box_height);
+                let mesh_rect = Rectangle::new(0., Self::MARGIN_TOP, box_width, box_height);
 
 
                 let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg_box"));
                 let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg_box"));
                 mesh.draw_outline(&mesh_rect, color, 1.);
                 mesh.draw_outline(&mesh_rect, color, 1.);
@@ -459,7 +460,7 @@ impl FileMsgNode {
                 instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
                 instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
 
 
                 instrs.push(DrawInstruction::Move(Point::new(
                 instrs.push(DrawInstruction::Move(Point::new(
-                    timestamp_width + BOX_PADDING_X,
+                    BOX_PADDING_X,
                     Self::MARGIN_TOP + BOX_PADDING_Y,
                     Self::MARGIN_TOP + BOX_PADDING_Y,
                 )));
                 )));
                 for layout in layouts {
                 for layout in layouts {
@@ -696,7 +697,7 @@ mod tests {
     #[test]
     #[test]
     fn derivation_keys_and_orders() {
     fn derivation_keys_and_orders() {
         let payload =
         let payload =
-            codec::encode_privmsg_payload("alice", "grab fud://abcdef/file.tar now", true);
+            codec::encode_privmsg_payload("alice", "grab fud://abcdef/file.tar now", true, "#dev");
         let privmsg = MsgRecord {
         let privmsg = MsgRecord {
             ts: 1000,
             ts: 1000,
             id: MessageId([7; 32]),
             id: MessageId([7; 32]),

+ 9 - 1
bin/app/src/ui/chatview/msg/mod.rs

@@ -38,7 +38,7 @@ use url::Url;
 use crate::{
 use crate::{
     app::node::{create_datemsg_node, create_filemsg_node, create_privmsg_node},
     app::node::{create_datemsg_node, create_filemsg_node, create_privmsg_node},
     gfx::{DrawInstruction, Point, Renderer},
     gfx::{DrawInstruction, Point, Renderer},
-    prop::{PropertyColor, PropertyFloat32, PropertyPtr, PropertyRect, Role},
+    prop::{PropertyBool, PropertyColor, PropertyFloat32, PropertyPtr, PropertyRect, Role},
     scene::{Pimpl, SceneNodePtr, SceneNodeWeak},
     scene::{Pimpl, SceneNodePtr, SceneNodeWeak},
     util::i18n::I18nBabelFish,
     util::i18n::I18nBabelFish,
 };
 };
@@ -68,6 +68,8 @@ pub struct SharedProps {
     pub timestamp_color: PropertyColor,
     pub timestamp_color: PropertyColor,
     pub text_color: PropertyColor,
     pub text_color: PropertyColor,
     pub hi_bg_color: PropertyColor,
     pub hi_bg_color: PropertyColor,
+    /// Whether each privmsg renders its channel name in the gutter.
+    pub show_channel: PropertyBool,
     pub window_scale: PropertyFloat32,
     pub window_scale: PropertyFloat32,
     /// The chatview's rect; `.w` is the wrap width.
     /// The chatview's rect; `.w` is the wrap width.
     pub rect: PropertyRect,
     pub rect: PropertyRect,
@@ -97,6 +99,8 @@ impl SharedProps {
             .expect("chatview text_color");
             .expect("chatview text_color");
         let hi_bg_color = PropertyColor::wrap(chatview_node, Role::Internal, "hi_bg_color")
         let hi_bg_color = PropertyColor::wrap(chatview_node, Role::Internal, "hi_bg_color")
             .expect("chatview hi_bg_color");
             .expect("chatview hi_bg_color");
+        let show_channel = PropertyBool::wrap(chatview_node, Role::Internal, "show_channel", 0)
+            .expect("chatview show_channel");
         let rect =
         let rect =
             PropertyRect::wrap(chatview_node, Role::Internal, "rect").expect("chatview rect");
             PropertyRect::wrap(chatview_node, Role::Internal, "rect").expect("chatview rect");
 
 
@@ -110,6 +114,7 @@ impl SharedProps {
             timestamp_color,
             timestamp_color,
             text_color,
             text_color,
             hi_bg_color,
             hi_bg_color,
+            show_channel,
             window_scale,
             window_scale,
             rect,
             rect,
         }
         }
@@ -122,6 +127,9 @@ impl SharedProps {
 pub enum Hit {
 pub enum Hit {
     Url(String),
     Url(String),
     Nick(String),
     Nick(String),
+    /// The channel span in the timestamp gutter (the message's room,
+    /// clickable for navigation).
+    Channel(String),
     /// The collapsed/expanded toggle affordance of a capped message.
     /// The collapsed/expanded toggle affordance of a capped message.
     Expand,
     Expand,
     /// A file message's activation (download request) target.
     /// A file message's activation (download request) target.

+ 493 - 68
bin/app/src/ui/chatview/msg/privmsg.rs

@@ -41,7 +41,7 @@ use url::Url;
 
 
 use crate::{
 use crate::{
     gfx::{gfxtag, DrawInstruction, EpochTracker, Point, Rectangle, Renderer},
     gfx::{gfxtag, DrawInstruction, EpochTracker, Point, Rectangle, Renderer},
-    mesh::Color,
+    mesh::{Color, MeshBuilder},
     prop::{
     prop::{
         Property, PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyPermission,
         Property, PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyPermission,
         PropertyPtr, PropertyStr, PropertySubType, PropertyType, Role,
         PropertyPtr, PropertyStr, PropertySubType, PropertyType, Role,
@@ -128,6 +128,14 @@ pub struct PrivOwnProps {
     pub url_bg_border_size: PropertyFloat32,
     pub url_bg_border_size: PropertyFloat32,
     pub url_bg_border_color: PropertyColor,
     pub url_bg_border_color: PropertyColor,
     pub cap_max_height: PropertyFloat32,
     pub cap_max_height: PropertyFloat32,
+    /// Channel label styling (the room-name span).
+    pub channel_color: PropertyColor,
+    pub channel_font_size: PropertyFloat32,
+    /// Spacing between timestamp, chip, and line.
+    pub channel_spacing: PropertyFloat32,
+    pub channel_bg_color: PropertyColor,
+    pub channel_border_size: PropertyFloat32,
+    pub channel_border_color: PropertyColor,
 }
 }
 
 
 /// Everything a materialized instance needs to render: data decoded
 /// Everything a materialized instance needs to render: data decoded
@@ -142,6 +150,11 @@ pub struct PrivMsg {
     sig: LayoutSig,
     sig: LayoutSig,
     txt_layout: text::TextLayout,
     txt_layout: text::TextLayout,
     ts_layout: text::TextLayout,
     ts_layout: text::TextLayout,
+    /// The channel span's layout, when rendered (show_channel active).
+    chan_layout: Option<text::TextLayout>,
+    /// The channel chip rect (label bounds + padding for bg/border),
+    /// when the span is rendered.
+    channel_chip: Option<Rectangle>,
     instrs: Option<Vec<DrawInstruction>>,
     instrs: Option<Vec<DrawInstruction>>,
     /// URL hit rects in message-local coordinates, tagged with their
     /// URL hit rects in message-local coordinates, tagged with their
     /// URL string.
     /// URL string.
@@ -149,6 +162,9 @@ pub struct PrivMsg {
     /// Nick-prefix hit rects in message-local coordinates, tagged with
     /// Nick-prefix hit rects in message-local coordinates, tagged with
     /// the nick.
     /// the nick.
     nick_rects: Vec<(String, Rectangle)>,
     nick_rects: Vec<(String, Rectangle)>,
+    /// The channel span's hit rect (the label's row, after the
+    /// timestamp), when rendered.
+    channel_rect: Option<Rectangle>,
     /// The expand/collapse affordance hit rect, when the message is
     /// The expand/collapse affordance hit rect, when the message is
     /// over the cap.
     /// over the cap.
     affordance_rect: Option<Rectangle>,
     affordance_rect: Option<Rectangle>,
@@ -168,6 +184,9 @@ pub struct PrivData {
     pub nick: String,
     pub nick: String,
     pub text: String,
     pub text: String,
     pub confirmed: bool,
     pub confirmed: bool,
+    /// The channel the message belongs to (from the payload); rendered
+    /// as the channel span when channel display is active.
+    pub channel: String,
     /// IRC-style CTCP ACTION (`/me`); `text` holds the stripped body.
     /// IRC-style CTCP ACTION (`/me`); `text` holds the stripped body.
     pub is_action: bool,
     pub is_action: bool,
     pub is_notice: bool,
     pub is_notice: bool,
@@ -207,16 +226,25 @@ impl PrivData {
 #[derive(PartialEq)]
 #[derive(PartialEq)]
 struct LayoutSig {
 struct LayoutSig {
     width: f32,
     width: f32,
-    timestamp_width: f32,
     font_size: f32,
     font_size: f32,
     timestamp_font_size: f32,
     timestamp_font_size: f32,
     line_height: f32,
     line_height: f32,
     window_scale: f32,
     window_scale: f32,
     confirmed: bool,
     confirmed: bool,
     expanded: bool,
     expanded: bool,
+    show_channel: bool,
     body_color: Color,
     body_color: Color,
     nick_color: Color,
     nick_color: Color,
     ts_color: Color,
     ts_color: Color,
+    channel_color: Color,
+    /// The channel label's font size (timestamp size when inherited).
+    channel_font_size: f32,
+    /// Spacing between timestamp, chip, and line (auto fraction of
+    /// the channel font size when the property is 0).
+    channel_spacing: f32,
+    channel_bg_color: Color,
+    channel_border_color: Color,
+    channel_border_size: f32,
 }
 }
 
 
 struct PrivInner {
 struct PrivInner {
@@ -277,6 +305,17 @@ impl PrivMsgNode {
             PropertyColor::wrap(node_ref, Role::Internal, "url_bg_border_color").unwrap();
             PropertyColor::wrap(node_ref, Role::Internal, "url_bg_border_color").unwrap();
         let cap_max_height =
         let cap_max_height =
             PropertyFloat32::wrap(node_ref, Role::Internal, "cap_max_height", 0).unwrap();
             PropertyFloat32::wrap(node_ref, Role::Internal, "cap_max_height", 0).unwrap();
+        let channel_color = PropertyColor::wrap(node_ref, Role::Internal, "channel_color").unwrap();
+        let channel_font_size =
+            PropertyFloat32::wrap(node_ref, Role::Internal, "channel_font_size", 0).unwrap();
+        let channel_spacing =
+            PropertyFloat32::wrap(node_ref, Role::Internal, "channel_spacing", 0).unwrap();
+        let channel_bg_color =
+            PropertyColor::wrap(node_ref, Role::Internal, "channel_bg_color").unwrap();
+        let channel_border_size =
+            PropertyFloat32::wrap(node_ref, Role::Internal, "channel_border_size", 0).unwrap();
+        let channel_border_color =
+            PropertyColor::wrap(node_ref, Role::Internal, "channel_border_color").unwrap();
         let url_copy_text =
         let url_copy_text =
             PropertyStr::wrap(node_ref, Role::Internal, "url_copy_text", 0).unwrap();
             PropertyStr::wrap(node_ref, Role::Internal, "url_copy_text", 0).unwrap();
         let url_copy_fg_color =
         let url_copy_fg_color =
@@ -303,6 +342,12 @@ impl PrivMsgNode {
                 url_bg_border_size,
                 url_bg_border_size,
                 url_bg_border_color,
                 url_bg_border_color,
                 cap_max_height,
                 cap_max_height,
+                channel_color,
+                channel_font_size,
+                channel_spacing,
+                channel_bg_color,
+                channel_border_size,
+                channel_border_color,
             },
             },
             loader,
             loader,
             buffer,
             buffer,
@@ -328,6 +373,8 @@ impl PrivMsgNode {
 
 
     /// Read the current layout signature off the live properties.
     /// Read the current layout signature off the live properties.
     fn current_sig(&self, data: &PrivData) -> LayoutSig {
     fn current_sig(&self, data: &PrivData) -> LayoutSig {
+        // The body wraps at the full view width; the first line is
+        // indented past the gutter via the layout's text-indent.
         let width = self.shared.rect.get().w;
         let width = self.shared.rect.get().w;
         let font_size = if data.is_notice {
         let font_size = if data.is_notice {
             self.shared.font_size.get() * 0.8
             self.shared.font_size.get() * 0.8
@@ -349,16 +396,22 @@ impl PrivMsgNode {
         };
         };
         LayoutSig {
         LayoutSig {
             width,
             width,
-            timestamp_width: self.shared.timestamp_width.get(),
             font_size,
             font_size,
             timestamp_font_size: self.shared.timestamp_font_size.get(),
             timestamp_font_size: self.shared.timestamp_font_size.get(),
             line_height: self.shared.line_height.get(),
             line_height: self.shared.line_height.get(),
             window_scale: self.shared.window_scale.get(),
             window_scale: self.shared.window_scale.get(),
             confirmed: data.confirmed,
             confirmed: data.confirmed,
             expanded: data.expanded,
             expanded: data.expanded,
+            show_channel: self.shared.show_channel.get(),
             body_color,
             body_color,
             nick_color,
             nick_color,
             ts_color: self.shared.timestamp_color.get(),
             ts_color: self.shared.timestamp_color.get(),
+            channel_color: self.own.channel_color.get(),
+            channel_font_size: self.own.channel_font_size.get(),
+            channel_spacing: self.own.channel_spacing.get(),
+            channel_bg_color: self.own.channel_bg_color.get(),
+            channel_border_color: self.own.channel_border_color.get(),
+            channel_border_size: self.own.channel_border_size.get(),
         }
         }
     }
     }
 
 
@@ -381,6 +434,56 @@ impl PrivMsgNode {
             self.own.url_text_color.get(),
             self.own.url_text_color.get(),
         ));
         ));
 
 
+        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,
+            &[],
+        );
+        // The spacing property governs every junction on the first
+        // line: timestamp → line when the label is hidden, and
+        // timestamp → chip → line when it is shown. Wrapped lines
+        // return to the view's left edge.
+        let gap = sig.channel_spacing;
+        let (chan_layout, channel_rect, channel_chip, body_indent) = if sig.show_channel &&
+            !data.channel.is_empty()
+        {
+            let chan_x = ts_layout.width() + gap;
+            // Keep an absurd room name from eating the line.
+            let max_chan_w = (self.shared.rect.get().w * 0.3).max(sig.channel_font_size);
+            let label =
+                ellipsize_to_width(&data.channel, max_chan_w, sig.channel_font_size, window_scale);
+            let layout = text::make_layout(
+                &label,
+                sig.channel_color,
+                sig.channel_font_size,
+                line_height / sig.channel_font_size,
+                window_scale,
+                None,
+                &[],
+            );
+            let rect = Rectangle::new(chan_x, 0., layout.width(), ts_layout.height());
+            // The chip (bg + border) hugs the label's glyphs. The
+            // bounds are in the label layout's LOCAL coordinates,
+            // so shift them onto the label's position on the
+            // timestamp row before padding.
+            let pad = sig.channel_border_size + sig.channel_font_size * 0.2;
+            let bounds = layout_glyph_bounds(&layout);
+            let chip =
+                inflate_rect(&Rectangle::new(bounds.x + chan_x, bounds.y, bounds.w, bounds.h), pad);
+            let indent = chan_x + layout.width() + gap;
+            (Some(layout), Some(rect), Some(chip), indent)
+        } else {
+            (None, None, None, ts_layout.width() + gap)
+        };
+
+        // The body wraps at the full view width; the first line is
+        // indented past the gutter (and channel span) via the
+        // layout's text-indent, continuation lines start at x=0.
         let txt_layout = text::make_layout_with_indent(
         let txt_layout = text::make_layout_with_indent(
             &linetext,
             &linetext,
             sig.body_color,
             sig.body_color,
@@ -392,22 +495,13 @@ impl PrivMsgNode {
             if data.is_notice { &[] } else { &foreground_colors },
             if data.is_notice { &[] } else { &foreground_colors },
             parley::Alignment::Start,
             parley::Alignment::Start,
             parley::OverflowWrap::Normal,
             parley::OverflowWrap::Normal,
-            sig.timestamp_width,
-        );
-
-        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,
-            &[],
+            body_indent,
         );
         );
 
 
         // Hit rects: URL-colored and nick-colored glyph runs become
         // Hit rects: URL-colored and nick-colored glyph runs become
-        // clickable regions in message-local coordinates.
+        // clickable regions in message-local coordinates. The body
+        // layout spans the full width (the first line's indent is
+        // internal), so run offsets are already view-relative.
         let url_color = self.own.url_text_color.get();
         let url_color = self.own.url_text_color.get();
         let nick_color = sig.nick_color;
         let nick_color = sig.nick_color;
         let url_rects = Self::compute_hit_rects(
         let url_rects = Self::compute_hit_rects(
@@ -446,9 +540,12 @@ impl PrivMsgNode {
             sig,
             sig,
             txt_layout,
             txt_layout,
             ts_layout,
             ts_layout,
+            chan_layout,
+            channel_chip,
             instrs: None,
             instrs: None,
             url_rects,
             url_rects,
             nick_rects,
             nick_rects,
+            channel_rect,
             affordance_rect,
             affordance_rect,
             full_text_height,
             full_text_height,
             over_cap,
             over_cap,
@@ -472,7 +569,8 @@ impl PrivMsgNode {
             }
             }
         }
         }
 
 
-        let (nick, text, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
+        let (nick, text, confirmed, channel) =
+            codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
         let (is_action, text) = match parse_ctcp_action(&text) {
         let (is_action, text) = match parse_ctcp_action(&text) {
             Some(action) => (true, action.to_string()),
             Some(action) => (true, action.to_string()),
             None => (false, text),
             None => (false, text),
@@ -484,6 +582,7 @@ impl PrivMsgNode {
             nick,
             nick,
             text,
             text,
             confirmed,
             confirmed,
+            channel,
             is_action,
             is_action,
             is_notice,
             is_notice,
             expanded: true,
             expanded: true,
@@ -570,6 +669,12 @@ impl PrivMsgNode {
         self.own.action_text_color.eval(atom).expect("action_text_color");
         self.own.action_text_color.eval(atom).expect("action_text_color");
         self.own.url_text_color.eval(atom).expect("url_text_color");
         self.own.url_text_color.eval(atom).expect("url_text_color");
         self.own.cap_max_height.eval(atom).expect("cap_max_height");
         self.own.cap_max_height.eval(atom).expect("cap_max_height");
+        self.own.channel_color.eval(atom).expect("channel_color");
+        self.own.channel_font_size.eval(atom).expect("channel_font_size");
+        self.own.channel_spacing.eval(atom).expect("channel_spacing");
+        self.own.channel_bg_color.eval(atom).expect("channel_bg_color");
+        self.own.channel_border_color.eval(atom).expect("channel_border_color");
+        self.own.channel_border_size.eval(atom).expect("channel_border_size");
     }
     }
 
 
     pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> super::DrawOutcome {
     pub fn draw(&self, rec: &MsgRecord, renderer: &Renderer) -> super::DrawOutcome {
@@ -588,12 +693,25 @@ impl PrivMsgNode {
 
 
         let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
         let Some(inst) = inner.instances.get_mut(&key) else { return DrawOutcome::Inline(vec![]) };
         if inst.instrs.is_none() {
         if inst.instrs.is_none() {
+            // `Move` offsets are RELATIVE (they add to the draw
+            // cursor), so the assembly below tracks the cursor
+            // explicitly and emits deltas to each segment's absolute
+            // message-local position.
+            let mut cursor = Point::new(0., 0.);
+            let mut move_to =
+                |instrs: &mut Vec<DrawInstruction>, cursor: &mut Point, x: f32, y: f32| {
+                    instrs.push(DrawInstruction::Move(Point::new(x - cursor.x, y - cursor.y)));
+                    *cursor = Point::new(x, y);
+                };
+
+            // Timestamp at the message origin.
             let mut instrs =
             let mut instrs =
                 text::render_layout(&inst.ts_layout, renderer, gfxtag!("chatview_privmsg_ts"));
                 text::render_layout(&inst.ts_layout, renderer, gfxtag!("chatview_privmsg_ts"));
 
 
-            // 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.
+            // The body renders at the origin too: its layout spans the
+            // full width and carries the first line's gutter indent
+            // internally, so wrapped lines begin at the left edge.
+            // (No Move needed — the cursor never left the origin.)
             if url_regex().is_match(&inst.data.text) {
             if url_regex().is_match(&inst.data.text) {
                 let bg_instrs = text::render_backgrounds(
                 let bg_instrs = text::render_backgrounds(
                     &inst.txt_layout,
                     &inst.txt_layout,
@@ -611,6 +729,32 @@ impl PrivMsgNode {
                 text::render_layout(&inst.txt_layout, renderer, gfxtag!("chatview_privmsg_text"));
                 text::render_layout(&inst.txt_layout, renderer, gfxtag!("chatview_privmsg_text"));
             instrs.extend(text_instrs);
             instrs.extend(text_instrs);
 
 
+            // The channel chip: bg fill + border behind the label,
+            // drawn at the origin (absolute coords) so it sits under
+            // the label's Move. Invisible parts are skipped by the
+            // same alpha/size rules the URL chip uses.
+            if let (Some(chan_layout), Some(rect), Some(chip)) =
+                (&inst.chan_layout, &inst.channel_rect, &inst.channel_chip)
+            {
+                let bg_color = self.own.channel_bg_color.get();
+                let border_color = self.own.channel_border_color.get();
+                let border_size = self.own.channel_border_size.get();
+                if bg_color[3] > 0.01 || (border_size > 0.01 && border_color[3] > 0.01) {
+                    let mut mesh = MeshBuilder::new(gfxtag!("chatview_privmsg_chanbg"));
+                    if bg_color[3] > 0.01 {
+                        mesh.draw_filled_box(chip, bg_color);
+                    }
+                    if border_size > 0.01 && border_color[3] > 0.01 {
+                        mesh.draw_outline(chip, border_color, border_size);
+                    }
+                    instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
+                }
+                move_to(&mut instrs, &mut cursor, rect.x, 0.);
+                let chan_instrs =
+                    text::render_layout(chan_layout, renderer, gfxtag!("chatview_privmsg_chan"));
+                instrs.extend(chan_instrs);
+            }
+
             // The expand/collapse affordance label, centered in its rect.
             // The expand/collapse affordance label, centered in its rect.
             if let Some(rect) = &inst.affordance_rect {
             if let Some(rect) = &inst.affordance_rect {
                 let label = if inst.collapsed { "+" } else { "-" };
                 let label = if inst.collapsed { "+" } else { "-" };
@@ -623,10 +767,12 @@ impl PrivMsgNode {
                     None,
                     None,
                     &[],
                     &[],
                 );
                 );
-                instrs.push(DrawInstruction::Move(Point::new(
+                move_to(
+                    &mut instrs,
+                    &mut cursor,
                     rect.x + (rect.w - label_layout.width()) / 2.,
                     rect.x + (rect.w - label_layout.width()) / 2.,
                     rect.y + (rect.h - label_layout.height()) / 2.,
                     rect.y + (rect.h - label_layout.height()) / 2.,
-                )));
+                );
                 let label_instrs =
                 let label_instrs =
                     text::render_layout(&label_layout, renderer, gfxtag!("chatview_privmsg_cap"));
                     text::render_layout(&label_layout, renderer, gfxtag!("chatview_privmsg_cap"));
                 instrs.extend(label_instrs);
                 instrs.extend(label_instrs);
@@ -664,6 +810,11 @@ impl PrivMsgNode {
                 return Some(Hit::Nick(nick.clone()))
                 return Some(Hit::Nick(nick.clone()))
             }
             }
         }
         }
+        if let Some(rect) = &inst.channel_rect {
+            if rect.contains(pos) {
+                return Some(Hit::Channel(inst.data.channel.clone()))
+            }
+        }
         if let Some(rect) = &inst.affordance_rect {
         if let Some(rect) = &inst.affordance_rect {
             if rect.contains(pos) {
             if rect.contains(pos) {
                 return Some(Hit::Expand)
                 return Some(Hit::Expand)
@@ -705,6 +856,20 @@ impl PrivMsgNode {
             .unwrap_or_default()
             .unwrap_or_default()
     }
     }
 
 
+    /// The channel span's hit rect of a materialized instance, when
+    /// rendered (test access).
+    pub(crate) fn channel_rect(&self, rec: &MsgRecord) -> Option<Rectangle> {
+        let inner = self.inner.lock();
+        inner.instances.get(&(rec.ts, rec.id)).and_then(|inst| inst.channel_rect)
+    }
+
+    /// The channel chip rect of a materialized instance, when the
+    /// span is rendered (test access).
+    pub(crate) fn channel_chip(&self, rec: &MsgRecord) -> Option<Rectangle> {
+        let inner = self.inner.lock();
+        inner.instances.get(&(rec.ts, rec.id)).and_then(|inst| inst.channel_chip)
+    }
+
     /// Build hit rects for the glyph runs whose brush matches
     /// Build hit rects for the glyph runs whose brush matches
     /// `match_color`, mapping each run to its entry in `ranges` by
     /// `match_color`, mapping each run to its entry in `ranges` by
     /// intersecting the (coarse) font-run text range. Layout
     /// intersecting the (coarse) font-run text range. Layout
@@ -749,30 +914,43 @@ impl PrivMsgNode {
         rects
         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 a confirmed privmsg into the buffer (display-only:
+    /// persistence is the receive path's job — `handle_receive`
+    /// persists through `persist_side_channel` before showing).
+    /// Measure, insert, keep the view stable if the message grows
+    /// content below the viewport. An insert for a message this view
+    /// is already showing acts as its confirmation.
+    pub async fn insert_line(
+        &self,
+        ts: Timestamp,
+        id: MessageId,
+        channel: String,
+        nick: String,
+        text: String,
+    ) {
+        self.insert_privmsg(ts, id, channel, nick, text, true).await
     }
     }
 
 
     /// Insert an unconfirmed (sent, not yet seen on the network)
     /// Insert an unconfirmed (sent, not yet seen on the network)
-    /// privmsg; persisted with the confirmed flag in the payload.
+    /// privmsg — display-only, nothing is persisted; the trees only
+    /// ever hold confirmed copies (written by the receive path when
+    /// the network relay comes back).
     pub async fn insert_unconf_line(
     pub async fn insert_unconf_line(
         &self,
         &self,
         ts: Timestamp,
         ts: Timestamp,
         id: MessageId,
         id: MessageId,
+        channel: String,
         nick: String,
         nick: String,
         text: String,
         text: String,
     ) {
     ) {
-        self.insert_privmsg(ts, id, nick, text, false).await
+        self.insert_privmsg(ts, id, channel, nick, text, false).await
     }
     }
 
 
     async fn insert_privmsg(
     async fn insert_privmsg(
         &self,
         &self,
         ts: Timestamp,
         ts: Timestamp,
         id: MessageId,
         id: MessageId,
+        channel: String,
         nick: String,
         nick: String,
         text: String,
         text: String,
         confirmed: bool,
         confirmed: bool,
@@ -781,22 +959,24 @@ impl PrivMsgNode {
             error!(target: "ui::chatview::privmsg", "rejecting insert with non-millisecond timestamp {ts}");
             error!(target: "ui::chatview::privmsg", "rejecting insert with non-millisecond timestamp {ts}");
             return
             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
-        }
-
+        t!("insert ts={ts} id={id} channel={channel} nick={nick} confirmed={confirmed}");
+
+        // Display-only: persistence is the receive path's job
+        // (`handle_receive` persists every message through
+        // `persist_side_channel` before deciding to show it). The
+        // buffer insert is the per-view gate: a composite key already
+        // present means this view is showing it — the re-receive is
+        // its confirmation (unconfirmed → confirmed flip), or a
+        // duplicate relay, for which confirm is inert.
+        let payload = codec::encode_privmsg_payload(&nick, &text, confirmed, &channel);
         let rec = MsgRecord { ts, id: id.clone(), msg_type: MsgType::PrivMsg, payload, height: 0. };
         let rec = MsgRecord { ts, id: id.clone(), msg_type: MsgType::PrivMsg, payload, height: 0. };
         let height = self.measure(&rec);
         let height = self.measure(&rec);
 
 
         let full_rec = MsgRecord { height, ..rec };
         let full_rec = MsgRecord { height, ..rec };
         let mut buffer = self.buffer.lock().await;
         let mut buffer = self.buffer.lock().await;
         if !buffer.insert(full_rec.clone()) {
         if !buffer.insert(full_rec.clone()) {
+            drop(buffer);
+            self.confirm(id).await;
             return
             return
         }
         }
         let top = buffer.pos_of_key(&(full_rec.ts, full_rec.id));
         let top = buffer.pos_of_key(&(full_rec.ts, full_rec.id));
@@ -826,6 +1006,10 @@ impl PrivMsgNode {
 
 
     /// Mark an unconfirmed message confirmed: rewrite the payload in
     /// Mark an unconfirmed message confirmed: rewrite the payload in
     /// place (same kvdb entry), update the record, regen for styling.
     /// place (same kvdb entry), update the record, regen for styling.
+    /// Mark an unconfirmed message confirmed: flip the buffered
+    /// record's payload and regen for styling. Buffer-only — the trees
+    /// only ever hold confirmed copies (persistence is the receive
+    /// path's job).
     pub async fn confirm(&self, id: MessageId) {
     pub async fn confirm(&self, id: MessageId) {
         let (ts, payload) = {
         let (ts, payload) = {
             let buffer = self.buffer.lock().await;
             let buffer = self.buffer.lock().await;
@@ -836,13 +1020,12 @@ impl PrivMsgNode {
             (rec.ts, rec.payload.clone())
             (rec.ts, rec.payload.clone())
         };
         };
 
 
-        let (nick, text, confirmed) = codec::decode_privmsg_payload(&payload, ts, &id);
+        let (nick, text, confirmed, channel) = codec::decode_privmsg_payload(&payload, ts, &id);
         if confirmed {
         if confirmed {
             return
             return
         }
         }
 
 
-        let new_payload = codec::encode_privmsg_payload(&nick, &text, true);
-        self.loader.update(ts, &id, MsgType::PrivMsg, &new_payload);
+        let new_payload = codec::encode_privmsg_payload(&nick, &text, true, &channel);
         {
         {
             let mut buffer = self.buffer.lock().await;
             let mut buffer = self.buffer.lock().await;
             if let Some(rec) = buffer.record_mut(&id) {
             if let Some(rec) = buffer.record_mut(&id) {
@@ -905,6 +1088,68 @@ fn url_regex() -> &'static regex::Regex {
         .get_or_init(|| regex::Regex::new(r"https?://[^\s]+|fud://[^\s]+|www\.[^\s]+").unwrap())
         .get_or_init(|| regex::Regex::new(r"https?://[^\s]+|fud://[^\s]+|www\.[^\s]+").unwrap())
 }
 }
 
 
+/// The tight glyph bounds of a layout, in virtual units. Empty when
+/// the layout holds no glyphs.
+fn layout_glyph_bounds(layout: &text::TextLayout) -> Rectangle {
+    let scale = layout.scale();
+    let mut x0 = f32::MAX;
+    let mut y0 = f32::MAX;
+    let mut x1 = f32::MIN;
+    let mut y1 = f32::MIN;
+    for line in layout.lines() {
+        for item in line.items() {
+            let parley::PositionedLayoutItem::GlyphRun(run) = item else { continue };
+            let metrics = run.run().metrics();
+            let x = run.offset() / scale;
+            let y = (run.baseline() - metrics.ascent) / scale;
+            x0 = x0.min(x);
+            y0 = y0.min(y);
+            x1 = x1.max(x + run.advance() / scale);
+            y1 = y1.max(y + (metrics.ascent + metrics.descent) / scale);
+        }
+    }
+    if x0 > x1 || y0 > y1 {
+        return Rectangle::zero()
+    }
+    Rectangle::new(x0, y0, x1 - x0, y1 - y0)
+}
+
+/// A rectangle grown by `pad` on every side.
+fn inflate_rect(rect: &Rectangle, pad: f32) -> Rectangle {
+    Rectangle::new(rect.x - pad, rect.y - pad, rect.w + 2. * pad, rect.h + 2. * pad)
+}
+
+/// Truncate `s` until it fits `max_w` at `font_size`, marking the cut
+/// with an ellipsis. Room names are short; this runs per
+/// materialization only (cached in the instance). A monospace width
+/// estimate bounds the probe start so an absurdly long name cannot
+/// turn this into quadratic layout work.
+fn ellipsize_to_width(s: &str, max_w: f32, font_size: f32, window_scale: f32) -> String {
+    let chars: Vec<char> = s.chars().collect();
+    let estimate = ((max_w / (font_size * 0.6)).ceil() as usize).saturating_add(4);
+    let mut len = chars.len().min(estimate);
+    while len > 0 {
+        let mut candidate: String = chars[..len].iter().collect();
+        if len < chars.len() {
+            candidate.push('…');
+        }
+        let probe = text::make_layout(
+            &candidate,
+            [1., 1., 1., 1.],
+            font_size,
+            1.2,
+            window_scale,
+            None,
+            &[],
+        );
+        if probe.width() <= max_w {
+            return candidate
+        }
+        len -= 1;
+    }
+    s.chars().take(1).collect()
+}
+
 /// Sanitize an extracted URL match for use as a hit payload (opening,
 /// Sanitize an extracted URL match for use as a hit payload (opening,
 /// copying): strip trailing punctuation and control characters (an
 /// copying): strip trailing punctuation and control characters (an
 /// interior NUL would abort the Android intent JNI path), resolve
 /// interior NUL would abort the Android intent JNI path), resolve
@@ -954,21 +1199,29 @@ fn url_color_ranges(
 
 
 /// Scene node factory for the privmsg type node.
 /// 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)> {
+/// Decode `(ts, id, channel, nick, text)` method-call data.
+pub fn decode_insert_data(data: &[u8]) -> Option<(Timestamp, MessageId, String, String, String)> {
     let mut cur = Cursor::new(data);
     let mut cur = Cursor::new(data);
     let ts = Timestamp::decode(&mut cur).ok()?;
     let ts = Timestamp::decode(&mut cur).ok()?;
     let id = MessageId::decode(&mut cur).ok()?;
     let id = MessageId::decode(&mut cur).ok()?;
+    let channel = String::decode(&mut cur).ok()?;
     let nick = String::decode(&mut cur).ok()?;
     let nick = String::decode(&mut cur).ok()?;
     let text = String::decode(&mut cur).ok()?;
     let text = String::decode(&mut cur).ok()?;
-    Some((ts, id, nick, text))
+    Some((ts, id, channel, 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> {
+/// Encode `(ts, id, channel, nick, text)` for method-call data.
+pub fn encode_insert_data(
+    ts: Timestamp,
+    id: &MessageId,
+    channel: &str,
+    nick: &str,
+    text: &str,
+) -> Vec<u8> {
     let mut data = vec![];
     let mut data = vec![];
     ts.encode(&mut data).unwrap();
     ts.encode(&mut data).unwrap();
     id.encode(&mut data).unwrap();
     id.encode(&mut data).unwrap();
+    channel.encode(&mut data).unwrap();
     nick.encode(&mut data).unwrap();
     nick.encode(&mut data).unwrap();
     text.encode(&mut data).unwrap();
     text.encode(&mut data).unwrap();
     data
     data
@@ -1015,7 +1268,7 @@ mod tests {
             .join(format!("darkfi-chatview-privmsg-{tag}-{}.db", std::process::id()));
             .join(format!("darkfi-chatview-privmsg-{tag}-{}.db", std::process::id()));
         let _ = std::fs::remove_file(&path);
         let _ = std::fs::remove_file(&path);
         let db = kvdb_overlay::Database::open_default(&path).unwrap();
         let db = kvdb_overlay::Database::open_default(&path).unwrap();
-        let tree = db.open_tree_default("chat").unwrap();
+        let tree = db.open_tree_default("#dev__chat_tree_v3").unwrap();
         (
         (
             Loader::new(
             Loader::new(
                 Arc::new(AsyncMutex::new(MsgBuffer::new())),
                 Arc::new(AsyncMutex::new(MsgBuffer::new())),
@@ -1058,9 +1311,9 @@ mod tests {
         let buffer = Arc::new(AsyncMutex::new(raw));
         let buffer = Arc::new(AsyncMutex::new(raw));
         let (redraw, _rx) = crate::ui::RedrawTrigger::new();
         let (redraw, _rx) = crate::ui::RedrawTrigger::new();
         let loader = Loader::new(buffer.clone(), redraw);
         let loader = Loader::new(buffer.clone(), redraw);
-        loader.bind(tag.to_string(), fixture_loader(tag).1);
+        loader.bind("#dev".to_string(), fixture_loader(tag).1);
 
 
-        // A dangling chat weak: insert still persists and buffers, it
+        // A dangling chat weak: insert still displays and buffers, it
         // just skips view compensation (no chatview to talk to).
         // just skips view compensation (no chatview to talk to).
         let chat_weak: Weak<ChatView> = Weak::new();
         let chat_weak: Weak<ChatView> = Weak::new();
 
 
@@ -1090,7 +1343,7 @@ mod tests {
     fn rec_of(ts: Timestamp, idb: u8, text: &str, confirmed: bool) -> MsgRecord {
     fn rec_of(ts: Timestamp, idb: u8, text: &str, confirmed: bool) -> MsgRecord {
         let mut id = [0u8; 32];
         let mut id = [0u8; 32];
         id[0] = idb;
         id[0] = idb;
-        let payload = codec::encode_privmsg_payload("alice", text, confirmed);
+        let payload = codec::encode_privmsg_payload("alice", text, confirmed, "#dev");
         MsgRecord { ts, id: MessageId(id), msg_type: MsgType::PrivMsg, payload, height: 0. }
         MsgRecord { ts, id: MessageId(id), msg_type: MsgType::PrivMsg, payload, height: 0. }
     }
     }
 
 
@@ -1149,6 +1402,13 @@ mod tests {
         let (chat, node, _buffer) = smol::block_on(make_node("leftwrap"));
         let (chat, node, _buffer) = smol::block_on(make_node("leftwrap"));
         let atom = &mut PropertyAtomicGuard::none();
         let atom = &mut PropertyAtomicGuard::none();
         chat.get_property("rect").unwrap().set_f32(atom, Role::App, 2, 240.).unwrap();
         chat.get_property("rect").unwrap().set_f32(atom, Role::App, 2, 240.).unwrap();
+        // Fixed spacing so the expected first-line offset is exact:
+        // timestamp glyph width + spacing.
+        node.node()
+            .upgrade()
+            .unwrap()
+            .set_property_f32(atom, Role::App, "channel_spacing", 20.)
+            .unwrap();
         let body = "word ".repeat(30);
         let body = "word ".repeat(30);
         for (nick, text, copied) in [
         for (nick, text, copied) in [
             ("alice", body.clone(), format!("alice {body}")),
             ("alice", body.clone(), format!("alice {body}")),
@@ -1156,14 +1416,22 @@ mod tests {
             ("NOTICE", body.clone(), body.clone()),
             ("NOTICE", body.clone(), body.clone()),
         ] {
         ] {
             let mut rec = rec_of(1_000_000, b'a', &text, true);
             let mut rec = rec_of(1_000_000, b'a', &text, true);
-            rec.payload = codec::encode_privmsg_payload(nick, &text, true);
+            rec.payload = codec::encode_privmsg_payload(nick, &text, true, "#dev");
             let height = node.measure(&rec);
             let height = node.measure(&rec);
             assert_eq!(node.copy_text(&rec), Some(copied));
             assert_eq!(node.copy_text(&rec), Some(copied));
             let inner = node.inner.lock();
             let inner = node.inner.lock();
             let inst = &inner.instances[&(rec.ts, rec.id)];
             let inst = &inner.instances[&(rec.ts, rec.id)];
             assert!(inst.txt_layout.lines().count() > 2);
             assert!(inst.txt_layout.lines().count() > 2);
+            let expected_first = inst.ts_layout.width() + 20.;
             for (i, line) in inst.txt_layout.lines().enumerate() {
             for (i, line) in inst.txt_layout.lines().enumerate() {
-                assert_eq!(line.metrics().offset, if i == 0 { 50. } else { 0. });
+                // The indent lives in the line's inline_min_coord (the
+                // breaker positions the line at the indent; align does
+                // not carry it in metrics().offset).
+                let expected = if i == 0 { expected_first } else { 0. };
+                assert!(
+                    (line.metrics().inline_min_coord - expected).abs() < 0.1,
+                    "line {i} starts at {expected}"
+                );
                 assert_eq!(line.metrics().inline_max_coord, 240.);
                 assert_eq!(line.metrics().inline_max_coord, 240.);
             }
             }
             assert!((height - inst.txt_layout.height() - 4.).abs() < 0.01);
             assert!((height - inst.txt_layout.height() - 4.).abs() < 0.01);
@@ -1171,22 +1439,29 @@ mod tests {
     }
     }
 
 
     #[test]
     #[test]
-    fn timestamp_width_change_rebuilds_first_line() {
-        let (chat, node, _buffer) = smol::block_on(make_node("timestampwidth"));
+    fn channel_spacing_change_rebuilds_first_line() {
+        let (_chat, node, _buffer) = smol::block_on(make_node("chanspacing"));
         let rec = rec_of(1_000_000, b'a', "hello", true);
         let rec = rec_of(1_000_000, b'a', "hello", true);
         let height = node.measure(&rec);
         let height = node.measure(&rec);
-        assert_eq!(node.nick_rects(&rec)[0].1.x, 50.);
+        let ts_w = {
+            let inner = node.inner.lock();
+            inner.instances[&(rec.ts, rec.id)].ts_layout.width()
+        };
 
 
         let atom = &mut PropertyAtomicGuard::none();
         let atom = &mut PropertyAtomicGuard::none();
-        chat.set_property_f32(atom, Role::App, "timestamp_width", 80.).unwrap();
+        node.node()
+            .upgrade()
+            .unwrap()
+            .set_property_f32(atom, Role::App, "channel_spacing", 30.)
+            .unwrap();
         assert_eq!(node.measure(&rec), height);
         assert_eq!(node.measure(&rec), height);
         assert_eq!(node.inner.lock().layout_builds, 2);
         assert_eq!(node.inner.lock().layout_builds, 2);
-        assert_eq!(node.nick_rects(&rec)[0].1.x, 80.);
+        assert!((node.nick_rects(&rec)[0].1.x - (ts_w + 30.)).abs() < 0.5);
 
 
         node.shared.window_scale.set(atom, 1.5);
         node.shared.window_scale.set(atom, 1.5);
         assert!((node.measure(&rec) - height).abs() < 0.01);
         assert!((node.measure(&rec) - height).abs() < 0.01);
         assert_eq!(node.inner.lock().layout_builds, 3);
         assert_eq!(node.inner.lock().layout_builds, 3);
-        assert!((node.nick_rects(&rec)[0].1.x - 80.).abs() < 0.01);
+        assert!((node.nick_rects(&rec)[0].1.x - (ts_w + 30.)).abs() < 0.5);
     }
     }
 
 
     #[test]
     #[test]
@@ -1209,12 +1484,13 @@ mod tests {
     }
     }
 
 
     #[test]
     #[test]
-    fn insert_line_persists_and_buffers() {
+    fn insert_line_displays_and_buffers() {
         let (_chat, node, buffer) = smol::block_on(make_node("insert"));
         let (_chat, node, buffer) = smol::block_on(make_node("insert"));
         smol::block_on(async {
         smol::block_on(async {
             node.insert_line(
             node.insert_line(
                 1_756_000_000_000,
                 1_756_000_000_000,
                 MessageId([b'x'; 32]),
                 MessageId([b'x'; 32]),
+                "#dev".to_string(),
                 "alice".to_string(),
                 "alice".to_string(),
                 "hi there".to_string(),
                 "hi there".to_string(),
             )
             )
@@ -1225,8 +1501,35 @@ mod tests {
         assert_eq!(buffer.len(), 1);
         assert_eq!(buffer.len(), 1);
         let rec = buffer.record(&MessageId([b'x'; 32])).unwrap();
         let rec = buffer.record(&MessageId([b'x'; 32])).unwrap();
         assert!(rec.height > 0., "inserted with measured height");
         assert!(rec.height > 0., "inserted with measured height");
-        let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
+        let (_, _, confirmed, channel) =
+            codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
         assert!(confirmed);
         assert!(confirmed);
+        assert_eq!(channel, "#dev", "payload carries the channel");
+    }
+
+    #[test]
+    fn confirm_flips_buffer_in_place() {
+        let (_chat, node, buffer) = smol::block_on(make_node("confirm"));
+        let id = MessageId([b'x'; 32]);
+        let ts = 1_756_000_000_000;
+
+        smol::block_on(async {
+            node.insert_unconf_line(
+                ts,
+                id,
+                "#dev".to_string(),
+                "alice".to_string(),
+                "hi".to_string(),
+            )
+            .await;
+            node.confirm(id).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 in the buffer");
     }
     }
 
 
     #[test]
     #[test]
@@ -1236,13 +1539,20 @@ mod tests {
         let ts = 1_756_000_000_000;
         let ts = 1_756_000_000_000;
 
 
         smol::block_on(async {
         smol::block_on(async {
-            node.insert_unconf_line(ts, id, "alice".to_string(), "hi".to_string()).await;
+            node.insert_unconf_line(
+                ts,
+                id,
+                "#dev".to_string(),
+                "alice".to_string(),
+                "hi".to_string(),
+            )
+            .await;
         });
         });
         {
         {
             let buffer = smol::block_on(buffer.lock());
             let buffer = smol::block_on(buffer.lock());
             assert_eq!(buffer.len(), 1);
             assert_eq!(buffer.len(), 1);
             let rec = buffer.record(&id).unwrap();
             let rec = buffer.record(&id).unwrap();
-            let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
+            let (_, _, confirmed, _) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
             assert!(!confirmed, "starts unconfirmed");
             assert!(!confirmed, "starts unconfirmed");
         }
         }
 
 
@@ -1250,13 +1560,14 @@ mod tests {
         // one entry, confirmed payload, regen invalidated the rendered
         // one entry, confirmed payload, regen invalidated the rendered
         // state (lazily rebuilt on the next touch).
         // state (lazily rebuilt on the next touch).
         smol::block_on(async {
         smol::block_on(async {
-            node.insert_line(ts, id, "alice".to_string(), "hi".to_string()).await;
+            node.insert_line(ts, id, "#dev".to_string(), "alice".to_string(), "hi".to_string())
+                .await;
         });
         });
         {
         {
             let buffer = smol::block_on(buffer.lock());
             let buffer = smol::block_on(buffer.lock());
             assert_eq!(buffer.len(), 1, "no duplicate record");
             assert_eq!(buffer.len(), 1, "no duplicate record");
             let rec = buffer.record(&id).unwrap();
             let rec = buffer.record(&id).unwrap();
-            let (_, _, confirmed) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
+            let (_, _, confirmed, _) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
             assert!(confirmed, "confirmed in place");
             assert!(confirmed, "confirmed in place");
         }
         }
         assert!(
         assert!(
@@ -1282,7 +1593,8 @@ mod tests {
         // A plain duplicate changes nothing.
         // A plain duplicate changes nothing.
         let builds = node.inner.lock().layout_builds;
         let builds = node.inner.lock().layout_builds;
         smol::block_on(async {
         smol::block_on(async {
-            node.insert_line(ts, id, "alice".to_string(), "hi".to_string()).await;
+            node.insert_line(ts, id, "#dev".to_string(), "alice".to_string(), "hi".to_string())
+                .await;
         });
         });
         let buffer = smol::block_on(buffer.lock());
         let buffer = smol::block_on(buffer.lock());
         assert_eq!(buffer.len(), 1);
         assert_eq!(buffer.len(), 1);
@@ -1298,6 +1610,7 @@ mod tests {
             nick: "alice".to_string(),
             nick: "alice".to_string(),
             text: "waves".to_string(),
             text: "waves".to_string(),
             confirmed: true,
             confirmed: true,
+            channel: "#dev".to_string(),
             is_action: true,
             is_action: true,
             is_notice: false,
             is_notice: false,
             expanded: true,
             expanded: true,
@@ -1314,6 +1627,111 @@ mod tests {
         assert_eq!(data.body_offset(), 0);
         assert_eq!(data.body_offset(), 0);
     }
     }
 
 
+    #[test]
+    fn channel_span_renders_only_when_shown() {
+        let (chat, node, _buffer) = smol::block_on(make_node("chanspan"));
+
+        let rec = rec_of(1_000_000, b'a', "hello", true);
+        node.measure(&rec);
+        assert!(node.channel_rect(&rec).is_none(), "hidden by default");
+
+        // No channel hit in the gutter while display is inactive.
+        assert_eq!(node.hit_test(&rec, Point::new(5., 25.)), None);
+
+        // Toggling display rebuilds the instance with the span.
+        let atom = &mut PropertyAtomicGuard::none();
+        chat.set_property_bool(atom, Role::App, "show_channel", true).unwrap();
+        let builds = node.inner.lock().layout_builds;
+        node.measure(&rec);
+        assert_eq!(node.inner.lock().layout_builds, builds + 1, "toggle regenerates");
+
+        let rect = node.channel_rect(&rec).expect("span rendered");
+        assert!(rect.x > 0., "after the timestamp: {:?}", rect);
+        let mid = Point::new(rect.x + rect.w / 2., rect.y + rect.h / 2.);
+        assert_eq!(node.hit_test(&rec, mid), Some(Hit::Channel("#dev".to_string())));
+
+        // The chip hugs the label — it must not reach back over the
+        // timestamp (x=0) and must cover the label's middle.
+        let chip = node.channel_chip(&rec).expect("chip rect present");
+        assert!(chip.x > 0., "chip clear of the timestamp: {:?}", chip);
+        assert!(chip.x <= mid.x && mid.x <= chip.x + chip.w, "chip covers the label");
+        assert!(chip.y <= mid.y && mid.y <= chip.y + chip.h, "chip covers the label vertically");
+
+        // The spacing property widens the timestamp → chip gap (and
+        // the chip → line indent) and regens the rendered state.
+        let chip_x = chip.x;
+        node.node()
+            .upgrade()
+            .unwrap()
+            .set_property_f32(atom, Role::App, "channel_spacing", 30.)
+            .unwrap();
+        node.measure(&rec);
+        let chip2 = node.channel_chip(&rec).expect("chip after respacing");
+        assert!(chip2.x > chip_x + 20., "gap widened: {chip_x:?} -> {:?}", chip2.x);
+
+        // Toggling back off removes the span and its hit target.
+        chat.set_property_bool(atom, Role::App, "show_channel", false).unwrap();
+        node.measure(&rec);
+        assert!(node.channel_rect(&rec).is_none(), "hidden again");
+        assert_eq!(node.hit_test(&rec, mid), None);
+    }
+
+    #[test]
+    fn channel_ellipsizes_when_absurdly_long() {
+        use super::ellipsize_to_width;
+        assert_eq!(ellipsize_to_width("#dev", 240., 10., 1.), "#dev");
+        let long = ellipsize_to_width("#a-very-long-channel-name-here", 50., 10., 1.);
+        assert!(long.ends_with('…'), "cut marked: {long}");
+        assert!(long.chars().count() < "#a-very-long-channel-name-here".chars().count());
+        // The ellipsized form fits the bound.
+        let probe = text::make_layout(&long, [1., 1., 1., 1.], 10., 1.2, 1., None, &[]);
+        assert!(probe.width() <= 50.);
+    }
+
+    #[test]
+    fn wrapped_lines_return_to_the_left_edge() {
+        let (chat, node, _buffer) = smol::block_on(make_node("outdent"));
+
+        // A message long enough to wrap at the full width.
+        let long = "word ".repeat(60);
+        let rec = rec_of(1_000_000, b'a', &long, true);
+        node.measure(&rec);
+
+        // The first line is indented past the gutter; the second line
+        // starts at the view's left edge (x=0).
+        let layout = {
+            let inner = node.inner.lock();
+            inner.instances.get(&(rec.ts, rec.id)).expect("materialized").txt_layout.clone()
+        };
+        let mut lines = layout.lines();
+        let scale = layout.scale();
+        let line_x = |line: parley::Line<'_, Color>| {
+            let mut x = None;
+            for item in line.items() {
+                if let parley::PositionedLayoutItem::GlyphRun(run) = item {
+                    x = Some(run.offset());
+                    break
+                }
+            }
+            x.unwrap_or(0.) / scale
+        };
+        let first = lines.next().expect("first line");
+        let second = lines.next().expect("wrapped second line");
+        let first_x = line_x(first);
+        let second_x = line_x(second);
+        // The first line starts after the timestamp glyphs plus the
+        // spacing (auto here: a fraction of the channel font size).
+        // The exact ts width is font-metric dependent ("HH:MM" at
+        // font 10 ≈ 30px), so assert a conservative lower bound.
+        assert!(first_x >= 25., "first line clears the timestamp: {first_x}");
+        assert!(second_x.abs() < 0.5, "continuation at the left edge: {second_x}");
+
+        // The wrap width is the full view width, not width minus gutter.
+        assert!(layout.width() <= 800.);
+
+        let _ = chat;
+    }
+
     #[test]
     #[test]
     fn url_hit_rects_resolve_through_hit_test() {
     fn url_hit_rects_resolve_through_hit_test() {
         let (_chat, node, _buffer) = smol::block_on(make_node("urls"));
         let (_chat, node, _buffer) = smol::block_on(make_node("urls"));
@@ -1335,7 +1753,14 @@ mod tests {
         assert!(!nicks.is_empty(), "nick prefix is clickable");
         assert!(!nicks.is_empty(), "nick prefix is clickable");
         let (nick, nrect) = &nicks[0];
         let (nick, nrect) = &nicks[0];
         assert_eq!(nick, "alice");
         assert_eq!(nick, "alice");
-        assert_eq!(nrect.x, 50.);
+        // The nick starts at the first-line indent: timestamp glyph
+        // width plus the channel spacing (the factory default here —
+        // the test harness doesn't set it).
+        let ts_w = {
+            let inner = node.inner.lock();
+            inner.instances[&(rec.ts, rec.id)].ts_layout.width()
+        };
+        assert!((nrect.x - (ts_w + 4.)).abs() < 0.5, "nick at the indent: {}", nrect.x);
         assert_eq!(
         assert_eq!(
             node.hit_test(&rec, Point::new(nrect.x + nrect.w / 2., nrect.y + nrect.h / 2.)),
             node.hit_test(&rec, Point::new(nrect.x + nrect.w / 2., nrect.y + nrect.h / 2.)),
             Some(Hit::Nick("alice".to_string()))
             Some(Hit::Nick("alice".to_string()))

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

@@ -282,11 +282,17 @@ impl UIObject for Layer {
         }
         }
         false
         false
     }
     }
-    async fn handle_mouse_wheel(&self, mut wheel_pos: Point) -> bool {
+    async fn handle_mouse_wheel(&self, wheel_pos: Point) -> bool {
         if !self.is_visible.get() {
         if !self.is_visible.get() {
             return false
             return false
         }
         }
-        wheel_pos -= self.rect.get().pos();
+        // The wheel payload is a scroll delta, not a position: it must
+        // pass through unmodified. Subtracting the layer origin here
+        // corrupts it — a +1 notch under a layer at y=415 arrives as
+        // −414, and every wheel consumer (chatview page_tick, menu,
+        // edit) clamps the inverted target to zero. Layers at (0,0)
+        // masked the bug; any offset layer (e.g. the wallet feed)
+        // could never wheel-scroll.
         for child in self.get_children() {
         for child in self.get_children() {
             let obj = get_ui_object3(&child);
             let obj = get_ui_object3(&child);
             if obj.handle_mouse_wheel(wheel_pos).await {
             if obj.handle_mouse_wheel(wheel_pos).await {

+ 2 - 0
bin/app/src/ui/win/mod.rs

@@ -490,10 +490,12 @@ impl Window {
     }
     }
 
 
     async fn handle_mouse_wheel(&self, mut wheel_pos: Point) {
     async fn handle_mouse_wheel(&self, mut wheel_pos: Point) {
+        t!("handle_mouse_wheel({wheel_pos:?})");
         self.local_scale(&mut wheel_pos);
         self.local_scale(&mut wheel_pos);
         for child in self.get_children() {
         for child in self.get_children() {
             let obj = get_ui_object3(&child);
             let obj = get_ui_object3(&child);
             if obj.handle_mouse_wheel(wheel_pos).await {
             if obj.handle_mouse_wheel(wheel_pos).await {
+                t!("wheel swallowed by {child:?}");
                 return
                 return
             }
             }
         }
         }