Просмотр исходного кода

app/fud: fix FileMessage render, handle click to download, use signals, support fud directories

epiphany 7 месяцев назад
Родитель
Сommit
9f26a2e8d3

+ 20 - 1
bin/app/src/app/node.rs

@@ -476,6 +476,20 @@ pub fn create_chatview(name: &str) -> SceneNode {
     prop.set_defaults_f32(vec![6.]).unwrap();
     node.add_property(prop).unwrap();
 
+    node.add_signal(
+        "fileurl_detected",
+        "File URL detected in message",
+        vec![("url", "File URL", CallArgType::Str)],
+    )
+    .unwrap();
+
+    node.add_signal(
+        "file_download_request",
+        "User requested file download",
+        vec![("url", "File URL", CallArgType::Str)],
+    )
+    .unwrap();
+
     node.add_method(
         "insert_line",
         vec![
@@ -500,7 +514,12 @@ pub fn create_chatview(name: &str) -> SceneNode {
     )
     .unwrap();
 
-    node.add_method("update_file", vec![("hash", "Hash", CallArgType::Str)], None).unwrap();
+    node.add_method(
+        "set_file_status",
+        vec![("url", "File URL", CallArgType::Str), ("status", "File status", CallArgType::Str)],
+        None,
+    )
+    .unwrap();
 
     node
 }

+ 24 - 0
bin/app/src/app/schema/chat.rs

@@ -566,6 +566,30 @@ pub async fn make(
         .await;
     layer_node.link(chatview_node.clone());
 
+    let (slot, recvr) = Slot::new("fileurl_detect");
+    chatview_node.register("fileurl_detected", slot).unwrap();
+    let sg_root2 = app.sg_root.clone();
+    let listen_fileurl = app.ex.spawn(async move {
+        while let Ok(data) = recvr.recv().await {
+            if let Some(fud_node) = sg_root2.lookup_node("/plugin/fud") {
+                let _ = fud_node.call_method("track_file", data).await;
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_fileurl);
+
+    let (slot, recvr) = Slot::new("file_download_request");
+    chatview_node.register("file_download_request", slot).unwrap();
+    let sg_root2 = app.sg_root.clone();
+    let listen_file_download = app.ex.spawn(async move {
+        while let Ok(data) = recvr.recv().await {
+            if let Some(fud_node) = sg_root2.lookup_node("/plugin/fud") {
+                let _ = fud_node.call_method("get", data).await;
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_file_download);
+
     if is_first_time {
         let chatview = match chatview_node.pimpl() {
             Pimpl::ChatView(obj) => obj.as_ref(),

+ 40 - 10
bin/app/src/main.rs

@@ -23,6 +23,7 @@
 use clap::Parser;
 use darkfi::system::CondVar;
 use std::sync::{Arc, OnceLock};
+use url::Url;
 
 #[macro_use]
 extern crate tracing;
@@ -276,14 +277,6 @@ async fn load_plugins(
         })
         .await;
 
-    let fud = create_fud("fud");
-    let sg_root2 = sg_root.clone();
-    let fud = fud
-        .setup(|me| async {
-            plugin::FudPlugin::new(me, sg_root2, ex.clone()).await.expect("Fud pimpl setup")
-        })
-        .await;
-
     let (slot, recvr) = Slot::new("recvmsg");
     darkirc.register("recv", slot).unwrap();
     let sg_root2 = sg_root.clone();
@@ -399,10 +392,39 @@ async fn load_plugins(
     });
 
     plugin.link(darkirc);
+
+    let fud = create_fud("fud");
+    let sg_root2 = sg_root.clone();
+    let fud = fud
+        .setup(|me| async {
+            plugin::FudPlugin::new(me, sg_root2, ex.clone()).await.expect("Fud pimpl setup")
+        })
+        .await;
+
+    let (slot, recv) = Slot::new("file_status_update");
+    let _ = fud.register("file_status_updated", slot);
+    let sg_root2 = sg_root.clone();
+    let listen_file_status = ex.spawn(async move {
+        while let Ok(data) = recv.recv().await {
+            let window = sg_root2.lookup_node("/window/content").unwrap();
+            let mut cur = Cursor::new(&data);
+            let url = Url::decode(&mut cur).unwrap();
+            let status = chatview::FileMessageStatus::decode(&mut cur).unwrap();
+            for child in window.get_children() {
+                if let Some(chatty) = child.lookup_node("/content/chatty") {
+                    let mut data = vec![];
+                    url.encode(&mut data).unwrap();
+                    status.encode(&mut data).unwrap();
+                    let _ = chatty.call_method("set_file_status", data).await;
+                }
+            }
+        }
+    });
+
     plugin.link(fud);
 
     i!("Plugins loaded");
-    futures::join!(listen_recv, listen_connect);
+    futures::join!(listen_recv, listen_connect, listen_file_status);
 }
 
 pub fn create_darkirc(name: &str) -> SceneNode {
@@ -457,7 +479,15 @@ pub fn create_fud(name: &str) -> SceneNode {
     prop.set_defaults_bool(vec![false]).unwrap();
     node.add_property(prop).unwrap();
 
-    node.add_method("get", vec![("hash", "Hash", CallArgType::Str)], None).unwrap();
+    node.add_signal(
+        "file_status_updated",
+        "File download status updated",
+        vec![("url", "File URL", CallArgType::Str), ("status", "File status", CallArgType::Str)],
+    )
+    .unwrap();
+
+    node.add_method("get", vec![("url", "Url", CallArgType::Str)], None).unwrap();
+    node.add_method("track_file", vec![("url", "Url", CallArgType::Str)], None).unwrap();
 
     node
 }

+ 255 - 84
bin/app/src/plugin/fud.rs

@@ -26,7 +26,12 @@ use darkfi::{
 };
 use darkfi_serial::{Decodable, Encodable};
 use fud::{
-    event::FudEvent, proto::ProtocolFud, settings::Args as FudSettings, util::hash_to_string, Fud,
+    event::FudEvent,
+    proto::ProtocolFud,
+    resource::ResourceStatus,
+    settings::Args as FudSettings,
+    util::{hash_to_string, FileSelection},
+    Fud,
 };
 use sled_overlay::sled;
 use smol::lock::Mutex;
@@ -41,8 +46,10 @@ use url::Url;
 use crate::{
     error::{Error, Result},
     prop::{BatchGuardPtr, PropertyAtomicGuard, PropertyBool, Role},
-    scene::{MethodCallSub, Pimpl, SceneNode, SceneNodePtr, SceneNodeType, SceneNodeWeak},
-    ui::OnModify,
+    scene::{
+        MethodCall, MethodCallSub, Pimpl, SceneNode, SceneNodePtr, SceneNodeType, SceneNodeWeak,
+    },
+    ui::{chatview::FileMessageStatus, OnModify},
     ExecutorPtr,
 };
 
@@ -119,7 +126,7 @@ pub struct FudPlugin {
     event_pub: PublisherPtr<FudEvent>,
     fud: Arc<Fud>,
 
-    download_on_ready: Arc<Mutex<HashSet<Url>>>,
+    tracked_files: Arc<Mutex<HashSet<Url>>>,
 
     settings: PluginSettings,
 }
@@ -264,7 +271,7 @@ impl FudPlugin {
             p2p,
             event_pub,
             fud,
-            download_on_ready: Arc::new(Mutex::new(HashSet::new())),
+            tracked_files: Arc::new(Mutex::new(HashSet::new())),
             settings,
         });
         self_.clone().start(ex).await;
@@ -303,6 +310,11 @@ impl FudPlugin {
         let get_method_task =
             ex.spawn(async move { while Self::process_get(&me2, &method_sub).await {} });
 
+        let method_sub = node.subscribe_method_call("track_file").unwrap();
+        let me2 = me.clone();
+        let track_file_method_task =
+            ex.spawn(async move { while Self::process_track_file(&me2, &method_sub).await {} });
+
         let event_pub = self.event_pub.clone();
         let me2 = me.clone();
         let ev_task = ex.spawn(async move {
@@ -326,7 +338,7 @@ impl FudPlugin {
             }
         });
 
-        let mut tasks = vec![get_method_task, ev_task, start_task];
+        let mut tasks = vec![get_method_task, track_file_method_task, ev_task, start_task];
         tasks.append(&mut on_modify.tasks);
         self.tasks.set(tasks).unwrap();
 
@@ -360,79 +372,265 @@ impl FudPlugin {
         Ok(blake3::Hash::from_bytes(hash_buf_arr))
     }
 
-    async fn process_get(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
-        let Ok(method_call) = sub.receive().await else {
-            d!("Fud event relayer closed");
-            return false
-        };
+    fn parse_url(url: &Url) -> std::io::Result<(String, blake3::Hash)> {
+        let hash_string = url
+            .host_str()
+            .map(|s| s.to_string())
+            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "Missing fud hash"))?;
 
-        t!("method called: get({method_call:?})");
-        assert!(method_call.send_res.is_none());
+        let hash = Self::string_to_hash(&hash_string)?;
 
-        fn decode_data(data: &[u8]) -> std::io::Result<(String, Url)> {
+        Ok((hash_string, hash))
+    }
+
+    fn url_to_file_selection(url: &Url) -> FileSelection {
+        match url.path() {
+            "/" | "" => FileSelection::All,
+            path => {
+                let mut selection = HashSet::new();
+                selection.insert(PathBuf::from(path.strip_prefix("/").unwrap_or(path)));
+                FileSelection::Set(selection)
+            }
+        }
+    }
+
+    async fn find_urls_by_hash(&self, hash: &blake3::Hash) -> Vec<Url> {
+        let tracked = self.tracked_files.lock().await;
+        let hash_str = hash_to_string(hash);
+        tracked
+            .iter()
+            .filter(|url| url.host_str() == Some(hash_str.as_str()))
+            .cloned()
+            .collect()
+    }
+
+    fn decode_data(
+        &self,
+        method_call: &MethodCall,
+    ) -> (Option<String>, std::io::Result<(blake3::Hash, Url, Option<String>)>) {
+        fn decode_data(data: &[u8]) -> std::io::Result<(String, Url, Option<String>)> {
             let mut cur = Cursor::new(&data);
             let url = Url::decode(&mut cur)?;
-            let Some(hash_string) = url.host_str().clone() else {
+            let Some(hash_string) = url.host_str() else {
                 return Err(std::io::Error::new(std::io::ErrorKind::Other, "Missing fud hash"))
             };
             let hash_string = hash_string.to_string();
+            let err_msg = String::decode(&mut cur).ok();
 
-            Ok((hash_string, url))
+            Ok((hash_string, url, err_msg))
         }
 
+        let Ok((hash_string, url, err_msg)) = decode_data(&method_call.data) else {
+            return (None, Err(std::io::Error::new(std::io::ErrorKind::Other, "Invalid fud url")))
+        };
+
+        let Ok(hash) = FudPlugin::string_to_hash(&hash_string) else {
+            return (
+                Some(hash_string),
+                Err(std::io::Error::new(std::io::ErrorKind::Other, "Invalid fud url")),
+            )
+        };
+
+        (Some(hash_string), Ok((hash, url, err_msg)))
+    }
+
+    async fn process_get(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("Fud event relayer closed");
+            return false
+        };
+
+        t!("method called: get({method_call:?})");
+        assert!(method_call.send_res.is_none());
+
         let Some(self_) = me.upgrade() else {
             // Should not happen
             panic!("self destroyed before get_method_task was stopped!");
         };
 
-        let Ok((hash_string, url)) = decode_data(&method_call.data) else {
-            e!("get() method invalid arg data");
+        let (hash_string, data) = self_.decode_data(&method_call);
+        if let Err(e) = data {
+            e!("get() method invalid arg data: {e}");
             return true
         };
 
-        let Ok(hash) = FudPlugin::string_to_hash(&hash_string) else {
-            let mut data = vec![];
-            "invalid fud url".encode(&mut data).unwrap();
-            self_.update_file(hash_string, "error", data).await;
-            return true
-        };
+        let hash_string = hash_string.unwrap();
+        let (hash, url, _) = data.unwrap();
 
         if self_.node.upgrade().unwrap().get_property_bool("ready").unwrap() {
-            let file_selection = match url.path() {
-                "/" | "" => fud::util::FileSelection::All,
-                path => {
-                    let mut selection = HashSet::new();
-                    selection.insert(PathBuf::from(path.strip_prefix("/").unwrap_or(path)));
-                    fud::util::FileSelection::Set(selection)
-                }
-            };
+            let file_selection = Self::url_to_file_selection(&url);
             let _ = self_
                 .fud
                 .get(&hash, &get_downloads_path().join(&hash_string), file_selection)
                 .await;
-        } else {
-            self_.download_on_ready.lock().await.insert(url);
         }
 
         true
     }
 
-    async fn update_file(self: &Arc<Self>, hash: String, status: &str, encoded_data: Vec<u8>) {
-        let window = self.sg_root.lookup_node("/window");
-        if window.is_none() {
-            return
+    /// Get the current file status for a fileurl, a `None` means it should not
+    /// be updated
+    async fn get_status(&self, hash: &blake3::Hash, url: &Url) -> Option<FileMessageStatus> {
+        let resources = self.fud.resources().await;
+        let resource = resources.get(hash);
+        if resource.is_none() {
+            return Some(FileMessageStatus::Idle)
         }
-        let window = window.unwrap();
-
-        for child in window.get_children() {
-            if let Some(chatty) = child.lookup_node("/content/chatty") {
-                let mut data = vec![];
-                hash.encode(&mut data).unwrap();
-                status.encode(&mut data).unwrap();
-                data.extend(encoded_data.clone());
-                let _ = chatty.call_method("update_file", data).await;
+        let resource = resource.unwrap();
+        let mut path = resource.path.clone();
+        let file_selection = Self::url_to_file_selection(url);
+        if let FileSelection::Set(selection) = &file_selection {
+            if let Some(rel_path) = selection.iter().next() {
+                path = path.join(rel_path);
             }
         }
+        let path = path.to_string_lossy().to_string();
+
+        if file_selection.is_disjoint(&resource.last_file_selection) {
+            return None::<FileMessageStatus>
+        }
+
+        let (bytes_downloaded, bytes_total) = self.fud.get_progress(hash, &file_selection).await;
+        let progress =
+            if bytes_total != 0 { bytes_downloaded as f32 / bytes_total as f32 * 100. } else { 0. };
+
+        match resource.status {
+            ResourceStatus::Discovering => Some(FileMessageStatus::Downloading { progress }),
+            ResourceStatus::Downloading => {
+                if progress < 100. {
+                    Some(FileMessageStatus::Downloading { progress })
+                } else {
+                    Some(FileMessageStatus::Downloaded { path })
+                }
+            }
+            ResourceStatus::Incomplete(ref err) => {
+                if progress < 100. {
+                    if let Some(msg) = err {
+                        Some(FileMessageStatus::Error { msg: msg.clone(), progress })
+                    } else {
+                        Some(FileMessageStatus::Error { msg: "incomplete".to_string(), progress })
+                    }
+                } else {
+                    Some(FileMessageStatus::Downloaded { path })
+                }
+            }
+            ResourceStatus::Verifying => None,
+            // Seeding status means we have the full resource
+            // (partial seeding is not supported by fud)
+            ResourceStatus::Seeding => Some(FileMessageStatus::Downloaded { path }),
+        }
+    }
+
+    async fn process_track_file(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("Fud event relayer closed");
+            return false
+        };
+
+        t!("method called: track_file({method_call:?})");
+        assert!(method_call.send_res.is_none());
+
+        let Some(self_) = me.upgrade() else {
+            // Should not happen
+            panic!("self destroyed before track_file_method_task was stopped!");
+        };
+
+        let mut cur = Cursor::new(&method_call.data);
+        let Ok(url) = Url::decode(&mut cur) else {
+            e!("track_file() method invalid arg data");
+            return true
+        };
+
+        self_.track_file(url).await;
+
+        true
+    }
+
+    /// Emit file_status_updated signal to all ChatViews
+    async fn emit_file_status(&self, url: &Url, status: &FileMessageStatus) {
+        let mut data = vec![];
+        url.encode(&mut data).unwrap();
+        status.encode(&mut data).unwrap();
+        let _ = self.node.upgrade().unwrap().trigger("file_status_updated", data).await;
+    }
+
+    /// Emit error status for a URL
+    async fn emit_error(&self, url: &Url, msg: String) {
+        self.emit_file_status(url, &FileMessageStatus::Error { msg, progress: 0. }).await;
+    }
+
+    /// Update tracked files and emit status signal
+    async fn update_resource(&self, hash: &blake3::Hash) {
+        let urls = self.find_urls_by_hash(hash).await;
+        for url in urls {
+            self.update_fileurl(&url).await;
+        }
+    }
+
+    async fn update_fileurl(&self, url: &Url) -> bool {
+        let (_hash_string, hash) = match Self::parse_url(url) {
+            Ok(h) => h,
+            Err(err) => {
+                self.emit_error(url, err.to_string()).await;
+                return true
+            }
+        };
+
+        let status = self.get_status(&hash, url).await;
+
+        // Emit signal
+        if let Some(status) = status {
+            self.emit_file_status(url, &status).await;
+            return true
+        }
+
+        false
+    }
+
+    /// Emit status for all tracked files
+    async fn ready_files(&self) {
+        let tracked = self.tracked_files.lock().await;
+        let urls: Vec<Url> = tracked.iter().cloned().collect();
+        drop(tracked);
+
+        for url in urls {
+            let (_hash_string, hash) = match Self::parse_url(&url) {
+                Ok(h) => h,
+                Err(err) => {
+                    self.emit_error(&url, err.to_string()).await;
+                    continue
+                }
+            };
+
+            let status = self.get_status(&hash, &url).await;
+
+            if let Some(status) = status {
+                self.emit_file_status(&url, &status).await;
+            } else {
+                self.emit_file_status(&url, &FileMessageStatus::Idle).await;
+            }
+        }
+    }
+
+    /// Track a file URL (called when the fileurl_detected signal is emitted)
+    async fn track_file(&self, url: Url) {
+        let (_hash_string, _hash) = match Self::parse_url(&url) {
+            Ok(h) => h,
+            Err(err) => {
+                self.emit_error(&url, err.to_string()).await;
+                return
+            }
+        };
+
+        if self.node.upgrade().unwrap().get_property_bool("ready").unwrap() {
+            let updated = self.update_fileurl(&url).await;
+            if !updated {
+                self.emit_file_status(&url, &FileMessageStatus::Idle).await;
+            }
+        }
+
+        let mut tracked = self.tracked_files.lock().await;
+        tracked.insert(url);
     }
 
     async fn process_events(me: &Weak<Self>, publisher: PublisherPtr<FudEvent>) {
@@ -454,55 +652,28 @@ impl FudPlugin {
                         .set_property_bool(atom, Role::App, "ready", true)
                         .unwrap();
 
-                    let window = self_.sg_root.lookup_node("/window");
-                    if window.is_none() {
-                        continue
-                    }
-
-                    for url in self_.download_on_ready.lock().await.iter() {
-                        let mut data = vec![];
-                        url.encode(&mut data).unwrap();
-                        let _ = self_.node.upgrade().unwrap().call_method("get", data).await;
-                    }
+                    self_.ready_files().await;
                 }
                 FudEvent::DownloadStarted(ev) => {
-                    let mut data = vec![];
-                    let bytes_downloaded = ev.resource.target_bytes_downloaded as f32;
-                    let bytes_size = ev.resource.target_bytes_size as f32;
-                    let progress =
-                        if bytes_size != 0.0 { bytes_downloaded / bytes_size * 100.0 } else { 0.0 };
-                    progress.encode(&mut data).unwrap();
-                    self_.update_file(hash_to_string(&ev.resource.hash), "downloading", data).await;
+                    self_.update_resource(&ev.resource.hash).await;
                 }
                 FudEvent::ChunkDownloadCompleted(ev) => {
-                    let mut data = vec![];
-                    let bytes_downloaded = ev.resource.target_bytes_downloaded as f32;
-                    let bytes_size = ev.resource.target_bytes_size as f32;
-                    let progress =
-                        if bytes_size != 0.0 { bytes_downloaded / bytes_size * 100.0 } else { 0.0 };
-                    progress.encode(&mut data).unwrap();
-                    self_.update_file(hash_to_string(&ev.resource.hash), "downloading", data).await;
+                    self_.update_resource(&ev.resource.hash).await;
                 }
                 FudEvent::DownloadCompleted(ev) => {
-                    let mut data = vec![];
-                    let path_string = ev.resource.path.to_string_lossy().to_string();
-                    path_string.encode(&mut data).unwrap();
-                    self_.update_file(hash_to_string(&ev.resource.hash), "downloaded", data).await;
+                    self_.update_resource(&ev.resource.hash).await;
+                }
+                FudEvent::ResourceUpdated(ev) => {
+                    self_.update_resource(&ev.resource.hash).await;
                 }
                 FudEvent::DownloadError(ev) => {
-                    let mut data = vec![];
-                    ev.error.encode(&mut data).unwrap();
-                    self_.update_file(hash_to_string(&ev.hash), "error", data).await;
+                    self_.update_resource(&ev.hash).await;
                 }
                 FudEvent::MissingChunks(ev) => {
-                    let mut data = vec![];
-                    "missing chunks".encode(&mut data).unwrap();
-                    self_.update_file(hash_to_string(&ev.hash), "error", data).await;
+                    self_.update_resource(&ev.hash).await;
                 }
                 FudEvent::MetadataNotFound(ev) => {
-                    let mut data = vec![];
-                    "missing metadata".encode(&mut data).unwrap();
-                    self_.update_file(hash_to_string(&ev.hash), "error", data).await;
+                    self_.update_resource(&ev.hash).await;
                 }
                 _ => {}
             };

+ 132 - 48
bin/app/src/ui/chatview/mod.rs

@@ -38,7 +38,8 @@ use tracing::instrument;
 use url::Url;
 
 mod page;
-use page::{FileMessageStatus, MessageBuffer};
+pub use page::FileMessageStatus;
+use page::MessageBuffer;
 
 use crate::{
     gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi},
@@ -344,6 +345,54 @@ impl ChatView {
         self_.handle_insert_unconf_line(timestamp, msg_id, nick, text).await;
         true
     }
+    async fn process_set_file_status_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("Event relayer closed");
+            return false
+        };
+
+        t!("method called: set_file_status({method_call:?})");
+        assert!(method_call.send_res.is_none());
+
+        fn decode_data(data: &[u8]) -> std::io::Result<(Url, FileMessageStatus)> {
+            let mut cur = Cursor::new(&data);
+            let url = Url::decode(&mut cur)?;
+            let file_status = FileMessageStatus::decode(&mut cur)?;
+            Ok((url, file_status))
+        }
+
+        let Ok((url, file_status)) = decode_data(&method_call.data) else {
+            error!(target: "ui::chatview", "set_file_status() method invalid arg data");
+            return true
+        };
+
+        let Some(self_) = me.upgrade() else {
+            // Should not happen
+            panic!("self destroyed before set_file_status_task was stopped!");
+        };
+
+        let mut msgbuf = self_.msgbuf.lock().await;
+        msgbuf.update_file_status(&url, &file_status);
+        msgbuf.adjust_params();
+        let atom = self_.render_api.make_guard(gfxtag!("ChatView::set_file_status"));
+        self_.redraw_cached(atom.batch_id, &mut msgbuf).await;
+
+        true
+    }
+
+    fn to_msgbuf_pos(&self, pos: Point) -> Point {
+        let mut x = pos.x;
+        let mut y = pos.y;
+
+        let rect = self.rect.get();
+
+        x -= rect.x;
+        y -= rect.y;
+        let scroll = self.scroll.get();
+        y = rect.h - y + scroll;
+
+        Point::new(x, y)
+    }
 
     /// Mark line as selected
     #[instrument(target = "ui::chatview")]
@@ -460,25 +509,16 @@ impl ChatView {
                 return
             }
 
-            if let Some(url) = get_file_url(&text) {
-                msgbuf.insert_filemsg(
-                    timest,
-                    msg_id,
-                    FileMessageStatus::Initializing,
-                    nick,
-                    url.clone(),
-                );
-
-                // This is incorrect. Scenegraph paths should not be hardcoded in widgets
-                // nor should there be dependencies on other widgets.
-                // Instead use signals and slots through app layer. See how focus is done with
-                // the edit widgets.
-                #[cfg(feature = "enable-plugins")]
-                {
+            #[cfg(feature = "enable-plugins")]
+            {
+                if let Some(url) = get_file_url(&text) {
+                    let _ =
+                        msgbuf.insert_filemsg(self.node.clone(), timest, msg_id, nick, url.clone());
+
+                    let node_ref = self.node.upgrade().unwrap();
                     let mut data = vec![];
                     url.encode(&mut data).unwrap();
-                    let fud = self.sg_root.lookup_node("/plugin/fud").unwrap();
-                    fud.call_method("get", data).await.unwrap();
+                    let _ = node_ref.trigger("fileurl_detected", data).await;
                 }
             }
         }
@@ -615,22 +655,21 @@ impl ChatView {
                 )
                 .await;
 
-            if let Some(url) = get_file_url(&chatmsg.text) {
-                msgbuf.insert_filemsg(
-                    timest,
-                    msg_id,
-                    FileMessageStatus::Initializing,
-                    chatmsg.nick.clone(),
-                    url.clone(),
-                );
-
-                // See comment in handle_insert_line()
-                #[cfg(feature = "enable-plugins")]
-                {
+            #[cfg(feature = "enable-plugins")]
+            {
+                if let Some(url) = get_file_url(&chatmsg.text) {
+                    let _ = msgbuf.insert_filemsg(
+                        self.node.clone(),
+                        timest,
+                        msg_id,
+                        chatmsg.nick.clone(),
+                        url.clone(),
+                    );
+
+                    let node_ref = self.node.upgrade().unwrap();
                     let mut data = vec![];
                     url.encode(&mut data).unwrap();
-                    let fud = self.sg_root.lookup_node("/plugin/fud").unwrap();
-                    fud.call_method("get", data).await.unwrap();
+                    let _ = node_ref.trigger("fileurl_detected", data).await;
                 }
             }
 
@@ -816,20 +855,10 @@ impl UIObject for ChatView {
             }
         });
 
-        let method_sub = node_ref.subscribe_method_call("update_file").unwrap();
-        let self_ = self.clone();
-        let update_file_task = ex.spawn(async move {
-            loop {
-                let Ok(method_call) = method_sub.receive().await else {
-                    d!("Event relayer closed");
-                    return
-                };
-                let mut msgbuf = self_.msgbuf.lock().await;
-                msgbuf.update_file(&method_call.data).await;
-                msgbuf.adjust_params();
-                let atom = self_.render_api.make_guard(gfxtag!("ChatView::update_file_task"));
-                self_.redraw_cached(atom.batch_id, &mut msgbuf).await;
-            }
+        let method_sub = node_ref.subscribe_method_call("set_file_status").unwrap();
+        let me2 = me.clone();
+        let set_file_status_method_task = ex.spawn(async move {
+            while Self::process_set_file_status_method(&me2, &method_sub).await {}
         });
 
         let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
@@ -866,7 +895,7 @@ impl UIObject for ChatView {
             insert_unconf_line_method_task,
             motion_task,
             bgload_task,
-            update_file_task,
+            set_file_status_method_task,
         ];
         tasks.append(&mut on_modify.tasks);
 
@@ -939,11 +968,25 @@ impl UIObject for ChatView {
     }
 
     async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        let rect = self.rect.get();
+
+        if rect.contains(mouse_pos) {
+            let mut msgbuf = self.msgbuf.lock().await;
+            let msgbuf_pos = self.to_msgbuf_pos(mouse_pos);
+            if let Some((msg, msg_top)) = msgbuf.get_line(msgbuf_pos.y).await {
+                if msg
+                    .handle_mouse_btn_down(btn, Point::new(msgbuf_pos.x, msg_top - msgbuf_pos.y))
+                    .await
+                {
+                    return true
+                }
+            }
+        }
+
         if btn != MouseButton::Left {
             return false
         }
 
-        let rect = self.rect.get();
         if !rect.contains(mouse_pos) {
             return false
         }
@@ -960,6 +1003,20 @@ impl UIObject for ChatView {
     async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
         t!("handle_mouse_btn_up({btn:?}, {mouse_pos:?})");
 
+        let rect = self.rect.get();
+        if rect.contains(mouse_pos) {
+            let mut msgbuf = self.msgbuf.lock().await;
+            let msgbuf_pos = self.to_msgbuf_pos(mouse_pos);
+            if let Some((msg, msg_top)) = msgbuf.get_line(msgbuf_pos.y).await {
+                if msg
+                    .handle_mouse_btn_up(btn, Point::new(msgbuf_pos.x, msg_top - msgbuf_pos.y))
+                    .await
+                {
+                    return true
+                }
+            }
+        }
+
         if btn != MouseButton::Left {
             return false
         }
@@ -987,6 +1044,12 @@ impl UIObject for ChatView {
             let atom = &mut self.render_api.make_guard(gfxtag!("ChatView::handle_mouse_move"));
             self.select_line(atom.batch_id, mouse_pos.y).await;
         }
+
+        let mut msgbuf = self.msgbuf.lock().await;
+        let msgbuf_pos = self.to_msgbuf_pos(mouse_pos);
+        if let Some((msg, msg_top)) = msgbuf.get_line(msgbuf_pos.y).await {
+            msg.handle_mouse_move(Point::new(msgbuf_pos.x, msg_top - msgbuf_pos.y)).await;
+        }
         false
     }
 
@@ -1101,6 +1164,27 @@ impl UIObject for ChatView {
                 self.scrollview(scroll, atom).await;
             }
             TouchPhase::Ended | TouchPhase::Cancelled => {
+                let (start_y, is_select_mode) = {
+                    let touch_info = self.touch_info.lock();
+                    let Some(touch_info) = &*touch_info else { return true };
+                    (touch_info.start_y, touch_info.is_select_mode)
+                };
+
+                // If this selection mode is off and movement was minimal,
+                // it is a tap so we forward the touch event to the message
+                if is_select_mode != Some(true) && (touch_y - start_y).abs() < BIG_EPSILON {
+                    let mut msgbuf = self.msgbuf.lock().await;
+                    let msgbuf_pos = self.to_msgbuf_pos(touch_pos);
+                    if let Some((msg, msg_top)) = msgbuf.get_line(msgbuf_pos.y).await {
+                        msg.handle_touch(
+                            TouchPhase::Ended,
+                            0,
+                            Point::new(msgbuf_pos.x, msg_top - msgbuf_pos.y),
+                        )
+                        .await;
+                    }
+                }
+
                 self.end_touch_phase(touch_y);
             }
         }

+ 204 - 85
bin/app/src/ui/chatview/page.rs

@@ -19,17 +19,20 @@
 use async_gen::{gen as async_gen, AsyncIter};
 use async_trait::async_trait;
 use chrono::{Local, NaiveDate, TimeZone};
-use darkfi_serial::{Decodable, FutAsyncWriteExt, SerialDecodable, SerialEncodable};
+use darkfi_serial::{Encodable, FutAsyncWriteExt, SerialDecodable, SerialEncodable};
 use futures::stream::{Stream, StreamExt};
 use image::{ImageBuffer, ImageReader, Rgba};
-use miniquad::TextureFormat;
+use miniquad::{MouseButton, TextureFormat, TouchPhase};
 use parking_lot::Mutex as SyncMutex;
 use std::{
     collections::HashMap,
     hash::{DefaultHasher, Hash, Hasher},
     io::Cursor,
     pin::pin,
-    sync::Arc,
+    sync::{
+        atomic::{AtomicBool, Ordering},
+        Arc,
+    },
 };
 use url::Url;
 
@@ -38,7 +41,9 @@ use crate::{
     gfx::{gfxtag, DrawInstruction, ManagedTexturePtr, Point, Rectangle, RenderApi},
     mesh::{Color, MeshBuilder, COLOR_CYAN, COLOR_GREEN, COLOR_RED, COLOR_WHITE},
     prop::{PropertyColor, PropertyFloat32, PropertyPtr},
+    scene::SceneNodeWeak,
     text,
+    ui::UIObject,
     util::enumerate_mut,
 };
 
@@ -320,27 +325,32 @@ impl std::fmt::Debug for DateMessage {
     }
 }
 
-#[derive(Clone, SerialEncodable, SerialDecodable)]
+#[derive(Clone, Debug, SerialEncodable, SerialDecodable)]
 pub enum FileMessageStatus {
     Initializing,
+    Idle,
     Downloading { progress: f32 },
     Downloaded { path: String },
-    Error { msg: String },
+    Error { msg: String, progress: f32 },
 }
 
 type GenericImageBuffer = ImageBuffer<Rgba<u8>, Vec<u8>>;
 
-#[derive(Clone)]
 pub struct FileMessage {
+    chatview_node: SceneNodeWeak,
+
     font_size: f32,
     window_scale: f32,
     max_width: f32,
 
     file_url: Url,
-    status: FileMessageStatus,
+    pub status: FileMessageStatus,
     imgbuf: Arc<SyncMutex<Option<GenericImageBuffer>>>,
     timestamp: Timestamp,
 
+    active_rect: Option<Rectangle>,
+    mouse_btn_held: AtomicBool,
+
     mesh_cache: Option<Vec<DrawInstruction>>,
 }
 
@@ -349,12 +359,13 @@ impl FileMessage {
     const GLOW_SIZE: f32 = 20.;
     const MARGIN_TOP: f32 = 4.;
     const MARGIN_BOTTOM: f32 = 10.;
-    const BOX_PADDING_TOP: f32 = 15.;
-    const BOX_PADDING_BOTTOM: f32 = 8.;
+    const BOX_PADDING_Y: f32 = 12.;
     const BOX_PADDING_X: f32 = 15.;
     const IMG_MAX_HEIGHT: f32 = 500.;
 
     pub fn new(
+        chatview_node: SceneNodeWeak,
+
         font_size: f32,
         window_scale: f32,
 
@@ -363,6 +374,7 @@ impl FileMessage {
         timestamp: Timestamp,
     ) -> Message {
         Message::File(Self {
+            chatview_node,
             font_size,
             window_scale,
             max_width: 0.,
@@ -370,6 +382,8 @@ impl FileMessage {
             status,
             imgbuf: Arc::new(SyncMutex::new(None)),
             timestamp,
+            active_rect: None,
+            mouse_btn_held: AtomicBool::new(false),
             mesh_cache: None,
         })
     }
@@ -377,9 +391,16 @@ impl FileMessage {
     fn filestr(file_url: &Url, status: &FileMessageStatus) -> Vec<String> {
         let status_str = match status {
             FileMessageStatus::Initializing => "starting fud".to_string(),
+            FileMessageStatus::Idle => "tap to download".to_string(),
             FileMessageStatus::Downloading { progress } => format!("downloading [{progress:.1}%]"),
             FileMessageStatus::Downloaded { .. } => "downloaded".to_string(),
-            FileMessageStatus::Error { msg } => msg.to_lowercase(),
+            FileMessageStatus::Error { msg, progress } => {
+                if *progress > 0. {
+                    format!("{} [{progress:.1}%]", msg.to_lowercase())
+                } else {
+                    msg.to_lowercase()
+                }
+            }
         };
 
         vec![
@@ -422,7 +443,7 @@ impl FileMessage {
         let img_w = imgbuf.width() as f32;
         let img_h = imgbuf.height() as f32;
 
-        let width_scale = (self.max_width - Self::GLOW_SIZE) / img_w;
+        let width_scale = self.max_width / img_w;
         let height_scale = Self::IMG_MAX_HEIGHT / img_h;
 
         let scale = width_scale.min(height_scale);
@@ -441,7 +462,7 @@ impl FileMessage {
             return instrs.clone()
         }
 
-        self.max_width = clip.w - timestamp_width;
+        self.max_width = clip.w - timestamp_width - Self::GLOW_SIZE;
 
         // Extract image size while holding lock, then drop it
         let mut img_size = None;
@@ -451,12 +472,12 @@ impl FileMessage {
 
         // Lock is dropped here, safe to await now
         if let Some((img_w, img_h)) = img_size {
-            let mesh_rect =
-                Rectangle::from([timestamp_width, -img_h - Self::MARGIN_BOTTOM, img_w, img_h]);
+            let mesh_rect = Rectangle::from([timestamp_width, Self::MARGIN_TOP, img_w, img_h]);
             let texture = self.load_texture(render_api);
             let mut mesh_gradient = MeshBuilder::new(gfxtag!("file_gradient"));
             let glow_color = [timestamp_color[0], timestamp_color[1], timestamp_color[2], 0.5];
             mesh_gradient.draw_box_shadow(&mesh_rect, glow_color, Self::GLOW_SIZE);
+            self.active_rect = Some(mesh_rect);
 
             let mesh_gradient = mesh_gradient.alloc(render_api);
             let mut instrs = vec![DrawInstruction::Draw(mesh_gradient.draw_untextured())];
@@ -472,39 +493,23 @@ impl FileMessage {
             return instrs;
         }
 
-        // Image is not downloaded yet
+        // File is not an image, or the image is not downloaded yet
 
         let mut all_instrs = vec![];
 
-        // Draw background box
-
         let color = match self.status {
             FileMessageStatus::Initializing => timestamp_color,
+            FileMessageStatus::Idle => timestamp_color,
             FileMessageStatus::Downloading { .. } => COLOR_CYAN,
             FileMessageStatus::Downloaded { .. } => COLOR_GREEN,
             FileMessageStatus::Error { .. } => COLOR_RED,
         };
-        let box_height = 2. * line_height + Self::BOX_PADDING_TOP + Self::BOX_PADDING_BOTTOM;
-
-        let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg_box"));
-        let box_width = self.max_width + Self::BOX_PADDING_X * 2.;
-        let mesh_rect = Rectangle::new(
-            timestamp_width,
-            -box_height + Self::MARGIN_BOTTOM,
-            box_width,
-            box_height,
-        );
-        mesh.draw_outline(&mesh_rect, color, 1.);
-
-        let glow_color = [color[0], color[1], color[2], 0.3];
-        mesh.draw_box_shadow(&mesh_rect, glow_color, Self::GLOW_SIZE);
-        let mesh = mesh.alloc(render_api);
 
-        all_instrs.push(DrawInstruction::Draw(mesh.draw_untextured()));
+        // Compute text
 
         let file_strs = Self::filestr(&self.file_url, &self.status);
-
         let mut layouts = Vec::with_capacity(file_strs.len());
+        let mut text_width = 0.;
         for file_str in &file_strs {
             let layout = text::make_layout(
                 file_str,
@@ -515,15 +520,39 @@ impl FileMessage {
                 Some(self.max_width),
                 &[],
             );
+            if layout.width() > text_width {
+                text_width = layout.width();
+            }
             layouts.push(layout);
         }
 
-        all_instrs
-            .push(DrawInstruction::Move(Point::new(timestamp_width + Self::BOX_PADDING_X, 0.)));
+        // Draw background box
+
+        let box_height = 2. * line_height + Self::BOX_PADDING_Y * 2.;
+
+        let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg_box"));
+        let box_width = if text_width > self.max_width { self.max_width } else { text_width } +
+            Self::BOX_PADDING_X * 2.;
+        let mesh_rect = Rectangle::new(timestamp_width, Self::MARGIN_TOP, box_width, box_height);
+        mesh.draw_outline(&mesh_rect, color, 1.);
+        self.active_rect = Some(mesh_rect);
+
+        let glow_color = [color[0], color[1], color[2], 0.3];
+        mesh.draw_box_shadow(&mesh_rect, glow_color, Self::GLOW_SIZE);
+        let mesh = mesh.alloc(render_api);
+
+        all_instrs.push(DrawInstruction::Draw(mesh.draw_untextured()));
+
+        // Draw text
+
+        all_instrs.push(DrawInstruction::Move(Point::new(
+            timestamp_width + Self::BOX_PADDING_X,
+            Self::MARGIN_TOP + Self::BOX_PADDING_Y,
+        )));
         for layout in layouts {
             let instrs = text::render_layout(&layout, render_api, gfxtag!("chatview_filemsg_text"));
             all_instrs.extend(instrs);
-            all_instrs.push(DrawInstruction::Move(Point::new(0., -line_height)));
+            all_instrs.push(DrawInstruction::Move(Point::new(0., line_height)));
         }
 
         self.mesh_cache = Some(all_instrs.clone());
@@ -532,15 +561,11 @@ impl FileMessage {
 
     fn load_img(&self) -> Option<ImageBuffer<Rgba<u8>, Vec<u8>>> {
         if let FileMessageStatus::Downloaded { path } = &self.status {
-            let path = path.as_str();
-
             let data = Arc::new(SyncMutex::new(vec![]));
             let data2 = data.clone();
-            miniquad::fs::load_file(path, move |res| match res {
+            miniquad::fs::load_file(path.as_str(), move |res| match res {
                 Ok(res) => *data2.lock() = res,
-                Err(e) => {
-                    error!("Resource not found! {e}");
-                }
+                Err(_) => {}
             });
             let data = std::mem::take(&mut *data.lock());
             let Ok(img) =
@@ -583,10 +608,89 @@ impl FileMessage {
 
         // No image yet, so calculate height for text box
         // filestr() always returns 2 lines: [file_hash, status_string]
-        2. * line_height
+        2. * line_height + Self::BOX_PADDING_Y * 2. + Self::MARGIN_TOP + Self::MARGIN_BOTTOM
     }
 
     fn select(&mut self) {}
+
+    async fn download(&self) {
+        let node_ref = self.chatview_node.upgrade().unwrap();
+        let mut data = vec![];
+        self.file_url.encode(&mut data).unwrap();
+        let _ = node_ref.trigger("file_download_request", data).await;
+    }
+}
+
+#[async_trait]
+impl UIObject for FileMessage {
+    fn priority(&self) -> u32 {
+        1
+    }
+
+    async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        if btn != MouseButton::Left {
+            return false
+        }
+        if self.active_rect.is_none() {
+            return false
+        }
+        let rect = self.active_rect.unwrap();
+        if !rect.contains(mouse_pos) {
+            return false
+        }
+
+        self.mouse_btn_held.store(true, Ordering::Relaxed);
+        true
+    }
+
+    async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        if btn != MouseButton::Left {
+            return false
+        }
+
+        // Did we start the click inside this FileMessage?
+        let btn_held = self.mouse_btn_held.swap(false, Ordering::Relaxed);
+        if !btn_held {
+            return false
+        }
+
+        if self.active_rect.is_none() {
+            return false
+        }
+        let rect = self.active_rect.unwrap();
+        if !rect.contains(mouse_pos) {
+            return false
+        }
+
+        match self.status {
+            FileMessageStatus::Idle | FileMessageStatus::Error { .. } => {
+                self.download().await;
+            }
+            _ => {}
+        }
+        true
+    }
+
+    async fn handle_touch(&self, phase: TouchPhase, _id: u64, touch_pos: Point) -> bool {
+        if phase != TouchPhase::Ended {
+            return false
+        }
+        if self.active_rect.is_none() {
+            return false
+        }
+        let rect = self.active_rect.unwrap();
+        if !rect.contains(touch_pos) {
+            return false
+        }
+
+        match self.status {
+            FileMessageStatus::Idle | FileMessageStatus::Error { .. } => {
+                self.download().await;
+            }
+            _ => {}
+        }
+        true
+    }
 }
 
 impl std::fmt::Debug for FileMessage {
@@ -718,6 +822,34 @@ impl Message {
     }
 }
 
+#[async_trait]
+impl UIObject for Message {
+    fn priority(&self) -> u32 {
+        1
+    }
+    async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        match self {
+            Self::Priv(_) => false,
+            Self::Date(_) => false,
+            Self::File(m) => m.handle_mouse_btn_down(btn, mouse_pos).await,
+        }
+    }
+    async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        match self {
+            Self::Priv(_) => false,
+            Self::Date(_) => false,
+            Self::File(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
+        }
+    }
+    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
+        match self {
+            Self::Priv(_) => false,
+            Self::Date(_) => false,
+            Self::File(m) => m.handle_touch(phase, id, touch_pos).await,
+        }
+    }
+}
+
 fn select_nick_color(nick: &str, nick_colors: &[Color]) -> Color {
     let mut hasher = DefaultHasher::new();
     nick.hash(&mut hasher);
@@ -1028,9 +1160,9 @@ impl MessageBuffer {
 
     pub fn insert_filemsg(
         &mut self,
+        chatview_node: SceneNodeWeak,
         timest: Timestamp,
         msg_id: MessageId,
-        status: FileMessageStatus,
         nick: String,
         file_url: Url,
     ) -> Option<&mut FileMessage> {
@@ -1038,7 +1170,14 @@ impl MessageBuffer {
         let font_size = self.font_size.get();
         let window_scale = self.window_scale.get();
 
-        let msg = FileMessage::new(font_size, window_scale, file_url, status, timest);
+        let msg = FileMessage::new(
+            chatview_node,
+            font_size,
+            window_scale,
+            file_url,
+            FileMessageStatus::Initializing,
+            timest,
+        );
 
         // Timestamps go from most recent backwards
         let mut idx = None;
@@ -1049,13 +1188,7 @@ impl MessageBuffer {
             }
         }
 
-        let idx = match idx {
-            Some(idx) => idx,
-            None => {
-                let last_page_idx = 0;
-                last_page_idx
-            }
-        };
+        let idx = idx.unwrap_or_default();
 
         self.msgs.insert(idx, msg);
         self.msgs[idx].get_filemsg_mut()
@@ -1128,7 +1261,7 @@ impl MessageBuffer {
         colors
     }
 
-    pub async fn select_line(&mut self, y: f32) {
+    pub async fn get_line(&mut self, y: f32) -> Option<(&mut Message, f32)> {
         let line_height = self.line_height.get();
         let msg_spacing = self.msg_spacing.get();
 
@@ -1142,48 +1275,34 @@ impl MessageBuffer {
             let msg_top = current_pos + mesh_height + msg_spacing;
 
             if msg_bottom <= y && y <= msg_top {
-                // Do nothing
-                if msg.is_date() {
-                    break
-                }
-
-                msg.select();
-                msg.clear_mesh();
-                break
+                return Some((msg, msg_top))
             }
 
             current_pos += msg_spacing;
             current_pos += mesh_height;
         }
-    }
 
-    pub async fn update_file(&mut self, data: &Vec<u8>) {
-        let mut cur = Cursor::new(data);
-        let hash = String::decode(&mut cur).unwrap();
-        let status = String::decode(&mut cur).unwrap();
+        None
+    }
 
-        let status = match status.as_str() {
-            "downloading" => {
-                let progress = f32::decode(&mut cur).unwrap();
-                FileMessageStatus::Downloading { progress }
-            }
-            "downloaded" => {
-                let path = String::decode(&mut cur).unwrap();
-                FileMessageStatus::Downloaded { path }
-            }
-            "error" => {
-                let msg = String::decode(&mut cur).unwrap();
-                FileMessageStatus::Error { msg }
+    pub async fn select_line(&mut self, y: f32) {
+        if let Some((msg, _)) = self.get_line(y).await {
+            // Do nothing
+            if msg.is_date() {
+                return
             }
-            _ => FileMessageStatus::Initializing,
-        };
 
-        // TODO: keep a cache of file messages somewhere to avoid looping
-        // over all messages
+            msg.select();
+
+            msg.clear_mesh();
+        }
+    }
+
+    pub fn update_file_status(&mut self, url: &Url, status: &FileMessageStatus) {
         for msg in &mut self.msgs {
             if let Some(filemsg) = msg.get_filemsg_mut() {
-                if filemsg.file_url.host_str() == Some(&hash) {
-                    filemsg.set_status(&status);
+                if filemsg.file_url == *url {
+                    filemsg.set_status(status);
                     filemsg.clear_mesh();
                 }
             }