1use client::{
2 channel::{Channel, ChannelEvent, ChannelList, ChannelMessage},
3 Client,
4};
5use editor::Editor;
6use gpui::{
7 action,
8 elements::*,
9 keymap::Binding,
10 platform::CursorStyle,
11 views::{ItemType, Select, SelectStyle},
12 AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View,
13 ViewContext, ViewHandle,
14};
15use postage::prelude::Stream;
16use std::sync::Arc;
17use time::{OffsetDateTime, UtcOffset};
18use util::{ResultExt, TryFutureExt};
19use workspace::{settings::SoftWrap, Settings};
20
21const MESSAGE_LOADING_THRESHOLD: usize = 50;
22
23pub struct ChatPanel {
24 rpc: Arc<Client>,
25 channel_list: ModelHandle<ChannelList>,
26 active_channel: Option<(ModelHandle<Channel>, Subscription)>,
27 message_list: ListState,
28 input_editor: ViewHandle<Editor>,
29 channel_select: ViewHandle<Select>,
30 local_timezone: UtcOffset,
31 _observe_status: Task<()>,
32}
33
34pub enum Event {}
35
36action!(Send);
37action!(LoadMoreMessages);
38
39pub fn init(cx: &mut MutableAppContext) {
40 cx.add_action(ChatPanel::send);
41 cx.add_action(ChatPanel::load_more_messages);
42
43 cx.add_bindings(vec![Binding::new("enter", Send, Some("ChatPanel"))]);
44}
45
46impl ChatPanel {
47 pub fn new(
48 rpc: Arc<Client>,
49 channel_list: ModelHandle<ChannelList>,
50 cx: &mut ViewContext<Self>,
51 ) -> Self {
52 let input_editor = cx.add_view(|cx| {
53 let mut editor =
54 Editor::auto_height(4, Some(|theme| theme.chat_panel.input_editor.clone()), cx);
55 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
56 editor
57 });
58 let channel_select = cx.add_view(|cx| {
59 let channel_list = channel_list.clone();
60 Select::new(0, cx, {
61 move |ix, item_type, is_hovered, cx| {
62 Self::render_channel_name(
63 &channel_list,
64 ix,
65 item_type,
66 is_hovered,
67 &cx.app_state::<Settings>().theme.chat_panel.channel_select,
68 cx,
69 )
70 }
71 })
72 .with_style(move |cx| {
73 let theme = &cx.app_state::<Settings>().theme.chat_panel.channel_select;
74 SelectStyle {
75 header: theme.header.container.clone(),
76 menu: theme.menu.clone(),
77 }
78 })
79 });
80
81 let mut message_list = ListState::new(0, Orientation::Bottom, 1000., {
82 let this = cx.weak_handle();
83 move |ix, cx| {
84 let this = this.upgrade(cx).unwrap().read(cx);
85 let message = this.active_channel.as_ref().unwrap().0.read(cx).message(ix);
86 this.render_message(message, cx)
87 }
88 });
89 message_list.set_scroll_handler(|visible_range, cx| {
90 if visible_range.start < MESSAGE_LOADING_THRESHOLD {
91 cx.dispatch_action(LoadMoreMessages);
92 }
93 });
94 let _observe_status = cx.spawn_weak(|this, mut cx| {
95 let mut status = rpc.status();
96 async move {
97 while let Some(_) = status.recv().await {
98 if let Some(this) = this.upgrade(&cx) {
99 this.update(&mut cx, |_, cx| cx.notify());
100 } else {
101 break;
102 }
103 }
104 }
105 });
106
107 let mut this = Self {
108 rpc,
109 channel_list,
110 active_channel: Default::default(),
111 message_list,
112 input_editor,
113 channel_select,
114 local_timezone: cx.platform().local_timezone(),
115 _observe_status,
116 };
117
118 this.init_active_channel(cx);
119 cx.observe(&this.channel_list, |this, _, cx| {
120 this.init_active_channel(cx);
121 })
122 .detach();
123 cx.observe(&this.channel_select, |this, channel_select, cx| {
124 let selected_ix = channel_select.read(cx).selected_index();
125 let selected_channel = this.channel_list.update(cx, |channel_list, cx| {
126 let available_channels = channel_list.available_channels()?;
127 let channel_id = available_channels.get(selected_ix)?.id;
128 channel_list.get_channel(channel_id, cx)
129 });
130 if let Some(selected_channel) = selected_channel {
131 this.set_active_channel(selected_channel, cx);
132 }
133 })
134 .detach();
135
136 this
137 }
138
139 fn init_active_channel(&mut self, cx: &mut ViewContext<Self>) {
140 let (active_channel, channel_count) = self.channel_list.update(cx, |list, cx| {
141 let channel_count;
142 let mut active_channel = None;
143
144 if let Some(available_channels) = list.available_channels() {
145 channel_count = available_channels.len();
146 if self.active_channel.is_none() {
147 if let Some(channel_id) = available_channels.first().map(|channel| channel.id) {
148 active_channel = list.get_channel(channel_id, cx);
149 }
150 }
151 } else {
152 channel_count = 0;
153 }
154
155 (active_channel, channel_count)
156 });
157
158 if let Some(active_channel) = active_channel {
159 self.set_active_channel(active_channel, cx);
160 } else {
161 self.message_list.reset(0);
162 self.active_channel = None;
163 }
164
165 self.channel_select.update(cx, |select, cx| {
166 select.set_item_count(channel_count, cx);
167 });
168 }
169
170 fn set_active_channel(&mut self, channel: ModelHandle<Channel>, cx: &mut ViewContext<Self>) {
171 if self.active_channel.as_ref().map(|e| &e.0) != Some(&channel) {
172 {
173 let channel = channel.read(cx);
174 self.message_list.reset(channel.message_count());
175 let placeholder = format!("Message #{}", channel.name());
176 self.input_editor.update(cx, move |editor, cx| {
177 editor.set_placeholder_text(placeholder, cx);
178 });
179 }
180 let subscription = cx.subscribe(&channel, Self::channel_did_change);
181 self.active_channel = Some((channel, subscription));
182 }
183 }
184
185 fn channel_did_change(
186 &mut self,
187 _: ModelHandle<Channel>,
188 event: &ChannelEvent,
189 cx: &mut ViewContext<Self>,
190 ) {
191 match event {
192 ChannelEvent::MessagesUpdated {
193 old_range,
194 new_count,
195 } => {
196 self.message_list.splice(old_range.clone(), *new_count);
197 }
198 }
199 cx.notify();
200 }
201
202 fn render_channel(&self, cx: &mut RenderContext<Self>) -> ElementBox {
203 let theme = &cx.app_state::<Settings>().theme;
204 Flex::column()
205 .with_child(
206 Container::new(ChildView::new(&self.channel_select).boxed())
207 .with_style(theme.chat_panel.channel_select.container)
208 .boxed(),
209 )
210 .with_child(self.render_active_channel_messages())
211 .with_child(self.render_input_box(cx))
212 .boxed()
213 }
214
215 fn render_active_channel_messages(&self) -> ElementBox {
216 let messages = if self.active_channel.is_some() {
217 List::new(self.message_list.clone()).boxed()
218 } else {
219 Empty::new().boxed()
220 };
221
222 Flexible::new(1., true, messages).boxed()
223 }
224
225 fn render_message(&self, message: &ChannelMessage, cx: &AppContext) -> ElementBox {
226 let now = OffsetDateTime::now_utc();
227 let settings = cx.app_state::<Settings>();
228 let theme = if message.is_pending() {
229 &settings.theme.chat_panel.pending_message
230 } else {
231 &settings.theme.chat_panel.message
232 };
233
234 Container::new(
235 Flex::column()
236 .with_child(
237 Flex::row()
238 .with_child(
239 Container::new(
240 Label::new(
241 message.sender.github_login.clone(),
242 theme.sender.text.clone(),
243 )
244 .boxed(),
245 )
246 .with_style(theme.sender.container)
247 .boxed(),
248 )
249 .with_child(
250 Container::new(
251 Label::new(
252 format_timestamp(message.timestamp, now, self.local_timezone),
253 theme.timestamp.text.clone(),
254 )
255 .boxed(),
256 )
257 .with_style(theme.timestamp.container)
258 .boxed(),
259 )
260 .boxed(),
261 )
262 .with_child(Text::new(message.body.clone(), theme.body.clone()).boxed())
263 .boxed(),
264 )
265 .with_style(theme.container)
266 .boxed()
267 }
268
269 fn render_input_box(&self, cx: &AppContext) -> ElementBox {
270 let theme = &cx.app_state::<Settings>().theme;
271 Container::new(ChildView::new(&self.input_editor).boxed())
272 .with_style(theme.chat_panel.input_editor.container)
273 .boxed()
274 }
275
276 fn render_channel_name(
277 channel_list: &ModelHandle<ChannelList>,
278 ix: usize,
279 item_type: ItemType,
280 is_hovered: bool,
281 theme: &theme::ChannelSelect,
282 cx: &AppContext,
283 ) -> ElementBox {
284 let channel = &channel_list.read(cx).available_channels().unwrap()[ix];
285 let theme = match (item_type, is_hovered) {
286 (ItemType::Header, _) => &theme.header,
287 (ItemType::Selected, false) => &theme.active_item,
288 (ItemType::Selected, true) => &theme.hovered_active_item,
289 (ItemType::Unselected, false) => &theme.item,
290 (ItemType::Unselected, true) => &theme.hovered_item,
291 };
292 Container::new(
293 Flex::row()
294 .with_child(
295 Container::new(Label::new("#".to_string(), theme.hash.text.clone()).boxed())
296 .with_style(theme.hash.container)
297 .boxed(),
298 )
299 .with_child(Label::new(channel.name.clone(), theme.name.clone()).boxed())
300 .boxed(),
301 )
302 .with_style(theme.container)
303 .boxed()
304 }
305
306 fn render_sign_in_prompt(&self, cx: &mut RenderContext<Self>) -> ElementBox {
307 let theme = cx.app_state::<Settings>().theme.clone();
308 let rpc = self.rpc.clone();
309 let this = cx.handle();
310
311 enum SignInPromptLabel {}
312
313 Align::new(
314 MouseEventHandler::new::<SignInPromptLabel, _, _>(0, cx, |mouse_state, _| {
315 Label::new(
316 "Sign in to use chat".to_string(),
317 if mouse_state.hovered {
318 theme.chat_panel.hovered_sign_in_prompt.clone()
319 } else {
320 theme.chat_panel.sign_in_prompt.clone()
321 },
322 )
323 .boxed()
324 })
325 .with_cursor_style(CursorStyle::PointingHand)
326 .on_click(move |cx| {
327 let rpc = rpc.clone();
328 let this = this.clone();
329 cx.spawn(|mut cx| async move {
330 if rpc.authenticate_and_connect(&cx).log_err().await.is_some() {
331 cx.update(|cx| {
332 if let Some(this) = this.upgrade(cx) {
333 if this.is_focused(cx) {
334 this.update(cx, |this, cx| cx.focus(&this.input_editor));
335 }
336 }
337 })
338 }
339 })
340 .detach();
341 })
342 .boxed(),
343 )
344 .boxed()
345 }
346
347 fn send(&mut self, _: &Send, cx: &mut ViewContext<Self>) {
348 if let Some((channel, _)) = self.active_channel.as_ref() {
349 let body = self.input_editor.update(cx, |editor, cx| {
350 let body = editor.text(cx);
351 editor.clear(cx);
352 body
353 });
354
355 if let Some(task) = channel
356 .update(cx, |channel, cx| channel.send_message(body, cx))
357 .log_err()
358 {
359 task.detach();
360 }
361 }
362 }
363
364 fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
365 if let Some((channel, _)) = self.active_channel.as_ref() {
366 channel.update(cx, |channel, cx| {
367 channel.load_more_messages(cx);
368 })
369 }
370 }
371}
372
373impl Entity for ChatPanel {
374 type Event = Event;
375}
376
377impl View for ChatPanel {
378 fn ui_name() -> &'static str {
379 "ChatPanel"
380 }
381
382 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
383 let element = if self.rpc.user_id().is_some() {
384 self.render_channel(cx)
385 } else {
386 self.render_sign_in_prompt(cx)
387 };
388 let theme = &cx.app_state::<Settings>().theme;
389 ConstrainedBox::new(
390 Container::new(element)
391 .with_style(theme.chat_panel.container)
392 .boxed(),
393 )
394 .with_min_width(150.)
395 .boxed()
396 }
397
398 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
399 if matches!(
400 *self.rpc.status().borrow(),
401 client::Status::Connected { .. }
402 ) {
403 cx.focus(&self.input_editor);
404 }
405 }
406}
407
408fn format_timestamp(
409 mut timestamp: OffsetDateTime,
410 mut now: OffsetDateTime,
411 local_timezone: UtcOffset,
412) -> String {
413 timestamp = timestamp.to_offset(local_timezone);
414 now = now.to_offset(local_timezone);
415
416 let today = now.date();
417 let date = timestamp.date();
418 let mut hour = timestamp.hour();
419 let mut part = "am";
420 if hour > 12 {
421 hour -= 12;
422 part = "pm";
423 }
424 if date == today {
425 format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
426 } else if date.next_day() == Some(today) {
427 format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
428 } else {
429 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
430 }
431}