1use crate::{
2 format_timestamp, is_channels_feature_enabled,
3 notifications::contact_notification::ContactNotification, render_avatar,
4 NotificationPanelSettings,
5};
6use anyhow::Result;
7use channel::ChannelStore;
8use client::{Client, Notification, UserStore};
9use db::kvp::KEY_VALUE_STORE;
10use futures::StreamExt;
11use gpui::{
12 actions,
13 elements::*,
14 platform::{CursorStyle, MouseButton},
15 serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View,
16 ViewContext, ViewHandle, WeakViewHandle, WindowContext,
17};
18use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
19use project::Fs;
20use serde::{Deserialize, Serialize};
21use settings::SettingsStore;
22use std::sync::Arc;
23use theme::{IconButton, Theme};
24use time::{OffsetDateTime, UtcOffset};
25use util::{ResultExt, TryFutureExt};
26use workspace::{
27 dock::{DockPosition, Panel},
28 Workspace,
29};
30
31const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
32
33pub struct NotificationPanel {
34 client: Arc<Client>,
35 user_store: ModelHandle<UserStore>,
36 channel_store: ModelHandle<ChannelStore>,
37 notification_store: ModelHandle<NotificationStore>,
38 fs: Arc<dyn Fs>,
39 width: Option<f32>,
40 active: bool,
41 notification_list: ListState<Self>,
42 pending_serialization: Task<Option<()>>,
43 subscriptions: Vec<gpui::Subscription>,
44 workspace: WeakViewHandle<Workspace>,
45 local_timezone: UtcOffset,
46 has_focus: bool,
47}
48
49#[derive(Serialize, Deserialize)]
50struct SerializedNotificationPanel {
51 width: Option<f32>,
52}
53
54#[derive(Debug)]
55pub enum Event {
56 DockPositionChanged,
57 Focus,
58 Dismissed,
59}
60
61actions!(chat_panel, [ToggleFocus]);
62
63pub fn init(_cx: &mut AppContext) {}
64
65impl NotificationPanel {
66 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
67 let fs = workspace.app_state().fs.clone();
68 let client = workspace.app_state().client.clone();
69 let user_store = workspace.app_state().user_store.clone();
70 let workspace_handle = workspace.weak_handle();
71
72 let notification_list =
73 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
74 this.render_notification(ix, cx)
75 });
76
77 cx.add_view(|cx| {
78 let mut status = client.status();
79
80 cx.spawn(|this, mut cx| async move {
81 while let Some(_) = status.next().await {
82 if this
83 .update(&mut cx, |_, cx| {
84 cx.notify();
85 })
86 .is_err()
87 {
88 break;
89 }
90 }
91 })
92 .detach();
93
94 let mut this = Self {
95 fs,
96 client,
97 user_store,
98 local_timezone: cx.platform().local_timezone(),
99 channel_store: ChannelStore::global(cx),
100 notification_store: NotificationStore::global(cx),
101 notification_list,
102 pending_serialization: Task::ready(None),
103 workspace: workspace_handle,
104 has_focus: false,
105 subscriptions: Vec::new(),
106 active: false,
107 width: None,
108 };
109
110 let mut old_dock_position = this.position(cx);
111 this.subscriptions.extend([
112 cx.subscribe(&this.notification_store, Self::on_notification_event),
113 cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
114 let new_dock_position = this.position(cx);
115 if new_dock_position != old_dock_position {
116 old_dock_position = new_dock_position;
117 cx.emit(Event::DockPositionChanged);
118 }
119 cx.notify();
120 }),
121 ]);
122 this
123 })
124 }
125
126 pub fn load(
127 workspace: WeakViewHandle<Workspace>,
128 cx: AsyncAppContext,
129 ) -> Task<Result<ViewHandle<Self>>> {
130 cx.spawn(|mut cx| async move {
131 let serialized_panel = if let Some(panel) = cx
132 .background()
133 .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
134 .await
135 .log_err()
136 .flatten()
137 {
138 Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
139 } else {
140 None
141 };
142
143 workspace.update(&mut cx, |workspace, cx| {
144 let panel = Self::new(workspace, cx);
145 if let Some(serialized_panel) = serialized_panel {
146 panel.update(cx, |panel, cx| {
147 panel.width = serialized_panel.width;
148 cx.notify();
149 });
150 }
151 panel
152 })
153 })
154 }
155
156 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
157 let width = self.width;
158 self.pending_serialization = cx.background().spawn(
159 async move {
160 KEY_VALUE_STORE
161 .write_kvp(
162 NOTIFICATION_PANEL_KEY.into(),
163 serde_json::to_string(&SerializedNotificationPanel { width })?,
164 )
165 .await?;
166 anyhow::Ok(())
167 }
168 .log_err(),
169 );
170 }
171
172 fn render_notification(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
173 self.try_render_notification(ix, cx)
174 .unwrap_or_else(|| Empty::new().into_any())
175 }
176
177 fn try_render_notification(
178 &mut self,
179 ix: usize,
180 cx: &mut ViewContext<Self>,
181 ) -> Option<AnyElement<Self>> {
182 let notification_store = self.notification_store.read(cx);
183 let user_store = self.user_store.read(cx);
184 let channel_store = self.channel_store.read(cx);
185 let entry = notification_store.notification_at(ix)?;
186 let notification = entry.notification.clone();
187 let now = OffsetDateTime::now_utc();
188 let timestamp = entry.timestamp;
189
190 let icon;
191 let text;
192 let actor;
193 let needs_acceptance;
194 match notification {
195 Notification::ContactRequest { sender_id } => {
196 let requester = user_store.get_cached_user(sender_id)?;
197 icon = "icons/plus.svg";
198 text = format!("{} wants to add you as a contact", requester.github_login);
199 needs_acceptance = true;
200 actor = Some(requester);
201 }
202 Notification::ContactRequestAccepted { responder_id } => {
203 let responder = user_store.get_cached_user(responder_id)?;
204 icon = "icons/plus.svg";
205 text = format!("{} accepted your contact invite", responder.github_login);
206 needs_acceptance = false;
207 actor = Some(responder);
208 }
209 Notification::ChannelInvitation {
210 ref channel_name, ..
211 } => {
212 actor = None;
213 icon = "icons/hash.svg";
214 text = format!("you were invited to join the #{channel_name} channel");
215 needs_acceptance = true;
216 }
217 Notification::ChannelMessageMention {
218 sender_id,
219 channel_id,
220 message_id,
221 } => {
222 let sender = user_store.get_cached_user(sender_id)?;
223 let channel = channel_store.channel_for_id(channel_id)?;
224 let message = notification_store.channel_message_for_id(message_id)?;
225
226 icon = "icons/conversations.svg";
227 text = format!(
228 "{} mentioned you in the #{} channel:\n{}",
229 sender.github_login, channel.name, message.body,
230 );
231 needs_acceptance = false;
232 actor = Some(sender);
233 }
234 }
235
236 let theme = theme::current(cx);
237 let style = &theme.notification_panel;
238 let response = entry.response;
239
240 let message_style = if entry.is_read {
241 style.read_text.clone()
242 } else {
243 style.unread_text.clone()
244 };
245
246 enum Decline {}
247 enum Accept {}
248
249 Some(
250 MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
251 let container = message_style.container;
252
253 Flex::column()
254 .with_child(
255 Flex::row()
256 .with_children(
257 actor.map(|actor| render_avatar(actor.avatar.clone(), &theme)),
258 )
259 .with_child(render_icon_button(&theme.chat_panel.icon_button, icon))
260 .with_child(
261 Label::new(
262 format_timestamp(timestamp, now, self.local_timezone),
263 style.timestamp.text.clone(),
264 )
265 .contained()
266 .with_style(style.timestamp.container),
267 )
268 .align_children_center(),
269 )
270 .with_child(Text::new(text, message_style.text.clone()))
271 .with_children(if let Some(is_accepted) = response {
272 Some(
273 Label::new(
274 if is_accepted { "Accepted" } else { "Declined" },
275 style.button.text.clone(),
276 )
277 .into_any(),
278 )
279 } else if needs_acceptance {
280 Some(
281 Flex::row()
282 .with_children([
283 MouseEventHandler::new::<Decline, _>(ix, cx, |state, _| {
284 let button = style.button.style_for(state);
285 Label::new("Decline", button.text.clone())
286 .contained()
287 .with_style(button.container)
288 })
289 .with_cursor_style(CursorStyle::PointingHand)
290 .on_click(
291 MouseButton::Left,
292 {
293 let notification = notification.clone();
294 move |_, view, cx| {
295 view.respond_to_notification(
296 notification.clone(),
297 false,
298 cx,
299 );
300 }
301 },
302 ),
303 MouseEventHandler::new::<Accept, _>(ix, cx, |state, _| {
304 let button = style.button.style_for(state);
305 Label::new("Accept", button.text.clone())
306 .contained()
307 .with_style(button.container)
308 })
309 .with_cursor_style(CursorStyle::PointingHand)
310 .on_click(
311 MouseButton::Left,
312 {
313 let notification = notification.clone();
314 move |_, view, cx| {
315 view.respond_to_notification(
316 notification.clone(),
317 true,
318 cx,
319 );
320 }
321 },
322 ),
323 ])
324 .aligned()
325 .right()
326 .into_any(),
327 )
328 } else {
329 None
330 })
331 .contained()
332 .with_style(container)
333 .into_any()
334 })
335 .into_any(),
336 )
337 }
338
339 fn render_sign_in_prompt(
340 &self,
341 theme: &Arc<Theme>,
342 cx: &mut ViewContext<Self>,
343 ) -> AnyElement<Self> {
344 enum SignInPromptLabel {}
345
346 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
347 Label::new(
348 "Sign in to view your notifications".to_string(),
349 theme
350 .chat_panel
351 .sign_in_prompt
352 .style_for(mouse_state)
353 .clone(),
354 )
355 })
356 .with_cursor_style(CursorStyle::PointingHand)
357 .on_click(MouseButton::Left, move |_, this, cx| {
358 let client = this.client.clone();
359 cx.spawn(|_, cx| async move {
360 client.authenticate_and_connect(true, &cx).log_err().await;
361 })
362 .detach();
363 })
364 .aligned()
365 .into_any()
366 }
367
368 fn render_empty_state(
369 &self,
370 theme: &Arc<Theme>,
371 _cx: &mut ViewContext<Self>,
372 ) -> AnyElement<Self> {
373 Label::new(
374 "You have no notifications".to_string(),
375 theme.chat_panel.sign_in_prompt.default.clone(),
376 )
377 .aligned()
378 .into_any()
379 }
380
381 fn on_notification_event(
382 &mut self,
383 _: ModelHandle<NotificationStore>,
384 event: &NotificationEvent,
385 cx: &mut ViewContext<Self>,
386 ) {
387 match event {
388 NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
389 NotificationEvent::NotificationRemoved { entry }
390 | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx),
391 NotificationEvent::NotificationsUpdated {
392 old_range,
393 new_count,
394 } => {
395 self.notification_list.splice(old_range.clone(), *new_count);
396 cx.notify();
397 }
398 }
399 }
400
401 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
402 let id = entry.id as usize;
403 match entry.notification {
404 Notification::ContactRequest {
405 sender_id: actor_id,
406 }
407 | Notification::ContactRequestAccepted {
408 responder_id: actor_id,
409 } => {
410 let user_store = self.user_store.clone();
411 let Some(user) = user_store.read(cx).get_cached_user(actor_id) else {
412 return;
413 };
414 self.workspace
415 .update(cx, |workspace, cx| {
416 workspace.show_notification(id, cx, |cx| {
417 cx.add_view(|_| {
418 ContactNotification::new(
419 user,
420 entry.notification.clone(),
421 user_store,
422 )
423 })
424 })
425 })
426 .ok();
427 }
428 Notification::ChannelInvitation { .. } => {}
429 Notification::ChannelMessageMention { .. } => {}
430 }
431 }
432
433 fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
434 let id = entry.id as usize;
435 match entry.notification {
436 Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => {
437 self.workspace
438 .update(cx, |workspace, cx| {
439 workspace.dismiss_notification::<ContactNotification>(id, cx)
440 })
441 .ok();
442 }
443 Notification::ChannelInvitation { .. } => {}
444 Notification::ChannelMessageMention { .. } => {}
445 }
446 }
447
448 fn respond_to_notification(
449 &mut self,
450 notification: Notification,
451 response: bool,
452 cx: &mut ViewContext<Self>,
453 ) {
454 self.notification_store.update(cx, |store, cx| {
455 store.respond_to_notification(notification, response, cx);
456 });
457 }
458}
459
460impl Entity for NotificationPanel {
461 type Event = Event;
462}
463
464impl View for NotificationPanel {
465 fn ui_name() -> &'static str {
466 "NotificationPanel"
467 }
468
469 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
470 let theme = theme::current(cx);
471 let element = if self.client.user_id().is_none() {
472 self.render_sign_in_prompt(&theme, cx)
473 } else if self.notification_list.item_count() == 0 {
474 self.render_empty_state(&theme, cx)
475 } else {
476 List::new(self.notification_list.clone())
477 .contained()
478 .with_style(theme.chat_panel.list)
479 .into_any()
480 };
481 element
482 .contained()
483 .with_style(theme.chat_panel.container)
484 .constrained()
485 .with_min_width(150.)
486 .into_any()
487 }
488
489 fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
490 self.has_focus = true;
491 }
492
493 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
494 self.has_focus = false;
495 }
496}
497
498impl Panel for NotificationPanel {
499 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
500 settings::get::<NotificationPanelSettings>(cx).dock
501 }
502
503 fn position_is_valid(&self, position: DockPosition) -> bool {
504 matches!(position, DockPosition::Left | DockPosition::Right)
505 }
506
507 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
508 settings::update_settings_file::<NotificationPanelSettings>(
509 self.fs.clone(),
510 cx,
511 move |settings| settings.dock = Some(position),
512 );
513 }
514
515 fn size(&self, cx: &gpui::WindowContext) -> f32 {
516 self.width
517 .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
518 }
519
520 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
521 self.width = size;
522 self.serialize(cx);
523 cx.notify();
524 }
525
526 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
527 self.active = active;
528 if active {
529 if !is_channels_feature_enabled(cx) {
530 cx.emit(Event::Dismissed);
531 }
532 }
533 }
534
535 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
536 (settings::get::<NotificationPanelSettings>(cx).button && is_channels_feature_enabled(cx))
537 .then(|| "icons/bell.svg")
538 }
539
540 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
541 (
542 "Notification Panel".to_string(),
543 Some(Box::new(ToggleFocus)),
544 )
545 }
546
547 fn icon_label(&self, cx: &WindowContext) -> Option<String> {
548 let count = self.notification_store.read(cx).unread_notification_count();
549 if count == 0 {
550 None
551 } else {
552 Some(count.to_string())
553 }
554 }
555
556 fn should_change_position_on_event(event: &Self::Event) -> bool {
557 matches!(event, Event::DockPositionChanged)
558 }
559
560 fn should_close_on_event(event: &Self::Event) -> bool {
561 matches!(event, Event::Dismissed)
562 }
563
564 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
565 self.has_focus
566 }
567
568 fn is_focus_event(event: &Self::Event) -> bool {
569 matches!(event, Event::Focus)
570 }
571}
572
573fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
574 Svg::new(svg_path)
575 .with_color(style.color)
576 .constrained()
577 .with_width(style.icon_width)
578 .aligned()
579 .constrained()
580 .with_width(style.button_width)
581 .with_height(style.button_width)
582 .contained()
583 .with_style(style.container)
584}