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