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 now = OffsetDateTime::now_utc();
187 let timestamp = entry.timestamp;
188
189 let icon;
190 let text;
191 let actor;
192 match entry.notification {
193 Notification::ContactRequest {
194 actor_id: requester_id,
195 } => {
196 actor = user_store.get_cached_user(requester_id)?;
197 icon = "icons/plus.svg";
198 text = format!("{} wants to add you as a contact", actor.github_login);
199 }
200 Notification::ContactRequestAccepted {
201 actor_id: contact_id,
202 } => {
203 actor = user_store.get_cached_user(contact_id)?;
204 icon = "icons/plus.svg";
205 text = format!("{} accepted your contact invite", actor.github_login);
206 }
207 Notification::ChannelInvitation {
208 actor_id: inviter_id,
209 channel_id,
210 } => {
211 actor = user_store.get_cached_user(inviter_id)?;
212 let channel = channel_store.channel_for_id(channel_id).or_else(|| {
213 channel_store
214 .channel_invitations()
215 .iter()
216 .find(|c| c.id == channel_id)
217 })?;
218
219 icon = "icons/hash.svg";
220 text = format!(
221 "{} invited you to join the #{} channel",
222 actor.github_login, channel.name
223 );
224 }
225 Notification::ChannelMessageMention {
226 actor_id: sender_id,
227 channel_id,
228 message_id,
229 } => {
230 actor = user_store.get_cached_user(sender_id)?;
231 let channel = channel_store.channel_for_id(channel_id)?;
232 let message = notification_store.channel_message_for_id(message_id)?;
233
234 icon = "icons/conversations.svg";
235 text = format!(
236 "{} mentioned you in the #{} channel:\n{}",
237 actor.github_login, channel.name, message.body,
238 );
239 }
240 }
241
242 let theme = theme::current(cx);
243 let style = &theme.chat_panel.message;
244
245 Some(
246 MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |state, _| {
247 let container = style.container.style_for(state);
248
249 Flex::column()
250 .with_child(
251 Flex::row()
252 .with_child(render_avatar(actor.avatar.clone(), &theme))
253 .with_child(render_icon_button(&theme.chat_panel.icon_button, icon))
254 .with_child(
255 Label::new(
256 format_timestamp(timestamp, now, self.local_timezone),
257 style.timestamp.text.clone(),
258 )
259 .contained()
260 .with_style(style.timestamp.container),
261 )
262 .align_children_center(),
263 )
264 .with_child(Text::new(text, style.body.clone()))
265 .contained()
266 .with_style(*container)
267 .into_any()
268 })
269 .into_any(),
270 )
271 }
272
273 fn render_sign_in_prompt(
274 &self,
275 theme: &Arc<Theme>,
276 cx: &mut ViewContext<Self>,
277 ) -> AnyElement<Self> {
278 enum SignInPromptLabel {}
279
280 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
281 Label::new(
282 "Sign in to view your notifications".to_string(),
283 theme
284 .chat_panel
285 .sign_in_prompt
286 .style_for(mouse_state)
287 .clone(),
288 )
289 })
290 .with_cursor_style(CursorStyle::PointingHand)
291 .on_click(MouseButton::Left, move |_, this, cx| {
292 let client = this.client.clone();
293 cx.spawn(|_, cx| async move {
294 client.authenticate_and_connect(true, &cx).log_err().await;
295 })
296 .detach();
297 })
298 .aligned()
299 .into_any()
300 }
301
302 fn on_notification_event(
303 &mut self,
304 _: ModelHandle<NotificationStore>,
305 event: &NotificationEvent,
306 cx: &mut ViewContext<Self>,
307 ) {
308 match event {
309 NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
310 NotificationEvent::NotificationRemoved { entry } => self.remove_toast(entry, cx),
311 NotificationEvent::NotificationsUpdated {
312 old_range,
313 new_count,
314 } => {
315 self.notification_list.splice(old_range.clone(), *new_count);
316 cx.notify();
317 }
318 }
319 }
320
321 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
322 let id = entry.id as usize;
323 match entry.notification {
324 Notification::ContactRequest { actor_id }
325 | Notification::ContactRequestAccepted { actor_id } => {
326 let user_store = self.user_store.clone();
327 let Some(user) = user_store.read(cx).get_cached_user(actor_id) else {
328 return;
329 };
330 self.workspace
331 .update(cx, |workspace, cx| {
332 workspace.show_notification(id, cx, |cx| {
333 cx.add_view(|_| {
334 ContactNotification::new(
335 user,
336 entry.notification.clone(),
337 user_store,
338 )
339 })
340 })
341 })
342 .ok();
343 }
344 Notification::ChannelInvitation { .. } => {}
345 Notification::ChannelMessageMention { .. } => {}
346 }
347 }
348
349 fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
350 let id = entry.id as usize;
351 match entry.notification {
352 Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => {
353 self.workspace
354 .update(cx, |workspace, cx| {
355 workspace.dismiss_notification::<ContactNotification>(id, cx)
356 })
357 .ok();
358 }
359 Notification::ChannelInvitation { .. } => {}
360 Notification::ChannelMessageMention { .. } => {}
361 }
362 }
363}
364
365impl Entity for NotificationPanel {
366 type Event = Event;
367}
368
369impl View for NotificationPanel {
370 fn ui_name() -> &'static str {
371 "NotificationPanel"
372 }
373
374 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
375 let theme = theme::current(cx);
376 let element = if self.client.user_id().is_some() {
377 List::new(self.notification_list.clone())
378 .contained()
379 .with_style(theme.chat_panel.list)
380 .into_any()
381 } else {
382 self.render_sign_in_prompt(&theme, cx)
383 };
384 element
385 .contained()
386 .with_style(theme.chat_panel.container)
387 .constrained()
388 .with_min_width(150.)
389 .into_any()
390 }
391
392 fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
393 self.has_focus = true;
394 }
395
396 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
397 self.has_focus = false;
398 }
399}
400
401impl Panel for NotificationPanel {
402 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
403 settings::get::<NotificationPanelSettings>(cx).dock
404 }
405
406 fn position_is_valid(&self, position: DockPosition) -> bool {
407 matches!(position, DockPosition::Left | DockPosition::Right)
408 }
409
410 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
411 settings::update_settings_file::<NotificationPanelSettings>(
412 self.fs.clone(),
413 cx,
414 move |settings| settings.dock = Some(position),
415 );
416 }
417
418 fn size(&self, cx: &gpui::WindowContext) -> f32 {
419 self.width
420 .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
421 }
422
423 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
424 self.width = size;
425 self.serialize(cx);
426 cx.notify();
427 }
428
429 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
430 self.active = active;
431 if active {
432 if !is_channels_feature_enabled(cx) {
433 cx.emit(Event::Dismissed);
434 }
435 }
436 }
437
438 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
439 (settings::get::<NotificationPanelSettings>(cx).button && is_channels_feature_enabled(cx))
440 .then(|| "icons/bell.svg")
441 }
442
443 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
444 (
445 "Notification Panel".to_string(),
446 Some(Box::new(ToggleFocus)),
447 )
448 }
449
450 fn icon_label(&self, cx: &WindowContext) -> Option<String> {
451 let count = self.notification_store.read(cx).unread_notification_count();
452 if count == 0 {
453 None
454 } else {
455 Some(count.to_string())
456 }
457 }
458
459 fn should_change_position_on_event(event: &Self::Event) -> bool {
460 matches!(event, Event::DockPositionChanged)
461 }
462
463 fn should_close_on_event(event: &Self::Event) -> bool {
464 matches!(event, Event::Dismissed)
465 }
466
467 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
468 self.has_focus
469 }
470
471 fn is_focus_event(event: &Self::Event) -> bool {
472 matches!(event, Event::Focus)
473 }
474}
475
476fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
477 Svg::new(svg_path)
478 .with_color(style.color)
479 .constrained()
480 .with_width(style.icon_width)
481 .aligned()
482 .constrained()
483 .with_width(style.button_width)
484 .with_height(style.button_width)
485 .contained()
486 .with_style(style.container)
487}