Sfoglia il codice sorgente

app: add fud plugin, add FileMessage to MessageBuffer

epiphany 8 mesi fa
parent
commit
1f99bf931d

+ 125 - 0
bin/app/Cargo.lock

@@ -765,6 +765,15 @@ dependencies = [
  "wyz",
 ]
 
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
 [[package]]
 name = "blake2b_simd"
 version = "1.0.3"
@@ -1505,6 +1514,7 @@ dependencies = [
  "async-trait",
  "atomic_float",
  "blake3",
+ "bs58",
  "chrono",
  "clap 4.5.53",
  "colored",
@@ -1515,6 +1525,7 @@ dependencies = [
  "file-rotate",
  "fluent",
  "freetype-rs",
+ "fud",
  "futures",
  "glam",
  "harfbuzz-sys",
@@ -1909,6 +1920,33 @@ version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
 
+[[package]]
+name = "dynasm"
+version = "3.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f7d4c414c94bc830797115b8e5f434d58e7e80cb42ba88508c14bc6ea270625"
+dependencies = [
+ "bitflags 2.10.0",
+ "byteorder",
+ "lazy_static",
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.111",
+]
+
+[[package]]
+name = "dynasmrt"
+version = "3.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "602f7458a3859195fb840e6e0cce5f4330dd9dfbfece0edaf31fe427af346f55"
+dependencies = [
+ "byteorder",
+ "dynasm",
+ "fnv",
+ "memmap2",
+]
+
 [[package]]
 name = "easy-parallel"
 version = "3.3.1"
@@ -2053,6 +2091,19 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
+[[package]]
+name = "equix"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89178c5241f5cc0c8f2b5ac5008f3c7a32caad341b1ec747a6e1e51d2e877110"
+dependencies = [
+ "arrayvec",
+ "hashx",
+ "num-traits",
+ "thiserror 2.0.17",
+ "visibility",
+]
+
 [[package]]
 name = "errno"
 version = "0.3.14"
@@ -2228,6 +2279,12 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
 
+[[package]]
+name = "fixed-capacity-vec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b31a14f5ee08ed1a40e1252b35af18bed062e3f39b69aab34decde36bc43e40"
+
 [[package]]
 name = "flate2"
 version = "1.1.5"
@@ -2450,6 +2507,37 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "fud"
+version = "0.5.0"
+dependencies = [
+ "async-trait",
+ "blake2",
+ "blake3",
+ "bs58",
+ "darkfi",
+ "darkfi-sdk",
+ "darkfi-serial",
+ "easy-parallel",
+ "equix",
+ "futures",
+ "num-bigint",
+ "rand 0.8.5",
+ "serde",
+ "sha2",
+ "signal-hook",
+ "signal-hook-async-std",
+ "sled-overlay",
+ "smol",
+ "structopt",
+ "structopt-toml",
+ "tinyjson",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+ "url",
+]
+
 [[package]]
 name = "funty"
 version = "2.0.0"
@@ -2815,6 +2903,21 @@ dependencies = [
  "hashbrown 0.15.5",
 ]
 
+[[package]]
+name = "hashx"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cb639748a589a17df2126f8015897ab416e81113afb82f56df5d47fa1486ab1"
+dependencies = [
+ "arrayvec",
+ "blake2",
+ "dynasmrt",
+ "fixed-capacity-vec",
+ "hex",
+ "rand_core 0.9.3",
+ "thiserror 2.0.17",
+]
+
 [[package]]
 name = "heck"
 version = "0.3.3"
@@ -5181,6 +5284,28 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
 
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-async-std"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ae704a5db1c75caeec797eccb081a9980c8e374e8774c7cb7b26799cc727b38"
+dependencies = [
+ "async-io",
+ "futures-lite",
+ "libc",
+ "signal-hook",
+]
+
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.7"

+ 2 - 0
bin/app/Cargo.toml

@@ -27,6 +27,8 @@ zeromq = { version = "0.4.1", default-features = false, features = ["async-std-r
 darkfi = {path = "../../", features = ["async-daemonize", "event-graph", "net", "util", "system", "zk"]}
 #darkfi-sdk = {path = "../../src/sdk", features = ["async"]}
 darkfi-serial = {version = "0.5.0", features = ["async"]}
+fud = { path = "../fud/fud/" }
+bs58 = "0.5.1"
 thiserror = "2.0.12"
 smol = "2.0.2"
 atomic_float = "1.1.0"

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

@@ -486,6 +486,8 @@ pub fn create_chatview(name: &str) -> SceneNode {
     )
     .unwrap();
 
+    node.add_method("update_file", vec![("hash", "Hash", CallArgType::Str)], None).unwrap();
+
     node
 }
 

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

@@ -563,6 +563,7 @@ pub async fn make(
                 window_scale.clone(),
                 app.render_api.clone(),
                 app.text_shaper.clone(),
+                app.sg_root.clone(),
             )
         })
         .await;

+ 22 - 0
bin/app/src/main.rs

@@ -283,6 +283,14 @@ async fn load_plugins(
         })
         .await;
 
+    let fud = create_fud("fud");
+    let sg_root2 = sg_root.clone();
+    let fud = fud
+        .setup(|me| async {
+            plugin::Fud::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,6 +407,7 @@ async fn load_plugins(
     });
 
     plugin.link(darkirc);
+    plugin.link(fud);
 
     i!("Plugins loaded");
     futures::join!(listen_recv, listen_connect);
@@ -446,6 +455,19 @@ pub fn create_darkirc(name: &str) -> SceneNode {
     node
 }
 
+pub fn create_fud(name: &str) -> SceneNode {
+    t!("create_fud({name})");
+    let mut node = SceneNode::new(name, SceneNodeType::Plugin);
+
+    let mut prop = Property::new("ready", PropertyType::Bool, PropertySubType::Null);
+    prop.set_defaults_bool(vec![false]).unwrap();
+    node.add_property(prop).unwrap();
+
+    node.add_method("get", vec![("hash", "Hash", CallArgType::Str)], None).unwrap();
+
+    node
+}
+
 /// Simple program to greet a person
 #[derive(Parser, Debug)]
 #[command(version, about, long_about = None)]

+ 56 - 0
bin/app/src/mesh.rs

@@ -32,6 +32,7 @@ pub const COLOR_LIGHTGREY: Color = [0.7, 0.7, 0.7, 1.];
 pub const COLOR_GREEN: Color = [0., 1., 0., 1.];
 pub const COLOR_BLUE: Color = [0., 0., 1., 1.];
 pub const COLOR_PINK: Color = [0.8, 0.3, 0.8, 1.];
+pub const COLOR_CYAN: Color = [0., 1., 1., 1.];
 #[allow(dead_code)]
 pub const COLOR_PURPLE: Color = [1., 0., 1., 1.];
 pub const COLOR_WHITE: Color = [1., 1., 1., 1.];
@@ -112,6 +113,61 @@ impl MeshBuilder {
         self.draw_box(obj, color, &uv);
     }
 
+    pub fn draw_box_shadow(&mut self, obj: &Rectangle, color: Color, spread: f32) {
+        let (x1, y1) = obj.pos().unpack();
+        let (x2, y2) = obj.corner().unpack();
+
+        let uv = Rectangle::zero();
+        let (u1, v1) = uv.pos().unpack();
+        let (u2, v2) = uv.corner().unpack();
+
+        let color2 = [color[0], color[1], color[2], 0.];
+
+        // left
+        self.append(
+            vec![
+                Vertex { pos: [x1, y1], color, uv: [u1, v1] },
+                Vertex { pos: [x1 - spread, y1 - spread], color: color2, uv: [u2, v1] },
+                Vertex { pos: [x1, y2], color, uv: [u1, v2] },
+                Vertex { pos: [x1 - spread, y2 + spread], color: color2, uv: [u2, v2] },
+            ],
+            vec![0, 2, 1, 1, 2, 3],
+        );
+
+        // top
+        self.append(
+            vec![
+                Vertex { pos: [x1, y1], color, uv: [u1, v1] },
+                Vertex { pos: [x1 - spread, y1 - spread], color: color2, uv: [u2, v1] },
+                Vertex { pos: [x2, y1], color, uv: [u1, v2] },
+                Vertex { pos: [x2 + spread, y1 - spread], color: color2, uv: [u2, v2] },
+            ],
+            vec![0, 2, 1, 1, 2, 3],
+        );
+
+        // right
+        self.append(
+            vec![
+                Vertex { pos: [x2, y1], color, uv: [u1, v1] },
+                Vertex { pos: [x2 + spread, y1 - spread], color: color2, uv: [u2, v1] },
+                Vertex { pos: [x2, y2], color, uv: [u1, v2] },
+                Vertex { pos: [x2 + spread, y2 + spread], color: color2, uv: [u2, v2] },
+            ],
+            vec![0, 2, 1, 1, 2, 3],
+        );
+
+        // bottom
+        self.append(
+            vec![
+                Vertex { pos: [x1, y2], color, uv: [u1, v1] },
+                Vertex { pos: [x1 - spread, y2 + spread], color: color2, uv: [u2, v1] },
+                Vertex { pos: [x2, y2], color, uv: [u1, v2] },
+                Vertex { pos: [x2 + spread, y2 + spread], color: color2, uv: [u2, v2] },
+            ],
+            vec![0, 2, 1, 1, 2, 3],
+        );
+    }
+
     pub fn draw_outline(&mut self, obj: &Rectangle, color: Color, thickness: f32) {
         let (x1, y1) = obj.pos().unpack();
         let (dist_x, dist_y) = (obj.w, obj.h);

+ 507 - 0
bin/app/src/plugin/fud.rs

@@ -0,0 +1,507 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2025 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use darkfi::{
+    net::{
+        session::{SESSION_DIRECT, SESSION_INBOUND},
+        settings::Settings as NetSettings,
+        P2p, P2pPtr,
+    },
+    system::{sleep, Publisher, PublisherPtr},
+};
+use darkfi_serial::{Decodable, Encodable};
+use sled_overlay::sled;
+use smol::lock::Mutex;
+use std::{
+    collections::HashSet,
+    io::Cursor,
+    path::PathBuf,
+    sync::{Arc, OnceLock, Weak},
+};
+use url::Url;
+
+use fud::{
+    event::FudEvent, proto::ProtocolFud, settings::Args as FudSettings, util::hash_to_string, Fud,
+};
+
+use crate::{
+    error::{Error, Result},
+    prop::{BatchGuardPtr, PropertyAtomicGuard, PropertyBool, Role},
+    scene::{MethodCallSub, Pimpl, SceneNode, SceneNodePtr, SceneNodeType, SceneNodeWeak},
+    ui::OnModify,
+    ExecutorPtr,
+};
+
+use super::PluginSettings;
+
+const P2P_RETRY_TIME: u64 = 20;
+
+#[cfg(target_os = "android")]
+mod paths {
+    use crate::android::{get_appdata_path, get_external_storage_path};
+    use std::path::PathBuf;
+
+    pub fn get_base_path() -> PathBuf {
+        get_external_storage_path().join("fud")
+    }
+    pub fn get_db_path() -> PathBuf {
+        get_external_storage_path().join("fud/db")
+    }
+    pub fn get_downloads_path() -> PathBuf {
+        get_external_storage_path().join("fud/downloads")
+    }
+    pub fn get_use_tor_filename() -> PathBuf {
+        get_external_storage_path().join("use_tor.txt")
+    }
+
+    pub fn p2p_datastore_path() -> PathBuf {
+        get_appdata_path().join("fud/p2p")
+    }
+    pub fn hostlist_path() -> PathBuf {
+        get_appdata_path().join("fud/hostlist.tsv")
+    }
+}
+
+#[cfg(not(target_os = "android"))]
+mod paths {
+    use std::path::PathBuf;
+
+    pub fn get_base_path() -> PathBuf {
+        dirs::data_local_dir().unwrap().join("darkfi/app/fud")
+    }
+    pub fn get_db_path() -> PathBuf {
+        dirs::data_local_dir().unwrap().join("darkfi/app/fud/db")
+    }
+    pub fn get_downloads_path() -> PathBuf {
+        dirs::data_local_dir().unwrap().join("darkfi/app/fud/downloads")
+    }
+    pub fn get_use_tor_filename() -> PathBuf {
+        dirs::data_local_dir().unwrap().join("darkfi/app/use_tor.txt")
+    }
+
+    pub fn p2p_datastore_path() -> PathBuf {
+        dirs::cache_dir().unwrap().join("darkfi/app/fud/p2p")
+    }
+    pub fn hostlist_path() -> PathBuf {
+        dirs::cache_dir().unwrap().join("darkfi/app/fud/hostlist.tsv")
+    }
+}
+
+use paths::*;
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "plugin::fud", $($arg)*); } }
+macro_rules! d { ($($arg:tt)*) => { debug!(target: "plugin::fud", $($arg)*); } }
+macro_rules! i { ($($arg:tt)*) => { info!(target: "plugin::fud", $($arg)*); } }
+macro_rules! e { ($($arg:tt)*) => { error!(target: "plugin::fud", $($arg)*); } }
+
+pub type FudPluginPtr = Arc<FudPlugin>;
+
+pub struct FudPlugin {
+    node: SceneNodeWeak,
+    sg_root: SceneNodePtr,
+    tasks: OnceLock<Vec<smol::Task<()>>>,
+
+    p2p: P2pPtr,
+    event_pub: PublisherPtr<FudEvent>,
+    fud: Arc<Fud>,
+
+    download_on_ready: Arc<Mutex<HashSet<Url>>>,
+
+    settings: PluginSettings,
+}
+
+impl FudPlugin {
+    pub async fn new(node: SceneNodeWeak, sg_root: SceneNodePtr, ex: ExecutorPtr) -> Result<Pimpl> {
+        let node_ref = &node.upgrade().unwrap();
+        // let fud_node_id = PropertyStr::wrap(node_ref, Role::Internal, "node_id", 0).unwrap();
+        let fud_ready = PropertyBool::wrap(node_ref, Role::Internal, "ready", 0).unwrap();
+        fud_ready.set(&mut PropertyAtomicGuard::none(), false);
+
+        let setting_root = Arc::new(SceneNode::new("setting", SceneNodeType::SettingRoot));
+        node_ref.clone().link(setting_root.clone());
+
+        let basedir = get_base_path();
+
+        i!("Starting Fud backend");
+        let db_path = get_db_path();
+        let db = match sled::open(&db_path) {
+            Ok(db) => db,
+            Err(err) => {
+                e!("Sled database '{}' failed to open: {err}!", db_path.display());
+                return Err(Error::SledDbErr)
+            }
+        };
+
+        let setting_tree = db.open_tree("settings")?;
+        let settings = PluginSettings { setting_root, sled_tree: setting_tree };
+
+        let mut fud_settings: FudSettings = Default::default();
+        fud_settings.base_dir = basedir.to_string_lossy().to_string();
+        let mut p2p_settings: NetSettings = fud_settings.net.clone().into();
+        p2p_settings.app_version = semver::Version::parse("0.5.0").unwrap();
+        if get_use_tor_filename().exists() {
+            i!("Setup P2P network [tor]");
+            p2p_settings.outbound_connect_timeout = 60;
+            p2p_settings.channel_handshake_timeout = 55;
+            p2p_settings.channel_heartbeat_interval = 90;
+            p2p_settings.outbound_peer_discovery_cooloff_time = 60;
+
+            p2p_settings.seeds.push(
+                url::Url::parse(
+                    "tor://g7fxelebievvpr27w7gt24lflptpw3jeeuvafovgliq5utdst6xyruyd.onion:24442",
+                )
+                .unwrap(),
+            );
+            p2p_settings.seeds.push(
+                url::Url::parse(
+                    "tor://yvklzjnfmwxhyodhrkpomawjcdvcaushsj6torjz2gyd7e25f3gfunyd.onion:24442",
+                )
+                .unwrap(),
+            );
+            p2p_settings.allowed_transports = vec!["tor".to_string()];
+
+            fud_settings.pow.btc_electrum_nodes.push(
+                url::Url::parse(
+                    "tor://hezojf7rda2c33yxgcgcvvsxflechdz5vkm64gwlszgx2r4gc5e42kqd.onion:50001",
+                )
+                .unwrap(),
+            );
+            fud_settings.pow.btc_electrum_nodes.push(
+                url::Url::parse(
+                    "tor://n4widoxtm3xpo2fjvtdffhb63q5td3utaxkolaegnpzb5khbwxvdrlad.onion:50001",
+                )
+                .unwrap(),
+            );
+            fud_settings.pow.btc_electrum_nodes.push(
+                url::Url::parse(
+                    "tor://duras25aqnp3tnn2zgma7pusms6c7umtunyu2sp6e5byotr3c4c6rzad.onion:50001",
+                )
+                .unwrap(),
+            );
+            fud_settings.pow.btc_electrum_nodes.push(
+                url::Url::parse(
+                    "tor://n3dz6thzxobyphuosoftgtf36rnsxlsjknke4yrbdys55zvd7nsx7qid.onion:50001",
+                )
+                .unwrap(),
+            );
+        } else {
+            i!("Setup P2P network [clearnet]");
+            p2p_settings.outbound_connect_timeout = 40;
+            p2p_settings.channel_handshake_timeout = 30;
+
+            p2p_settings.seeds.push(url::Url::parse("tcp+tls://lilith0.dark.fi:24441").unwrap());
+            p2p_settings.seeds.push(url::Url::parse("tcp+tls://lilith1.dark.fi:24441").unwrap());
+
+            fud_settings
+                .pow
+                .btc_electrum_nodes
+                .push(url::Url::parse("tcp+tls://erbium1.sytes.net:50002").unwrap());
+            fud_settings
+                .pow
+                .btc_electrum_nodes
+                .push(url::Url::parse("tcp+tls://ecdsa.net:110").unwrap());
+            fud_settings
+                .pow
+                .btc_electrum_nodes
+                .push(url::Url::parse("tcp+tls://electrum.no-ip.org:50002").unwrap());
+            fud_settings
+                .pow
+                .btc_electrum_nodes
+                .push(url::Url::parse("tcp+tls://electrumx.not.fyi:50002").unwrap());
+        }
+        p2p_settings.p2p_datastore = p2p_datastore_path().into_os_string().into_string().ok();
+        p2p_settings.hostlist = hostlist_path().into_os_string().into_string().ok();
+
+        settings.add_p2p_settings(&p2p_settings);
+        // TODO: add other fud settings
+
+        settings.load_settings();
+        settings.update_p2p_settings(&mut p2p_settings);
+
+        let p2p = match P2p::new(p2p_settings.clone(), ex.clone()).await {
+            Ok(p2p) => p2p,
+            Err(err) => {
+                e!("Create p2p network failed: {err}!");
+                return Err(Error::ServiceFailed)
+            }
+        };
+
+        p2p.session_direct().start_peer_discovery();
+
+        let event_pub = Publisher::new();
+        let fud: Arc<Fud> =
+            match Fud::new(fud_settings, p2p.clone(), &db, event_pub.clone(), ex.clone()).await {
+                Ok(fud) => fud,
+                Err(err) => {
+                    e!("Cannot create fud instance: {err}");
+                    return Err(Error::ServiceFailed)
+                }
+            };
+
+        let self_ = Arc::new(Self {
+            node: node.clone(),
+            sg_root,
+            tasks: OnceLock::new(),
+            p2p,
+            event_pub,
+            fud,
+            download_on_ready: Arc::new(Mutex::new(HashSet::new())),
+            settings,
+        });
+        self_.clone().start(ex).await;
+        Ok(Pimpl::Fud(self_))
+    }
+
+    async fn apply_settings(self_: Arc<Self>, _batch: BatchGuardPtr) {
+        self_.settings.save_settings();
+
+        let p2p_settings = self_.p2p.settings();
+        let mut write_guard = p2p_settings.write().await;
+        self_.settings.update_p2p_settings(&mut write_guard);
+
+        // TODO: add other fud settings
+    }
+
+    async fn start(self: Arc<Self>, ex: ExecutorPtr) {
+        i!("Registering Fud protocol");
+        let registry = self.p2p.protocol_registry();
+        let fud = self.fud.clone();
+        let p2p = self.p2p.clone();
+        registry
+            .register(SESSION_DIRECT | SESSION_INBOUND, move |channel, _| {
+                let fud_ = fud.clone();
+                let p2p_ = p2p.clone();
+                async move { ProtocolFud::init(fud_, channel, p2p_).await.unwrap() }
+            })
+            .await;
+
+        let me = Arc::downgrade(&self);
+
+        let node = &self.node.upgrade().unwrap();
+
+        let method_sub = node.subscribe_method_call("get").unwrap();
+        let me2 = me.clone();
+        let get_method_task =
+            ex.spawn(async move { while Self::process_get(&me2, &method_sub).await {} });
+
+        let event_pub = self.event_pub.clone();
+        let me2 = me.clone();
+        let ev_task = ex.spawn(async move {
+            Self::process_events(&me2, event_pub).await;
+        });
+
+        let mut on_modify = OnModify::new(ex.clone(), self.node.clone(), me.clone());
+
+        // `apply_settings` is triggered if any setting changes
+        for setting_node in self.settings.setting_root.get_children().iter() {
+            on_modify.when_change(
+                setting_node.get_property("value").clone().unwrap(),
+                Self::apply_settings,
+            );
+        }
+
+        let fud = self.fud.clone();
+        let start_task = ex.spawn(async move {
+            while fud.start().await.is_err() {
+                sleep(10).await;
+            }
+        });
+
+        let mut tasks = vec![get_method_task, ev_task, start_task];
+        tasks.append(&mut on_modify.tasks);
+        self.tasks.set(tasks).unwrap();
+
+        i!("Starting Fud P2P");
+        while let Err(err) = self.p2p.clone().start().await {
+            // This usually means we cannot listen on the inbound ports
+            e!("Failed to start fud's p2p network: {err}!");
+            e!("Usually this means there is another process listening on the same ports.");
+            e!("Trying again in {P2P_RETRY_TIME} secs");
+            sleep(P2P_RETRY_TIME).await;
+        }
+    }
+
+    fn string_to_hash(str: &str) -> std::io::Result<blake3::Hash> {
+        let mut hash_buf = vec![];
+
+        match bs58::decode(str).onto(&mut hash_buf) {
+            Ok(_) => {}
+            Err(_) => {
+                return Err(std::io::Error::new(std::io::ErrorKind::Other, "Invalid fud hash"))
+            }
+        }
+
+        if hash_buf.len() != 32 {
+            return Err(std::io::Error::new(std::io::ErrorKind::Other, "Invalid fud hash"))
+        }
+
+        let mut hash_buf_arr = [0u8; 32];
+        hash_buf_arr.copy_from_slice(&hash_buf);
+
+        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
+        };
+
+        t!("method called: get({method_call:?})");
+        assert!(method_call.send_res.is_none());
+
+        fn decode_data(data: &[u8]) -> std::io::Result<(String, Url)> {
+            let mut cur = Cursor::new(&data);
+            let url = Url::decode(&mut cur)?;
+            let Some(hash_string) = url.host_str().clone() else {
+                return Err(std::io::Error::new(std::io::ErrorKind::Other, "Missing fud hash"))
+            };
+            let hash_string = hash_string.to_string();
+
+            Ok((hash_string, url))
+        }
+
+        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");
+            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
+        };
+
+        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 _ = 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
+        }
+        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;
+            }
+        }
+    }
+
+    async fn process_events(me: &Weak<Self>, publisher: PublisherPtr<FudEvent>) {
+        let Some(self_) = me.upgrade() else {
+            // Should not happen
+            panic!("self destroyed before ev_task was stopped!");
+        };
+
+        let sub = publisher.subscribe().await;
+
+        loop {
+            match sub.receive().await {
+                FudEvent::Ready => {
+                    let atom = &mut PropertyAtomicGuard::none();
+                    self_
+                        .node
+                        .upgrade()
+                        .unwrap()
+                        .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;
+                    }
+                }
+                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;
+                }
+                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;
+                }
+                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;
+                }
+                FudEvent::DownloadError(ev) => {
+                    let mut data = vec![];
+                    ev.error.encode(&mut data).unwrap();
+                    self_.update_file(hash_to_string(&ev.hash), "error", data).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;
+                }
+                FudEvent::MetadataNotFound(ev) => {
+                    let mut data = vec![];
+                    "missing metadata".encode(&mut data).unwrap();
+                    self_.update_file(hash_to_string(&ev.hash), "error", data).await;
+                }
+                _ => {}
+            };
+        }
+    }
+}

+ 3 - 0
bin/app/src/plugin/mod.rs

@@ -24,6 +24,9 @@ pub mod darkirc;
 pub use darkirc::DarkIrc;
 pub use darkirc::DarkIrcPtr;
 
+pub mod fud;
+pub use fud::{FudPlugin as Fud, FudPluginPtr as FudPtr};
+
 use darkfi::net::Settings as NetSettings;
 
 use crate::{

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

@@ -546,6 +546,7 @@ pub enum Pimpl {
     Gesture(ui::GesturePtr),
     EmojiPicker(ui::EmojiPickerPtr),
     DarkIrc(plugin::DarkIrcPtr),
+    Fud(plugin::FudPtr),
 }
 
 impl std::fmt::Debug for Pimpl {

+ 81 - 6
bin/app/src/ui/chatview/mod.rs

@@ -24,6 +24,7 @@ use darkfi_serial::{deserialize, Decodable, Encodable, SerialDecodable, SerialEn
 use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
+use regex::Regex;
 use sled_overlay::sled;
 use std::{
     collections::VecDeque,
@@ -34,9 +35,10 @@ use std::{
     },
 };
 use tracing::instrument;
+use url::Url;
 
 mod page;
-use page::MessageBuffer;
+use page::{FileMessageStatus, MessageBuffer};
 
 use crate::{
     gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi},
@@ -44,7 +46,7 @@ use crate::{
         BatchGuardId, BatchGuardPtr, PropertyAtomicGuard, PropertyBool, PropertyColor,
         PropertyFloat32, PropertyRect, PropertyUint32, Role,
     },
-    scene::{MethodCallSub, Pimpl, SceneNodeWeak},
+    scene::{MethodCallSub, Pimpl, SceneNodePtr, SceneNodeWeak},
     text::TextShaperPtr,
     ExecutorPtr,
 };
@@ -73,6 +75,11 @@ fn max(a: f32, b: f32) -> f32 {
     }
 }
 
+fn get_file_url(text: &String) -> Option<Url> {
+    let url_regex = Regex::new(r"fud://[^\s]+").unwrap();
+    url_regex.find(text).and_then(|match_| Url::parse(match_.as_str()).ok())
+}
+
 #[derive(Clone, Debug, SerialEncodable, SerialDecodable)]
 pub struct ChatMsg {
     pub nick: String,
@@ -147,6 +154,7 @@ pub struct ChatView {
     node: SceneNodeWeak,
     tasks: SyncMutex<Vec<smol::Task<()>>>,
     render_api: RenderApi,
+    sg_root: SceneNodePtr,
 
     tree: sled::Tree,
     msgbuf: AsyncMutex<MessageBuffer>,
@@ -189,6 +197,7 @@ impl ChatView {
         window_scale: PropertyFloat32,
         render_api: RenderApi,
         text_shaper: TextShaperPtr,
+        sg_root: SceneNodePtr,
     ) -> Pimpl {
         t!("ChatView::new()");
 
@@ -230,6 +239,7 @@ impl ChatView {
             node: node.clone(),
             tasks: SyncMutex::new(vec![]),
             render_api: render_api.clone(),
+            sg_root,
 
             tree,
             msgbuf: AsyncMutex::new(MessageBuffer::new(
@@ -444,11 +454,31 @@ impl ChatView {
             t!("Mark sent message as confirmed");
         } else {
             t!("Inserting new message");
+
             // Insert the privmsg since it doesn't already exist
-            if msgbuf.insert_privmsg(timest, msg_id, nick, text).is_none() {
+            let privmsg = msgbuf.insert_privmsg(timest, msg_id.clone(), nick.clone(), text.clone());
+            if privmsg.is_none() {
                 // Not visible so no need to redraw
                 return
             }
+
+            if let Some(url) = get_file_url(&text) {
+                if let Some(fud) = self.sg_root.lookup_node("/plugin/fud") {
+                    msgbuf.insert_filemsg(
+                        timest,
+                        msg_id,
+                        FileMessageStatus::Initializing,
+                        nick,
+                        url.clone(),
+                    );
+
+                    let mut data = vec![];
+                    url.encode(&mut data).unwrap();
+                    fud.call_method("get", data).await.unwrap();
+                }
+            } else {
+                error!(target: "ui::chatview", "Fud plugin has not been loaded");
+            }
         }
 
         let atom = self.render_api.make_guard(gfxtag!("ChatView::handle_insert_line"));
@@ -559,6 +589,11 @@ impl ChatView {
             }
         };
 
+        let Some(fud) = self.sg_root.lookup_node("/plugin/fud") else {
+            error!(target: "ui::chatview", "Fud plugin has not been loaded");
+            return
+        };
+
         let mut do_redraw = false;
         for entry in iter {
             let Ok((k, v)) = entry else { break };
@@ -569,7 +604,26 @@ impl ChatView {
             let chatmsg: ChatMsg = deserialize(&v).unwrap();
 
             //t!("{timest:?} {chatmsg:?} [trace_id={trace_id}]");
-            let msg_height = msgbuf.push_privmsg(timest, msg_id, chatmsg.nick, chatmsg.text);
+            let msg_height = msgbuf.push_privmsg(
+                timest,
+                msg_id.clone(),
+                chatmsg.nick.clone(),
+                chatmsg.text.clone(),
+            );
+
+            if let Some(url) = get_file_url(&chatmsg.text) {
+                msgbuf.insert_filemsg(
+                    timest,
+                    msg_id,
+                    FileMessageStatus::Initializing,
+                    chatmsg.nick.clone(),
+                    url.clone(),
+                );
+
+                let mut data = vec![];
+                url.encode(&mut data).unwrap();
+                fud.call_method("get", data).await.unwrap();
+            }
 
             remaining_load_height -= msg_height;
             if remaining_load_height <= 0. {
@@ -754,6 +808,22 @@ 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 mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
 
         async fn reload_view(self_: Arc<ChatView>, batch: BatchGuardPtr) {
@@ -783,8 +853,13 @@ impl UIObject for ChatView {
         on_modify.when_change(self.rect.prop(), redraw);
         //on_modify.when_change(self.debug.prop(), redraw);
 
-        let mut tasks =
-            vec![insert_line_method_task, insert_unconf_line_method_task, motion_task, bgload_task];
+        let mut tasks = vec![
+            insert_line_method_task,
+            insert_unconf_line_method_task,
+            motion_task,
+            bgload_task,
+            update_file_task,
+        ];
         tasks.append(&mut on_modify.tasks);
 
         *self.tasks.lock() = tasks;

+ 412 - 10
bin/app/src/ui/chatview/page.rs

@@ -17,18 +17,27 @@
  */
 
 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 futures::stream::{Stream, StreamExt};
+use image::{ImageBuffer, ImageReader, Rgba};
+use parking_lot::Mutex as SyncMutex;
 use std::{
     collections::HashMap,
     hash::{DefaultHasher, Hash, Hasher},
+    io::Cursor,
     pin::pin,
+    sync::Arc,
 };
+use url::Url;
 
 use super::{max, MessageId, Timestamp};
 use crate::{
-    gfx::{gfxtag, DrawMesh, Rectangle, RenderApi},
-    mesh::{Color, MeshBuilder, COLOR_BLUE, COLOR_PINK, COLOR_WHITE},
+    gfx::{gfxtag, DrawMesh, ManagedTexturePtr, Rectangle, RenderApi},
+    mesh::{
+        Color, MeshBuilder, COLOR_BLUE, COLOR_CYAN, COLOR_GREEN, COLOR_PINK, COLOR_RED, COLOR_WHITE,
+    },
     prop::{PropertyBool, PropertyColor, PropertyFloat32, PropertyPtr},
     text::{self, Glyph, GlyphPositionIter, TextShaper, TextShaperPtr},
     util::enumerate_mut,
@@ -484,11 +493,300 @@ impl std::fmt::Debug for DateMessage {
     }
 }
 
+#[derive(Clone, SerialEncodable, SerialDecodable)]
+pub enum FileMessageStatus {
+    Initializing,
+    Downloading { progress: f32 },
+    Downloaded { path: String },
+    Error { msg: String },
+}
+
+type GenericImageBuffer = ImageBuffer<Rgba<u8>, Vec<u8>>;
+
+#[derive(Clone)]
+pub struct FileMessage {
+    font_size: f32,
+    window_scale: f32,
+    max_width: f32,
+
+    file_url: Url,
+    status: FileMessageStatus,
+    imgbuf: Arc<SyncMutex<Option<GenericImageBuffer>>>,
+    timestamp: Timestamp,
+    glyphs: Vec<Vec<Glyph>>,
+
+    atlas: text::RenderedAtlas,
+}
+
+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_X: f32 = 15.;
+    const IMG_MAX_HEIGHT: f32 = 500.;
+
+    pub fn new(
+        font_size: f32,
+        window_scale: f32,
+
+        file_url: Url,
+        status: FileMessageStatus,
+        timestamp: Timestamp,
+        _nick: String,
+
+        text_shaper: &TextShaper,
+        render_api: &RenderApi,
+    ) -> Message {
+        let mut glyphs = Vec::new();
+        let mut atlas = text::Atlas::new(render_api, gfxtag!("chatview_filemsg"));
+
+        for str in Self::filestr(&file_url, &status) {
+            let glyphs_ = text_shaper.shape(str, font_size, window_scale);
+            atlas.push(&glyphs_);
+            glyphs.push(glyphs_);
+        }
+
+        let atlas = atlas.make();
+
+        Message::File(Self {
+            font_size,
+            window_scale,
+            max_width: 0.,
+            file_url,
+            status,
+            imgbuf: Arc::new(SyncMutex::new(None)),
+            timestamp,
+            glyphs,
+            atlas,
+        })
+    }
+
+    fn filestr(file_url: &Url, status: &FileMessageStatus) -> Vec<String> {
+        let status_str = match status {
+            FileMessageStatus::Initializing => "starting fud".to_string(),
+            FileMessageStatus::Downloading { progress } => format!("downloading [{progress:.1}%]"),
+            FileMessageStatus::Downloaded { .. } => "downloaded".to_string(),
+            FileMessageStatus::Error { msg } => msg.to_lowercase(),
+        };
+
+        vec![
+            file_url
+                .host_str()
+                .map(|file_hash| {
+                    if file_hash.len() >= 12 {
+                        let first_part = &file_hash[..4];
+                        let last_part = &file_hash[file_hash.len() - 4..];
+                        format!("{}...{}", first_part, last_part)
+                    } else {
+                        file_hash.to_string()
+                    }
+                })
+                .unwrap_or("???".to_string()),
+            status_str,
+        ]
+    }
+
+    pub fn set_status(&mut self, status: &FileMessageStatus) {
+        self.status = status.clone();
+
+        if let FileMessageStatus::Downloaded { .. } = status {
+            let mut imgbuf = self.imgbuf.lock();
+            *imgbuf = self.load_img();
+        }
+    }
+
+    fn adjust_params(
+        &mut self,
+        font_size: f32,
+        window_scale: f32,
+        text_shaper: &TextShaper,
+        render_api: &RenderApi,
+    ) {
+        self.font_size = font_size;
+        self.window_scale = window_scale;
+
+        self.glyphs = Vec::new();
+        let mut atlas = text::Atlas::new(render_api, gfxtag!("chatview_filemsg"));
+
+        for str in Self::filestr(&self.file_url, &self.status) {
+            let glyphs = text_shaper.shape(str, font_size, window_scale);
+            atlas.push(&glyphs);
+            self.glyphs.push(glyphs);
+        }
+
+        self.atlas = atlas.make();
+    }
+
+    fn adjust_width(&mut self, line_width: f32, timestamp_width: f32) {
+        let width = line_width - timestamp_width;
+        // clamp to > 0
+        self.max_width = max(width, 0.);
+    }
+
+    fn clear_mesh(&mut self) {}
+
+    fn get_img_size(&self, imgbuf: &ImageBuffer<Rgba<u8>, Vec<u8>>) -> (f32, f32) {
+        let img_w = imgbuf.width() as f32;
+        let img_h = imgbuf.height() as f32;
+
+        let width_scale = (self.max_width - Self::GLOW_SIZE) / img_w;
+        let height_scale = Self::IMG_MAX_HEIGHT / img_h;
+
+        let scale = width_scale.min(height_scale);
+        (img_w * scale, img_h * scale)
+    }
+
+    fn gen_mesh(
+        &mut self,
+        _clip: &Rectangle,
+        line_height: f32,
+        baseline: f32,
+        timestamp_width: f32,
+        _nick_colors: &[Color],
+        timestamp_color: Color,
+        _text_color: Color,
+        _debug_render: bool,
+        render_api: &RenderApi,
+    ) -> Vec<DrawMesh> {
+        let uv_rect = Rectangle::from([0., 0., 1., 1.]);
+
+        let imgbuf_ = self.imgbuf.lock();
+        if let Some(ref imgbuf) = *imgbuf_ {
+            let (img_w, img_h) = self.get_img_size(imgbuf);
+            drop(imgbuf_);
+
+            let mesh_rect =
+                Rectangle::from([timestamp_width, -img_h - Self::MARGIN_BOTTOM, 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);
+
+            let mesh_gradient = mesh_gradient.alloc(render_api);
+            let mesh_gradient = mesh_gradient.draw_untextured();
+
+            let mut mesh_img = MeshBuilder::new(gfxtag!("file_img"));
+            mesh_img.draw_box(&mesh_rect, COLOR_WHITE, &uv_rect);
+            let mesh_img = mesh_img.alloc(render_api);
+            let mesh_img = mesh_img.draw_with_texture(texture);
+            return vec![mesh_img, mesh_gradient];
+        }
+        drop(imgbuf_);
+
+        let mut mesh = MeshBuilder::new(gfxtag!("chatview_filemsg"));
+
+        let color = match self.status {
+            FileMessageStatus::Initializing => timestamp_color,
+            FileMessageStatus::Downloading { .. } => COLOR_CYAN,
+            FileMessageStatus::Downloaded { .. } => COLOR_GREEN,
+            FileMessageStatus::Error { .. } => COLOR_RED,
+        };
+
+        let mut text_width = 0.;
+        for (i, glyphs) in self.glyphs.iter().enumerate() {
+            let glyph_pos_iter =
+                GlyphPositionIter::new(self.font_size, self.window_scale, &glyphs, baseline);
+            for (mut glyph_rect, glyph) in glyph_pos_iter.zip(glyphs.iter()) {
+                let uv_rect = self.atlas.fetch_uv(glyph.glyph_id).expect("missing glyph UV rect");
+                if glyph_rect.x + glyph_rect.w > text_width {
+                    text_width = glyph_rect.x + glyph_rect.w;
+                }
+                glyph_rect.x += timestamp_width + Self::BOX_PADDING_X;
+                glyph_rect.y -= line_height * (self.glyphs.len() - i) as f32 +
+                    Self::BOX_PADDING_BOTTOM +
+                    Self::MARGIN_BOTTOM;
+                mesh.draw_box(&glyph_rect, color, uv_rect);
+            }
+        }
+
+        let box_width = text_width + Self::BOX_PADDING_X * 2.;
+        let box_height = self.glyphs.len() as f32 * line_height +
+            Self::BOX_PADDING_TOP +
+            Self::BOX_PADDING_BOTTOM;
+        let mesh_rect = Rectangle::from([
+            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);
+        let mesh = mesh.draw_with_texture(self.atlas.texture.clone());
+
+        vec![mesh]
+    }
+
+    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 {
+                Ok(res) => *data2.lock() = res,
+                Err(e) => {
+                    error!("Resource not found! {e}");
+                }
+            });
+            let data = std::mem::take(&mut *data.lock());
+            let Ok(img) =
+                ImageReader::new(Cursor::new(data)).with_guessed_format().unwrap().decode()
+            else {
+                return None;
+            };
+            return Some(img.to_rgba8());
+        }
+
+        None
+    }
+
+    fn load_texture(&self, render_api: &RenderApi) -> ManagedTexturePtr {
+        let imgbuf = self.imgbuf.lock();
+        let img = imgbuf.as_ref().unwrap();
+
+        let width = img.width() as u16;
+        let height = img.height() as u16;
+        let bmp = img.as_raw().clone();
+        drop(imgbuf);
+
+        render_api.new_texture(width, height, bmp, gfxtag!("file_img_texture"))
+    }
+
+    pub fn height(&self, line_height: f32) -> f32 {
+        let imgbuf = self.imgbuf.lock();
+        imgbuf
+            .as_ref()
+            .map(|buf| self.get_img_size(buf).1 as f32 + Self::MARGIN_TOP + Self::MARGIN_BOTTOM)
+            .unwrap_or(
+                line_height * self.glyphs.len() as f32 +
+                    Self::BOX_PADDING_TOP +
+                    Self::BOX_PADDING_BOTTOM +
+                    Self::MARGIN_TOP +
+                    Self::MARGIN_BOTTOM,
+            )
+    }
+
+    fn select(&mut self) {}
+}
+
+impl std::fmt::Debug for FileMessage {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "file: {}", self.file_url)
+    }
+}
+
 /// Easier than fucking around with traits nonsense
 #[derive(Debug)]
 pub enum Message {
     Priv(PrivMessage),
     Date(DateMessage),
+    File(FileMessage),
 }
 
 impl Message {
@@ -496,6 +794,7 @@ impl Message {
         match self {
             Self::Priv(m) => m.timestamp,
             Self::Date(m) => m.timestamp,
+            Self::File(m) => m.timestamp,
         }
     }
 
@@ -503,6 +802,7 @@ impl Message {
         match self {
             Self::Priv(m) => m.height(line_height),
             Self::Date(_) => line_height,
+            Self::File(m) => m.height(line_height),
         }
     }
 
@@ -527,6 +827,7 @@ impl Message {
                 render_api,
             ),
             Self::Date(m) => m.adjust_params(font_size, window_scale, text_shaper, render_api),
+            Self::File(m) => m.adjust_params(font_size, window_scale, text_shaper, render_api),
         }
     }
 
@@ -534,6 +835,7 @@ impl Message {
         match self {
             Self::Priv(m) => m.adjust_width(line_width, timestamp_width),
             Self::Date(_) => {}
+            Self::File(m) => m.adjust_width(line_width, timestamp_width),
         }
     }
 
@@ -541,6 +843,7 @@ impl Message {
         match self {
             Self::Priv(m) => m.clear_mesh(),
             Self::Date(m) => m.clear_mesh(),
+            Self::File(m) => m.clear_mesh(),
         }
     }
 
@@ -557,9 +860,9 @@ impl Message {
         hi_bg_color: Color,
         debug_render: bool,
         render_api: &RenderApi,
-    ) -> DrawMesh {
+    ) -> Vec<DrawMesh> {
         match self {
-            Self::Priv(m) => m.gen_mesh(
+            Self::Priv(m) => vec![m.gen_mesh(
                 clip,
                 line_height,
                 msg_spacing,
@@ -571,8 +874,8 @@ impl Message {
                 hi_bg_color,
                 debug_render,
                 render_api,
-            ),
-            Self::Date(m) => m.gen_mesh(
+            )],
+            Self::Date(m) => vec![m.gen_mesh(
                 clip,
                 line_height,
                 baseline,
@@ -583,6 +886,17 @@ impl Message {
                 // No hi_bg_color since dates can't be highlighted
                 debug_render,
                 render_api,
+            )],
+            Self::File(m) => m.gen_mesh(
+                clip,
+                line_height,
+                baseline,
+                timestamp_width,
+                nick_colors,
+                timestamp_color,
+                text_color,
+                debug_render,
+                render_api,
             ),
         }
     }
@@ -591,6 +905,7 @@ impl Message {
         match self {
             Self::Priv(_) => false,
             Self::Date(_) => true,
+            Self::File(_) => false,
         }
     }
 
@@ -598,6 +913,7 @@ impl Message {
         match self {
             Self::Priv(m) => m.select(),
             Self::Date(_) => {}
+            Self::File(m) => m.select(),
         }
     }
 
@@ -607,6 +923,13 @@ impl Message {
             _ => None,
         }
     }
+
+    fn get_filemsg_mut(&mut self) -> Option<&mut FileMessage> {
+        match self {
+            Message::File(msg) => Some(msg),
+            _ => None,
+        }
+    }
 }
 
 fn select_nick_color(nick: &str, nick_colors: &[Color]) -> Color {
@@ -925,7 +1248,7 @@ impl MessageBuffer {
                 continue
             }
 
-            let mesh = msg.gen_mesh(
+            for mesh in msg.gen_mesh(
                 rect,
                 line_height,
                 msg_spacing,
@@ -937,9 +1260,9 @@ impl MessageBuffer {
                 hi_bg_color,
                 debug_render,
                 &render_api,
-            );
-
-            meshes.push((current_pos, mesh));
+            ) {
+                meshes.push((current_pos, mesh));
+            }
 
             current_pos += msg_spacing;
             current_pos += mesh_height;
@@ -949,6 +1272,50 @@ impl MessageBuffer {
         meshes
     }
 
+    pub fn insert_filemsg(
+        &mut self,
+        timest: Timestamp,
+        msg_id: MessageId,
+        status: FileMessageStatus,
+        nick: String,
+        file_url: Url,
+    ) -> Option<&mut FileMessage> {
+        t!("insert_filemsg({timest}, {msg_id}, {nick}, {file_url})");
+        let font_size = self.font_size.get();
+        let window_scale = self.window_scale.get();
+
+        let msg = FileMessage::new(
+            font_size,
+            window_scale,
+            file_url,
+            status,
+            timest,
+            nick,
+            &self.text_shaper,
+            &self.render_api,
+        );
+
+        // Timestamps go from most recent backwards
+        let mut idx = None;
+        for (i, msg) in enumerate_mut(&mut self.msgs) {
+            if timest >= msg.timestamp() {
+                idx = Some(i);
+                break
+            }
+        }
+
+        let idx = match idx {
+            Some(idx) => idx,
+            None => {
+                let last_page_idx = 0;
+                last_page_idx
+            }
+        };
+
+        self.msgs.insert(idx, msg);
+        self.msgs[idx].get_filemsg_mut()
+    }
+
     /// Gets around borrow checker with unsafe
     fn msgs_with_date(&mut self) -> impl Stream<Item = &mut Message> {
         let font_size = self.font_size.get();
@@ -1042,6 +1409,7 @@ impl MessageBuffer {
                 }
 
                 msg.select();
+
                 msg.clear_mesh();
                 break
             }
@@ -1050,4 +1418,38 @@ impl MessageBuffer {
             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();
+
+        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 }
+            }
+            _ => FileMessageStatus::Initializing,
+        };
+
+        // TODO: keep a cache of file messages somewhere to avoid looping
+        // over all messages
+        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);
+                    filemsg.adjust_width(self.line_width, self.timestamp_width.get());
+                    filemsg.clear_mesh();
+                }
+            }
+        }
+    }
 }