From f6e0db4ce6cbf38ce915130cfbcc7f4f76cf519f Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Thu, 29 Aug 2019 18:59:36 +0100 Subject: [PATCH 1/8] Add indication to channel view when a message has an associated thread. --- app.py | 6 ++++-- sclack/component/message.py | 8 +++++++- sclack/components.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 70afea8..64e19e6 100755 --- a/app.py +++ b/app.py @@ -95,7 +95,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()) @@ -467,6 +466,8 @@ def render_message(self, message, channel_id=None): for reaction in message.get('reactions', []) ] + responses = ['1' for response in 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) diff --git a/sclack/component/message.py b/sclack/component/message.py index 1422dcd..cdf78a8 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 @@ -20,7 +21,7 @@ class Message(urwid.AttrMap): 'mark_read', ] - 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 +31,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)), diff --git a/sclack/components.py b/sclack/components.py index 6990ee5..b902a5c 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'] From fa8749d6fad6a890cf84d7384553ded74117d8c8 Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Thu, 29 Aug 2019 22:25:48 +0100 Subject: [PATCH 2/8] Add a new thread screen, accessible from a message with the 't' key --- app.py | 46 +++++++++++++++++++++++++++++++++++++ config.json | 5 ++-- sclack/component/message.py | 4 ++++ sclack/components.py | 2 +- sclack/store.py | 16 +++++++++++++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 64e19e6..cf9e9e1 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() @@ -408,6 +409,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 @@ -521,6 +523,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 @@ -663,6 +666,49 @@ def go_to_channel(self, channel_id): self.quick_switcher = None 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 + 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)) 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 cdf78a8..7ee9146 100644 --- a/sclack/component/message.py +++ b/sclack/component/message.py @@ -19,6 +19,7 @@ class Message(urwid.AttrMap): 'quit_application', 'set_insert_mode', 'mark_read', + 'toggle_thread', ] def __init__(self, ts, channel_id, user, text, indicators, reactions=(), attachments=(), responses=()): @@ -82,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 b902a5c..92cc511 100644 --- a/sclack/components.py +++ b/sclack/components.py @@ -901,7 +901,7 @@ class ThreadText(urwid.Text): def __init__(self, num_replies): color = "#" + shorten_hex('146BF7') markup = [ - (urwid.AttrSpec(color, 'h235'), '[Thread ({})]'.format(num_replies)) + (urwid.AttrSpec(color, 'h235'), 'Thread ({})'.format(num_replies)) ] super(ThreadText, self).__init__(markup) diff --git a/sclack/store.py b/sclack/store.py index d5bf306..d504b2d 100644 --- a/sclack/store.py +++ b/sclack/store.py @@ -8,6 +8,7 @@ def __init__(self): self.groups = [] self.stars = [] self.messages = [] + self.thread_messages = [] self.users = [] self.pin_count = 0 self.has_more = False @@ -81,6 +82,21 @@ 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. + """ + original = self.slack.api_call( + "conversations.history", + channel=channel_id, + latest=parent_ts, + inclusive=True, + limit=1 + ) + + if len(original['messages']) > 0: + self.state.thread_messages = original['messages'] + def is_valid_channel_id(self, channel_id): """ Check whether channel_id is valid From c95549d1472b97f81f59c671a18de9d8e36e6941 Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Thu, 29 Aug 2019 22:37:25 +0100 Subject: [PATCH 3/8] Correctly retrieve all replies when rendering a thread --- sclack/store.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/sclack/store.py b/sclack/store.py index d504b2d..8b49bac 100644 --- a/sclack/store.py +++ b/sclack/store.py @@ -86,16 +86,14 @@ def load_thread_messages(self, channel_id, parent_ts): """ Load all of the messages sent in reply to the message with the given timestamp. """ - original = self.slack.api_call( - "conversations.history", + replies = self.slack.api_call( + "conversations.replies", channel=channel_id, - latest=parent_ts, - inclusive=True, - limit=1 + ts=parent_ts, ) - if len(original['messages']) > 0: - self.state.thread_messages = original['messages'] + self.state.thread_messages = replies['messages'] + self.state.has_more = replies.get('has_more', False) def is_valid_channel_id(self, channel_id): """ From c1b8002a6381da34b17aa687869c892025e1c87d Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Thu, 29 Aug 2019 22:48:17 +0100 Subject: [PATCH 4/8] Add method to post to a thread --- app.py | 6 ++++++ sclack/store.py | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/app.py b/app.py index cf9e9e1..dc1f178 100755 --- a/app.py +++ b/app.py @@ -707,6 +707,7 @@ def toggle_thread(self, channel_id, parent_ts): 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): @@ -894,6 +895,11 @@ 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() else: channel = self.store.state.channel['id'] if message.strip() != '': diff --git a/sclack/store.py b/sclack/store.py index 8b49bac..06dcb12 100644 --- a/sclack/store.py +++ b/sclack/store.py @@ -9,6 +9,7 @@ def __init__(self): self.stars = [] self.messages = [] self.thread_messages = [] + self.thread_parent = None self.users = [] self.pin_count = 0 self.has_more = False @@ -233,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) From 8166533f9b4690c83c039fd76a32c17e7563c0a5 Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Thu, 29 Aug 2019 23:16:54 +0100 Subject: [PATCH 5/8] Add an indication to the user when you are viewing a thread --- app.py | 24 ++++++++++++++++++++++-- sclack/component/message.py | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index dc1f178..f804570 100755 --- a/app.py +++ b/app.py @@ -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( @@ -409,7 +408,6 @@ 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 @@ -553,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() @@ -664,6 +672,12 @@ 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 @@ -900,12 +914,18 @@ def submit_message(self, message): 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/sclack/component/message.py b/sclack/component/message.py index 7ee9146..45e169b 100644 --- a/sclack/component/message.py +++ b/sclack/component/message.py @@ -83,7 +83,7 @@ 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']: + elif key == keymap['toggle_thread'] or key == keymap['cursor_right']: urwid.emit_signal(self, 'toggle_thread', self.channel_id, self.ts) return True elif key == 'enter': From b45dced9e8086e208bf185a2bcab050f45d282f8 Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Thu, 29 Aug 2019 23:19:08 +0100 Subject: [PATCH 6/8] Add the thread key to the README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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" } } ``` From ad12c8da6abc7ccd6b0f9cf4fdf6159f6c9d09f1 Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Thu, 29 Aug 2019 23:47:40 +0100 Subject: [PATCH 7/8] Simplify storage of reply messages --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index f804570..ebdf13b 100755 --- a/app.py +++ b/app.py @@ -466,7 +466,7 @@ def render_message(self, message, channel_id=None): for reaction in message.get('reactions', []) ] - responses = ['1' for response in message.get('replies', [])] + responses = message.get('replies', []) attachments = [] for attachment in message.get('attachments', []): From b78ded4d9471c1c4e3d68b6ce1bd15822a498938 Mon Sep 17 00:00:00 2001 From: Peter Macgregor Date: Fri, 30 Aug 2019 16:59:45 +0100 Subject: [PATCH 8/8] Fix bug in channel initially loaded - set the channel ID on the messages --- app.py | 2 +- sclack/component/message.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index ebdf13b..0d8488e 100755 --- a/app.py +++ b/app.py @@ -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) diff --git a/sclack/component/message.py b/sclack/component/message.py index 45e169b..7ee9146 100644 --- a/sclack/component/message.py +++ b/sclack/component/message.py @@ -83,7 +83,7 @@ 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'] or key == keymap['cursor_right']: + elif key == keymap['toggle_thread']: urwid.emit_signal(self, 'toggle_thread', self.channel_id, self.ts) return True elif key == 'enter':