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