diff --git a/README.md b/README.md index 10b623a..fed4bbe 100644 --- a/README.md +++ b/README.md @@ -144,11 +144,12 @@ Focus on message and press r (or your custom shortcut) to get permali "go_to_sidebar": "esc", "open_quick_switcher": "ctrl k", "quit_application": "q", - "set_edit_topic_mode": "t", + "set_edit_topic_mode": "ctrl t", "set_insert_mode": "i", "yank_message": "y", "get_permalink": "r", - "set_snooze": "ctrl d" + "set_snooze": "ctrl d", + "toggle_thread": "t" } } ``` diff --git a/app.py b/app.py index 70afea8..0d8488e 100755 --- a/app.py +++ b/app.py @@ -62,6 +62,7 @@ def __init__(self, config): self.set_snooze_widget = None self.workspaces = list(config['workspaces'].items()) self.store = Store(self.workspaces, self.config) + self.showing_thread = False Store.instance = self.store urwid.set_encoding('UTF-8') sidebar = LoadingSideBar() @@ -95,7 +96,6 @@ def __init__(self, config): def sidebar_column(self): return self.columns.contents[0] - def start(self): self._loading = True loop.create_task(self.animate_loading()) @@ -294,7 +294,7 @@ def mount_chatbox(self, executor, channel): loop.run_in_executor(executor, self.store.load_channel, channel), loop.run_in_executor(executor, self.store.load_messages, channel) ) - messages = self.render_messages(self.store.state.messages) + messages = self.render_messages(self.store.state.messages, channel_id=channel) header = self.render_chatbox_header() self._loading = False self.sidebar.select_channel(channel) @@ -362,7 +362,6 @@ def go_to_profile(self, user_id): self.columns.contents.append((profile, ('given', 35, False))) def render_chatbox_header(self): - if self.store.state.channel['id'][0] == 'D': user = self.store.find_user_by_id(self.store.state.channel['user']) header = ChannelHeader( @@ -467,6 +466,8 @@ def render_message(self, message, channel_id=None): for reaction in message.get('reactions', []) ] + responses = message.get('replies', []) + attachments = [] for attachment in message.get('attachments', []): attachment_widget = Attachment( @@ -506,7 +507,8 @@ def render_message(self, message, channel_id=None): text, indicators, attachments=attachments, - reactions=reactions + reactions=reactions, + responses=responses ) self.lazy_load_images(files, message) @@ -519,6 +521,7 @@ def render_message(self, message, channel_id=None): urwid.connect_signal(message, 'quit_application', self.quit_application) urwid.connect_signal(message, 'set_insert_mode', self.set_insert_mode) urwid.connect_signal(message, 'mark_read', self.handle_mark_read) + urwid.connect_signal(message, 'toggle_thread', self.toggle_thread) return message @@ -548,6 +551,16 @@ def render_messages(self, messages, channel_id=None): previous_date = self.store.state.last_date last_read_datetime = datetime.fromtimestamp(float(self.store.state.channel.get('last_read', '0'))) today = datetime.today().date() + + # If we are viewing a thread, add a dummy 'message' to indicate this + # to the user. + if self.showing_thread: + _messages.append(self.render_message({ + 'text': "VIEWING THREAD", + 'ts': '0', + 'subtype': SCLACK_SUBTYPE, + })) + for message in messages: message_datetime = datetime.fromtimestamp(float(message['ts'])) message_date = message_datetime.date() @@ -659,8 +672,58 @@ def go_to_channel(self, channel_id): urwid.disconnect_signal(self.quick_switcher, 'go_to_channel', self.go_to_channel) self.urwid_loop.widget = self._body self.quick_switcher = None + + # We are not showing a thread - this needs to be reset as this method might be + # triggered from the sidebar while a thread is being shown. + self.showing_thread = False + + # Show the channel in the chatbox loop.create_task(self._go_to_channel(channel_id)) + @asyncio.coroutine + def _show_thread(self, channel_id, parent_ts): + """ + Display the requested thread in the chatbox + """ + with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: + yield from asyncio.gather( + loop.run_in_executor(executor, self.store.load_thread_messages, channel_id, parent_ts) + ) + self.store.state.last_date = None + + if len(self.store.state.thread_messages) == 0: + messages = self.render_messages([{ + 'text': "There was an error showing this thread :(", + 'ts': '0', + 'subtype': SCLACK_SUBTYPE, + }]) + else: + messages = self.render_messages(self.store.state.thread_messages, channel_id=channel_id) + + header = self.render_chatbox_header() + if self.is_chatbox_rendered: + self.chatbox.body.body[:] = messages + self.chatbox.header = header + self.chatbox.message_box.is_read_only = self.store.state.channel.get('is_read_only', False) + self.sidebar.select_channel(channel_id) + self.urwid_loop.set_alarm_in(0, self.scroll_messages) + + if len(self.store.state.messages) == 0: + self.go_to_sidebar() + else: + self.go_to_chatbox() + + def toggle_thread(self, channel_id, parent_ts): + if self.showing_thread: + # Currently showing a thread, return to the main channel + self.showing_thread = False + loop.create_task(self._go_to_channel(channel_id)) + else: + # Show the chosen thread + self.showing_thread = True + self.store.state.thread_parent = parent_ts + loop.create_task(self._show_thread(channel_id, parent_ts)) + def handle_set_snooze_time(self, snoozed_time): loop.create_task(self.dispatch_snooze_time(snoozed_time)) @@ -846,12 +909,23 @@ def submit_message(self, message): self.store.state.editing_widget.original_text = edit_result['text'] self.store.state.editing_widget.set_text(MarkdownText(edit_result['text'])) self.leave_edit_mode() + if self.showing_thread: + channel = self.store.state.channel['id'] + if message.strip() != '': + self.store.post_thread_message(channel, self.store.state.thread_parent, message) + self.leave_edit_mode() + + # Refresh the thread to make sure the new message immediately shows up + loop.create_task(self._show_thread(channel, self.store.state.thread_parent)) else: channel = self.store.state.channel['id'] if message.strip() != '': self.store.post_message(channel, message) self.leave_edit_mode() + # Refresh the channel to make sure the new message shows up + loop.create_task(self._go_to_channel(channel)) + def go_to_last_message(self): self.go_to_chatbox() self.chatbox.body.go_to_last_message() diff --git a/config.json b/config.json index 28181ff..3f102a8 100644 --- a/config.json +++ b/config.json @@ -13,12 +13,13 @@ "go_to_sidebar": "esc", "open_quick_switcher": "ctrl k", "quit_application": "q", - "set_edit_topic_mode": "t", + "set_edit_topic_mode": "ctrl t", "set_insert_mode": "i", "toggle_sidebar": "s", "yank_message": "y", "get_permalink": "r", - "set_snooze": "ctrl d" + "set_snooze": "ctrl d", + "toggle_thread": "t" }, "sidebar": { "width": 25, diff --git a/sclack/component/message.py b/sclack/component/message.py index 1422dcd..7ee9146 100644 --- a/sclack/component/message.py +++ b/sclack/component/message.py @@ -4,6 +4,7 @@ import pyperclip import webbrowser from sclack.store import Store +from sclack.components import ThreadText from sclack.component.time import Time @@ -18,9 +19,10 @@ class Message(urwid.AttrMap): 'quit_application', 'set_insert_mode', 'mark_read', + 'toggle_thread', ] - def __init__(self, ts, channel_id, user, text, indicators, reactions=(), attachments=()): + def __init__(self, ts, channel_id, user, text, indicators, reactions=(), attachments=(), responses=()): self.ts = ts self.channel_id = channel_id self.user_id = user.id @@ -30,10 +32,15 @@ def __init__(self, ts, channel_id, user, text, indicators, reactions=(), attachm main_column = [urwid.Columns([('pack', user), self.text_widget])] main_column.extend(attachments) self._file_index = len(main_column) + if reactions: main_column.append(urwid.Columns([ ('pack', reaction) for reaction in reactions ])) + + if responses: + main_column.append(ThreadText(len(responses))) + self.main_column = urwid.Pile(main_column) columns = [ ('fixed', 7, Time(ts)), @@ -76,6 +83,9 @@ def keypress(self, size, key): elif key == keymap['get_permalink']: # FIXME urwid.emit_signal(self, 'get_permalink', self, self.channel_id, self.ts) + elif key == keymap['toggle_thread']: + urwid.emit_signal(self, 'toggle_thread', self.channel_id, self.ts) + return True elif key == 'enter': browser_name = Store.instance.config['features']['browser'] diff --git a/sclack/components.py b/sclack/components.py index 6990ee5..92cc511 100644 --- a/sclack/components.py +++ b/sclack/components.py @@ -894,6 +894,18 @@ def __init__(self, id, name, color=None, is_app=False): super(User, self).__init__(markup) +class ThreadText(urwid.Text): + """ + A text element used to indicate the number of messages in a thread + """ + def __init__(self, num_replies): + color = "#" + shorten_hex('146BF7') + markup = [ + (urwid.AttrSpec(color, 'h235'), 'Thread ({})'.format(num_replies)) + ] + super(ThreadText, self).__init__(markup) + + class Workspace(urwid.AttrMap): __metaclass__ = urwid.MetaSignals signals = ['select_workspace'] diff --git a/sclack/store.py b/sclack/store.py index d5bf306..06dcb12 100644 --- a/sclack/store.py +++ b/sclack/store.py @@ -8,6 +8,8 @@ def __init__(self): self.groups = [] self.stars = [] self.messages = [] + self.thread_messages = [] + self.thread_parent = None self.users = [] self.pin_count = 0 self.has_more = False @@ -81,6 +83,19 @@ def load_messages(self, channel_id): self.state.pin_count = history['pin_count'] self.state.messages.reverse() + def load_thread_messages(self, channel_id, parent_ts): + """ + Load all of the messages sent in reply to the message with the given timestamp. + """ + replies = self.slack.api_call( + "conversations.replies", + channel=channel_id, + ts=parent_ts, + ) + + self.state.thread_messages = replies['messages'] + self.state.has_more = replies.get('has_more', False) + def is_valid_channel_id(self, channel_id): """ Check whether channel_id is valid @@ -219,6 +234,16 @@ def post_message(self, channel_id, message): text=message ) + def post_thread_message(self, channel_id, parent_ts, message): + return self.slack.api_call( + 'chat.postMessage', + channel=channel_id, + as_user=True, + link_name=True, + text=message, + thread_ts=parent_ts + ) + def get_presence(self, user_id): response = self.slack.api_call('users.getPresence', user=user_id)