diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..2419ad5b0a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.9 diff --git a/docs/hotkeys.md b/docs/hotkeys.md index 43bbf6125a..fa380e4843 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -26,6 +26,8 @@ |Scroll down|PgDn / J| |Go to bottom / Last message|End / G| |Trigger the selected entry|Enter / Space| +|Open recent conversations|^| +|Search recent conversations|Ctrl+f| ## Switching Messages View |Command|Key Combination| @@ -136,4 +138,3 @@ |View current message in browser|v| |Show/hide full rendered message|f| |Show/hide full raw message|r| - diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index a86420a364..db7c5c49fa 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -152,6 +152,11 @@ class KeyBinding(TypedDict): 'help_text': 'Send a message', 'key_category': 'compose_box', }, + 'OPEN_RECENT_CONVERSATIONS': { + 'keys': ['^'], + 'help_text': 'Open recent conversations', + 'key_category': 'navigation', + }, 'SAVE_AS_DRAFT': { 'keys': ['meta s'], 'help_text': 'Save current message as a draft', @@ -209,6 +214,11 @@ class KeyBinding(TypedDict): 'help_text': 'Toggle topics in a stream', 'key_category': 'stream_list', }, + "SEARCH_RECENT_CONVERSATIONS": { + "keys": ["ctrl+f"], + "help_text": "Search recent conversations", + "key_category": "navigation" + }, 'ALL_MESSAGES': { 'keys': ['a', 'esc'], 'help_text': 'View all messages', diff --git a/zulipterminal/config/ui_sizes.py b/zulipterminal/config/ui_sizes.py index cdc95a7433..9459885c21 100644 --- a/zulipterminal/config/ui_sizes.py +++ b/zulipterminal/config/ui_sizes.py @@ -3,7 +3,7 @@ """ TAB_WIDTH = 3 -LEFT_WIDTH = 31 +LEFT_WIDTH = 32 RIGHT_WIDTH = 23 # These affect popup width-scaling, dependent upon window width diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 61c5f79922..8cc711339c 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -593,6 +593,7 @@ def copy_to_clipboard(self, text: str, text_category: str) -> None: def _narrow_to(self, anchor: Optional[int], **narrow: Any) -> None: already_narrowed = self.model.set_narrow(**narrow) + self.view.middle_column.set_view("messages") if already_narrowed and anchor is None: return diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 1b8064fa87..9c3640099e 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -8,7 +8,7 @@ from collections import defaultdict from concurrent.futures import Future, ThreadPoolExecutor, wait from copy import deepcopy -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import ( Any, Callable, @@ -116,7 +116,6 @@ def __init__(self, controller: Any) -> None: self.recipients: FrozenSet[Any] = frozenset() self.index = initial_index self.last_unread_pm = None - self.user_id = -1 self.user_email = "" self.user_full_name = "" @@ -1095,6 +1094,94 @@ def get_other_subscribers_in_stream( if sub != self.user_id ] + def group_recent_conversations(self) -> List[Dict[str, Any]]: + """Return the 10 most recent stream conversations within the last 30 days.""" + + recency_threshold = datetime.now(timezone.utc) - timedelta(days=30) + recency_timestamp = int(recency_threshold.timestamp()) + + # Fetch the most recent messages without a narrow + request = { + "anchor": "newest", + "num_before": 100, + "num_after": 0, + "apply_markdown": True, + "narrow": json.dumps([]), # No narrow, fetch all messages + } + response = self.client.get_messages(message_filters=request) + if response["result"] != "success": + return [] + + # Debug: Inspect the fetched messages + messages = [ + self.modernize_message_response(msg) for msg in response["messages"] + ] # noqa: E501 + if messages: + most_recent_msg = max(messages, key=lambda x: x["timestamp"]) + datetime.fromtimestamp(most_recent_msg["timestamp"], tz=timezone.utc) + else: + return [] + + # Filter for stream messages within the last 30 days + stream_msgs = [ + m + for m in messages + if m["type"] == "stream" and m["timestamp"] >= recency_timestamp + ] + if not stream_msgs: + return [] + + # Sort messages by timestamp (most recent first) + stream_msgs.sort(key=lambda x: x["timestamp"], reverse=True) + + # Group messages by stream and topic + convos = defaultdict(list) + for msg in stream_msgs[:100]: + convos[(msg["stream_id"], msg["subject"])].append(msg) + + # Process conversations into the desired format + processed_conversations = [] + now = datetime.now(timezone.utc) + for (stream_id, topic), msg_list in sorted( + convos.items(), + key=lambda x: max(m["timestamp"] for m in x[1]), + reverse=True, + )[:30]: + stream_name = self.stream_name_from_id(stream_id) + topic_name = topic if topic else "(no topic)" + participants = set() + for msg in msg_list: + participants.add(msg["sender_full_name"]) + most_recent_msg = max(msg_list, key=lambda x: x["timestamp"]) + timestamp = most_recent_msg["timestamp"] + conv_time = datetime.fromtimestamp(timestamp, tz=timezone.utc) + delta = now - conv_time + + # Format the time difference with the specified precision + total_seconds = int(delta.total_seconds()) + if total_seconds < 60: # Less than 1 minute + time_str = "just now" + elif total_seconds < 3600: # Less than 1 hour + minutes = total_seconds // 60 + time_str = f"{minutes} min{'s' if minutes != 1 else ''} ago" + elif total_seconds < 86400: # Less than 24 hours + hours = total_seconds // 3600 + time_str = f"{hours} hour{'s' if hours != 1 else ''} ago" + else: # More than 24 hours + days = delta.days + time_str = f"{days} day{'s' if days != 1 else ''} ago" + + processed_conversations.append( + { + "stream": stream_name, + "topic": topic_name, + "participants": list(participants), + "time": time_str, + } + ) + + return processed_conversations + def _clean_and_order_custom_profile_data( self, custom_profile_data: Dict[str, CustomFieldValue] ) -> List[CustomProfileData]: @@ -1418,6 +1505,9 @@ def stream_id_from_name(self, stream_name: str) -> int: return stream_id raise RuntimeError("Invalid stream name.") + def stream_name_from_id(self, stream_id: int) -> str: + return self.stream_dict[stream_id]["name"] + def stream_access_type(self, stream_id: int) -> StreamAccessType: if stream_id not in self.stream_dict: raise RuntimeError("Invalid stream id.") diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index d0785bd928..f05dcfa132 100644 --- a/zulipterminal/ui.py +++ b/zulipterminal/ui.py @@ -72,6 +72,7 @@ def middle_column_view(self) -> Any: self.middle_column = MiddleColumnView( self, self.model, self.write_box, self.search_box ) + return urwid.LineBox( self.middle_column, title="Messages", @@ -160,6 +161,14 @@ def footer_view(self) -> Any: text_header = self.get_random_help() return urwid.AttrWrap(urwid.Text(text_header), "footer") + def on_column_focus_changed(self, index: int) -> None: + if self.middle_column.current_view == self.message_view: + self.message_view.read_message() + elif ( + self.middle_column.current_view == self.middle_column.recent_convo_view + ): # noqa: E501 + self.middle_column.recent_convo_view.focus_restored() + def main_window(self) -> Any: self.left_panel, self.left_tab = self.left_column_view() self.center_panel = self.middle_column_view() @@ -184,7 +193,8 @@ def main_window(self) -> Any: # NOTE: set_focus_changed_callback is actually called before the # focus is set, so the message is not read yet, it will be read when # the focus is changed again either vertically or horizontally. - self.body._contents.set_focus_changed_callback(self.message_view.read_message) + + self.body._contents.set_focus_changed_callback(self.on_column_focus_changed) title_text = " {full_name} ({email}) - {server_name} ({url}) ".format( full_name=self.model.user_full_name, @@ -213,7 +223,6 @@ def main_window(self) -> Any: def show_left_panel(self, *, visible: bool) -> None: if not self.controller.autohide: return - if visible: self.frame.body = urwid.Overlay( urwid.Columns( @@ -234,7 +243,6 @@ def show_left_panel(self, *, visible: bool) -> None: def show_right_panel(self, *, visible: bool) -> None: if not self.controller.autohide: return - if visible: self.frame.body = urwid.Overlay( urwid.Columns( @@ -273,6 +281,8 @@ def keypress(self, size: urwid_Box, key: str) -> Optional[str]: self.pm_button.activate(key) elif is_command_key("ALL_STARRED", key): self.starred_button.activate(key) + elif is_command_key("OPEN_RECENT_CONVERSATIONS", key): + self.time_button.activate(key) elif is_command_key("ALL_MENTIONS", key): self.mentioned_button.activate(key) elif is_command_key("SEARCH_PEOPLE", key): diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 8e67d79adf..4fac636359 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -24,6 +24,7 @@ MENTIONED_MESSAGES_MARKER, MUTE_MARKER, STARRED_MESSAGES_MARKER, + TIME_MENTION_MARKER, ) from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS, STREAM_ACCESS_TYPE from zulipterminal.helper import StreamData, hash_util_decode, process_media @@ -129,9 +130,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: class HomeButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = ( - f"All messages [{primary_display_key_for_command('ALL_MESSAGES')}]" - ) + button_text = f"All messages [{primary_display_key_for_command('ALL_MESSAGES')}]" # noqa: E501 super().__init__( controller=controller, @@ -145,7 +144,9 @@ def __init__(self, *, controller: Any, count: int) -> None: class PMButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = f"Direct messages [{primary_display_key_for_command('ALL_PM')}]" + button_text = ( + f"Direct messages [{primary_display_key_for_command('ALL_PM')}]" + ) super().__init__( controller=controller, @@ -159,9 +160,7 @@ def __init__(self, *, controller: Any, count: int) -> None: class MentionedButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = ( - f"Mentions [{primary_display_key_for_command('ALL_MENTIONS')}]" - ) + button_text = f"Mentions [{primary_display_key_for_command('ALL_MENTIONS')}]" # noqa: E501 super().__init__( controller=controller, @@ -173,10 +172,26 @@ def __init__(self, *, controller: Any, count: int) -> None: ) +class TimeMentionedButton(TopButton): + def __init__(self, *, controller: Any, count: int) -> None: + button_text = f"Recent Conversations [{primary_display_key_for_command('OPEN_RECENT_CONVERSATIONS')}]" # noqa: E501 + super().__init__( + controller=controller, + prefix_markup=("title", TIME_MENTION_MARKER), + label_markup=(None, button_text), + suffix_markup=("unread_count", f" ({count})" if count > 0 else ""), + show_function=self.show_recent_conversations, + count=count, + ) + + def show_recent_conversations(self) -> None: + self.controller.view.middle_column.set_view("recent") + + class StarredButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: button_text = ( - f"Starred messages [{primary_display_key_for_command('ALL_STARRED')}]" + f"Starred messages [{primary_display_key_for_command('ALL_STARRED')}]" ) super().__init__( diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 02b3afbd0b..bd4c100cc4 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -53,6 +53,7 @@ PMButton, StarredButton, StreamButton, + TimeMentionedButton, TopicButton, UserButton, ) @@ -80,6 +81,7 @@ def set_focus(self, position: int) -> None: def _set_focus(self, index: int) -> None: # This method is called when directly setting focus via # self.focus = focus_position + if not self: # type: ignore[truthy-bool] # Implemented in base class self._focus = 0 return @@ -113,7 +115,6 @@ def __init__(self, model: Any, view: Any) -> None: # Initialize for reference self.focus_msg = 0 self.log = ModListWalker(contents=self.main_view(), action=self.read_message) - super().__init__(self.log) self.set_focus(self.focus_msg) # if loading new/old messages - True @@ -304,6 +305,190 @@ def read_message(self, index: int = -1) -> None: self.model.mark_message_ids_as_read(read_msg_ids) +class RecentConversationsView(urwid.Frame): + def __init__(self, controller: Any) -> None: + self.controller = controller + self.model = controller.model + + self.conversations = self.model.group_recent_conversations() + self.all_conversations = self.conversations.copy() + self.search_lock = threading.Lock() + self.empty_search = False + + self.search_box = PanelSearchBox( + self, "SEARCH_RECENT_CONVERSATIONS", self.update_conversations + ) + search_header = urwid.Pile( + [self.search_box, urwid.Divider(SECTION_DIVIDER_LINE)] + ) + + self.log = urwid.SimpleFocusListWalker( + self._build_body_contents(self.conversations) + ) + list_box = urwid.ListBox(self.log) + + super().__init__(list_box, header=search_header) + + if len(self.log) > 1: + self.log.set_focus(1) + self.body.set_focus(1) + self.set_focus("body") + self.body.set_focus_valign("middle") + + def _build_body_contents( + self, conversations: List[Dict[str, Any]] + ) -> List[urwid.Widget]: + contents = [] + header = self._build_header_row() + contents.append(header) + + for idx, conv in enumerate(conversations): + row = self._build_conversation_row(conv, idx) + contents.append(row) + + return contents + + def focus_restored(self) -> None: + if self.focus_position is not None: + self.set_focus(self.focus_position) + self.controller.update_screen() + + def _build_header_row(self) -> urwid.Widget: + columns = [ + ("weight", 1, urwid.Text(("header", "Channel"))), + ("weight", 2, urwid.Text(("header", "Topic"))), + ("weight", 1, urwid.Text(("header", "Participants"))), + ("weight", 1, urwid.Text(("header", "Time"))), + ] + return urwid.Columns(columns, dividechars=1) + + def _build_conversation_row(self, conv: Dict[str, Any], idx: int) -> urwid.Widget: + stream = conv["stream"] + topic = conv["topic"] + participants = conv["participants"] + time = conv["time"] + + participant_text = ( + f"{len(participants)} users" + if len(participants) > 3 + else ", ".join(participants) + ) + + columns = [ + ("weight", 1, urwid.Text(f"#{stream}")), + ("weight", 2, urwid.Text(topic)), + ("weight", 1, urwid.Text(participant_text)), + ("weight", 1, urwid.Text(time)), + ] + row = urwid.Columns(columns, dividechars=1) + focus_style = "selected" + decorated_row = urwid.AttrMap(row, None, focus_style) + button = urwid.Button("", on_press=self._on_row_click, user_data=conv) + button._label = f"#{stream} / {topic}" # Fixed: Use a string + button._w = decorated_row + return button + + def _on_row_click(self, button: urwid.Button, conv: Dict[str, Any]) -> None: + stream = conv["stream"] + topic = conv["topic"] + self.controller.narrow_to_topic(stream_name=stream, topic_name=topic) + self.controller.view.middle_column.set_view("messages") + + @asynch + def update_conversations(self, search_box: Any, new_text: str) -> None: + if not self.controller.is_in_editor_mode(): + return + + with self.search_lock: + new_text = new_text.lower() + filtered_conversations = [ + conv + for conv in self.all_conversations + if ( + new_text in conv["stream"].lower() + or new_text in conv["topic"].lower() + or any(new_text in p.lower() for p in conv["participants"]) + ) + ] + + self.empty_search = len(filtered_conversations) == 0 + self.log.clear() + if not self.empty_search: + self.log.extend(self._build_body_contents(filtered_conversations)) + else: + self.log.extend([self.search_box.search_error]) + + if len(self.log) > 1: + self.log.set_focus(1) + + def mouse_event( + self, + size: Tuple[int, int], + event: str, + button: int, + col: int, + row: int, + focus: bool, + ) -> bool: + if event == "mouse press": + if button == 4: + for _ in range(5): + self.keypress(size, primary_key_for_command("GO_UP")) + return True + elif button == 5: + for _ in range(5): + self.keypress(size, primary_key_for_command("GO_DOWN")) + return True + return super().mouse_event(size, event, button, col, row, focus) + + def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]: + if is_command_key("SEARCH_RECENT_CONVERSATIONS", key): + self.set_focus("header") + self.search_box.set_caption(" ") + self.controller.enter_editor_mode_with(self.search_box) + return None + elif is_command_key("CLEAR_SEARCH", key): + self.search_box.reset_search_text() + self.log.clear() + self.log.extend(self._build_body_contents(self.all_conversations)) + self.set_focus("body") + if len(self.log) > 1: + self.log.set_focus(1) + self.controller.update_screen() + return None + elif is_command_key("GO_DOWN", key): + focused_widget, focused_position = self.log.get_focus() + if focused_position < len(self.log) - 1: + self.log.set_focus(focused_position + 1) + return None + elif is_command_key("GO_UP", key): + focused_widget, focused_position = self.log.get_focus() + if focused_position > 1: + self.log.set_focus(focused_position - 1) + return None + elif key == "enter": + focused_widget, focused_position = self.log.get_focus() + if focused_position > 0: + focused_widget._emit("click") + return None + elif is_command_key("ALL_MESSAGES", key): + self.controller.view.middle_column.set_view("messages") + return None + elif is_command_key("GO_RIGHT", key): + self.controller.view.show_right_panel(visible=True) + self.controller.view.body.set_focus(2) + self.set_focus("body") + self.controller.update_screen() + return None + elif is_command_key("GO_LEFT", key): + self.controller.view.show_left_panel(visible=True) + self.controller.view.body.set_focus(0) + self.set_focus("body") + self.controller.update_screen() + return None + return super().keypress(size, key) + + class StreamsViewDivider(urwid.Divider): """ A custom urwid.Divider to visually separate pinned and unpinned streams. @@ -553,83 +738,124 @@ def mouse_event( class MiddleColumnView(urwid.Frame): def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> None: - message_view = MessageView(model, view) self.model = model self.controller = model.controller self.view = view self.search_box = search_box - view.message_view = message_view - super().__init__(message_view, header=search_box, footer=write_box) + self.write_box = write_box + + self.message_view = MessageView(model, view) + view.message_view = self.message_view + self.recent_convo_view = RecentConversationsView(self.controller) + self.current_view = self.message_view + self.last_narrow = self.model.narrow + super().__init__(self.message_view, header=search_box, footer=write_box) + + def set_view(self, view_name: str) -> None: + if view_name == "recent": + self.current_view = self.recent_convo_view + header = None + else: + self.current_view = self.message_view + header = self.search_box + self.set_body(self.current_view) + self.set_header(header) + self.set_footer(self.write_box) + self.set_focus("body") + self.controller.update_screen() def update_message_list_status_markers(self) -> None: - for message_w in self.body.log: - message_box = message_w.original_widget + if isinstance(self.current_view, MessageView): + for message_w in self.body.log: + message_box = message_w.original_widget + message_box.update_message_author_status() + self.controller.update_screen() - message_box.update_message_author_status() + def check_narrow_and_switch_view(self) -> None: + """ + Check if the model's narrow has changed and switch to MessageView if necessary. + """ - self.controller.update_screen() + current_narrow = self.model.narrow + if ( + current_narrow != self.last_narrow + and self.current_view != self.message_view + ): + self.set_view("messages") + self.last_narrow = current_narrow + + def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]: + self.check_narrow_and_switch_view() - def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if self.focus_position in ["footer", "header"]: return super().keypress(size, key) elif is_command_key("SEARCH_MESSAGES", key): self.controller.enter_editor_mode_with(self.search_box) self.set_focus("header") - return key + return None - elif is_command_key("REPLY_MESSAGE", key): - self.body.keypress(size, key) - if self.footer.focus is not None: - self.set_focus("footer") - self.footer.focus_position = 1 - return key + elif is_command_key("OPEN_RECENT_CONVERSATIONS", key): + self.set_view("recent") + return None - elif is_command_key("STREAM_MESSAGE", key): - self.body.keypress(size, key) - # For new streams with no previous conversation. - if self.footer.focus is None: - stream_id = self.model.stream_id - stream_dict = self.model.stream_dict - if stream_id is None: - self.footer.stream_box_view(0) - else: - self.footer.stream_box_view(caption=stream_dict[stream_id]["name"]) + elif is_command_key("ALL_MESSAGES", key): + self.controller.narrow_to_all_messages() + self.set_view("messages") + return None + + elif is_command_key("ALL_PM", key): + self.controller.narrow_to_all_pm() + self.set_view("messages") + return None + + elif is_command_key("ALL_STARRED", key): + self.controller.narrow_to_all_starred() + self.set_view("messages") + return None + + elif is_command_key("ALL_MENTIONS", key): + self.controller.narrow_to_all_mentions() + self.set_view("messages") + return None + + elif is_command_key("PRIVATE_MESSAGE", key): + self.footer.private_box_view() self.set_focus("footer") self.footer.focus_position = 0 - return key + return None - elif is_command_key("REPLY_AUTHOR", key): - self.body.keypress(size, key) - if self.footer.focus is not None: - self.set_focus("footer") - self.footer.focus_position = 1 - return key + elif is_command_key("GO_LEFT", key): + self.view.show_left_panel(visible=True) + + elif is_command_key("GO_RIGHT", key): + self.view.show_right_panel(visible=True) elif is_command_key("NEXT_UNREAD_TOPIC", key): - # narrow to next unread topic - focus = self.view.message_view.focus narrow = self.model.narrow - if focus: - current_msg_id = focus.original_widget.message["id"] + if self.current_view == self.message_view and self.view.message_view.focus: + current_msg_id = self.view.message_view.focus.original_widget.message[ + "id" + ] stream_topic = self.model.next_unread_topic_from_message_id( current_msg_id ) - if stream_topic is None: - return key elif narrow[0][0] == "stream" and narrow[1][0] == "topic": stream_topic = self.model.next_unread_topic_from_message_id(None) else: - return key + stream_topic = self.model.next_unread_topic_from_message_id(None) + if stream_topic is None: + return key stream_id, topic = stream_topic self.controller.narrow_to_topic( stream_name=self.model.stream_dict[stream_id]["name"], topic_name=topic, ) - return key + self.set_view("messages") + return None + elif is_command_key("NEXT_UNREAD_PM", key): - # narrow to next unread pm pm = self.model.get_next_unread_pm() if pm is None: return key @@ -638,16 +864,116 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: recipient_emails=[email], contextual_message_id=pm, ) - elif is_command_key("PRIVATE_MESSAGE", key): - # Create new PM message - self.footer.private_box_view() + self.set_view("messages") + return None + + if hasattr(self.current_view, "keypress"): + result = self.current_view.keypress(size, key) + if result is None: + return None + + if ( + is_command_key("REPLY_MESSAGE", key) + or is_command_key("MENTION_REPLY", key) + or is_command_key("QUOTE_REPLY", key) + or is_command_key("REPLY_AUTHOR", key) + ): # 'r', 'enter', '@', '>', 'R' + if self.current_view != self.message_view: + self.set_view("messages") + if len(self.message_view.log) > 0: + self.message_view.set_focus(len(self.message_view.log) - 1) + self.current_view.keypress(size, key) + if self.footer.focus is not None: + self.set_focus("footer") + self.footer.focus_position = 1 + return None + + elif is_command_key("STREAM_MESSAGE", key): + if self.controller.is_in_editor_mode(): + self.controller.exit_editor_mode() + if self.current_view != self.message_view: + self.set_view("messages") + self.current_view.keypress(size, key) + if self.footer.focus is None: + stream_id = self.model.stream_id + stream_dict = self.model.stream_dict + if stream_id is None: + # Set to a default or the intended stream + default_stream_id = next(iter(stream_dict.keys()), 0) + self.model.stream_id = default_stream_id + stream_id = default_stream_id + try: + stream_data = stream_dict.get(stream_id, {}) + if not stream_data: + raise KeyError(f"No data for stream_id {stream_id}") + caption = stream_data.get("name", "Unknown Stream") + self.footer.stream_box_view(stream_id, caption=caption) + except KeyError: + self.footer.stream_box_view(0, caption="Unknown Stream") # Fallback self.set_focus("footer") self.footer.focus_position = 0 - return key - elif is_command_key("GO_LEFT", key): - self.view.show_left_panel(visible=True) - elif is_command_key("GO_RIGHT", key): - self.view.show_right_panel(visible=True) + return None + elif is_command_key("STREAM_NARROW", key): + if ( + self.current_view != self.message_view + or not self.view.message_view.focus + ): + return key + message = self.view.message_view.focus.original_widget.message + if message["type"] != "stream": + return key + self.controller.narrow_to_stream(stream_name=message["stream"]) + self.set_view("messages") + return None + + elif is_command_key("TOPIC_NARROW", key): + if ( + self.current_view != self.message_view + or not self.view.message_view.focus + ): + return key + message = self.view.message_view.focus.original_widget.message + if message["type"] != "stream": + return key + self.controller.narrow_to_topic( + stream_name=message["stream"], + topic_name=message["subject"], + ) + self.set_view("messages") + return None + + elif is_command_key("THUMBS_UP", key): + if ( + self.current_view != self.message_view + or not self.view.message_view.focus + ): + return key + message = self.view.message_view.focus.original_widget.message + self.controller.toggle_message_reaction(message["id"], "thumbs_up") + self.controller.update_screen() + return None + + elif is_command_key("TOGGLE_STAR_STATUS", key): + if ( + self.current_view != self.message_view + or not self.view.message_view.focus + ): + return key + message = self.view.message_view.focus.original_widget.message + self.controller.toggle_message_star_status(message["id"]) + self.controller.update_screen() + return None + + elif is_command_key("ADD_REACTION", key): + if ( + self.current_view != self.message_view + or not self.view.message_view.focus + ): + return key + message = self.view.message_view.focus.original_widget.message + self.controller.show_emoji_picker(message["id"]) + return None + return super().keypress(size, key) @@ -784,7 +1110,7 @@ def __init__(self, view: Any) -> None: self.stream_v = self.streams_view() self.is_in_topic_view = False - contents = [(4, self.menu_v), self.stream_v] + contents = [(5, self.menu_v), self.stream_v] super().__init__(contents) def menu_view(self) -> Any: @@ -794,6 +1120,10 @@ def menu_view(self) -> Any: count = self.model.unread_counts.get("all_pms", 0) self.view.pm_button = PMButton(controller=self.controller, count=count) + self.view.time_button = TimeMentionedButton( + controller=self.controller, count=count + ) + self.view.mentioned_button = MentionedButton( controller=self.controller, count=self.model.unread_counts["all_mentions"], @@ -807,9 +1137,11 @@ def menu_view(self) -> Any: menu_btn_list = [ self.view.home_button, self.view.pm_button, + self.view.time_button, self.view.mentioned_button, self.view.starred_button, ] + w = urwid.ListBox(urwid.SimpleFocusListWalker(menu_btn_list)) return w