From 9a022671a2ea92d773d3d1d7abf3b09c739a6ca9 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Nov 2023 21:06:00 -0700 Subject: [PATCH 1/5] Simplify IME support --- crates/editor2/src/editor.rs | 4 +- crates/editor2/src/element.rs | 19 +-- crates/gpui2/src/element.rs | 40 +++--- crates/gpui2/src/gpui2.rs | 4 +- crates/gpui2/src/input.rs | 106 ++++++++++++++ crates/gpui2/src/platform.rs | 10 +- crates/gpui2/src/window.rs | 16 ++- crates/gpui2/src/window_input_handler.rs | 167 ----------------------- 8 files changed, 148 insertions(+), 218 deletions(-) create mode 100644 crates/gpui2/src/input.rs delete mode 100644 crates/gpui2/src/window_input_handler.rs diff --git a/crates/editor2/src/editor.rs b/crates/editor2/src/editor.rs index 2fe35bb1f68e325ca9be8e40272b9bede754c4b0..43af9466b03c8d7f2ed9f2f03ed05cd1b6de5d57 100644 --- a/crates/editor2/src/editor.rs +++ b/crates/editor2/src/editor.rs @@ -9565,7 +9565,7 @@ impl Render for Editor { impl InputHandler for Editor { fn text_for_range( - &self, + &mut self, range_utf16: Range, cx: &mut ViewContext, ) -> Option { @@ -9578,7 +9578,7 @@ impl InputHandler for Editor { ) } - fn selected_text_range(&self, cx: &mut ViewContext) -> Option> { + fn selected_text_range(&mut self, cx: &mut ViewContext) -> Option> { // Prevent the IME menu from appearing when holding down an alphabetic key // while input is disabled. if !self.input_enabled { diff --git a/crates/editor2/src/element.rs b/crates/editor2/src/element.rs index 0e53aa449d4d5bcc13626cf014f92511776f7481..3e77a66936443aa872fc6f196fb71257bfede82f 100644 --- a/crates/editor2/src/element.rs +++ b/crates/editor2/src/element.rs @@ -17,11 +17,10 @@ use collections::{BTreeMap, HashMap}; use gpui::{ black, hsla, point, px, relative, size, transparent_black, Action, AnyElement, BorrowAppContext, BorrowWindow, Bounds, ContentMask, Corners, DispatchContext, DispatchPhase, - Edges, Element, ElementId, Entity, FocusHandle, GlobalElementId, Hsla, InputHandler, - InputHandlerView, KeyDownEvent, KeyListener, KeyMatch, Line, LineLayout, Modifiers, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, ScrollWheelEvent, - ShapedGlyph, Size, Style, TextRun, TextStyle, TextSystem, ViewContext, WindowContext, - WrappedLineLayout, + Edges, Element, ElementId, ElementInputHandler, Entity, FocusHandle, GlobalElementId, Hsla, + InputHandler, KeyDownEvent, KeyListener, KeyMatch, Line, LineLayout, Modifiers, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, ScrollWheelEvent, ShapedGlyph, Size, + Style, TextRun, TextStyle, TextSystem, ViewContext, WindowContext, WrappedLineLayout, }; use itertools::Itertools; use language::language_settings::ShowWhitespaceSetting; @@ -2517,16 +2516,10 @@ impl Element for EditorElement { self.paint_gutter(gutter_bounds, &layout, editor, cx); } self.paint_text(text_bounds, &layout, editor, cx); + let input_handler = ElementInputHandler::new(bounds, cx); + cx.handle_input(&editor.focus_handle, input_handler); }); } - - fn handle_text_input<'a>( - &self, - editor: &'a mut Editor, - cx: &mut ViewContext, - ) -> Option<(Box, &'a FocusHandle)> { - Some((Box::new(cx.view()), &editor.focus_handle)) - } } // impl EditorElement { diff --git a/crates/gpui2/src/element.rs b/crates/gpui2/src/element.rs index a6067eb68d68168e73991941678b2280b8115546..de9788a9a0ca72d8f16c441c0322e5e1921ebc90 100644 --- a/crates/gpui2/src/element.rs +++ b/crates/gpui2/src/element.rs @@ -1,7 +1,4 @@ -use crate::{ - BorrowWindow, Bounds, ElementId, FocusHandle, InputHandlerView, LayoutId, Pixels, ViewContext, - WindowInputHandler, -}; +use crate::{BorrowWindow, Bounds, ElementId, LayoutId, Pixels, ViewContext}; use derive_more::{Deref, DerefMut}; pub(crate) use smallvec::SmallVec; use std::{any::Any, mem}; @@ -34,14 +31,6 @@ pub trait Element { element_state: &mut Self::ElementState, cx: &mut ViewContext, ); - - fn handle_text_input<'a>( - &self, - _view_state: &'a mut V, - _cx: &mut ViewContext, - ) -> Option<(Box, &'a FocusHandle)> { - None - } } #[derive(Deref, DerefMut, Default, Clone, Debug, Eq, PartialEq, Hash)] @@ -165,18 +154,21 @@ where mut frame_state, } => { let bounds = cx.layout_bounds(layout_id); - if let Some((input_handler, focus_handle)) = - self.element.handle_text_input(view_state, cx) - { - if focus_handle.is_focused(cx) { - cx.window.requested_input_handler = Some(Box::new(WindowInputHandler { - cx: cx.app.this.clone(), - window: cx.window_handle(), - input_handler, - element_bounds: bounds, - })); - } - } + // if let Some((input_handler, focus_handle)) = + // self.element.handle_text_input(view_state, cx) + // { + // todo!() + // // cx.handle_input(&focus_handle, Box::new()) + + // // if focus_handle.is_focused(cx) { + // // cx.window.requested_input_handler = Some(Box::new(WindowInputHandler { + // // cx: cx.app.this.clone(), + // // window: cx.window_handle(), + // // input_handler, + // // element_bounds: bounds, + // // })); + // // } + // } if let Some(id) = self.element.id() { cx.with_element_state(id, |element_state, cx| { let mut element_state = element_state.unwrap(); diff --git a/crates/gpui2/src/gpui2.rs b/crates/gpui2/src/gpui2.rs index 91e41417351293cf69866a85e61cd9f57a43a488..86c052845687a72df1f9bc8952bb145f478a143e 100644 --- a/crates/gpui2/src/gpui2.rs +++ b/crates/gpui2/src/gpui2.rs @@ -9,6 +9,7 @@ mod executor; mod focusable; mod geometry; mod image_cache; +mod input; mod interactive; mod keymap; mod platform; @@ -24,7 +25,6 @@ mod text_system; mod util; mod view; mod window; -mod window_input_handler; mod private { /// A mechanism for restricting implementations of a trait to only those in GPUI. @@ -45,6 +45,7 @@ pub use focusable::*; pub use geometry::*; pub use gpui2_macros::*; pub use image_cache::*; +pub use input::*; pub use interactive::*; pub use keymap::*; pub use platform::*; @@ -66,7 +67,6 @@ pub use text_system::*; pub use util::arc_cow::ArcCow; pub use view::*; pub use window::*; -pub use window_input_handler::*; use derive_more::{Deref, DerefMut}; use std::{ diff --git a/crates/gpui2/src/input.rs b/crates/gpui2/src/input.rs new file mode 100644 index 0000000000000000000000000000000000000000..8d9e9b01ad6626c57dc2eb940527e00d52f2a5b9 --- /dev/null +++ b/crates/gpui2/src/input.rs @@ -0,0 +1,106 @@ +use crate::{AsyncWindowContext, Bounds, Pixels, PlatformInputHandler, View, ViewContext}; +use std::ops::Range; + +pub trait InputHandler: 'static + Sized { + fn text_for_range(&mut self, range: Range, cx: &mut ViewContext) + -> Option; + fn selected_text_range(&mut self, cx: &mut ViewContext) -> Option>; + fn marked_text_range(&self, cx: &mut ViewContext) -> Option>; + fn unmark_text(&mut self, cx: &mut ViewContext); + fn replace_text_in_range( + &mut self, + range: Option>, + text: &str, + cx: &mut ViewContext, + ); + fn replace_and_mark_text_in_range( + &mut self, + range: Option>, + new_text: &str, + new_selected_range: Option>, + cx: &mut ViewContext, + ); + fn bounds_for_range( + &mut self, + range_utf16: Range, + element_bounds: Bounds, + cx: &mut ViewContext, + ) -> Option>; +} + +pub struct ElementInputHandler { + view: View, + element_bounds: Bounds, + cx: AsyncWindowContext, +} + +impl ElementInputHandler { + pub fn new(element_bounds: Bounds, cx: &mut ViewContext) -> Self { + ElementInputHandler { + view: cx.view(), + element_bounds, + cx: cx.to_async(), + } + } +} + +impl PlatformInputHandler for ElementInputHandler { + fn selected_text_range(&mut self) -> Option> { + self.view + .update(&mut self.cx, |view, cx| view.selected_text_range(cx)) + .ok() + .flatten() + } + + fn marked_text_range(&mut self) -> Option> { + self.view + .update(&mut self.cx, |view, cx| view.marked_text_range(cx)) + .ok() + .flatten() + } + + fn text_for_range(&mut self, range_utf16: Range) -> Option { + self.view + .update(&mut self.cx, |view, cx| { + view.text_for_range(range_utf16, cx) + }) + .ok() + .flatten() + } + + fn replace_text_in_range(&mut self, replacement_range: Option>, text: &str) { + self.view + .update(&mut self.cx, |view, cx| { + view.replace_text_in_range(replacement_range, text, cx) + }) + .ok(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range: Option>, + ) { + self.view + .update(&mut self.cx, |view, cx| { + view.replace_and_mark_text_in_range(range_utf16, new_text, new_selected_range, cx) + }) + .ok(); + } + + fn unmark_text(&mut self) { + self.view + .update(&mut self.cx, |view, cx| view.unmark_text(cx)) + .ok(); + } + + fn bounds_for_range(&mut self, range_utf16: Range) -> Option> { + self.view + .update(&mut self.cx, |view, cx| { + view.bounds_for_range(range_utf16, self.element_bounds, cx) + }) + .ok() + .flatten() + } +} diff --git a/crates/gpui2/src/platform.rs b/crates/gpui2/src/platform.rs index 5ebb12b64d174103dc078ee3aa433761ca5033f7..8b49addec9cc6caf4f7b06ca8d23223d34535ef5 100644 --- a/crates/gpui2/src/platform.rs +++ b/crates/gpui2/src/platform.rs @@ -293,10 +293,10 @@ impl From for etagere::AllocId { } } -pub trait PlatformInputHandler { - fn selected_text_range(&self) -> Option>; - fn marked_text_range(&self) -> Option>; - fn text_for_range(&self, range_utf16: Range) -> Option; +pub trait PlatformInputHandler: 'static { + fn selected_text_range(&mut self) -> Option>; + fn marked_text_range(&mut self) -> Option>; + fn text_for_range(&mut self, range_utf16: Range) -> Option; fn replace_text_in_range(&mut self, replacement_range: Option>, text: &str); fn replace_and_mark_text_in_range( &mut self, @@ -305,7 +305,7 @@ pub trait PlatformInputHandler { new_selected_range: Option>, ); fn unmark_text(&mut self); - fn bounds_for_range(&self, range_utf16: Range) -> Option>; + fn bounds_for_range(&mut self, range_utf16: Range) -> Option>; } #[derive(Debug)] diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index 1daebf184c1f67e8af712ed8326e355ba65db22a..354c98813f017b8cb20c7998ecba0318919d0d46 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -211,7 +211,6 @@ pub struct Window { default_prevented: bool, mouse_position: Point, requested_cursor_style: Option, - pub(crate) requested_input_handler: Option>, scale_factor: f32, bounds: WindowBounds, bounds_observers: SubscriberSet<(), AnyObserver>, @@ -236,6 +235,7 @@ pub(crate) struct Frame { content_mask_stack: Vec>, element_offset_stack: Vec>, focus_stack: Vec, + input_handler: Option>, } impl Window { @@ -311,7 +311,6 @@ impl Window { default_prevented: true, mouse_position, requested_cursor_style: None, - requested_input_handler: None, scale_factor, bounds, bounds_observers: SubscriberSet::new(), @@ -1048,9 +1047,6 @@ impl<'a> WindowContext<'a> { .take() .unwrap_or(CursorStyle::Arrow); self.platform.set_cursor_style(cursor_style); - if let Some(handler) = self.window.requested_input_handler.take() { - self.window.platform_window.set_input_handler(handler); - } self.window.dirty = false; } @@ -2174,6 +2170,16 @@ impl<'a, V: 'static> ViewContext<'a, V> { }) }); } + + pub fn handle_input( + &mut self, + focus_handle: &FocusHandle, + input_handler: impl PlatformInputHandler, + ) { + if focus_handle.is_focused(self) { + self.window.current_frame.input_handler = Some(Box::new(input_handler)); + } + } } impl ViewContext<'_, V> diff --git a/crates/gpui2/src/window_input_handler.rs b/crates/gpui2/src/window_input_handler.rs deleted file mode 100644 index f3ff33f3c03457dcca78b60a6c586787fddf9d74..0000000000000000000000000000000000000000 --- a/crates/gpui2/src/window_input_handler.rs +++ /dev/null @@ -1,167 +0,0 @@ -use crate::{ - AnyWindowHandle, AppCell, Bounds, Context, Pixels, PlatformInputHandler, View, ViewContext, - WindowContext, -}; -use std::{ops::Range, rc::Weak}; - -pub struct WindowInputHandler { - pub cx: Weak, - pub input_handler: Box, - pub window: AnyWindowHandle, - pub element_bounds: Bounds, -} - -pub trait InputHandlerView { - fn text_for_range(&self, range: Range, cx: &mut WindowContext) -> Option; - fn selected_text_range(&self, cx: &mut WindowContext) -> Option>; - fn marked_text_range(&self, cx: &mut WindowContext) -> Option>; - fn unmark_text(&self, cx: &mut WindowContext); - fn replace_text_in_range( - &self, - range: Option>, - text: &str, - cx: &mut WindowContext, - ); - fn replace_and_mark_text_in_range( - &self, - range: Option>, - new_text: &str, - new_selected_range: Option>, - cx: &mut WindowContext, - ); - fn bounds_for_range( - &self, - range_utf16: std::ops::Range, - element_bounds: crate::Bounds, - cx: &mut WindowContext, - ) -> Option>; -} - -pub trait InputHandler: Sized { - fn text_for_range(&self, range: Range, cx: &mut ViewContext) -> Option; - fn selected_text_range(&self, cx: &mut ViewContext) -> Option>; - fn marked_text_range(&self, cx: &mut ViewContext) -> Option>; - fn unmark_text(&mut self, cx: &mut ViewContext); - fn replace_text_in_range( - &mut self, - range: Option>, - text: &str, - cx: &mut ViewContext, - ); - fn replace_and_mark_text_in_range( - &mut self, - range: Option>, - new_text: &str, - new_selected_range: Option>, - cx: &mut ViewContext, - ); - fn bounds_for_range( - &mut self, - range_utf16: std::ops::Range, - element_bounds: crate::Bounds, - cx: &mut ViewContext, - ) -> Option>; -} - -impl InputHandlerView for View { - fn text_for_range(&self, range: Range, cx: &mut WindowContext) -> Option { - self.update(cx, |this, cx| this.text_for_range(range, cx)) - } - - fn selected_text_range(&self, cx: &mut WindowContext) -> Option> { - self.update(cx, |this, cx| this.selected_text_range(cx)) - } - - fn marked_text_range(&self, cx: &mut WindowContext) -> Option> { - self.update(cx, |this, cx| this.marked_text_range(cx)) - } - - fn unmark_text(&self, cx: &mut WindowContext) { - self.update(cx, |this, cx| this.unmark_text(cx)) - } - - fn replace_text_in_range( - &self, - range: Option>, - text: &str, - cx: &mut WindowContext, - ) { - self.update(cx, |this, cx| this.replace_text_in_range(range, text, cx)) - } - - fn replace_and_mark_text_in_range( - &self, - range: Option>, - new_text: &str, - new_selected_range: Option>, - cx: &mut WindowContext, - ) { - self.update(cx, |this, cx| { - this.replace_and_mark_text_in_range(range, new_text, new_selected_range, cx) - }) - } - - fn bounds_for_range( - &self, - range_utf16: std::ops::Range, - element_bounds: crate::Bounds, - cx: &mut WindowContext, - ) -> Option> { - self.update(cx, |this, cx| { - this.bounds_for_range(range_utf16, element_bounds, cx) - }) - } -} - -impl PlatformInputHandler for WindowInputHandler { - fn selected_text_range(&self) -> Option> { - self.update(|handler, cx| handler.selected_text_range(cx)) - .flatten() - } - - fn marked_text_range(&self) -> Option> { - self.update(|handler, cx| handler.marked_text_range(cx)) - .flatten() - } - - fn text_for_range(&self, range_utf16: Range) -> Option { - self.update(|handler, cx| handler.text_for_range(range_utf16, cx)) - .flatten() - } - - fn replace_text_in_range(&mut self, replacement_range: Option>, text: &str) { - self.update(|handler, cx| handler.replace_text_in_range(replacement_range, text, cx)); - } - - fn replace_and_mark_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - new_selected_range: Option>, - ) { - self.update(|handler, cx| { - handler.replace_and_mark_text_in_range(range_utf16, new_text, new_selected_range, cx) - }); - } - - fn unmark_text(&mut self) { - self.update(|handler, cx| handler.unmark_text(cx)); - } - - fn bounds_for_range(&self, range_utf16: Range) -> Option> { - self.update(|handler, cx| handler.bounds_for_range(range_utf16, self.element_bounds, cx)) - .flatten() - } -} - -impl WindowInputHandler { - fn update( - &self, - f: impl FnOnce(&dyn InputHandlerView, &mut WindowContext) -> R, - ) -> Option { - let cx = self.cx.upgrade()?; - let mut cx = cx.borrow_mut(); - cx.update_window(self.window, |_, cx| f(&*self.input_handler, cx)) - .ok() - } -} From 8278a0735437cae7e5c152e0503e7fd1bf83cf81 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Nov 2023 21:43:14 -0700 Subject: [PATCH 2/5] Actually set the input handler --- crates/gpui2/src/window.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index 354c98813f017b8cb20c7998ecba0318919d0d46..b72793b99869f32c4131b67e608b62b57c433d87 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -235,7 +235,6 @@ pub(crate) struct Frame { content_mask_stack: Vec>, element_offset_stack: Vec>, focus_stack: Vec, - input_handler: Option>, } impl Window { @@ -2177,7 +2176,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { input_handler: impl PlatformInputHandler, ) { if focus_handle.is_focused(self) { - self.window.current_frame.input_handler = Some(Box::new(input_handler)); + self.window + .platform_window + .set_input_handler(Box::new(input_handler)); } } } From 7c922ad6ee1c71200c324db9bf60dc1f23f52bbf Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Nov 2023 21:49:21 -0700 Subject: [PATCH 3/5] Remove comments --- crates/gpui2/src/element.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/crates/gpui2/src/element.rs b/crates/gpui2/src/element.rs index de9788a9a0ca72d8f16c441c0322e5e1921ebc90..8fdc17de07296d95def915f3a605f3988913eb2a 100644 --- a/crates/gpui2/src/element.rs +++ b/crates/gpui2/src/element.rs @@ -154,21 +154,6 @@ where mut frame_state, } => { let bounds = cx.layout_bounds(layout_id); - // if let Some((input_handler, focus_handle)) = - // self.element.handle_text_input(view_state, cx) - // { - // todo!() - // // cx.handle_input(&focus_handle, Box::new()) - - // // if focus_handle.is_focused(cx) { - // // cx.window.requested_input_handler = Some(Box::new(WindowInputHandler { - // // cx: cx.app.this.clone(), - // // window: cx.window_handle(), - // // input_handler, - // // element_bounds: bounds, - // // })); - // // } - // } if let Some(id) = self.element.id() { cx.with_element_state(id, |element_state, cx| { let mut element_state = element_state.unwrap(); From d52c5646b451ae27475b3ebb3db0e3e5d772cc56 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 8 Nov 2023 22:03:26 -0700 Subject: [PATCH 4/5] Add docs --- crates/gpui2/src/input.rs | 8 ++++++++ crates/gpui2/src/window.rs | 3 +++ 2 files changed, 11 insertions(+) diff --git a/crates/gpui2/src/input.rs b/crates/gpui2/src/input.rs index 8d9e9b01ad6626c57dc2eb940527e00d52f2a5b9..d768ce946a157cbb0e7015cc10afa922e1f1e844 100644 --- a/crates/gpui2/src/input.rs +++ b/crates/gpui2/src/input.rs @@ -1,6 +1,10 @@ use crate::{AsyncWindowContext, Bounds, Pixels, PlatformInputHandler, View, ViewContext}; use std::ops::Range; +/// Implement this trait to allow views to handle textual input when implementing an editor, field, etc. +/// +/// Once your view `V` implements this trait, you can use it to construct an [ElementInputHandler]. +/// This input handler can then be assigned during paint by calling [WindowContext::handle_input]. pub trait InputHandler: 'static + Sized { fn text_for_range(&mut self, range: Range, cx: &mut ViewContext) -> Option; @@ -28,6 +32,8 @@ pub trait InputHandler: 'static + Sized { ) -> Option>; } +/// The canonical implementation of `PlatformInputHandler`. Call `WindowContext::handle_input` +/// with an instance during your element's paint. pub struct ElementInputHandler { view: View, element_bounds: Bounds, @@ -35,6 +41,8 @@ pub struct ElementInputHandler { } impl ElementInputHandler { + /// Used in [Element::paint] with the element's bounds and a view context for its + /// containing view. pub fn new(element_bounds: Bounds, cx: &mut ViewContext) -> Self { ElementInputHandler { view: cx.view(), diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index b72793b99869f32c4131b67e608b62b57c433d87..29980a486b7c6ed9977efe6931d5fab41cd3f73b 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -2170,6 +2170,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { }); } + /// Set an input handler, such as [ElementInputHandler], which interfaces with the + /// platform to receive textual input with proper integration with concerns such + /// as IME interactions. pub fn handle_input( &mut self, focus_handle: &FocusHandle, From 7888dc4592b4e9879626c423377cadfc22967c20 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 8 Nov 2023 21:23:31 -0800 Subject: [PATCH 5/5] Add notifications2 --- Cargo.lock | 21 + Cargo.toml | 1 + crates/collab2/Cargo.toml | 4 +- .../src/tests/channel_message_tests.rs | 204 ++++---- crates/collab2/src/tests/channel_tests.rs | 2 + .../collab2/src/tests/notification_tests.rs | 320 ++++++------ .../src/tests/random_channel_buffer_tests.rs | 8 - .../src/tests/randomized_test_helpers.rs | 2 +- crates/collab2/src/tests/test_server.rs | 17 +- crates/notifications2/Cargo.toml | 42 ++ .../notifications2/src/notification_store2.rs | 466 ++++++++++++++++++ 11 files changed, 803 insertions(+), 284 deletions(-) create mode 100644 crates/notifications2/Cargo.toml create mode 100644 crates/notifications2/src/notification_store2.rs diff --git a/Cargo.lock b/Cargo.lock index 52a4bae21f7d2676245a7d88b0986ea8bb00904d..ded64052c8e165e4b4ca3955e3382a38874d88bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1748,6 +1748,7 @@ dependencies = [ "lsp2", "nanoid", "node_runtime", + "notifications2", "parking_lot 0.11.2", "pretty_assertions", "project2", @@ -5484,6 +5485,26 @@ dependencies = [ "util", ] +[[package]] +name = "notifications2" +version = "0.1.0" +dependencies = [ + "anyhow", + "channel2", + "client2", + "clock", + "collections", + "db2", + "feature_flags2", + "gpui2", + "rpc2", + "settings2", + "sum_tree", + "text2", + "time", + "util", +] + [[package]] name = "ntapi" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index 5f4bebfe49371fd66636640eb4c24ee3e422c7b8..1b8081d06639c8ff611e2b5228cbfee1e6005b4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ members = [ "crates/multi_buffer2", "crates/node_runtime", "crates/notifications", + "crates/notifications2", "crates/outline", "crates/picker", "crates/picker2", diff --git a/crates/collab2/Cargo.toml b/crates/collab2/Cargo.toml index fe050a2aa84ec4a034da023cc1db7486beac18d0..4ce0a843f5a0cad6409ea8a3c1740c2ca5d9c610 100644 --- a/crates/collab2/Cargo.toml +++ b/crates/collab2/Cargo.toml @@ -72,10 +72,8 @@ fs = { package = "fs2", path = "../fs2", features = ["test-support"] } git = { package = "git3", path = "../git3", features = ["test-support"] } live_kit_client = { package = "live_kit_client2", path = "../live_kit_client2", features = ["test-support"] } lsp = { package = "lsp2", path = "../lsp2", features = ["test-support"] } - node_runtime = { path = "../node_runtime" } -#todo!(notifications) -#notifications = { path = "../notifications", features = ["test-support"] } +notifications = { package = "notifications2", path = "../notifications2", features = ["test-support"] } project = { package = "project2", path = "../project2", features = ["test-support"] } rpc = { package = "rpc2", path = "../rpc2", features = ["test-support"] } diff --git a/crates/collab2/src/tests/channel_message_tests.rs b/crates/collab2/src/tests/channel_message_tests.rs index 4d030dd679800f3d84c7decbdeaf284fc1ad152f..f5da0e3ee6fc85016dafee4b0e6d01c3ec738520 100644 --- a/crates/collab2/src/tests/channel_message_tests.rs +++ b/crates/collab2/src/tests/channel_message_tests.rs @@ -1,115 +1,115 @@ use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; -use channel::{ChannelChat, ChannelMessageId}; +use channel::{ChannelChat, ChannelMessageId, MessageParams}; use gpui::{BackgroundExecutor, Model, TestAppContext}; +use rpc::Notification; -// todo!(notifications) -// #[gpui::test] -// async fn test_basic_channel_messages( -// executor: BackgroundExecutor, -// mut cx_a: &mut TestAppContext, -// mut cx_b: &mut TestAppContext, -// mut cx_c: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// let client_c = server.create_client(cx_c, "user_c").await; +#[gpui::test] +async fn test_basic_channel_messages( + executor: BackgroundExecutor, + mut cx_a: &mut TestAppContext, + mut cx_b: &mut TestAppContext, + mut cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; -// let channel_id = server -// .make_channel( -// "the-channel", -// None, -// (&client_a, cx_a), -// &mut [(&client_b, cx_b), (&client_c, cx_c)], -// ) -// .await; + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; -// let channel_chat_a = client_a -// .channel_store() -// .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) -// .await -// .unwrap(); -// let channel_chat_b = client_b -// .channel_store() -// .update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx)) -// .await -// .unwrap(); + let channel_chat_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + let channel_chat_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); -// let message_id = channel_chat_a -// .update(cx_a, |c, cx| { -// c.send_message( -// MessageParams { -// text: "hi @user_c!".into(), -// mentions: vec![(3..10, client_c.id())], -// }, -// cx, -// ) -// .unwrap() -// }) -// .await -// .unwrap(); -// channel_chat_a -// .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap()) -// .await -// .unwrap(); + let message_id = channel_chat_a + .update(cx_a, |c, cx| { + c.send_message( + MessageParams { + text: "hi @user_c!".into(), + mentions: vec![(3..10, client_c.id())], + }, + cx, + ) + .unwrap() + }) + .await + .unwrap(); + channel_chat_a + .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap()) + .await + .unwrap(); -// executor.run_until_parked(); -// channel_chat_b -// .update(cx_b, |c, cx| c.send_message("three".into(), cx).unwrap()) -// .await -// .unwrap(); + executor.run_until_parked(); + channel_chat_b + .update(cx_b, |c, cx| c.send_message("three".into(), cx).unwrap()) + .await + .unwrap(); -// executor.run_until_parked(); + executor.run_until_parked(); -// let channel_chat_c = client_c -// .channel_store() -// .update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx)) -// .await -// .unwrap(); + let channel_chat_c = client_c + .channel_store() + .update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); -// for (chat, cx) in [ -// (&channel_chat_a, &mut cx_a), -// (&channel_chat_b, &mut cx_b), -// (&channel_chat_c, &mut cx_c), -// ] { -// chat.update(*cx, |c, _| { -// assert_eq!( -// c.messages() -// .iter() -// .map(|m| (m.body.as_str(), m.mentions.as_slice())) -// .collect::>(), -// vec![ -// ("hi @user_c!", [(3..10, client_c.id())].as_slice()), -// ("two", &[]), -// ("three", &[]) -// ], -// "results for user {}", -// c.client().id(), -// ); -// }); -// } - -// client_c.notification_store().update(cx_c, |store, _| { -// assert_eq!(store.notification_count(), 2); -// assert_eq!(store.unread_notification_count(), 1); -// assert_eq!( -// store.notification_at(0).unwrap().notification, -// Notification::ChannelMessageMention { -// message_id, -// sender_id: client_a.id(), -// channel_id, -// } -// ); -// assert_eq!( -// store.notification_at(1).unwrap().notification, -// Notification::ChannelInvitation { -// channel_id, -// channel_name: "the-channel".to_string(), -// inviter_id: client_a.id() -// } -// ); -// }); -// } + for (chat, cx) in [ + (&channel_chat_a, &mut cx_a), + (&channel_chat_b, &mut cx_b), + (&channel_chat_c, &mut cx_c), + ] { + chat.update(*cx, |c, _| { + assert_eq!( + c.messages() + .iter() + .map(|m| (m.body.as_str(), m.mentions.as_slice())) + .collect::>(), + vec![ + ("hi @user_c!", [(3..10, client_c.id())].as_slice()), + ("two", &[]), + ("three", &[]) + ], + "results for user {}", + c.client().id(), + ); + }); + } + + client_c.notification_store().update(cx_c, |store, _| { + assert_eq!(store.notification_count(), 2); + assert_eq!(store.unread_notification_count(), 1); + assert_eq!( + store.notification_at(0).unwrap().notification, + Notification::ChannelMessageMention { + message_id, + sender_id: client_a.id(), + channel_id, + } + ); + assert_eq!( + store.notification_at(1).unwrap().notification, + Notification::ChannelInvitation { + channel_id, + channel_name: "the-channel".to_string(), + inviter_id: client_a.id() + } + ); + }); +} #[gpui::test] async fn test_rejoin_channel_chat( diff --git a/crates/collab2/src/tests/channel_tests.rs b/crates/collab2/src/tests/channel_tests.rs index 31c092bd08cae75c0780cf2ac57fd627cf24185f..8ce5d99b80d3c630a81181e5f03f78d385186a10 100644 --- a/crates/collab2/src/tests/channel_tests.rs +++ b/crates/collab2/src/tests/channel_tests.rs @@ -1128,6 +1128,8 @@ async fn test_channel_link_notifications( .await .unwrap(); + executor.run_until_parked(); + // the members-only channel is still shown for c, but hidden for b assert_channels_list_shape( client_b.channel_store(), diff --git a/crates/collab2/src/tests/notification_tests.rs b/crates/collab2/src/tests/notification_tests.rs index 021591ee09fabe602513c912e1ac84be6cc48b98..f6066e64092a80250b39c3bc7a1759568c2ba937 100644 --- a/crates/collab2/src/tests/notification_tests.rs +++ b/crates/collab2/src/tests/notification_tests.rs @@ -1,160 +1,160 @@ -//todo!(notifications) -// use crate::tests::TestServer; -// use gpui::{executor::Deterministic, TestAppContext}; -// use notifications::NotificationEvent; -// use parking_lot::Mutex; -// use rpc::{proto, Notification}; -// use std::sync::Arc; - -// #[gpui::test] -// async fn test_notifications( -// deterministic: Arc, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// deterministic.forbid_parking(); -// let mut server = TestServer::start(&deterministic).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; - -// let notification_events_a = Arc::new(Mutex::new(Vec::new())); -// let notification_events_b = Arc::new(Mutex::new(Vec::new())); -// client_a.notification_store().update(cx_a, |_, cx| { -// let events = notification_events_a.clone(); -// cx.subscribe(&cx.handle(), move |_, _, event, _| { -// events.lock().push(event.clone()); -// }) -// .detach() -// }); -// client_b.notification_store().update(cx_b, |_, cx| { -// let events = notification_events_b.clone(); -// cx.subscribe(&cx.handle(), move |_, _, event, _| { -// events.lock().push(event.clone()); -// }) -// .detach() -// }); - -// // Client A sends a contact request to client B. -// client_a -// .user_store() -// .update(cx_a, |store, cx| store.request_contact(client_b.id(), cx)) -// .await -// .unwrap(); - -// // Client B receives a contact request notification and responds to the -// // request, accepting it. -// deterministic.run_until_parked(); -// client_b.notification_store().update(cx_b, |store, cx| { -// assert_eq!(store.notification_count(), 1); -// assert_eq!(store.unread_notification_count(), 1); - -// let entry = store.notification_at(0).unwrap(); -// assert_eq!( -// entry.notification, -// Notification::ContactRequest { -// sender_id: client_a.id() -// } -// ); -// assert!(!entry.is_read); -// assert_eq!( -// ¬ification_events_b.lock()[0..], -// &[ -// NotificationEvent::NewNotification { -// entry: entry.clone(), -// }, -// NotificationEvent::NotificationsUpdated { -// old_range: 0..0, -// new_count: 1 -// } -// ] -// ); - -// store.respond_to_notification(entry.notification.clone(), true, cx); -// }); - -// // Client B sees the notification is now read, and that they responded. -// deterministic.run_until_parked(); -// client_b.notification_store().read_with(cx_b, |store, _| { -// assert_eq!(store.notification_count(), 1); -// assert_eq!(store.unread_notification_count(), 0); - -// let entry = store.notification_at(0).unwrap(); -// assert!(entry.is_read); -// assert_eq!(entry.response, Some(true)); -// assert_eq!( -// ¬ification_events_b.lock()[2..], -// &[ -// NotificationEvent::NotificationRead { -// entry: entry.clone(), -// }, -// NotificationEvent::NotificationsUpdated { -// old_range: 0..1, -// new_count: 1 -// } -// ] -// ); -// }); - -// // Client A receives a notification that client B accepted their request. -// client_a.notification_store().read_with(cx_a, |store, _| { -// assert_eq!(store.notification_count(), 1); -// assert_eq!(store.unread_notification_count(), 1); - -// let entry = store.notification_at(0).unwrap(); -// assert_eq!( -// entry.notification, -// Notification::ContactRequestAccepted { -// responder_id: client_b.id() -// } -// ); -// assert!(!entry.is_read); -// }); - -// // Client A creates a channel and invites client B to be a member. -// let channel_id = client_a -// .channel_store() -// .update(cx_a, |store, cx| { -// store.create_channel("the-channel", None, cx) -// }) -// .await -// .unwrap(); -// client_a -// .channel_store() -// .update(cx_a, |store, cx| { -// store.invite_member(channel_id, client_b.id(), proto::ChannelRole::Member, cx) -// }) -// .await -// .unwrap(); - -// // Client B receives a channel invitation notification and responds to the -// // invitation, accepting it. -// deterministic.run_until_parked(); -// client_b.notification_store().update(cx_b, |store, cx| { -// assert_eq!(store.notification_count(), 2); -// assert_eq!(store.unread_notification_count(), 1); - -// let entry = store.notification_at(0).unwrap(); -// assert_eq!( -// entry.notification, -// Notification::ChannelInvitation { -// channel_id, -// channel_name: "the-channel".to_string(), -// inviter_id: client_a.id() -// } -// ); -// assert!(!entry.is_read); - -// store.respond_to_notification(entry.notification.clone(), true, cx); -// }); - -// // Client B sees the notification is now read, and that they responded. -// deterministic.run_until_parked(); -// client_b.notification_store().read_with(cx_b, |store, _| { -// assert_eq!(store.notification_count(), 2); -// assert_eq!(store.unread_notification_count(), 0); - -// let entry = store.notification_at(0).unwrap(); -// assert!(entry.is_read); -// assert_eq!(entry.response, Some(true)); -// }); -// } +use std::sync::Arc; + +use gpui::{BackgroundExecutor, TestAppContext}; +use notifications::NotificationEvent; +use parking_lot::Mutex; +use rpc::{proto, Notification}; + +use crate::tests::TestServer; + +#[gpui::test] +async fn test_notifications( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let notification_events_a = Arc::new(Mutex::new(Vec::new())); + let notification_events_b = Arc::new(Mutex::new(Vec::new())); + client_a.notification_store().update(cx_a, |_, cx| { + let events = notification_events_a.clone(); + cx.subscribe(&cx.handle(), move |_, _, event, _| { + events.lock().push(event.clone()); + }) + .detach() + }); + client_b.notification_store().update(cx_b, |_, cx| { + let events = notification_events_b.clone(); + cx.subscribe(&cx.handle(), move |_, _, event, _| { + events.lock().push(event.clone()); + }) + .detach() + }); + + // Client A sends a contact request to client B. + client_a + .user_store() + .update(cx_a, |store, cx| store.request_contact(client_b.id(), cx)) + .await + .unwrap(); + + // Client B receives a contact request notification and responds to the + // request, accepting it. + executor.run_until_parked(); + client_b.notification_store().update(cx_b, |store, cx| { + assert_eq!(store.notification_count(), 1); + assert_eq!(store.unread_notification_count(), 1); + + let entry = store.notification_at(0).unwrap(); + assert_eq!( + entry.notification, + Notification::ContactRequest { + sender_id: client_a.id() + } + ); + assert!(!entry.is_read); + assert_eq!( + ¬ification_events_b.lock()[0..], + &[ + NotificationEvent::NewNotification { + entry: entry.clone(), + }, + NotificationEvent::NotificationsUpdated { + old_range: 0..0, + new_count: 1 + } + ] + ); + + store.respond_to_notification(entry.notification.clone(), true, cx); + }); + + // Client B sees the notification is now read, and that they responded. + executor.run_until_parked(); + client_b.notification_store().read_with(cx_b, |store, _| { + assert_eq!(store.notification_count(), 1); + assert_eq!(store.unread_notification_count(), 0); + + let entry = store.notification_at(0).unwrap(); + assert!(entry.is_read); + assert_eq!(entry.response, Some(true)); + assert_eq!( + ¬ification_events_b.lock()[2..], + &[ + NotificationEvent::NotificationRead { + entry: entry.clone(), + }, + NotificationEvent::NotificationsUpdated { + old_range: 0..1, + new_count: 1 + } + ] + ); + }); + + // Client A receives a notification that client B accepted their request. + client_a.notification_store().read_with(cx_a, |store, _| { + assert_eq!(store.notification_count(), 1); + assert_eq!(store.unread_notification_count(), 1); + + let entry = store.notification_at(0).unwrap(); + assert_eq!( + entry.notification, + Notification::ContactRequestAccepted { + responder_id: client_b.id() + } + ); + assert!(!entry.is_read); + }); + + // Client A creates a channel and invites client B to be a member. + let channel_id = client_a + .channel_store() + .update(cx_a, |store, cx| { + store.create_channel("the-channel", None, cx) + }) + .await + .unwrap(); + client_a + .channel_store() + .update(cx_a, |store, cx| { + store.invite_member(channel_id, client_b.id(), proto::ChannelRole::Member, cx) + }) + .await + .unwrap(); + + // Client B receives a channel invitation notification and responds to the + // invitation, accepting it. + executor.run_until_parked(); + client_b.notification_store().update(cx_b, |store, cx| { + assert_eq!(store.notification_count(), 2); + assert_eq!(store.unread_notification_count(), 1); + + let entry = store.notification_at(0).unwrap(); + assert_eq!( + entry.notification, + Notification::ChannelInvitation { + channel_id, + channel_name: "the-channel".to_string(), + inviter_id: client_a.id() + } + ); + assert!(!entry.is_read); + + store.respond_to_notification(entry.notification.clone(), true, cx); + }); + + // Client B sees the notification is now read, and that they responded. + executor.run_until_parked(); + client_b.notification_store().read_with(cx_b, |store, _| { + assert_eq!(store.notification_count(), 2); + assert_eq!(store.unread_notification_count(), 0); + + let entry = store.notification_at(0).unwrap(); + assert!(entry.is_read); + assert_eq!(entry.response, Some(true)); + }); +} diff --git a/crates/collab2/src/tests/random_channel_buffer_tests.rs b/crates/collab2/src/tests/random_channel_buffer_tests.rs index 01f8daa5d2fe3b466ddd1886ff89090e3c490ef8..14b5da028795ace3b0c779353f4a7cf092c954a5 100644 --- a/crates/collab2/src/tests/random_channel_buffer_tests.rs +++ b/crates/collab2/src/tests/random_channel_buffer_tests.rs @@ -220,14 +220,6 @@ impl RandomizedTest for RandomChannelBufferTest { Ok(()) } - async fn on_client_added(client: &Rc, cx: &mut TestAppContext) { - let channel_store = client.channel_store(); - while channel_store.read_with(cx, |store, _| store.channel_count() == 0) { - // todo!(notifications) - // channel_store.next_notification(cx).await; - } - } - async fn on_quiesce(server: &mut TestServer, clients: &mut [(Rc, TestAppContext)]) { let channels = server.app_state.db.all_channels().await.unwrap(); diff --git a/crates/collab2/src/tests/randomized_test_helpers.rs b/crates/collab2/src/tests/randomized_test_helpers.rs index ac63738a36605e351f488b6efbdfe7d73ac3d344..91bd9cf6f698b3a8c6d436b99e35eaefc771e89e 100644 --- a/crates/collab2/src/tests/randomized_test_helpers.rs +++ b/crates/collab2/src/tests/randomized_test_helpers.rs @@ -115,7 +115,7 @@ pub trait RandomizedTest: 'static + Sized { async fn initialize(server: &mut TestServer, users: &[UserTestPlan]); - async fn on_client_added(client: &Rc, cx: &mut TestAppContext); + async fn on_client_added(_client: &Rc, _cx: &mut TestAppContext) {} async fn on_quiesce(server: &mut TestServer, client: &mut [(Rc, TestAppContext)]); } diff --git a/crates/collab2/src/tests/test_server.rs b/crates/collab2/src/tests/test_server.rs index 76a587ffde3424c4dd795fb7834e79fd23225b38..d0ab917d68ec7f84090fdfb213839fcc2e800f64 100644 --- a/crates/collab2/src/tests/test_server.rs +++ b/crates/collab2/src/tests/test_server.rs @@ -17,6 +17,7 @@ use gpui::{BackgroundExecutor, Context, Model, TestAppContext, WindowHandle}; use language::LanguageRegistry; use node_runtime::FakeNodeRuntime; +use notifications::NotificationStore; use parking_lot::Mutex; use project::{Project, WorktreeId}; use rpc::{proto::ChannelRole, RECEIVE_TIMEOUT}; @@ -47,8 +48,7 @@ pub struct TestClient { pub username: String, pub app_state: Arc, channel_store: Model, - // todo!(notifications) - // notification_store: Model, + notification_store: Model, state: RefCell, } @@ -234,8 +234,7 @@ impl TestServer { audio::init((), cx); call::init(client.clone(), user_store.clone(), cx); channel::init(&client, user_store.clone(), cx); - //todo(notifications) - // notifications::init(client.clone(), user_store, cx); + notifications::init(client.clone(), user_store, cx); }); client @@ -247,8 +246,7 @@ impl TestServer { app_state, username: name.to_string(), channel_store: cx.read(ChannelStore::global).clone(), - // todo!(notifications) - // notification_store: cx.read(NotificationStore::global).clone(), + notification_store: cx.read(NotificationStore::global).clone(), state: Default::default(), }; client.wait_for_current_user(cx).await; @@ -456,10 +454,9 @@ impl TestClient { &self.channel_store } - // todo!(notifications) - // pub fn notification_store(&self) -> &Model { - // &self.notification_store - // } + pub fn notification_store(&self) -> &Model { + &self.notification_store + } pub fn user_store(&self) -> &Model { &self.app_state.user_store diff --git a/crates/notifications2/Cargo.toml b/crates/notifications2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0720772a61feaee5770d0aff8d078716fb06b00b --- /dev/null +++ b/crates/notifications2/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "notifications2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/notification_store2.rs" +doctest = false + +[features] +test-support = [ + "channel/test-support", + "collections/test-support", + "gpui/test-support", + "rpc/test-support", +] + +[dependencies] +channel = { package = "channel2", path = "../channel2" } +client = { package = "client2", path = "../client2" } +clock = { path = "../clock" } +collections = { path = "../collections" } +db = { package = "db2", path = "../db2" } +feature_flags = { package = "feature_flags2", path = "../feature_flags2" } +gpui = { package = "gpui2", path = "../gpui2" } +rpc = { package = "rpc2", path = "../rpc2" } +settings = { package = "settings2", path = "../settings2" } +sum_tree = { path = "../sum_tree" } +text = { package = "text2", path = "../text2" } +util = { path = "../util" } + +anyhow.workspace = true +time.workspace = true + +[dev-dependencies] +client = { package = "client2", path = "../client2", features = ["test-support"] } +collections = { path = "../collections", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +rpc = { package = "rpc2", path = "../rpc2", features = ["test-support"] } +settings = { package = "settings2", path = "../settings2", features = ["test-support"] } +util = { path = "../util", features = ["test-support"] } diff --git a/crates/notifications2/src/notification_store2.rs b/crates/notifications2/src/notification_store2.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca474cd0c4cc8dc80d8cafcbba47fab53a0c4f3f --- /dev/null +++ b/crates/notifications2/src/notification_store2.rs @@ -0,0 +1,466 @@ +use anyhow::{Context, Result}; +use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; +use client::{Client, UserStore}; +use collections::HashMap; +use db::smol::stream::StreamExt; +use gpui::{AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, Task}; +use rpc::{proto, Notification, TypedEnvelope}; +use std::{ops::Range, sync::Arc}; +use sum_tree::{Bias, SumTree}; +use time::OffsetDateTime; +use util::ResultExt; + +pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { + let notification_store = cx.build_model(|cx| NotificationStore::new(client, user_store, cx)); + cx.set_global(notification_store); +} + +pub struct NotificationStore { + client: Arc, + user_store: Model, + channel_messages: HashMap, + channel_store: Model, + notifications: SumTree, + loaded_all_notifications: bool, + _watch_connection_status: Task>, + _subscriptions: Vec, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum NotificationEvent { + NotificationsUpdated { + old_range: Range, + new_count: usize, + }, + NewNotification { + entry: NotificationEntry, + }, + NotificationRemoved { + entry: NotificationEntry, + }, + NotificationRead { + entry: NotificationEntry, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct NotificationEntry { + pub id: u64, + pub notification: Notification, + pub timestamp: OffsetDateTime, + pub is_read: bool, + pub response: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct NotificationSummary { + max_id: u64, + count: usize, + unread_count: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct Count(usize); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct UnreadCount(usize); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct NotificationId(u64); + +impl NotificationStore { + pub fn global(cx: &AppContext) -> Model { + cx.global::>().clone() + } + + pub fn new( + client: Arc, + user_store: Model, + cx: &mut ModelContext, + ) -> Self { + let mut connection_status = client.status(); + let watch_connection_status = cx.spawn(|this, mut cx| async move { + while let Some(status) = connection_status.next().await { + let this = this.upgrade()?; + match status { + client::Status::Connected { .. } => { + if let Some(task) = this + .update(&mut cx, |this, cx| this.handle_connect(cx)) + .log_err()? + { + task.await.log_err()?; + } + } + _ => this + .update(&mut cx, |this, cx| this.handle_disconnect(cx)) + .log_err()?, + } + } + Some(()) + }); + + Self { + channel_store: ChannelStore::global(cx), + notifications: Default::default(), + loaded_all_notifications: false, + channel_messages: Default::default(), + _watch_connection_status: watch_connection_status, + _subscriptions: vec![ + client.add_message_handler(cx.weak_model(), Self::handle_new_notification), + client.add_message_handler(cx.weak_model(), Self::handle_delete_notification), + ], + user_store, + client, + } + } + + pub fn notification_count(&self) -> usize { + self.notifications.summary().count + } + + pub fn unread_notification_count(&self) -> usize { + self.notifications.summary().unread_count + } + + pub fn channel_message_for_id(&self, id: u64) -> Option<&ChannelMessage> { + self.channel_messages.get(&id) + } + + // Get the nth newest notification. + pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> { + let count = self.notifications.summary().count; + if ix >= count { + return None; + } + let ix = count - 1 - ix; + let mut cursor = self.notifications.cursor::(); + cursor.seek(&Count(ix), Bias::Right, &()); + cursor.item() + } + + pub fn notification_for_id(&self, id: u64) -> Option<&NotificationEntry> { + let mut cursor = self.notifications.cursor::(); + cursor.seek(&NotificationId(id), Bias::Left, &()); + if let Some(item) = cursor.item() { + if item.id == id { + return Some(item); + } + } + None + } + + pub fn load_more_notifications( + &self, + clear_old: bool, + cx: &mut ModelContext, + ) -> Option>> { + if self.loaded_all_notifications && !clear_old { + return None; + } + + let before_id = if clear_old { + None + } else { + self.notifications.first().map(|entry| entry.id) + }; + let request = self.client.request(proto::GetNotifications { before_id }); + Some(cx.spawn(|this, mut cx| async move { + let this = this + .upgrade() + .context("Notification store was dropped while loading notifications")?; + + let response = request.await?; + this.update(&mut cx, |this, _| { + this.loaded_all_notifications = response.done + })?; + Self::add_notifications( + this, + response.notifications, + AddNotificationsOptions { + is_new: false, + clear_old, + includes_first: response.done, + }, + cx, + ) + .await?; + Ok(()) + })) + } + + fn handle_connect(&mut self, cx: &mut ModelContext) -> Option>> { + self.notifications = Default::default(); + self.channel_messages = Default::default(); + cx.notify(); + self.load_more_notifications(true, cx) + } + + fn handle_disconnect(&mut self, cx: &mut ModelContext) { + cx.notify() + } + + async fn handle_new_notification( + this: Model, + envelope: TypedEnvelope, + _: Arc, + cx: AsyncAppContext, + ) -> Result<()> { + Self::add_notifications( + this, + envelope.payload.notification.into_iter().collect(), + AddNotificationsOptions { + is_new: true, + clear_old: false, + includes_first: false, + }, + cx, + ) + .await + } + + async fn handle_delete_notification( + this: Model, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.splice_notifications([(envelope.payload.notification_id, None)], false, cx); + Ok(()) + })? + } + + async fn add_notifications( + this: Model, + notifications: Vec, + options: AddNotificationsOptions, + mut cx: AsyncAppContext, + ) -> Result<()> { + let mut user_ids = Vec::new(); + let mut message_ids = Vec::new(); + + let notifications = notifications + .into_iter() + .filter_map(|message| { + Some(NotificationEntry { + id: message.id, + is_read: message.is_read, + timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) + .ok()?, + notification: Notification::from_proto(&message)?, + response: message.response, + }) + }) + .collect::>(); + if notifications.is_empty() { + return Ok(()); + } + + for entry in ¬ifications { + match entry.notification { + Notification::ChannelInvitation { inviter_id, .. } => { + user_ids.push(inviter_id); + } + Notification::ContactRequest { + sender_id: requester_id, + } => { + user_ids.push(requester_id); + } + Notification::ContactRequestAccepted { + responder_id: contact_id, + } => { + user_ids.push(contact_id); + } + Notification::ChannelMessageMention { + sender_id, + message_id, + .. + } => { + user_ids.push(sender_id); + message_ids.push(message_id); + } + } + } + + let (user_store, channel_store) = this.read_with(&cx, |this, _| { + (this.user_store.clone(), this.channel_store.clone()) + })?; + + user_store + .update(&mut cx, |store, cx| store.get_users(user_ids, cx))? + .await?; + let messages = channel_store + .update(&mut cx, |store, cx| { + store.fetch_channel_messages(message_ids, cx) + })? + .await?; + this.update(&mut cx, |this, cx| { + if options.clear_old { + cx.emit(NotificationEvent::NotificationsUpdated { + old_range: 0..this.notifications.summary().count, + new_count: 0, + }); + this.notifications = SumTree::default(); + this.channel_messages.clear(); + this.loaded_all_notifications = false; + } + + if options.includes_first { + this.loaded_all_notifications = true; + } + + this.channel_messages + .extend(messages.into_iter().filter_map(|message| { + if let ChannelMessageId::Saved(id) = message.id { + Some((id, message)) + } else { + None + } + })); + + this.splice_notifications( + notifications + .into_iter() + .map(|notification| (notification.id, Some(notification))), + options.is_new, + cx, + ); + }) + .log_err(); + + Ok(()) + } + + fn splice_notifications( + &mut self, + notifications: impl IntoIterator)>, + is_new: bool, + cx: &mut ModelContext<'_, NotificationStore>, + ) { + let mut cursor = self.notifications.cursor::<(NotificationId, Count)>(); + let mut new_notifications = SumTree::new(); + let mut old_range = 0..0; + + for (i, (id, new_notification)) in notifications.into_iter().enumerate() { + new_notifications.append(cursor.slice(&NotificationId(id), Bias::Left, &()), &()); + + if i == 0 { + old_range.start = cursor.start().1 .0; + } + + let old_notification = cursor.item(); + if let Some(old_notification) = old_notification { + if old_notification.id == id { + cursor.next(&()); + + if let Some(new_notification) = &new_notification { + if new_notification.is_read { + cx.emit(NotificationEvent::NotificationRead { + entry: new_notification.clone(), + }); + } + } else { + cx.emit(NotificationEvent::NotificationRemoved { + entry: old_notification.clone(), + }); + } + } + } else if let Some(new_notification) = &new_notification { + if is_new { + cx.emit(NotificationEvent::NewNotification { + entry: new_notification.clone(), + }); + } + } + + if let Some(notification) = new_notification { + new_notifications.push(notification, &()); + } + } + + old_range.end = cursor.start().1 .0; + let new_count = new_notifications.summary().count - old_range.start; + new_notifications.append(cursor.suffix(&()), &()); + drop(cursor); + + self.notifications = new_notifications; + cx.emit(NotificationEvent::NotificationsUpdated { + old_range, + new_count, + }); + } + + pub fn respond_to_notification( + &mut self, + notification: Notification, + response: bool, + cx: &mut ModelContext, + ) { + match notification { + Notification::ContactRequest { sender_id } => { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(sender_id, response, cx) + }) + .detach(); + } + Notification::ChannelInvitation { channel_id, .. } => { + self.channel_store + .update(cx, |store, cx| { + store.respond_to_channel_invite(channel_id, response, cx) + }) + .detach(); + } + _ => {} + } + } +} + +impl EventEmitter for NotificationStore {} + +impl sum_tree::Item for NotificationEntry { + type Summary = NotificationSummary; + + fn summary(&self) -> Self::Summary { + NotificationSummary { + max_id: self.id, + count: 1, + unread_count: if self.is_read { 0 } else { 1 }, + } + } +} + +impl sum_tree::Summary for NotificationSummary { + type Context = (); + + fn add_summary(&mut self, summary: &Self, _: &()) { + self.max_id = self.max_id.max(summary.max_id); + self.count += summary.count; + self.unread_count += summary.unread_count; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for NotificationId { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + debug_assert!(summary.max_id > self.0); + self.0 = summary.max_id; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for Count { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + self.0 += summary.count; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for UnreadCount { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + self.0 += summary.unread_count; + } +} + +struct AddNotificationsOptions { + is_new: bool, + clear_old: bool, + includes_first: bool, +}