Merge pull request #987 from zed-industries/notifications

Max Brunsfeld created

Notify when someone requests to add you as a contact or accepts your contact request

Change summary

assets/icons/decline.svg                          |   0 
assets/themes/cave-dark.json                      |  91 ++++
assets/themes/cave-light.json                     |  91 ++++
assets/themes/dark.json                           |  91 ++++
assets/themes/light.json                          |  91 ++++
assets/themes/solarized-dark.json                 |  91 ++++
assets/themes/solarized-light.json                |  91 ++++
assets/themes/sulphurpool-dark.json               |  91 ++++
assets/themes/sulphurpool-light.json              |  91 ++++
crates/chat_panel/src/chat_panel.rs               |   2 
crates/client/src/user.rs                         |  91 +++
crates/collab/src/db.rs                           | 381 +++++++++++-----
crates/collab/src/rpc.rs                          | 117 +++--
crates/collab/src/rpc/store.rs                    |  45 +
crates/command_palette/src/command_palette.rs     |   2 
crates/contacts_panel/src/contact_finder.rs       |   4 
crates/contacts_panel/src/contact_notification.rs | 237 ++++++++++
crates/contacts_panel/src/contacts_panel.rs       |  56 ++
crates/file_finder/src/file_finder.rs             |   2 
crates/go_to_line/src/go_to_line.rs               |   2 
crates/gpui/src/elements/empty.rs                 |  15 
crates/outline/src/outline.rs                     |   2 
crates/project_panel/src/project_panel.rs         |   6 
crates/project_symbols/src/project_symbols.rs     |   2 
crates/rpc/proto/zed.proto                        |   4 
crates/theme/src/theme.rs                         |  21 
crates/theme_selector/src/theme_selector.rs       |   2 
crates/workspace/src/sidebar.rs                   | 120 ++++-
crates/workspace/src/workspace.rs                 |  86 +++
crates/zed/src/zed.rs                             |   3 
styles/src/styleTree/app.ts                       |   4 
styles/src/styleTree/contactNotification.ts       |  44 +
styles/src/styleTree/statusBar.ts                 |   9 
styles/src/styleTree/workspace.ts                 |  20 
34 files changed, 1,745 insertions(+), 260 deletions(-)

Detailed changes

assets/themes/cave-dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#efecf4",
             "background": "#5852605c"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#26232a"
+          },
+          "background": "#576ddb"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#efecf4",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#26232a",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#19171c",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#0000003d",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#e2dfe7",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#8b8792",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#e2dfe7",
+      "size": 12,
+      "background": "#19171c",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#26232a3d"
+      }
+    },
+    "dismiss_button": {
+      "color": "#8b8792",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#e2dfe7"
+      }
+    }
   }
 }

assets/themes/cave-light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#19171c",
             "background": "#8b87922e"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#e2dfe7"
+          },
+          "background": "#576ddb"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#19171c",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#e2dfe7",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#efecf4",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#0000001f",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#26232a",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#585260",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#26232a",
+      "size": 12,
+      "background": "#efecf4",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#e2dfe71f"
+      }
+    },
+    "dismiss_button": {
+      "color": "#585260",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#26232a"
+      }
+    }
   }
 }

assets/themes/dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#ffffff",
             "background": "#2b2b2b"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#1c1c1c"
+          },
+          "background": "#2472f2"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#ffffff",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#1c1c1c",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#070707",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#00000052",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#f1f1f1",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#9c9c9c",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#f1f1f1",
+      "size": 12,
+      "background": "#0e0e0e80",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#070707"
+      }
+    },
+    "dismiss_button": {
+      "color": "#9c9c9c",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#c6c6c6"
+      }
+    }
   }
 }

assets/themes/light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#000000",
             "background": "#e3e3e3"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#f8f8f8"
+          },
+          "background": "#484bed"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#000000",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#f8f8f8",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#d5d5d5",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#0000001f",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#2b2b2b",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#474747",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#2b2b2b",
+      "size": 12,
+      "background": "#f1f1f1",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#e3e3e3"
+      }
+    },
+    "dismiss_button": {
+      "color": "#717171",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#393939"
+      }
+    }
   }
 }

assets/themes/solarized-dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#fdf6e3",
             "background": "#586e755c"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#073642"
+          },
+          "background": "#268bd2"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#fdf6e3",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#073642",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#002b36",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#0000003d",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#eee8d5",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#93a1a1",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#eee8d5",
+      "size": 12,
+      "background": "#002b36",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#0736423d"
+      }
+    },
+    "dismiss_button": {
+      "color": "#93a1a1",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#eee8d5"
+      }
+    }
   }
 }

assets/themes/solarized-light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#002b36",
             "background": "#93a1a12e"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#eee8d5"
+          },
+          "background": "#268bd2"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#002b36",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#eee8d5",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#fdf6e3",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#0000001f",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#073642",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#586e75",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#073642",
+      "size": 12,
+      "background": "#fdf6e3",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#eee8d51f"
+      }
+    },
+    "dismiss_button": {
+      "color": "#586e75",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#073642"
+      }
+    }
   }
 }

assets/themes/sulphurpool-dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#f5f7ff",
             "background": "#5e66875c"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#293256"
+          },
+          "background": "#3d8fd1"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#f5f7ff",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#293256",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#202746",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#0000003d",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#dfe2f1",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#979db4",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#dfe2f1",
+      "size": 12,
+      "background": "#202746",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#2932563d"
+      }
+    },
+    "dismiss_button": {
+      "color": "#979db4",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#dfe2f1"
+      }
+    }
   }
 }

assets/themes/sulphurpool-light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#202746",
             "background": "#979db42e"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#dfe2f1"
+          },
+          "background": "#3d8fd1"
         }
       }
     },
@@ -470,6 +483,33 @@
       "color": "#202746",
       "size": 14,
       "background": "#000000aa"
+    },
+    "notification": {
+      "margin": {
+        "top": 10
+      },
+      "background": "#dfe2f1",
+      "corner_radius": 6,
+      "padding": 12,
+      "border": {
+        "color": "#f5f7ff",
+        "width": 1
+      },
+      "shadow": {
+        "blur": 16,
+        "color": "#0000001f",
+        "offset": [
+          0,
+          2
+        ]
+      }
+    },
+    "notifications": {
+      "width": 380,
+      "margin": {
+        "right": 10,
+        "bottom": 10
+      }
     }
   },
   "editor": {
@@ -1646,5 +1686,56 @@
     "padding": {
       "left": 6
     }
+  },
+  "contact_notification": {
+    "header_avatar": {
+      "height": 12,
+      "width": 12,
+      "corner_radius": 6
+    },
+    "header_message": {
+      "family": "Zed Sans",
+      "color": "#293256",
+      "size": 12,
+      "margin": {
+        "left": 8,
+        "right": 8
+      }
+    },
+    "header_height": 18,
+    "body_message": {
+      "family": "Zed Sans",
+      "color": "#5e6687",
+      "size": 12,
+      "margin": {
+        "left": 20,
+        "top": 6,
+        "bottom": 6
+      }
+    },
+    "button": {
+      "family": "Zed Sans",
+      "color": "#293256",
+      "size": 12,
+      "background": "#f5f7ff",
+      "padding": 4,
+      "corner_radius": 6,
+      "margin": {
+        "left": 6
+      },
+      "hover": {
+        "background": "#dfe2f11f"
+      }
+    },
+    "dismiss_button": {
+      "color": "#5e6687",
+      "icon_width": 8,
+      "icon_height": 8,
+      "button_width": 8,
+      "button_height": 8,
+      "hover": {
+        "color": "#293256"
+      }
+    }
   }
 }

crates/chat_panel/src/chat_panel.rs 🔗

@@ -69,7 +69,7 @@ impl ChatPanel {
             .with_style(move |cx| {
                 let theme = &cx.global::<Settings>().theme.chat_panel.channel_select;
                 SelectStyle {
-                    header: theme.header.container.clone(),
+                    header: theme.header.container,
                     menu: theme.menu.clone(),
                 }
             })

crates/client/src/user.rs 🔗

@@ -54,10 +54,21 @@ pub struct UserStore {
     _maintain_current_user: Task<()>,
 }
 
-pub enum Event {}
+#[derive(Clone)]
+pub struct ContactEvent {
+    pub user: Arc<User>,
+    pub kind: ContactEventKind,
+}
+
+#[derive(Clone, Copy)]
+pub enum ContactEventKind {
+    Requested,
+    Accepted,
+    Cancelled,
+}
 
 impl Entity for UserStore {
-    type Event = Event;
+    type Event = ContactEvent;
 }
 
 enum UpdateContacts {
@@ -175,19 +186,23 @@ impl UserStore {
                     // No need to paralellize here
                     let mut updated_contacts = Vec::new();
                     for contact in message.contacts {
-                        updated_contacts.push(Arc::new(
-                            Contact::from_proto(contact, &this, &mut cx).await?,
+                        let should_notify = contact.should_notify;
+                        updated_contacts.push((
+                            Arc::new(Contact::from_proto(contact, &this, &mut cx).await?),
+                            should_notify,
                         ));
                     }
 
                     let mut incoming_requests = Vec::new();
                     for request in message.incoming_requests {
-                        incoming_requests.push(
-                            this.update(&mut cx, |this, cx| {
-                                this.fetch_user(request.requester_id, cx)
-                            })
-                            .await?,
-                        );
+                        incoming_requests.push({
+                            let user = this
+                                .update(&mut cx, |this, cx| {
+                                    this.fetch_user(request.requester_id, cx)
+                                })
+                                .await?;
+                            (user, request.should_notify)
+                        });
                     }
 
                     let mut outgoing_requests = Vec::new();
@@ -210,7 +225,13 @@ impl UserStore {
                         this.contacts
                             .retain(|contact| !removed_contacts.contains(&contact.user.id));
                         // Update existing contacts and insert new ones
-                        for updated_contact in updated_contacts {
+                        for (updated_contact, should_notify) in updated_contacts {
+                            if should_notify {
+                                cx.emit(ContactEvent {
+                                    user: updated_contact.user.clone(),
+                                    kind: ContactEventKind::Accepted,
+                                });
+                            }
                             match this.contacts.binary_search_by_key(
                                 &&updated_contact.user.github_login,
                                 |contact| &contact.user.github_login,
@@ -221,17 +242,33 @@ impl UserStore {
                         }
 
                         // Remove incoming contact requests
-                        this.incoming_contact_requests
-                            .retain(|user| !removed_incoming_requests.contains(&user.id));
+                        this.incoming_contact_requests.retain(|user| {
+                            if removed_incoming_requests.contains(&user.id) {
+                                cx.emit(ContactEvent {
+                                    user: user.clone(),
+                                    kind: ContactEventKind::Cancelled,
+                                });
+                                false
+                            } else {
+                                true
+                            }
+                        });
                         // Update existing incoming requests and insert new ones
-                        for request in incoming_requests {
+                        for (user, should_notify) in incoming_requests {
+                            if should_notify {
+                                cx.emit(ContactEvent {
+                                    user: user.clone(),
+                                    kind: ContactEventKind::Requested,
+                                });
+                            }
+
                             match this
                                 .incoming_contact_requests
-                                .binary_search_by_key(&&request.github_login, |contact| {
+                                .binary_search_by_key(&&user.github_login, |contact| {
                                     &contact.github_login
                                 }) {
-                                Ok(ix) => this.incoming_contact_requests[ix] = request,
-                                Err(ix) => this.incoming_contact_requests.insert(ix, request),
+                                Ok(ix) => this.incoming_contact_requests[ix] = user,
+                                Err(ix) => this.incoming_contact_requests.insert(ix, user),
                             }
                         }
 
@@ -334,13 +371,31 @@ impl UserStore {
                 response: if accept {
                     proto::ContactRequestResponse::Accept
                 } else {
-                    proto::ContactRequestResponse::Reject
+                    proto::ContactRequestResponse::Decline
                 } as i32,
             },
             cx,
         )
     }
 
+    pub fn dismiss_contact_request(
+        &mut self,
+        requester_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.upgrade();
+        cx.spawn_weak(|_, _| async move {
+            client
+                .ok_or_else(|| anyhow!("can't upgrade client reference"))?
+                .request(proto::RespondToContactRequest {
+                    requester_id,
+                    response: proto::ContactRequestResponse::Dismiss as i32,
+                })
+                .await?;
+            Ok(())
+        })
+    }
+
     fn perform_contact_request<T: RequestMessage>(
         &mut self,
         user_id: u64,

crates/collab/src/db.rs 🔗

@@ -17,10 +17,11 @@ pub trait Db: Send + Sync {
     async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()>;
     async fn destroy_user(&self, id: UserId) -> Result<()>;
 
-    async fn get_contacts(&self, id: UserId) -> Result<Contacts>;
+    async fn get_contacts(&self, id: UserId) -> Result<Vec<Contact>>;
+    async fn has_contact(&self, user_id_a: UserId, user_id_b: UserId) -> Result<bool>;
     async fn send_contact_request(&self, requester_id: UserId, responder_id: UserId) -> Result<()>;
     async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()>;
-    async fn dismiss_contact_request(
+    async fn dismiss_contact_notification(
         &self,
         responder_id: UserId,
         requester_id: UserId,
@@ -190,7 +191,7 @@ impl Db for PostgresDb {
 
     // contacts
 
-    async fn get_contacts(&self, user_id: UserId) -> Result<Contacts> {
+    async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
         let query = "
             SELECT user_id_a, user_id_b, a_to_b, accepted, should_notify
             FROM contacts
@@ -201,46 +202,67 @@ impl Db for PostgresDb {
             .bind(user_id)
             .fetch(&self.pool);
 
-        let mut current = vec![user_id];
-        let mut outgoing_requests = Vec::new();
-        let mut incoming_requests = Vec::new();
+        let mut contacts = vec![Contact::Accepted {
+            user_id,
+            should_notify: false,
+        }];
         while let Some(row) = rows.next().await {
             let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?;
 
             if user_id_a == user_id {
                 if accepted {
-                    current.push(user_id_b);
+                    contacts.push(Contact::Accepted {
+                        user_id: user_id_b,
+                        should_notify: should_notify && a_to_b,
+                    });
                 } else if a_to_b {
-                    outgoing_requests.push(user_id_b);
+                    contacts.push(Contact::Outgoing { user_id: user_id_b })
                 } else {
-                    incoming_requests.push(IncomingContactRequest {
-                        requester_id: user_id_b,
+                    contacts.push(Contact::Incoming {
+                        user_id: user_id_b,
                         should_notify,
                     });
                 }
             } else {
                 if accepted {
-                    current.push(user_id_a);
+                    contacts.push(Contact::Accepted {
+                        user_id: user_id_a,
+                        should_notify: should_notify && !a_to_b,
+                    });
                 } else if a_to_b {
-                    incoming_requests.push(IncomingContactRequest {
-                        requester_id: user_id_a,
+                    contacts.push(Contact::Incoming {
+                        user_id: user_id_a,
                         should_notify,
                     });
                 } else {
-                    outgoing_requests.push(user_id_a);
+                    contacts.push(Contact::Outgoing { user_id: user_id_a });
                 }
             }
         }
 
-        current.sort_unstable();
-        outgoing_requests.sort_unstable();
-        incoming_requests.sort_unstable();
+        contacts.sort_unstable_by_key(|contact| contact.user_id());
 
-        Ok(Contacts {
-            current,
-            outgoing_requests,
-            incoming_requests,
-        })
+        Ok(contacts)
+    }
+
+    async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result<bool> {
+        let (id_a, id_b) = if user_id_1 < user_id_2 {
+            (user_id_1, user_id_2)
+        } else {
+            (user_id_2, user_id_1)
+        };
+
+        let query = "
+            SELECT 1 FROM contacts
+            WHERE user_id_a = $1 AND user_id_b = $2 AND accepted = 't'
+            LIMIT 1
+        ";
+        Ok(sqlx::query_scalar::<_, i32>(query)
+            .bind(id_a.0)
+            .bind(id_b.0)
+            .fetch_optional(&self.pool)
+            .await?
+            .is_some())
     }
 
     async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
@@ -254,7 +276,8 @@ impl Db for PostgresDb {
             VALUES ($1, $2, $3, 'f', 't')
             ON CONFLICT (user_id_a, user_id_b) DO UPDATE
             SET
-                accepted = 't'
+                accepted = 't',
+                should_notify = 'f'
             WHERE
                 NOT contacts.accepted AND
                 ((contacts.a_to_b = excluded.a_to_b AND contacts.user_id_a = excluded.user_id_b) OR
@@ -297,21 +320,26 @@ impl Db for PostgresDb {
         }
     }
 
-    async fn dismiss_contact_request(
+    async fn dismiss_contact_notification(
         &self,
-        responder_id: UserId,
-        requester_id: UserId,
+        user_id: UserId,
+        contact_user_id: UserId,
     ) -> Result<()> {
-        let (id_a, id_b, a_to_b) = if responder_id < requester_id {
-            (responder_id, requester_id, false)
+        let (id_a, id_b, a_to_b) = if user_id < contact_user_id {
+            (user_id, contact_user_id, true)
         } else {
-            (requester_id, responder_id, true)
+            (contact_user_id, user_id, false)
         };
 
         let query = "
             UPDATE contacts
             SET should_notify = 'f'
-            WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3;
+            WHERE
+                user_id_a = $1 AND user_id_b = $2 AND
+                (
+                    (a_to_b = $3 AND accepted) OR
+                    (a_to_b != $3 AND NOT accepted)
+                );
         ";
 
         let result = sqlx::query(query)
@@ -342,7 +370,7 @@ impl Db for PostgresDb {
         let result = if accept {
             let query = "
                 UPDATE contacts
-                SET accepted = 't', should_notify = 'f'
+                SET accepted = 't', should_notify = 't'
                 WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3;
             ";
             sqlx::query(query)
@@ -702,10 +730,28 @@ pub struct ChannelMessage {
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
-pub struct Contacts {
-    pub current: Vec<UserId>,
-    pub incoming_requests: Vec<IncomingContactRequest>,
-    pub outgoing_requests: Vec<UserId>,
+pub enum Contact {
+    Accepted {
+        user_id: UserId,
+        should_notify: bool,
+    },
+    Outgoing {
+        user_id: UserId,
+    },
+    Incoming {
+        user_id: UserId,
+        should_notify: bool,
+    },
+}
+
+impl Contact {
+    pub fn user_id(&self) -> UserId {
+        match self {
+            Contact::Accepted { user_id, .. } => *user_id,
+            Contact::Outgoing { user_id } => *user_id,
+            Contact::Incoming { user_id, .. } => *user_id,
+        }
+    }
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
@@ -947,51 +993,60 @@ pub mod tests {
             // User starts with no contacts
             assert_eq!(
                 db.get_contacts(user_1).await.unwrap(),
-                Contacts {
-                    current: vec![user_1],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![],
-                },
+                vec![Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                }],
             );
 
             // User requests a contact. Both users see the pending request.
             db.send_contact_request(user_1, user_2).await.unwrap();
+            assert!(!db.has_contact(user_1, user_2).await.unwrap());
+            assert!(!db.has_contact(user_2, user_1).await.unwrap());
             assert_eq!(
                 db.get_contacts(user_1).await.unwrap(),
-                Contacts {
-                    current: vec![user_1],
-                    outgoing_requests: vec![user_2],
-                    incoming_requests: vec![],
-                },
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Outgoing { user_id: user_2 }
+                ],
             );
             assert_eq!(
                 db.get_contacts(user_2).await.unwrap(),
-                Contacts {
-                    current: vec![user_2],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![IncomingContactRequest {
-                        requester_id: user_1,
+                &[
+                    Contact::Incoming {
+                        user_id: user_1,
                         should_notify: true
-                    }],
-                },
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: false
+                    },
+                ]
             );
 
             // User 2 dismisses the contact request notification without accepting or rejecting.
             // We shouldn't notify them again.
-            db.dismiss_contact_request(user_1, user_2)
+            db.dismiss_contact_notification(user_1, user_2)
                 .await
                 .unwrap_err();
-            db.dismiss_contact_request(user_2, user_1).await.unwrap();
+            db.dismiss_contact_notification(user_2, user_1)
+                .await
+                .unwrap();
             assert_eq!(
                 db.get_contacts(user_2).await.unwrap(),
-                Contacts {
-                    current: vec![user_2],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![IncomingContactRequest {
-                        requester_id: user_1,
+                &[
+                    Contact::Incoming {
+                        user_id: user_1,
                         should_notify: false
-                    }],
-                },
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: false
+                    },
+                ]
             );
 
             // User can't accept their own contact request
@@ -1005,44 +1060,106 @@ pub mod tests {
                 .unwrap();
             assert_eq!(
                 db.get_contacts(user_1).await.unwrap(),
-                Contacts {
-                    current: vec![user_1, user_2],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![],
-                },
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: true
+                    }
+                ],
             );
+            assert!(db.has_contact(user_1, user_2).await.unwrap());
+            assert!(db.has_contact(user_2, user_1).await.unwrap());
             assert_eq!(
                 db.get_contacts(user_2).await.unwrap(),
-                Contacts {
-                    current: vec![user_1, user_2],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![],
-                },
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false,
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: false,
+                    },
+                ]
             );
 
             // Users cannot re-request existing contacts.
             db.send_contact_request(user_1, user_2).await.unwrap_err();
             db.send_contact_request(user_2, user_1).await.unwrap_err();
 
+            // Users can't dismiss notifications of them accepting other users' requests.
+            db.dismiss_contact_notification(user_2, user_1)
+                .await
+                .unwrap_err();
+            assert_eq!(
+                db.get_contacts(user_1).await.unwrap(),
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: true,
+                    },
+                ]
+            );
+
+            // Users can dismiss notifications of other users accepting their requests.
+            db.dismiss_contact_notification(user_1, user_2)
+                .await
+                .unwrap();
+            assert_eq!(
+                db.get_contacts(user_1).await.unwrap(),
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: false,
+                    },
+                ]
+            );
+
             // Users send each other concurrent contact requests and
             // see that they are immediately accepted.
             db.send_contact_request(user_1, user_3).await.unwrap();
             db.send_contact_request(user_3, user_1).await.unwrap();
             assert_eq!(
                 db.get_contacts(user_1).await.unwrap(),
-                Contacts {
-                    current: vec![user_1, user_2, user_3],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![],
-                },
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: false,
+                    },
+                    Contact::Accepted {
+                        user_id: user_3,
+                        should_notify: false
+                    },
+                ]
             );
             assert_eq!(
                 db.get_contacts(user_3).await.unwrap(),
-                Contacts {
-                    current: vec![user_1, user_3],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![],
-                },
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Accepted {
+                        user_id: user_3,
+                        should_notify: false
+                    }
+                ],
             );
 
             // User declines a contact request. Both users see that it is gone.
@@ -1050,21 +1167,33 @@ pub mod tests {
             db.respond_to_contact_request(user_3, user_2, false)
                 .await
                 .unwrap();
+            assert!(!db.has_contact(user_2, user_3).await.unwrap());
+            assert!(!db.has_contact(user_3, user_2).await.unwrap());
             assert_eq!(
                 db.get_contacts(user_2).await.unwrap(),
-                Contacts {
-                    current: vec![user_1, user_2],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![],
-                },
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Accepted {
+                        user_id: user_2,
+                        should_notify: false
+                    }
+                ]
             );
             assert_eq!(
                 db.get_contacts(user_3).await.unwrap(),
-                Contacts {
-                    current: vec![user_1, user_3],
-                    outgoing_requests: vec![],
-                    incoming_requests: vec![],
-                },
+                &[
+                    Contact::Accepted {
+                        user_id: user_1,
+                        should_notify: false
+                    },
+                    Contact::Accepted {
+                        user_id: user_3,
+                        should_notify: false
+                    }
+                ],
             );
         }
     }
@@ -1219,40 +1348,51 @@ pub mod tests {
             unimplemented!()
         }
 
-        async fn get_contacts(&self, id: UserId) -> Result<Contacts> {
+        async fn get_contacts(&self, id: UserId) -> Result<Vec<Contact>> {
             self.background.simulate_random_delay().await;
-            let mut current = vec![id];
-            let mut outgoing_requests = Vec::new();
-            let mut incoming_requests = Vec::new();
+            let mut contacts = vec![Contact::Accepted {
+                user_id: id,
+                should_notify: false,
+            }];
 
             for contact in self.contacts.lock().iter() {
                 if contact.requester_id == id {
                     if contact.accepted {
-                        current.push(contact.responder_id);
+                        contacts.push(Contact::Accepted {
+                            user_id: contact.responder_id,
+                            should_notify: contact.should_notify,
+                        });
                     } else {
-                        outgoing_requests.push(contact.responder_id);
+                        contacts.push(Contact::Outgoing {
+                            user_id: contact.responder_id,
+                        });
                     }
                 } else if contact.responder_id == id {
                     if contact.accepted {
-                        current.push(contact.requester_id);
+                        contacts.push(Contact::Accepted {
+                            user_id: contact.requester_id,
+                            should_notify: false,
+                        });
                     } else {
-                        incoming_requests.push(IncomingContactRequest {
-                            requester_id: contact.requester_id,
+                        contacts.push(Contact::Incoming {
+                            user_id: contact.requester_id,
                             should_notify: contact.should_notify,
                         });
                     }
                 }
             }
 
-            current.sort_unstable();
-            outgoing_requests.sort_unstable();
-            incoming_requests.sort_unstable();
+            contacts.sort_unstable_by_key(|contact| contact.user_id());
+            Ok(contacts)
+        }
 
-            Ok(Contacts {
-                current,
-                outgoing_requests,
-                incoming_requests,
-            })
+        async fn has_contact(&self, user_id_a: UserId, user_id_b: UserId) -> Result<bool> {
+            self.background.simulate_random_delay().await;
+            Ok(self.contacts.lock().iter().any(|contact| {
+                contact.accepted
+                    && ((contact.requester_id == user_id_a && contact.responder_id == user_id_b)
+                        || (contact.requester_id == user_id_b && contact.responder_id == user_id_a))
+            }))
         }
 
         async fn send_contact_request(
@@ -1274,6 +1414,7 @@ pub mod tests {
                         Err(anyhow!("contact already exists"))?;
                     } else {
                         contact.accepted = true;
+                        contact.should_notify = false;
                         return Ok(());
                     }
                 }
@@ -1294,22 +1435,29 @@ pub mod tests {
             Ok(())
         }
 
-        async fn dismiss_contact_request(
+        async fn dismiss_contact_notification(
             &self,
-            responder_id: UserId,
-            requester_id: UserId,
+            user_id: UserId,
+            contact_user_id: UserId,
         ) -> Result<()> {
             let mut contacts = self.contacts.lock();
             for contact in contacts.iter_mut() {
-                if contact.requester_id == requester_id && contact.responder_id == responder_id {
-                    if contact.accepted {
-                        return Err(anyhow!("contact already confirmed"));
-                    }
+                if contact.requester_id == contact_user_id
+                    && contact.responder_id == user_id
+                    && !contact.accepted
+                {
+                    contact.should_notify = false;
+                    return Ok(());
+                }
+                if contact.requester_id == user_id
+                    && contact.responder_id == contact_user_id
+                    && contact.accepted
+                {
                     contact.should_notify = false;
                     return Ok(());
                 }
             }
-            Err(anyhow!("no such contact request"))
+            Err(anyhow!("no such notification"))
         }
 
         async fn respond_to_contact_request(
@@ -1326,6 +1474,7 @@ pub mod tests {
                     }
                     if accept {
                         contact.accepted = true;
+                        contact.should_notify = true;
                     } else {
                         contacts.remove(ix);
                     }

crates/collab/src/rpc.rs 🔗

@@ -2,7 +2,7 @@ mod store;
 
 use crate::{
     auth,
-    db::{ChannelId, MessageId, UserId},
+    db::{self, ChannelId, MessageId, UserId},
     AppState, Result,
 };
 use anyhow::anyhow;
@@ -420,22 +420,28 @@ impl Server {
     async fn update_user_contacts(self: &Arc<Server>, user_id: UserId) -> Result<()> {
         let contacts = self.app_state.db.get_contacts(user_id).await?;
         let store = self.store().await;
-        let updated_contact = store.contact_for_user(user_id);
-        for contact_user_id in contacts.current {
-            for contact_conn_id in store.connection_ids_for_user(contact_user_id) {
-                self.peer
-                    .send(
-                        contact_conn_id,
-                        proto::UpdateContacts {
-                            contacts: vec![updated_contact.clone()],
-                            remove_contacts: Default::default(),
-                            incoming_requests: Default::default(),
-                            remove_incoming_requests: Default::default(),
-                            outgoing_requests: Default::default(),
-                            remove_outgoing_requests: Default::default(),
-                        },
-                    )
-                    .trace_err();
+        let updated_contact = store.contact_for_user(user_id, false);
+        for contact in contacts {
+            if let db::Contact::Accepted {
+                user_id: contact_user_id,
+                ..
+            } = contact
+            {
+                for contact_conn_id in store.connection_ids_for_user(contact_user_id) {
+                    self.peer
+                        .send(
+                            contact_conn_id,
+                            proto::UpdateContacts {
+                                contacts: vec![updated_contact.clone()],
+                                remove_contacts: Default::default(),
+                                incoming_requests: Default::default(),
+                                remove_incoming_requests: Default::default(),
+                                outgoing_requests: Default::default(),
+                                remove_outgoing_requests: Default::default(),
+                            },
+                        )
+                        .trace_err();
+                }
             }
         }
         Ok(())
@@ -473,8 +479,12 @@ impl Server {
             guest_user_id = state.user_id_for_connection(request.sender_id)?;
         };
 
-        let guest_contacts = self.app_state.db.get_contacts(guest_user_id).await?;
-        if !guest_contacts.current.contains(&host_user_id) {
+        let has_contact = self
+            .app_state
+            .db
+            .has_contact(guest_user_id, host_user_id)
+            .await?;
+        if !has_contact {
             return Err(anyhow!("no such project"))?;
         }
 
@@ -1023,35 +1033,46 @@ impl Server {
             .await
             .user_id_for_connection(request.sender_id)?;
         let requester_id = UserId::from_proto(request.payload.requester_id);
-        let accept = request.payload.response == proto::ContactRequestResponse::Accept as i32;
-        self.app_state
-            .db
-            .respond_to_contact_request(responder_id, requester_id, accept)
-            .await?;
-
-        let store = self.store().await;
-        // Update responder with new contact
-        let mut update = proto::UpdateContacts::default();
-        if accept {
-            update.contacts.push(store.contact_for_user(requester_id));
-        }
-        update
-            .remove_incoming_requests
-            .push(requester_id.to_proto());
-        for connection_id in store.connection_ids_for_user(responder_id) {
-            self.peer.send(connection_id, update.clone())?;
-        }
+        if request.payload.response == proto::ContactRequestResponse::Dismiss as i32 {
+            self.app_state
+                .db
+                .dismiss_contact_notification(responder_id, requester_id)
+                .await?;
+        } else {
+            let accept = request.payload.response == proto::ContactRequestResponse::Accept as i32;
+            self.app_state
+                .db
+                .respond_to_contact_request(responder_id, requester_id, accept)
+                .await?;
+
+            let store = self.store().await;
+            // Update responder with new contact
+            let mut update = proto::UpdateContacts::default();
+            if accept {
+                update
+                    .contacts
+                    .push(store.contact_for_user(requester_id, false));
+            }
+            update
+                .remove_incoming_requests
+                .push(requester_id.to_proto());
+            for connection_id in store.connection_ids_for_user(responder_id) {
+                self.peer.send(connection_id, update.clone())?;
+            }
 
-        // Update requester with new contact
-        let mut update = proto::UpdateContacts::default();
-        if accept {
-            update.contacts.push(store.contact_for_user(responder_id));
-        }
-        update
-            .remove_outgoing_requests
-            .push(responder_id.to_proto());
-        for connection_id in store.connection_ids_for_user(requester_id) {
-            self.peer.send(connection_id, update.clone())?;
+            // Update requester with new contact
+            let mut update = proto::UpdateContacts::default();
+            if accept {
+                update
+                    .contacts
+                    .push(store.contact_for_user(responder_id, true));
+            }
+            update
+                .remove_outgoing_requests
+                .push(responder_id.to_proto());
+            for connection_id in store.connection_ids_for_user(requester_id) {
+                self.peer.send(connection_id, update.clone())?;
+            }
         }
 
         response.send(proto::Ack {})?;
@@ -7257,7 +7278,7 @@ mod tests {
         }
 
         fn render(&mut self, _: &mut gpui::RenderContext<Self>) -> gpui::ElementBox {
-            gpui::Element::boxed(gpui::elements::Empty)
+            gpui::Element::boxed(gpui::elements::Empty::new())
         }
     }
 }

crates/collab/src/rpc/store.rs 🔗

@@ -217,33 +217,46 @@ impl Store {
             .is_empty()
     }
 
-    pub fn build_initial_contacts_update(&self, contacts: db::Contacts) -> proto::UpdateContacts {
+    pub fn build_initial_contacts_update(
+        &self,
+        contacts: Vec<db::Contact>,
+    ) -> proto::UpdateContacts {
         let mut update = proto::UpdateContacts::default();
-        for user_id in contacts.current {
-            update.contacts.push(self.contact_for_user(user_id));
-        }
-
-        for request in contacts.incoming_requests {
-            update
-                .incoming_requests
-                .push(proto::IncomingContactRequest {
-                    requester_id: request.requester_id.to_proto(),
-                    should_notify: request.should_notify,
-                })
-        }
 
-        for requested_user_id in contacts.outgoing_requests {
-            update.outgoing_requests.push(requested_user_id.to_proto())
+        for contact in contacts {
+            match contact {
+                db::Contact::Accepted {
+                    user_id,
+                    should_notify,
+                } => {
+                    update
+                        .contacts
+                        .push(self.contact_for_user(user_id, should_notify));
+                }
+                db::Contact::Outgoing { user_id } => {
+                    update.outgoing_requests.push(user_id.to_proto())
+                }
+                db::Contact::Incoming {
+                    user_id,
+                    should_notify,
+                } => update
+                    .incoming_requests
+                    .push(proto::IncomingContactRequest {
+                        requester_id: user_id.to_proto(),
+                        should_notify,
+                    }),
+            }
         }
 
         update
     }
 
-    pub fn contact_for_user(&self, user_id: UserId) -> proto::Contact {
+    pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact {
         proto::Contact {
             user_id: user_id.to_proto(),
             projects: self.project_metadata_for_user(user_id),
             online: self.is_user_online(user_id),
+            should_notify,
         }
     }
 

crates/command_palette/src/command_palette.rs 🔗

@@ -71,7 +71,7 @@ impl CommandPalette {
         cx.as_mut().defer(move |cx| {
             let this = cx.add_view(window_id, |cx| Self::new(focused_view_id, cx));
             workspace.update(cx, |workspace, cx| {
-                workspace.toggle_modal(cx, |cx, _| {
+                workspace.toggle_modal(cx, |_, cx| {
                     cx.subscribe(&this, Self::on_event).detach();
                     this
                 });

crates/contacts_panel/src/contact_finder.rs 🔗

@@ -118,7 +118,7 @@ impl PickerDelegate for ContactFinder {
                 "icons/accept.svg"
             }
             ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => {
-                "icons/reject.svg"
+                "icons/decline.svg"
             }
         };
         let button_style = if self.user_store.read(cx).is_contact_request_pending(&user) {
@@ -159,7 +159,7 @@ impl PickerDelegate for ContactFinder {
 
 impl ContactFinder {
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
-        workspace.toggle_modal(cx, |cx, workspace| {
+        workspace.toggle_modal(cx, |workspace, cx| {
             let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
             cx.subscribe(&finder, Self::on_event).detach();
             finder

crates/contacts_panel/src/contact_notification.rs 🔗

@@ -0,0 +1,237 @@
+use client::{ContactEvent, ContactEventKind, UserStore};
+use gpui::{
+    elements::*, impl_internal_actions, platform::CursorStyle, Entity, ModelHandle,
+    MutableAppContext, RenderContext, View, ViewContext,
+};
+use settings::Settings;
+use workspace::Notification;
+
+use crate::render_icon_button;
+
+impl_internal_actions!(contact_notifications, [Dismiss, RespondToContactRequest]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ContactNotification::dismiss);
+    cx.add_action(ContactNotification::respond_to_contact_request);
+}
+
+pub struct ContactNotification {
+    user_store: ModelHandle<UserStore>,
+    event: ContactEvent,
+}
+
+#[derive(Clone)]
+struct Dismiss(u64);
+
+#[derive(Clone)]
+pub struct RespondToContactRequest {
+    pub user_id: u64,
+    pub accept: bool,
+}
+
+pub enum Event {
+    Dismiss,
+}
+
+enum Decline {}
+enum Accept {}
+
+impl Entity for ContactNotification {
+    type Event = Event;
+}
+
+impl View for ContactNotification {
+    fn ui_name() -> &'static str {
+        "ContactNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        match self.event.kind {
+            ContactEventKind::Requested => self.render_incoming_request(cx),
+            ContactEventKind::Accepted => self.render_acceptance(cx),
+            _ => unreachable!(),
+        }
+    }
+}
+
+impl Notification for ContactNotification {
+    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
+        matches!(event, Event::Dismiss)
+    }
+}
+
+impl ContactNotification {
+    pub fn new(
+        event: ContactEvent,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        cx.subscribe(&user_store, move |this, _, event, cx| {
+            if let client::ContactEvent {
+                kind: ContactEventKind::Cancelled,
+                user,
+            } = event
+            {
+                if user.id == this.event.user.id {
+                    cx.emit(Event::Dismiss);
+                }
+            }
+        })
+        .detach();
+
+        Self { event, user_store }
+    }
+
+    fn render_incoming_request(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+        let theme = &theme.contact_notification;
+        let user = &self.event.user;
+        let user_id = user.id;
+
+        Flex::column()
+            .with_child(self.render_header("wants to add you as a contact.", theme, cx))
+            .with_child(
+                Label::new(
+                    "They won't know if you decline.".to_string(),
+                    theme.body_message.text.clone(),
+                )
+                .contained()
+                .with_style(theme.body_message.container)
+                .boxed(),
+            )
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        MouseEventHandler::new::<Decline, _, _>(
+                            self.event.user.id as usize,
+                            cx,
+                            |state, _| {
+                                let button = theme.button.style_for(state, false);
+                                Label::new("Decline".to_string(), button.text.clone())
+                                    .contained()
+                                    .with_style(button.container)
+                                    .boxed()
+                            },
+                        )
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(move |_, cx| {
+                            cx.dispatch_action(RespondToContactRequest {
+                                user_id,
+                                accept: false,
+                            });
+                        })
+                        .boxed(),
+                    )
+                    .with_child(
+                        MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |state, _| {
+                            let button = theme.button.style_for(state, false);
+                            Label::new("Accept".to_string(), button.text.clone())
+                                .contained()
+                                .with_style(button.container)
+                                .boxed()
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(move |_, cx| {
+                            cx.dispatch_action(RespondToContactRequest {
+                                user_id,
+                                accept: true,
+                            });
+                        })
+                        .boxed(),
+                    )
+                    .aligned()
+                    .right()
+                    .boxed(),
+            )
+            .contained()
+            .boxed()
+    }
+
+    fn render_acceptance(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+        let theme = &theme.contact_notification;
+
+        self.render_header("accepted your contact request", theme, cx)
+    }
+
+    fn render_header(
+        &self,
+        message: &'static str,
+        theme: &theme::ContactNotification,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let user = &self.event.user;
+        let user_id = user.id;
+        Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.header_avatar)
+                    .aligned()
+                    .constrained()
+                    .with_height(
+                        cx.font_cache()
+                            .line_height(theme.header_message.text.font_size),
+                    )
+                    .aligned()
+                    .top()
+                    .boxed()
+            }))
+            .with_child(
+                Text::new(
+                    format!("{} {}", user.github_login, message),
+                    theme.header_message.text.clone(),
+                )
+                .contained()
+                .with_style(theme.header_message.container)
+                .aligned()
+                .top()
+                .left()
+                .flex(1., true)
+                .boxed(),
+            )
+            .with_child(
+                MouseEventHandler::new::<Dismiss, _, _>(user.id as usize, cx, |state, _| {
+                    render_icon_button(
+                        theme.dismiss_button.style_for(state, false),
+                        "icons/decline.svg",
+                    )
+                    .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .with_padding(Padding::uniform(5.))
+                .on_click(move |_, cx| cx.dispatch_action(Dismiss(user_id)))
+                .aligned()
+                .constrained()
+                .with_height(
+                    cx.font_cache()
+                        .line_height(theme.header_message.text.font_size),
+                )
+                .aligned()
+                .top()
+                .flex_float()
+                .boxed(),
+            )
+            .named("contact notification header")
+    }
+
+    fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
+        self.user_store.update(cx, |store, cx| {
+            store
+                .dismiss_contact_request(self.event.user.id, cx)
+                .detach_and_log_err(cx);
+        });
+        cx.emit(Event::Dismiss);
+    }
+
+    fn respond_to_contact_request(
+        &mut self,
+        action: &RespondToContactRequest,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.user_store
+            .update(cx, |store, cx| {
+                store.respond_to_contact_request(action.user_id, action.accept, cx)
+            })
+            .detach();
+    }
+}

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,6 +1,8 @@
 mod contact_finder;
+mod contact_notification;
 
-use client::{Contact, User, UserStore};
+use client::{Contact, ContactEventKind, User, UserStore};
+use contact_notification::ContactNotification;
 use editor::{Cancel, Editor};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
@@ -8,14 +10,14 @@ use gpui::{
     geometry::{rect::RectF, vector::vec2f},
     impl_actions,
     platform::CursorStyle,
-    Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, RenderContext,
-    Subscription, View, ViewContext, ViewHandle,
+    AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext,
+    RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
 use theme::IconButton;
-use workspace::{AppState, JoinProject};
+use workspace::{sidebar::SidebarItem, AppState, JoinProject, Workspace};
 
 impl_actions!(
     contacts_panel,
@@ -53,6 +55,7 @@ pub struct RespondToContactRequest {
 
 pub fn init(cx: &mut MutableAppContext) {
     contact_finder::init(cx);
+    contact_notification::init(cx);
     cx.add_action(ContactsPanel::request_contact);
     cx.add_action(ContactsPanel::remove_contact);
     cx.add_action(ContactsPanel::respond_to_contact_request);
@@ -60,7 +63,11 @@ pub fn init(cx: &mut MutableAppContext) {
 }
 
 impl ContactsPanel {
-    pub fn new(app_state: Arc<AppState>, cx: &mut ViewContext<Self>) -> Self {
+    pub fn new(
+        app_state: Arc<AppState>,
+        workspace: WeakViewHandle<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
         let user_query_editor = cx.add_view(|cx| {
             let mut editor = Editor::single_line(
                 Some(|theme| theme.contacts_panel.user_query_editor.clone()),
@@ -77,6 +84,27 @@ impl ContactsPanel {
         })
         .detach();
 
+        cx.subscribe(&app_state.user_store, {
+            let user_store = app_state.user_store.downgrade();
+            move |_, _, event, cx| {
+                if let Some((workspace, user_store)) =
+                    workspace.upgrade(cx).zip(user_store.upgrade(cx))
+                {
+                    workspace.update(cx, |workspace, cx| match event.kind {
+                        ContactEventKind::Requested | ContactEventKind::Accepted => workspace
+                            .show_notification(
+                                cx.add_view(|cx| {
+                                    ContactNotification::new(event.clone(), user_store, cx)
+                                }),
+                                cx,
+                            ),
+                        _ => {}
+                    });
+                }
+            }
+        })
+        .detach();
+
         let mut this = Self {
             list_state: ListState::new(0, Orientation::Top, 1000., {
                 let this = cx.weak_handle();
@@ -316,7 +344,7 @@ impl ContactsPanel {
         is_incoming: bool,
         cx: &mut LayoutContext,
     ) -> ElementBox {
-        enum Reject {}
+        enum Decline {}
         enum Accept {}
         enum Cancel {}
 
@@ -345,13 +373,13 @@ impl ContactsPanel {
 
         if is_incoming {
             row.add_children([
-                MouseEventHandler::new::<Reject, _, _>(user.id as usize, cx, |mouse_state, _| {
+                MouseEventHandler::new::<Decline, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
                         &theme.disabled_contact_button
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
-                    render_icon_button(button_style, "icons/reject.svg")
+                    render_icon_button(button_style, "icons/decline.svg")
                         .aligned()
                         .flex_float()
                         .boxed()
@@ -393,7 +421,7 @@ impl ContactsPanel {
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
-                    render_icon_button(button_style, "icons/reject.svg")
+                    render_icon_button(button_style, "icons/decline.svg")
                         .aligned()
                         .flex_float()
                         .boxed()
@@ -568,6 +596,16 @@ impl ContactsPanel {
     }
 }
 
+impl SidebarItem for ContactsPanel {
+    fn should_show_badge(&self, cx: &AppContext) -> bool {
+        !self
+            .user_store
+            .read(cx)
+            .incoming_contact_requests()
+            .is_empty()
+    }
+}
+
 fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
     Svg::new(svg_path)
         .with_color(style.color)

crates/file_finder/src/file_finder.rs 🔗

@@ -85,7 +85,7 @@ impl FileFinder {
     }
 
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
-        workspace.toggle_modal(cx, |cx, workspace| {
+        workspace.toggle_modal(cx, |workspace, cx| {
             let project = workspace.project().clone();
             let finder = cx.add_view(|cx| Self::new(project, cx));
             cx.subscribe(&finder, Self::on_event).detach();

crates/go_to_line/src/go_to_line.rs 🔗

@@ -62,7 +62,7 @@ impl GoToLine {
             .active_item(cx)
             .and_then(|active_item| active_item.downcast::<Editor>())
         {
-            workspace.toggle_modal(cx, |cx, _| {
+            workspace.toggle_modal(cx, |_, cx| {
                 let view = cx.add_view(|cx| GoToLine::new(editor, cx));
                 cx.subscribe(&view, Self::on_event).detach();
                 view

crates/gpui/src/elements/empty.rs 🔗

@@ -8,11 +8,18 @@ use crate::{
 };
 use crate::{Element, Event, EventContext, LayoutContext, PaintContext, SizeConstraint};
 
-pub struct Empty;
+pub struct Empty {
+    collapsed: bool,
+}
 
 impl Empty {
     pub fn new() -> Self {
-        Self
+        Self { collapsed: false }
+    }
+
+    pub fn collapsed(mut self) -> Self {
+        self.collapsed = true;
+        self
     }
 }
 
@@ -25,12 +32,12 @@ impl Element for Empty {
         constraint: SizeConstraint,
         _: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
-        let x = if constraint.max.x().is_finite() {
+        let x = if constraint.max.x().is_finite() && !self.collapsed {
             constraint.max.x()
         } else {
             constraint.min.x()
         };
-        let y = if constraint.max.y().is_finite() {
+        let y = if constraint.max.y().is_finite() && !self.collapsed {
             constraint.max.y()
         } else {
             constraint.min.y()

crates/outline/src/outline.rs 🔗

@@ -87,7 +87,7 @@ impl OutlineView {
                 .read(cx)
                 .outline(Some(cx.global::<Settings>().theme.editor.syntax.as_ref()));
             if let Some(outline) = buffer {
-                workspace.toggle_modal(cx, |cx, _| {
+                workspace.toggle_modal(cx, |_, cx| {
                     let view = cx.add_view(|cx| OutlineView::new(outline, editor, cx));
                     cx.subscribe(&view, Self::on_event).detach();
                     view

crates/project_panel/src/project_panel.rs 🔗

@@ -900,6 +900,12 @@ impl Entity for ProjectPanel {
     type Event = Event;
 }
 
+impl workspace::sidebar::SidebarItem for ProjectPanel {
+    fn should_show_badge(&self, _: &AppContext) -> bool {
+        false
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/project_symbols/src/project_symbols.rs 🔗

@@ -71,7 +71,7 @@ impl ProjectSymbolsView {
     }
 
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
-        workspace.toggle_modal(cx, |cx, workspace| {
+        workspace.toggle_modal(cx, |workspace, cx| {
             let project = workspace.project().clone();
             let symbols = cx.add_view(|cx| Self::new(project, cx));
             cx.subscribe(&symbols, Self::on_event).detach();

crates/rpc/proto/zed.proto 🔗

@@ -564,8 +564,9 @@ message RespondToContactRequest {
 
 enum ContactRequestResponse {
     Accept = 0;
-    Reject = 1;
+    Decline = 1;
     Block = 2;
+    Dismiss = 3;
 }
 
 message SendChannelMessage {
@@ -876,6 +877,7 @@ message Contact {
     uint64 user_id = 1;
     repeated ProjectMetadata projects = 2;
     bool online = 3;
+    bool should_notify = 4;
 }
 
 message ProjectMetadata {

crates/theme/src/theme.rs 🔗

@@ -29,6 +29,7 @@ pub struct Theme {
     pub search: Search,
     pub project_diagnostics: ProjectDiagnostics,
     pub breadcrumbs: ContainedText,
+    pub contact_notification: ContactNotification,
 }
 
 #[derive(Deserialize, Default)]
@@ -45,6 +46,8 @@ pub struct Workspace {
     pub toolbar: Toolbar,
     pub disconnected_overlay: ContainedText,
     pub modal: ContainerStyle,
+    pub notification: ContainerStyle,
+    pub notifications: Notifications,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -109,6 +112,13 @@ pub struct Toolbar {
     pub item_spacing: f32,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct Notifications {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub width: f32,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct Search {
     #[serde(flatten)]
@@ -152,6 +162,7 @@ pub struct StatusBarSidebarButtons {
     pub group_left: ContainerStyle,
     pub group_right: ContainerStyle,
     pub item: Interactive<SidebarItem>,
+    pub badge: ContainerStyle,
 }
 
 #[derive(Deserialize, Default)]
@@ -345,6 +356,16 @@ pub struct ProjectDiagnostics {
     pub tab_summary_spacing: f32,
 }
 
+#[derive(Deserialize, Default)]
+pub struct ContactNotification {
+    pub header_avatar: ImageStyle,
+    pub header_message: ContainedText,
+    pub header_height: f32,
+    pub body_message: ContainedText,
+    pub button: Interactive<ContainedText>,
+    pub dismiss_button: Interactive<IconButton>,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct Editor {
     pub text_color: Color,

crates/theme_selector/src/theme_selector.rs 🔗

@@ -66,7 +66,7 @@ impl ThemeSelector {
 
     fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
         let themes = workspace.themes();
-        workspace.toggle_modal(cx, |cx, _| {
+        workspace.toggle_modal(cx, |_, cx| {
             let this = cx.add_view(|cx| Self::new(themes, cx));
             cx.subscribe(&this, Self::on_event).detach();
             this

crates/workspace/src/sidebar.rs 🔗

@@ -1,13 +1,40 @@
+use crate::StatusItemView;
 use gpui::{
-    elements::*, impl_actions, platform::CursorStyle, AnyViewHandle, Entity, RenderContext, View,
-    ViewContext, ViewHandle,
+    elements::*, impl_actions, platform::CursorStyle, AnyViewHandle, AppContext, Entity,
+    RenderContext, Subscription, View, ViewContext, ViewHandle,
 };
 use serde::Deserialize;
 use settings::Settings;
 use std::{cell::RefCell, rc::Rc};
 use theme::Theme;
 
-use crate::StatusItemView;
+pub trait SidebarItem: View {
+    fn should_show_badge(&self, cx: &AppContext) -> bool;
+}
+
+pub trait SidebarItemHandle {
+    fn should_show_badge(&self, cx: &AppContext) -> bool;
+    fn to_any(&self) -> AnyViewHandle;
+}
+
+impl<T> SidebarItemHandle for ViewHandle<T>
+where
+    T: SidebarItem,
+{
+    fn should_show_badge(&self, cx: &AppContext) -> bool {
+        self.read(cx).should_show_badge(cx)
+    }
+
+    fn to_any(&self) -> AnyViewHandle {
+        self.into()
+    }
+}
+
+impl Into<AnyViewHandle> for &dyn SidebarItemHandle {
+    fn into(self) -> AnyViewHandle {
+        self.to_any()
+    }
+}
 
 pub struct Sidebar {
     side: Side,
@@ -23,10 +50,10 @@ pub enum Side {
     Right,
 }
 
-#[derive(Clone)]
 struct Item {
     icon_path: &'static str,
-    view: AnyViewHandle,
+    view: Rc<dyn SidebarItemHandle>,
+    _observation: Subscription,
 }
 
 pub struct SidebarButtons {
@@ -58,13 +85,18 @@ impl Sidebar {
         }
     }
 
-    pub fn add_item(
+    pub fn add_item<T: SidebarItem>(
         &mut self,
         icon_path: &'static str,
-        view: AnyViewHandle,
+        view: ViewHandle<T>,
         cx: &mut ViewContext<Self>,
     ) {
-        self.items.push(Item { icon_path, view });
+        let subscription = cx.observe(&view, |_, _, cx| cx.notify());
+        self.items.push(Item {
+            icon_path,
+            view: Rc::new(view),
+            _observation: subscription,
+        });
         cx.notify()
     }
 
@@ -82,10 +114,10 @@ impl Sidebar {
         cx.notify();
     }
 
-    pub fn active_item(&self) -> Option<&AnyViewHandle> {
+    pub fn active_item(&self) -> Option<&dyn SidebarItemHandle> {
         self.active_item_ix
             .and_then(|ix| self.items.get(ix))
-            .map(|item| &item.view)
+            .map(|item| item.view.as_ref())
     }
 
     fn render_resize_handle(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
@@ -185,34 +217,62 @@ impl View for SidebarButtons {
             .sidebar_buttons;
         let sidebar = self.sidebar.read(cx);
         let item_style = theme.item;
+        let badge_style = theme.badge;
         let active_ix = sidebar.active_item_ix;
         let side = sidebar.side;
         let group_style = match side {
             Side::Left => theme.group_left,
             Side::Right => theme.group_right,
         };
-        let items = sidebar.items.clone();
+        let items = sidebar
+            .items
+            .iter()
+            .map(|item| (item.icon_path, item.view.clone()))
+            .collect::<Vec<_>>();
         Flex::row()
-            .with_children(items.iter().enumerate().map(|(ix, item)| {
-                MouseEventHandler::new::<Self, _, _>(ix, cx, move |state, _| {
-                    let style = item_style.style_for(state, Some(ix) == active_ix);
-                    Svg::new(item.icon_path)
-                        .with_color(style.icon_color)
-                        .constrained()
-                        .with_height(style.icon_size)
-                        .contained()
-                        .with_style(style.container)
+            .with_children(
+                items
+                    .into_iter()
+                    .enumerate()
+                    .map(|(ix, (icon_path, item_view))| {
+                        MouseEventHandler::new::<Self, _, _>(ix, cx, move |state, cx| {
+                            let is_active = Some(ix) == active_ix;
+                            let style = item_style.style_for(state, is_active);
+                            Stack::new()
+                                .with_child(
+                                    Svg::new(icon_path).with_color(style.icon_color).boxed(),
+                                )
+                                .with_children(if !is_active && item_view.should_show_badge(cx) {
+                                    Some(
+                                        Empty::new()
+                                            .collapsed()
+                                            .contained()
+                                            .with_style(badge_style)
+                                            .aligned()
+                                            .bottom()
+                                            .right()
+                                            .boxed(),
+                                    )
+                                } else {
+                                    None
+                                })
+                                .constrained()
+                                .with_width(style.icon_size)
+                                .with_height(style.icon_size)
+                                .contained()
+                                .with_style(style.container)
+                                .boxed()
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(move |_, cx| {
+                            cx.dispatch_action(ToggleSidebarItem {
+                                side,
+                                item_index: ix,
+                            })
+                        })
                         .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |_, cx| {
-                    cx.dispatch_action(ToggleSidebarItem {
-                        side,
-                        item_index: ix,
-                    })
-                })
-                .boxed()
-            }))
+                    }),
+            )
             .contained()
             .with_style(group_style)
             .boxed()

crates/workspace/src/workspace.rs 🔗

@@ -604,6 +604,31 @@ impl<T: Item> WeakItemHandle for WeakViewHandle<T> {
     }
 }
 
+pub trait Notification: View {
+    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool;
+}
+
+pub trait NotificationHandle {
+    fn id(&self) -> usize;
+    fn to_any(&self) -> AnyViewHandle;
+}
+
+impl<T: Notification> NotificationHandle for ViewHandle<T> {
+    fn id(&self) -> usize {
+        self.id()
+    }
+
+    fn to_any(&self) -> AnyViewHandle {
+        self.into()
+    }
+}
+
+impl Into<AnyViewHandle> for &dyn NotificationHandle {
+    fn into(self) -> AnyViewHandle {
+        self.to_any()
+    }
+}
+
 #[derive(Clone)]
 pub struct WorkspaceParams {
     pub project: ModelHandle<Project>,
@@ -683,6 +708,7 @@ pub struct Workspace {
     panes: Vec<ViewHandle<Pane>>,
     active_pane: ViewHandle<Pane>,
     status_bar: ViewHandle<StatusBar>,
+    notifications: Vec<Box<dyn NotificationHandle>>,
     project: ModelHandle<Project>,
     leader_state: LeaderState,
     follower_states_by_leader: FollowerStatesByLeader,
@@ -791,6 +817,7 @@ impl Workspace {
             panes: vec![pane.clone()],
             active_pane: pane.clone(),
             status_bar,
+            notifications: Default::default(),
             client: params.client.clone(),
             remote_entity_subscription: None,
             user_store: params.user_store.clone(),
@@ -943,7 +970,7 @@ impl Workspace {
     ) -> Option<ViewHandle<V>>
     where
         V: 'static + View,
-        F: FnOnce(&mut ViewContext<Self>, &mut Self) -> ViewHandle<V>,
+        F: FnOnce(&mut Self, &mut ViewContext<Self>) -> ViewHandle<V>,
     {
         cx.notify();
         // Whatever modal was visible is getting clobbered. If its the same type as V, then return
@@ -953,7 +980,7 @@ impl Workspace {
             cx.focus_self();
             Some(already_open_modal)
         } else {
-            let modal = add_view(cx, self);
+            let modal = add_view(self, cx);
             cx.focus(&modal);
             self.modal = Some(modal.into());
             None
@@ -971,6 +998,32 @@ impl Workspace {
         }
     }
 
+    pub fn show_notification<V: Notification>(
+        &mut self,
+        notification: ViewHandle<V>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        cx.subscribe(&notification, |this, handle, event, cx| {
+            if handle.read(cx).should_dismiss_notification_on_event(event) {
+                this.dismiss_notification(handle.id(), cx);
+            }
+        })
+        .detach();
+        self.notifications.push(Box::new(notification));
+        cx.notify();
+    }
+
+    fn dismiss_notification(&mut self, id: usize, cx: &mut ViewContext<Self>) {
+        self.notifications.retain(|handle| {
+            if handle.id() == id {
+                cx.notify();
+                false
+            } else {
+                true
+            }
+        });
+    }
+
     pub fn items<'a>(
         &'a self,
         cx: &'a AppContext,
@@ -1049,7 +1102,7 @@ impl Workspace {
         };
         let active_item = sidebar.update(cx, |sidebar, cx| {
             sidebar.toggle_item(action.item_index, cx);
-            sidebar.active_item().cloned()
+            sidebar.active_item().map(|item| item.to_any())
         });
         if let Some(active_item) = active_item {
             cx.focus(active_item);
@@ -1070,7 +1123,7 @@ impl Workspace {
         };
         let active_item = sidebar.update(cx, |sidebar, cx| {
             sidebar.activate_item(action.item_index, cx);
-            sidebar.active_item().cloned()
+            sidebar.active_item().map(|item| item.to_any())
         });
         if let Some(active_item) = active_item {
             if active_item.is_focused(cx) {
@@ -1703,6 +1756,30 @@ impl Workspace {
         }
     }
 
+    fn render_notifications(&self, theme: &theme::Workspace) -> Option<ElementBox> {
+        if self.notifications.is_empty() {
+            None
+        } else {
+            Some(
+                Flex::column()
+                    .with_children(self.notifications.iter().map(|notification| {
+                        ChildView::new(notification.as_ref())
+                            .contained()
+                            .with_style(theme.notification)
+                            .boxed()
+                    }))
+                    .constrained()
+                    .with_width(theme.notifications.width)
+                    .contained()
+                    .with_style(theme.notifications.container)
+                    .aligned()
+                    .bottom()
+                    .right()
+                    .boxed(),
+            )
+        }
+    }
+
     // RPC handlers
 
     async fn handle_follow(
@@ -2037,6 +2114,7 @@ impl View for Workspace {
                                     .top()
                                     .boxed()
                             }))
+                            .with_children(self.render_notifications(&theme.workspace))
                             .flex(1.0, true)
                             .boxed(),
                     )

crates/zed/src/zed.rs 🔗

@@ -172,7 +172,8 @@ pub fn build_workspace(
     });
 
     let project_panel = ProjectPanel::new(project, cx);
-    let contact_panel = cx.add_view(|cx| ContactsPanel::new(app_state.clone(), cx));
+    let contact_panel =
+        cx.add_view(|cx| ContactsPanel::new(app_state.clone(), workspace.weak_handle(), cx));
 
     workspace.left_sidebar().update(cx, |sidebar, cx| {
         sidebar.add_item("icons/folder-tree-solid-14.svg", project_panel.into(), cx)

styles/src/styleTree/app.ts 🔗

@@ -10,6 +10,7 @@ import search from "./search";
 import picker from "./picker";
 import workspace from "./workspace";
 import projectDiagnostics from "./projectDiagnostics";
+import contactNotification from "./contactNotification";
 
 export const panel = {
   padding: { top: 12, left: 12, bottom: 12, right: 12 },
@@ -32,6 +33,7 @@ export default function app(theme: Theme): Object {
       padding: {
         left: 6,
       },
-    }
+    },
+    contactNotification: contactNotification(theme),
   };
 }

styles/src/styleTree/contactNotification.ts 🔗

@@ -0,0 +1,44 @@
+import Theme from "../themes/theme";
+import { backgroundColor, iconColor, text } from "./components";
+
+const avatarSize = 12;
+const headerPadding = 8;
+
+export default function contactNotification(theme: Theme): Object {
+  return {
+    headerAvatar: {
+      height: avatarSize,
+      width: avatarSize,
+      cornerRadius: 6,
+    },
+    headerMessage: {
+      ...text(theme, "sans", "primary", { size: "xs" }),
+      margin: { left: headerPadding, right: headerPadding }
+    },
+    headerHeight: 18,
+    bodyMessage: {
+      ...text(theme, "sans", "secondary", { size: "xs" }),
+      margin: { left: avatarSize + headerPadding, top: 6, bottom: 6 },
+    },
+    button: {
+      ...text(theme, "sans", "primary", { size: "xs" }),
+      background: backgroundColor(theme, "on300"),
+      padding: 4,
+      cornerRadius: 6,
+      margin: { left: 6 },
+      hover: {
+        background: backgroundColor(theme, "on300", "hovered")
+      }
+    },
+    dismissButton: {
+      color: iconColor(theme, "secondary"),
+      iconWidth: 8,
+      iconHeight: 8,
+      buttonWidth: 8,
+      buttonHeight: 8,
+      hover: {
+        color: iconColor(theme, "primary")
+      }
+    }
+  }
+}

styles/src/styleTree/statusBar.ts 🔗

@@ -1,8 +1,8 @@
 import Theme from "../themes/theme";
 import { backgroundColor, border, iconColor, text } from "./components";
+import { workspaceBackground } from "./workspace";
 
 export default function statusBar(theme: Theme) {
-
   const statusContainer = {
     cornerRadius: 6,
     padding: { top: 3, bottom: 3, left: 6, right: 6 }
@@ -100,6 +100,13 @@ export default function statusBar(theme: Theme) {
           iconColor: iconColor(theme, "active"),
           background: backgroundColor(theme, 300, "active"),
         }
+      },
+      badge: {
+        cornerRadius: 3,
+        padding: 2,
+        margin: { bottom: -1, right: -1 },
+        border: { width: 1, color: workspaceBackground(theme) },
+        background: iconColor(theme, "feature"),
       }
     }
   }

styles/src/styleTree/workspace.ts 🔗

@@ -1,12 +1,16 @@
 import Theme from "../themes/theme";
-import { backgroundColor, border, iconColor, text } from "./components";
+import { backgroundColor, border, iconColor, shadow, text } from "./components";
 import statusBar from "./statusBar";
 
+export function workspaceBackground(theme: Theme) {
+  return backgroundColor(theme, 300)
+}
+
 export default function workspace(theme: Theme) {
 
   const tab = {
     height: 32,
-    background: backgroundColor(theme, 300),
+    background: workspaceBackground(theme),
     iconClose: iconColor(theme, "muted"),
     iconCloseActive: iconColor(theme, "active"),
     iconConflict: iconColor(theme, "warning"),
@@ -146,5 +150,17 @@ export default function workspace(theme: Theme) {
       ...text(theme, "sans", "active"),
       background: "#000000aa",
     },
+    notification: {
+      margin: { top: 10 },
+      background: backgroundColor(theme, 300),
+      cornerRadius: 6,
+      padding: 12,
+      border: border(theme, "primary"),
+      shadow: shadow(theme),
+    },
+    notifications: {
+      width: 380,
+      margin: { right: 10, bottom: 10 },
+    }
   };
 }