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