Merge pull request #987 from zed-industries/notifications

Notify when someone requests to add you as a contact or accepts your contact request
This commit is contained in:
Max Brunsfeld 2022-05-11 17:40:11 -07:00 committed by GitHub
commit 85d9ac5b95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1744 additions and 259 deletions

View file

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 184 B

Before After
Before After

View file

@ -341,6 +341,19 @@
"icon_color": "#efecf4", "icon_color": "#efecf4",
"background": "#5852605c" "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", "color": "#efecf4",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

@ -341,6 +341,19 @@
"icon_color": "#19171c", "icon_color": "#19171c",
"background": "#8b87922e" "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", "color": "#19171c",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

@ -341,6 +341,19 @@
"icon_color": "#ffffff", "icon_color": "#ffffff",
"background": "#2b2b2b" "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", "color": "#ffffff",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

@ -341,6 +341,19 @@
"icon_color": "#000000", "icon_color": "#000000",
"background": "#e3e3e3" "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", "color": "#000000",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

@ -341,6 +341,19 @@
"icon_color": "#fdf6e3", "icon_color": "#fdf6e3",
"background": "#586e755c" "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", "color": "#fdf6e3",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

@ -341,6 +341,19 @@
"icon_color": "#002b36", "icon_color": "#002b36",
"background": "#93a1a12e" "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", "color": "#002b36",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

@ -341,6 +341,19 @@
"icon_color": "#f5f7ff", "icon_color": "#f5f7ff",
"background": "#5e66875c" "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", "color": "#f5f7ff",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

@ -341,6 +341,19 @@
"icon_color": "#202746", "icon_color": "#202746",
"background": "#979db42e" "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", "color": "#202746",
"size": 14, "size": 14,
"background": "#000000aa" "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": { "editor": {
@ -1646,5 +1686,56 @@
"padding": { "padding": {
"left": 6 "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"
}
}
} }
} }

View file

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

View file

@ -54,10 +54,21 @@ pub struct UserStore {
_maintain_current_user: Task<()>, _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 { impl Entity for UserStore {
type Event = Event; type Event = ContactEvent;
} }
enum UpdateContacts { enum UpdateContacts {
@ -175,19 +186,23 @@ impl UserStore {
// No need to paralellize here // No need to paralellize here
let mut updated_contacts = Vec::new(); let mut updated_contacts = Vec::new();
for contact in message.contacts { for contact in message.contacts {
updated_contacts.push(Arc::new( let should_notify = contact.should_notify;
Contact::from_proto(contact, &this, &mut cx).await?, updated_contacts.push((
Arc::new(Contact::from_proto(contact, &this, &mut cx).await?),
should_notify,
)); ));
} }
let mut incoming_requests = Vec::new(); let mut incoming_requests = Vec::new();
for request in message.incoming_requests { for request in message.incoming_requests {
incoming_requests.push( incoming_requests.push({
this.update(&mut cx, |this, cx| { let user = this
this.fetch_user(request.requester_id, cx) .update(&mut cx, |this, cx| {
}) this.fetch_user(request.requester_id, cx)
.await?, })
); .await?;
(user, request.should_notify)
});
} }
let mut outgoing_requests = Vec::new(); let mut outgoing_requests = Vec::new();
@ -210,7 +225,13 @@ impl UserStore {
this.contacts this.contacts
.retain(|contact| !removed_contacts.contains(&contact.user.id)); .retain(|contact| !removed_contacts.contains(&contact.user.id));
// Update existing contacts and insert new ones // 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( match this.contacts.binary_search_by_key(
&&updated_contact.user.github_login, &&updated_contact.user.github_login,
|contact| &contact.user.github_login, |contact| &contact.user.github_login,
@ -221,17 +242,33 @@ impl UserStore {
} }
// Remove incoming contact requests // Remove incoming contact requests
this.incoming_contact_requests this.incoming_contact_requests.retain(|user| {
.retain(|user| !removed_incoming_requests.contains(&user.id)); 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 // 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 match this
.incoming_contact_requests .incoming_contact_requests
.binary_search_by_key(&&request.github_login, |contact| { .binary_search_by_key(&&user.github_login, |contact| {
&contact.github_login &contact.github_login
}) { }) {
Ok(ix) => this.incoming_contact_requests[ix] = request, Ok(ix) => this.incoming_contact_requests[ix] = user,
Err(ix) => this.incoming_contact_requests.insert(ix, request), Err(ix) => this.incoming_contact_requests.insert(ix, user),
} }
} }
@ -334,13 +371,31 @@ impl UserStore {
response: if accept { response: if accept {
proto::ContactRequestResponse::Accept proto::ContactRequestResponse::Accept
} else { } else {
proto::ContactRequestResponse::Reject proto::ContactRequestResponse::Decline
} as i32, } as i32,
}, },
cx, 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>( fn perform_contact_request<T: RequestMessage>(
&mut self, &mut self,
user_id: u64, user_id: u64,

View file

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

View file

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

View file

@ -217,33 +217,46 @@ impl Store {
.is_empty() .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(); 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 { for contact in contacts {
update match contact {
.incoming_requests db::Contact::Accepted {
.push(proto::IncomingContactRequest { user_id,
requester_id: request.requester_id.to_proto(), should_notify,
should_notify: request.should_notify, } => {
}) update
} .contacts
.push(self.contact_for_user(user_id, should_notify));
for requested_user_id in contacts.outgoing_requests { }
update.outgoing_requests.push(requested_user_id.to_proto()) 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 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 { proto::Contact {
user_id: user_id.to_proto(), user_id: user_id.to_proto(),
projects: self.project_metadata_for_user(user_id), projects: self.project_metadata_for_user(user_id),
online: self.is_user_online(user_id), online: self.is_user_online(user_id),
should_notify,
} }
} }

View file

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

View file

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

View file

@ -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();
}
}

View file

@ -1,6 +1,8 @@
mod contact_finder; 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 editor::{Cancel, Editor};
use fuzzy::{match_strings, StringMatchCandidate}; use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{ use gpui::{
@ -8,14 +10,14 @@ use gpui::{
geometry::{rect::RectF, vector::vec2f}, geometry::{rect::RectF, vector::vec2f},
impl_actions, impl_actions,
platform::CursorStyle, platform::CursorStyle,
Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, RenderContext, AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext,
Subscription, View, ViewContext, ViewHandle, RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
}; };
use serde::Deserialize; use serde::Deserialize;
use settings::Settings; use settings::Settings;
use std::sync::Arc; use std::sync::Arc;
use theme::IconButton; use theme::IconButton;
use workspace::{AppState, JoinProject}; use workspace::{sidebar::SidebarItem, AppState, JoinProject, Workspace};
impl_actions!( impl_actions!(
contacts_panel, contacts_panel,
@ -53,6 +55,7 @@ pub struct RespondToContactRequest {
pub fn init(cx: &mut MutableAppContext) { pub fn init(cx: &mut MutableAppContext) {
contact_finder::init(cx); contact_finder::init(cx);
contact_notification::init(cx);
cx.add_action(ContactsPanel::request_contact); cx.add_action(ContactsPanel::request_contact);
cx.add_action(ContactsPanel::remove_contact); cx.add_action(ContactsPanel::remove_contact);
cx.add_action(ContactsPanel::respond_to_contact_request); cx.add_action(ContactsPanel::respond_to_contact_request);
@ -60,7 +63,11 @@ pub fn init(cx: &mut MutableAppContext) {
} }
impl ContactsPanel { 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 user_query_editor = cx.add_view(|cx| {
let mut editor = Editor::single_line( let mut editor = Editor::single_line(
Some(|theme| theme.contacts_panel.user_query_editor.clone()), Some(|theme| theme.contacts_panel.user_query_editor.clone()),
@ -77,6 +84,27 @@ impl ContactsPanel {
}) })
.detach(); .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 { let mut this = Self {
list_state: ListState::new(0, Orientation::Top, 1000., { list_state: ListState::new(0, Orientation::Top, 1000., {
let this = cx.weak_handle(); let this = cx.weak_handle();
@ -316,7 +344,7 @@ impl ContactsPanel {
is_incoming: bool, is_incoming: bool,
cx: &mut LayoutContext, cx: &mut LayoutContext,
) -> ElementBox { ) -> ElementBox {
enum Reject {} enum Decline {}
enum Accept {} enum Accept {}
enum Cancel {} enum Cancel {}
@ -345,13 +373,13 @@ impl ContactsPanel {
if is_incoming { if is_incoming {
row.add_children([ 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 { let button_style = if is_contact_request_pending {
&theme.disabled_contact_button &theme.disabled_contact_button
} else { } else {
&theme.contact_button.style_for(mouse_state, false) &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() .aligned()
.flex_float() .flex_float()
.boxed() .boxed()
@ -393,7 +421,7 @@ impl ContactsPanel {
} else { } else {
&theme.contact_button.style_for(mouse_state, false) &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() .aligned()
.flex_float() .flex_float()
.boxed() .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 { fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
Svg::new(svg_path) Svg::new(svg_path)
.with_color(style.color) .with_color(style.color)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)] #[derive(Clone)]
pub struct WorkspaceParams { pub struct WorkspaceParams {
pub project: ModelHandle<Project>, pub project: ModelHandle<Project>,
@ -683,6 +708,7 @@ pub struct Workspace {
panes: Vec<ViewHandle<Pane>>, panes: Vec<ViewHandle<Pane>>,
active_pane: ViewHandle<Pane>, active_pane: ViewHandle<Pane>,
status_bar: ViewHandle<StatusBar>, status_bar: ViewHandle<StatusBar>,
notifications: Vec<Box<dyn NotificationHandle>>,
project: ModelHandle<Project>, project: ModelHandle<Project>,
leader_state: LeaderState, leader_state: LeaderState,
follower_states_by_leader: FollowerStatesByLeader, follower_states_by_leader: FollowerStatesByLeader,
@ -791,6 +817,7 @@ impl Workspace {
panes: vec![pane.clone()], panes: vec![pane.clone()],
active_pane: pane.clone(), active_pane: pane.clone(),
status_bar, status_bar,
notifications: Default::default(),
client: params.client.clone(), client: params.client.clone(),
remote_entity_subscription: None, remote_entity_subscription: None,
user_store: params.user_store.clone(), user_store: params.user_store.clone(),
@ -943,7 +970,7 @@ impl Workspace {
) -> Option<ViewHandle<V>> ) -> Option<ViewHandle<V>>
where where
V: 'static + View, V: 'static + View,
F: FnOnce(&mut ViewContext<Self>, &mut Self) -> ViewHandle<V>, F: FnOnce(&mut Self, &mut ViewContext<Self>) -> ViewHandle<V>,
{ {
cx.notify(); cx.notify();
// Whatever modal was visible is getting clobbered. If its the same type as V, then return // 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(); cx.focus_self();
Some(already_open_modal) Some(already_open_modal)
} else { } else {
let modal = add_view(cx, self); let modal = add_view(self, cx);
cx.focus(&modal); cx.focus(&modal);
self.modal = Some(modal.into()); self.modal = Some(modal.into());
None 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>( pub fn items<'a>(
&'a self, &'a self,
cx: &'a AppContext, cx: &'a AppContext,
@ -1049,7 +1102,7 @@ impl Workspace {
}; };
let active_item = sidebar.update(cx, |sidebar, cx| { let active_item = sidebar.update(cx, |sidebar, cx| {
sidebar.toggle_item(action.item_index, 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 { if let Some(active_item) = active_item {
cx.focus(active_item); cx.focus(active_item);
@ -1070,7 +1123,7 @@ impl Workspace {
}; };
let active_item = sidebar.update(cx, |sidebar, cx| { let active_item = sidebar.update(cx, |sidebar, cx| {
sidebar.activate_item(action.item_index, 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 let Some(active_item) = active_item {
if active_item.is_focused(cx) { 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 // RPC handlers
async fn handle_follow( async fn handle_follow(
@ -2037,6 +2114,7 @@ impl View for Workspace {
.top() .top()
.boxed() .boxed()
})) }))
.with_children(self.render_notifications(&theme.workspace))
.flex(1.0, true) .flex(1.0, true)
.boxed(), .boxed(),
) )

View file

@ -172,7 +172,8 @@ pub fn build_workspace(
}); });
let project_panel = ProjectPanel::new(project, cx); 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| { workspace.left_sidebar().update(cx, |sidebar, cx| {
sidebar.add_item("icons/folder-tree-solid-14.svg", project_panel.into(), cx) sidebar.add_item("icons/folder-tree-solid-14.svg", project_panel.into(), cx)

View file

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

View file

@ -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")
}
}
}
}

View file

@ -1,8 +1,8 @@
import Theme from "../themes/theme"; import Theme from "../themes/theme";
import { backgroundColor, border, iconColor, text } from "./components"; import { backgroundColor, border, iconColor, text } from "./components";
import { workspaceBackground } from "./workspace";
export default function statusBar(theme: Theme) { export default function statusBar(theme: Theme) {
const statusContainer = { const statusContainer = {
cornerRadius: 6, cornerRadius: 6,
padding: { top: 3, bottom: 3, left: 6, right: 6 } padding: { top: 3, bottom: 3, left: 6, right: 6 }
@ -100,6 +100,13 @@ export default function statusBar(theme: Theme) {
iconColor: iconColor(theme, "active"), iconColor: iconColor(theme, "active"),
background: backgroundColor(theme, 300, "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"),
} }
} }
} }

View file

@ -1,12 +1,16 @@
import Theme from "../themes/theme"; import Theme from "../themes/theme";
import { backgroundColor, border, iconColor, text } from "./components"; import { backgroundColor, border, iconColor, shadow, text } from "./components";
import statusBar from "./statusBar"; import statusBar from "./statusBar";
export function workspaceBackground(theme: Theme) {
return backgroundColor(theme, 300)
}
export default function workspace(theme: Theme) { export default function workspace(theme: Theme) {
const tab = { const tab = {
height: 32, height: 32,
background: backgroundColor(theme, 300), background: workspaceBackground(theme),
iconClose: iconColor(theme, "muted"), iconClose: iconColor(theme, "muted"),
iconCloseActive: iconColor(theme, "active"), iconCloseActive: iconColor(theme, "active"),
iconConflict: iconColor(theme, "warning"), iconConflict: iconColor(theme, "warning"),
@ -146,5 +150,17 @@ export default function workspace(theme: Theme) {
...text(theme, "sans", "active"), ...text(theme, "sans", "active"),
background: "#000000aa", 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 },
}
}; };
} }