Browse Source

app: add plumbing so contacts from UI are stored, and ability to send msgs to contacts

jkds 2 weeks ago
parent
commit
9069d9e8fa

+ 156 - 20
bin/app/src/app/schema/menu/contact.rs

@@ -16,12 +16,26 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use darkfi_serial::deserialize;
+use bs58;
+use darkfi_serial::{async_trait, deserialize, Encodable, SerialDecodable, SerialEncodable};
+use sled_overlay::sled;
 use ui_consts::*;
 
+macro_rules! d { ($($arg:tt)*) => { debug!(target: "app::contact", $($arg)*); } }
+macro_rules! i { ($($arg:tt)*) => { info!(target: "app::contact", $($arg)*); } }
+macro_rules! w { ($($arg:tt)*) => { warn!(target: "app::contact", $($arg)*); } }
+macro_rules! e { ($($arg:tt)*) => { error!(target: "app::contact", $($arg)*); } }
+
+#[derive(Clone, Debug, SerialEncodable, SerialDecodable)]
+pub struct Contact {
+    pub name: String,
+    /// Contact's dm_chacha_public key (base58-decoded)
+    pub public: [u8; 32],
+}
+
 use super::{
-    edit_buttons, edit_switch::edit_switch, ColorScheme, BTN_TEXT_Y, CHANNEL_ITEM_HEIGHT,
-    COLOR_SCHEME, MENU_BTN_W_L,
+    super::chat, edit_buttons, edit_switch::edit_switch, ColorScheme, BTN_TEXT_Y,
+    CHANNEL_ITEM_HEIGHT, COLOR_SCHEME, MENU_BTN_W_L,
 };
 use crate::{
     app::{
@@ -35,11 +49,11 @@ use crate::{
     gfx::gfxtag,
     mesh::{COLOR_CYAN, COLOR_INACTIVE, COLOR_MINT, COLOR_MINT_OP, MINT_BTN_GRADIENT},
     prop::{PropertyBool, PropertyFloat32, Role},
-    scene::{SceneNodePtr, Slot},
+    scene::{Pimpl, SceneNodePtr, Slot},
     shape,
     ui::{
-        BaseEdit, BaseEditType, Button, Layer, Menu, ShapeVertex, Shortcut, Text, VectorArt,
-        VectorShape,
+        emoji_picker::EmojiMeshesPtr, BaseEdit, BaseEditType, Button, Layer, Menu, ShapeVertex,
+        Shortcut, Text, UIObject, VectorArt, VectorShape,
     },
     util::i18n::I18nBabelFish,
 };
@@ -129,6 +143,10 @@ pub async fn make(
     window_scale: PropertyFloat32,
     contact_is_visible: PropertyBool,
     channel_is_visible: PropertyBool,
+    contacts_tree: sled::Tree,
+    db: &sled::Db,
+    emoji_meshes: EmojiMeshesPtr,
+    is_first_time: bool,
 ) -> SceneNodePtr {
     let mut cc = expr::Compiler::new();
     cc.add_const_f32("CHATEDIT_PAD", CHATEDIT_PAD);
@@ -1222,6 +1240,10 @@ pub async fn make(
     let code = cc.compile("MENU_BTN_W_L + 45").unwrap();
     prop.set_expr(atom, Role::App, 2, code).unwrap();
     prop.set_f32(atom, Role::App, 3, CHATEDIT_HEIGHT).unwrap();
+
+    let (slot, addcontact_recvr) = Slot::new("add_contact_clicked_handler");
+    node.register("click", slot).unwrap();
+
     let node = node.setup(|me| Button::new(me, app.renderer.clone())).await;
     editlayer_node.link(node);
 
@@ -1293,20 +1315,15 @@ pub async fn make(
     node.set_property_f32(atom, Role::App, "fade_zone", MENU_FADE).unwrap();
 
     let prop = node.get_property("items").unwrap();
-    for channel in [
-        "@alice",
-        "@einstein",
-        "@fidel",
-        "@theking",
-        "@JStark",
-        "@Mom",
-        "@Dad",
-        "@Joe",
-        "@Friend1",
-        "@Friend2",
-        "@Ga",
-    ] {
-        prop.push_str(atom, Role::App, channel).unwrap();
+    let mut contact_names: Vec<String> = vec![];
+    for item in contacts_tree.iter() {
+        let (_key, val) = item.unwrap();
+        let contact = deserialize::<Contact>(&val).unwrap();
+        contact_names.push(format!("@{}", contact.name));
+    }
+    contact_names.sort();
+    for contact_name in contact_names {
+        prop.push_str(atom, Role::App, &contact_name).unwrap();
     }
 
     let menu_node =
@@ -1316,6 +1333,125 @@ pub async fn make(
     // Connect cancel/done buttons and edit_active signal
     btns.connect_edit_handlers(app, &menu_node, None);
 
+    // "add contact" button handler: persist the contact and notify the plugin
+    let contacts_tree2 = contacts_tree.clone();
+    let nickedit2 = nickedit_node.clone();
+    let secedit2 = secedit_node.clone();
+    let menu_prop2 = menu_node.get_property("items").unwrap();
+    let renderer2 = app.renderer.clone();
+    let sg_root2 = app.sg_root.clone();
+
+    let save_contact = app.ex.spawn(async move {
+        while let Ok(_) = addcontact_recvr.recv().await {
+            let name_prop = nickedit2.get_property("text").unwrap();
+            let name = name_prop.get_str(0).unwrap();
+            let public_prop = secedit2.get_property("text").unwrap();
+            let public_str = public_prop.get_str(0).unwrap();
+
+            if name.is_empty() {
+                w!("Attempted to add contact with empty name");
+                continue;
+            }
+            let name = name.trim_start_matches('@').to_string();
+            if name.contains('#') || name.chars().any(char::is_whitespace) {
+                w!("Invalid contact name: {name}");
+                continue;
+            }
+
+            let Ok(public_bytes) = bs58::decode(&public_str).into_vec() else {
+                w!("Failed to decode contact public key base58");
+                continue;
+            };
+            if public_bytes.len() != 32 {
+                w!("Invalid public key length: {} (expected 32)", public_bytes.len());
+                continue;
+            }
+            let mut public = [0u8; 32];
+            public.copy_from_slice(&public_bytes);
+
+            let contact = Contact { name: name.clone(), public };
+            let mut val = vec![];
+            contact.encode(&mut val).unwrap();
+            contacts_tree2.insert(name.as_str(), val).unwrap();
+            let _ = contacts_tree2.flush_async().await;
+
+            let contact_name = format!("@{}", name);
+            let atom = &mut renderer2.make_guard(gfxtag!("add_contact"));
+            menu_prop2.push_str(atom, Role::App, &contact_name).unwrap();
+            i!("Successfully saved contact: {}", contact_name);
+
+            if let Some(darkirc) = sg_root2.lookup_node("/plugin/darkirc") {
+                let _ = darkirc.call_method("reload_contacts", vec![]).await;
+            }
+
+            let atom = &mut renderer2.make_guard(gfxtag!("clear_contact_fields"));
+            name_prop.set_str(atom, Role::App, 0, "").unwrap();
+            public_prop.set_str(atom, Role::App, 0, "").unwrap();
+        }
+    });
+    app.tasks.lock().unwrap().push(save_contact);
+
+    // Selecting a contact opens (creating if needed) its DM chat layer
+    let (slot, recvr) = Slot::new("contact_selected");
+    menu_node.register("select", slot).unwrap();
+
+    let sg_root = app.sg_root.clone();
+    let renderer = app.renderer.clone();
+    let ex = app.ex.clone();
+    let db2 = db.clone();
+    let i18n_fish2 = i18n_fish.clone();
+    let emoji_meshes2 = emoji_meshes.clone();
+    let contact_vis = contact_is_visible.clone();
+
+    let listen_select = app.ex.spawn(async move {
+        while let Ok(data) = recvr.recv().await {
+            let contact: String = deserialize(&data).unwrap();
+            i!("Selected contact: {contact}");
+            let path = format!("/window/content/{}_chat_layer", &contact);
+            let atom = &mut renderer.make_guard(gfxtag!("contact_selected"));
+
+            if let Some(node) = sg_root.lookup_node(&path) {
+                node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                contact_vis.set(atom, false);
+                continue;
+            }
+
+            let content = sg_root.lookup_node("/window/content").unwrap();
+            let node = chat::make(
+                &sg_root,
+                &renderer,
+                &ex,
+                content,
+                &contact,
+                &db2,
+                &i18n_fish2,
+                emoji_meshes2.clone(),
+                is_first_time,
+            )
+            .await;
+            match node.pimpl() {
+                Pimpl::Layer(layer) => layer.clone().start(ex.clone()).await,
+                _ => panic!("wrong pimpl"),
+            }
+            node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+
+            let main_menu = sg_root.lookup_node("/window/content/menu_layer/main_menu").unwrap();
+            let items_prop = main_menu.get_property("items").unwrap();
+            if !items_prop.contains_str(&contact) {
+                items_prop.push_str(atom, Role::App, &contact).unwrap();
+            }
+
+            contact_vis.set(atom, false);
+
+            let win = sg_root.lookup_node("/window").unwrap();
+            match win.pimpl() {
+                Pimpl::Window(win) => win.draw(atom).await,
+                _ => panic!("wrong pimpl"),
+            }
+        }
+    });
+    app.tasks.lock().unwrap().push(listen_select);
+
     // Only one input field may be focused (caret visible)
     edit_switch(
         &mut app.tasks.lock().unwrap(),

+ 13 - 1
bin/app/src/app/schema/menu/mod.rs

@@ -39,6 +39,7 @@ use crate::{
     util::i18n::I18nBabelFish,
 };
 use channel::Channel;
+use contact::Contact;
 
 #[cfg(any(target_os = "android", feature = "emulate-android"))]
 mod android_ui_consts {
@@ -101,7 +102,7 @@ mod ui_consts {
 }
 
 pub mod channel;
-mod contact;
+pub mod contact;
 mod edit_buttons;
 mod edit_switch;
 
@@ -110,6 +111,7 @@ pub async fn make(
     content: SceneNodePtr,
     i18n_fish: &I18nBabelFish,
     channels_tree: sled::Tree,
+    contacts_tree: sled::Tree,
     db: &sled::Db,
     emoji_meshes: EmojiMeshesPtr,
     is_first_time: bool,
@@ -186,6 +188,10 @@ pub async fn make(
         window_scale.clone(),
         contact_is_visible.clone(),
         channel_is_visible.clone(),
+        contacts_tree.clone(),
+        db,
+        emoji_meshes.clone(),
+        is_first_time,
     )
     .await;
 
@@ -438,6 +444,12 @@ pub async fn make(
         let channel_name = format!("#{}", channel.name);
         prop.push_str(atom, Role::App, &channel_name).unwrap();
     }
+    for item in contacts_tree.iter() {
+        let (_key, val) = item.unwrap();
+        let contact = deserialize::<Contact>(&val).unwrap();
+        let contact_name = format!("@{}", contact.name);
+        prop.push_str(atom, Role::App, &contact_name).unwrap();
+    }
 
     let (slot, recvr) = Slot::new("menu_clicked");
     node.register("select", slot).unwrap();

+ 22 - 1
bin/app/src/app/schema/mod.rs

@@ -38,7 +38,7 @@ use crate::{
 
 mod chat;
 pub mod menu;
-use menu::channel::Channel;
+use menu::{channel::Channel, contact::Contact};
 //mod settings;
 pub mod test;
 pub mod test_scroll_layer;
@@ -599,6 +599,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish, db
     }
 
     let channels_tree = db.open_tree("channels").expect("cannot open channels tree");
+    let contacts_tree = db.open_tree("contacts").expect("cannot open contacts tree");
 
     // Initialize default channels if tree is empty
     if channels_tree.is_empty() {
@@ -630,11 +631,31 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish, db
         )
         .await;
     }
+    // Initialize chat layers from contacts in database
+    for item in contacts_tree.iter() {
+        let (_key, val) = item.unwrap();
+        let contact = deserialize::<Contact>(&val).unwrap();
+        let contact_name = format!("@{}", contact.name);
+
+        chat::make(
+            &app.sg_root,
+            &app.renderer,
+            &app.ex,
+            content.clone(),
+            &contact_name,
+            &db,
+            i18n_fish,
+            emoji_meshes.clone(),
+            is_first_time,
+        )
+        .await;
+    }
     menu::make(
         app,
         content.clone(),
         i18n_fish,
         channels_tree,
+        contacts_tree,
         &db,
         emoji_meshes.clone(),
         is_first_time,

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

@@ -735,6 +735,12 @@ pub fn create_darkirc(name: &str) -> SceneNode {
     prop.set_defaults_str(vec!["anon".to_string()]).unwrap();
     node.add_property(prop).unwrap();
 
+    let mut prop = Property::new("dm_public", PropertyType::Str, PropertySubType::Null);
+    prop.set_ui_text("DM Public Key", "Your DM public key (share with contacts)");
+    prop.allow_null_values();
+    prop.set_defaults_null().unwrap();
+    node.add_property(prop).unwrap();
+
     node.add_signal(
         "recv",
         "Message received",
@@ -766,7 +772,6 @@ pub fn create_darkirc(name: &str) -> SceneNode {
     .unwrap();
 
     node.add_method("reconnect", vec![], None).unwrap();
-
     node.add_method("rescan", vec![("channel", "Channel", CallArgType::Str)], None).unwrap();
 
     node

+ 61 - 11
bin/app/src/plugin/darkirc2.rs

@@ -24,7 +24,7 @@ use std::{
 };
 
 use async_lock::RwLock;
-use crypto_box::{ChaChaBox, SecretKey};
+use crypto_box::{ChaChaBox, PublicKey, SecretKey};
 use darkfi::{
     event_graph::{
         self,
@@ -51,7 +51,7 @@ use parking_lot::Mutex as SyncMutex;
 use sled_overlay::sled;
 
 use crate::{
-    app::schema::menu::channel::Channel,
+    app::schema::menu::{channel::Channel, contact::Contact},
     error::{Error, Result},
     prop::{BatchGuardPtr, PropertyAtomicGuard, PropertyStr, Role},
     scene::{MethodCallSub, Pimpl, SceneNode, SceneNodePtr, SceneNodeType, SceneNodeWeak, Slot},
@@ -176,6 +176,8 @@ pub struct DarkIrc2 {
     pub channels: RwLock<HashMap<String, IrcChannel>>,
     pub contacts: RwLock<HashMap<String, IrcContact>>,
     channels_tree: sled::Tree,
+    contacts_tree: sled::Tree,
+    dm_secret: SecretKey,
     settings: PluginSettings,
     ex: ExecutorPtr,
 }
@@ -209,6 +211,22 @@ impl DarkIrc2 {
         let channels_tree = db.open_tree("channels")?;
         i!("Opened channels tree from unified db");
 
+        let contacts_tree = db.open_tree("contacts")?;
+        i!("Opened contacts tree from unified db");
+
+        let dm_secret = Self::load_or_create_dm_identity(&db);
+        let dm_public_b58 = bs58::encode(dm_secret.public_key().to_bytes()).into_string();
+        // Expose our DM public key on the plugin node so it can be displayed/shared.
+        node_ref
+            .set_property_str(
+                &mut PropertyAtomicGuard::none(),
+                Role::Internal,
+                "dm_public",
+                &dm_public_b58,
+            )
+            .unwrap();
+        i!("DM identity public key (share with contacts): {dm_public_b58}");
+
         let settings = PluginSettings { setting_root, sled_tree: setting_tree };
 
         let mut p2p_settings: NetSettings = Default::default();
@@ -306,12 +324,15 @@ impl DarkIrc2 {
             channels: RwLock::new(HashMap::new()),
             contacts: RwLock::new(HashMap::new()),
             channels_tree,
+            contacts_tree,
+            dm_secret,
 
             settings,
             ex: ex.clone(),
         });
 
         self_.load_channels_from_db().await;
+        self_.load_contacts_from_db().await;
         self_.clone().start(sg_root, ex).await;
         Ok(Pimpl::DarkIrc2(self_))
     }
@@ -580,15 +601,7 @@ impl DarkIrc2 {
         for item in self.channels_tree.iter() {
             let (key, val) = item.unwrap();
             let channel_name = String::from_utf8_lossy(&key).to_string();
-
-            // Deserialize UI channel struct
-            let ui_channel = match deserialize_async::<Channel>(&val).await {
-                Ok(ch) => ch,
-                Err(e) => {
-                    w!("Failed to deserialize channel {channel_name}: {e}");
-                    continue
-                }
-            };
+            let ui_channel = deserialize_async::<Channel>(&val).await.unwrap();
 
             // Convert to IrcChannel with encryption
             let full_name = format!("#{}", channel_name);
@@ -612,6 +625,42 @@ impl DarkIrc2 {
         }
     }
 
+    /// Load (or generate on first run) the single global DM identity key.
+    fn load_or_create_dm_identity(db: &sled::Db) -> SecretKey {
+        let tree = db.open_tree("dm_identity").expect("cannot open dm_identity tree");
+        if let Ok(Some(stored)) = tree.get(b"secret") {
+            if stored.len() == 32 {
+                let arr: [u8; 32] = stored.as_ref().try_into().unwrap();
+                return SecretKey::from_bytes(arr);
+            }
+        }
+        let bytes: [u8; 32] = rand::random();
+        let _ = tree.insert(b"secret", bytes.to_vec());
+        let _ = tree.flush();
+        SecretKey::from_bytes(bytes)
+    }
+
+    /// Load contacts from the UI database and build their encryption boxes.
+    pub async fn load_contacts_from_db(&self) {
+        let mut contacts = self.contacts.write().await;
+        contacts.clear();
+
+        for item in self.contacts_tree.iter() {
+            let (key, val) = item.unwrap();
+            let name = String::from_utf8_lossy(&key).to_string();
+            let contact = deserialize_async::<Contact>(&val).await.unwrap();
+
+            let their_public = PublicKey::from(contact.public);
+            let saltbox = Arc::new(ChaChaBox::new(&their_public, &self.dm_secret));
+            let self_saltbox =
+                Arc::new(ChaChaBox::new(&self.dm_secret.public_key(), &self.dm_secret));
+
+            let full_name = format!("@{}", name);
+            contacts.insert(full_name, IrcContact { saltbox, self_saltbox });
+            i!("Loaded contact: @{name}");
+        }
+    }
+
     async fn rescan_channel_history(self: Arc<Self>, channel: String) {
         i!("Starting background rescan for channel: {channel}");
 
@@ -682,6 +731,7 @@ impl DarkIrc2 {
         };
 
         self_.load_channels_from_db().await;
+        self_.load_contacts_from_db().await;
 
         let task = self_.ex.clone().spawn(self_.clone().rescan_channel_history(channel));
         self_.tasks.lock().push(task);