From 1dca8e956a38b647c28531e35650e296a5f2fa3f Mon Sep 17 00:00:00 2001 From: sahithch5 Date: Sun, 23 Mar 2025 21:57:40 +0530 Subject: [PATCH 01/11] added recent conversations --- zulipterminal/cli/run.py | 1 + zulipterminal/config/keys.py | 10 + zulipterminal/config/ui_sizes.py | 2 +- zulipterminal/core.py | 4 +- zulipterminal/model.py | 56 ++++- zulipterminal/ui.py | 3 + zulipterminal/ui_tools/buttons.py | 25 +- zulipterminal/ui_tools/views.py | 368 +++++++++++++++++++++++++----- 8 files changed, 402 insertions(+), 67 deletions(-) diff --git a/zulipterminal/cli/run.py b/zulipterminal/cli/run.py index a5cce2cf2d..4a0f45e90c 100755 --- a/zulipterminal/cli/run.py +++ b/zulipterminal/cli/run.py @@ -696,3 +696,4 @@ def print_setting(setting: str, data: SettingData, suffix: str = "") -> None: if __name__ == "__main__": main() + \ No newline at end of file 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..5a783cf04a 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -47,6 +47,7 @@ StreamInfoView, StreamMembersView, UserInfoView, + ) from zulipterminal.version import ZT_VERSION @@ -593,10 +594,11 @@ 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 - + msg_id_list = self.model.get_message_ids_in_current_narrow() # If no messages are found in the current narrow diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 1b8064fa87..c474161833 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,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 = "" @@ -315,6 +314,7 @@ def set_narrow( frozenset(["pm_with"]): [["pm-with", pm_with]], frozenset(["starred"]): [["is", "starred"]], frozenset(["mentioned"]): [["is", "mentioned"]], + } for narrow_param, narrow in valid_narrows.items(): if narrow_param == selected_params: @@ -856,6 +856,7 @@ def modernize_message_response(message: Message) -> Message: message["topic_links"] = topic_links return message + def fetch_message_history( self, message_id: int @@ -1094,7 +1095,56 @@ def get_other_subscribers_in_stream( if stream["name"] == stream_name if sub != self.user_id ] + def group_recent_conversations(self) -> List[Dict[str, Any]]: + """Return the 10 most recent stream conversations.""" + # Filter for stream messages + stream_msgs = [m for m in self.index["messages"].values() if m["type"] == "stream"] + 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[:50]: # Limit to 50 recent messages + 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 + )[:10]: + # Map stream_id to stream name + + stream_name=self.stream_name_from_id(stream_id) + topic_name = topic if topic else "(no topic)" + + # Extract participants + participants = set() + for msg in msg_list: + participants.add(msg["sender_full_name"]) + + # Format timestamp (using the most recent message in the conversation) + 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 + if delta.days > 0: + time_str = f"{delta.days} days ago" + else: + hours = delta.seconds // 3600 + time_str = f"{hours} hours ago" if hours > 0 else "just now" + + 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]: @@ -1417,6 +1467,8 @@ def stream_id_from_name(self, stream_name: str) -> int: if stream["name"] == stream_name: 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: diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index d0785bd928..a8dcfbf72a 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", @@ -273,6 +274,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..99ba7b580c 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 @@ -130,7 +131,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')}]" + f"All messages [{primary_display_key_for_command('ALL_MESSAGES')}]" ) super().__init__( @@ -145,7 +146,7 @@ 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, @@ -160,7 +161,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')}]" + f"Mentions [{primary_display_key_for_command('ALL_MENTIONS')}]" ) super().__init__( @@ -171,12 +172,28 @@ def __init__(self, *, controller: Any, count: int) -> None: show_function=controller.narrow_to_all_mentions, count=count, ) +class TimeMentionedButton(TopButton): + def __init__(self, *, controller: Any, count: int) -> None: + button_text = ( + f"Recent Conversations [{primary_display_key_for_command('OPEN_RECENT_CONVERSATIONS')}]" + ) + 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..4dca55df89 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -54,6 +54,7 @@ StarredButton, StreamButton, TopicButton, + TimeMentionedButton, UserButton, ) from zulipterminal.ui_tools.messages import MessageBox @@ -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 @@ -303,7 +304,149 @@ def read_message(self, index: int = -1) -> None: break 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.body.set_focus_valign("middle") # Fix: Call on self.body (the ListBox) + self.log.set_focus(1) # Focus on first conversation row + + 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 _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) + + button = urwid.Button("", on_press=self._on_row_click, user_data=conv) + button._label = row + button._w = urwid.AttrMap(row, None, "highlight") + + 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) + self.controller.update_screen() + + 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 + return super().keypress(size, key) class StreamsViewDivider(urwid.Divider): """ A custom urwid.Divider to visually separate pinned and unpinned streams. @@ -550,86 +693,120 @@ def mouse_event( self.keypress(size, primary_key_for_command("GO_DOWN")) return super().mouse_event(size, event, button, col, row, focus) - 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. + """ + 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 - self.controller.update_screen() + 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): + 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) + return None - elif is_command_key("NEXT_UNREAD_TOPIC", key): - # narrow to next unread topic - focus = self.view.message_view.focus + elif is_command_key("GO_RIGHT", key): + self.view.show_right_panel(visible=True) + return None + + elif is_command_key("NEXT_UNREAD_TOPIC", key): narrow = self.model.narrow - if focus: - current_msg_id = 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 + 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) 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 - elif is_command_key("NEXT_UNREAD_PM", key): - # narrow to next unread pm + self.set_view("messages") + return None + + elif is_command_key("NEXT_UNREAD_PM", key): pm = self.model.get_next_unread_pm() if pm is None: return key @@ -638,18 +815,87 @@ 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 self.message_view.log: + 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.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: + self.footer.stream_box_view(0) + else: + self.footer.stream_box_view(caption=stream_dict[stream_id]["name"]) 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 super().keypress(size, key) + 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) class RightColumnView(urwid.Frame): """ @@ -784,7 +1030,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 +1040,8 @@ 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 +1055,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 From d9f96628ab054ee0fe459f4d69645125ba322b73 Mon Sep 17 00:00:00 2001 From: sahithch5 Date: Mon, 24 Mar 2025 00:42:10 +0530 Subject: [PATCH 02/11] conversation: Add recent conversations view to zulip-terminal. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a new RecentConversationsView in ui_tools/views.py to display recent conversation data fetched via model.py. Updated core.py and ui.py to integrate this view into the main application flow. Modified cli/run.py to support initialization of conversation data, and adjusted keys.py and ui_sizes.py for navigation and layout consistency. Added button support in ui_tools/buttons.py for interacting with conversations. This enhances zulip-terminal’s interactivity by allowing users to view and navigate recent conversations, addressing requirements from issue #1565. Fixes #1565. --- .python-version | 1 + docs/hotkeys.md | 2 + zulipterminal/cli/run.py | 1 - zulipterminal/core.py | 3 +- zulipterminal/model.py | 35 ++++--- zulipterminal/ui.py | 2 +- zulipterminal/ui_tools/buttons.py | 22 ++--- zulipterminal/ui_tools/views.py | 150 ++++++++++++++++++++---------- 8 files changed, 138 insertions(+), 78 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..475ba515c0 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.7 diff --git a/docs/hotkeys.md b/docs/hotkeys.md index 43bbf6125a..f8c002a2fc 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| diff --git a/zulipterminal/cli/run.py b/zulipterminal/cli/run.py index 4a0f45e90c..a5cce2cf2d 100755 --- a/zulipterminal/cli/run.py +++ b/zulipterminal/cli/run.py @@ -696,4 +696,3 @@ def print_setting(setting: str, data: SettingData, suffix: str = "") -> None: if __name__ == "__main__": main() - \ No newline at end of file diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 5a783cf04a..8cc711339c 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -47,7 +47,6 @@ StreamInfoView, StreamMembersView, UserInfoView, - ) from zulipterminal.version import ZT_VERSION @@ -598,7 +597,7 @@ def _narrow_to(self, anchor: Optional[int], **narrow: Any) -> None: if already_narrowed and anchor is None: return - + msg_id_list = self.model.get_message_ids_in_current_narrow() # If no messages are found in the current narrow diff --git a/zulipterminal/model.py b/zulipterminal/model.py index c474161833..4667bf72d7 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,timezone +from datetime import datetime, timezone from typing import ( Any, Callable, @@ -314,7 +314,6 @@ def set_narrow( frozenset(["pm_with"]): [["pm-with", pm_with]], frozenset(["starred"]): [["is", "starred"]], frozenset(["mentioned"]): [["is", "mentioned"]], - } for narrow_param, narrow in valid_narrows.items(): if narrow_param == selected_params: @@ -856,7 +855,6 @@ def modernize_message_response(message: Message) -> Message: message["topic_links"] = topic_links return message - def fetch_message_history( self, message_id: int @@ -1095,10 +1093,13 @@ def get_other_subscribers_in_stream( if stream["name"] == stream_name if sub != self.user_id ] + def group_recent_conversations(self) -> List[Dict[str, Any]]: """Return the 10 most recent stream conversations.""" # Filter for stream messages - stream_msgs = [m for m in self.index["messages"].values() if m["type"] == "stream"] + stream_msgs = [ + m for m in self.index["messages"].values() if m["type"] == "stream" + ] if not stream_msgs: return [] @@ -1114,11 +1115,13 @@ def group_recent_conversations(self) -> List[Dict[str, Any]]: 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 + convos.items(), + key=lambda x: max(m["timestamp"] for m in x[1]), + reverse=True, )[:10]: # Map stream_id to stream name - stream_name=self.stream_name_from_id(stream_id) + stream_name = self.stream_name_from_id(stream_id) topic_name = topic if topic else "(no topic)" # Extract participants @@ -1137,14 +1140,17 @@ def group_recent_conversations(self) -> List[Dict[str, Any]]: hours = delta.seconds // 3600 time_str = f"{hours} hours ago" if hours > 0 else "just now" - processed_conversations.append({ - "stream": stream_name, - "topic": topic_name, - "participants": list(participants), - "time": time_str, - }) + processed_conversations.append( + { + "stream": stream_name, + "topic": topic_name, + "participants": list(participants), + "time": time_str, + } + ) + + return processed_conversations - return processed_conversations def _clean_and_order_custom_profile_data( self, custom_profile_data: Dict[str, CustomFieldValue] ) -> List[CustomProfileData]: @@ -1467,7 +1473,8 @@ def stream_id_from_name(self, stream_name: str) -> int: if stream["name"] == stream_name: return stream_id raise RuntimeError("Invalid stream name.") - def stream_name_from_id(self,stream_id:int)->str: + + 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: diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index a8dcfbf72a..56e5e04ff1 100644 --- a/zulipterminal/ui.py +++ b/zulipterminal/ui.py @@ -72,7 +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", diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 99ba7b580c..b5f6357d06 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -24,7 +24,7 @@ MENTIONED_MESSAGES_MARKER, MUTE_MARKER, STARRED_MESSAGES_MARKER, - TIME_MENTION_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 @@ -130,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, @@ -146,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, @@ -160,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, @@ -172,11 +170,11 @@ def __init__(self, *, controller: Any, count: int) -> None: show_function=controller.narrow_to_all_mentions, count=count, ) -class TimeMentionedButton(TopButton): + + +class TimeMentionedButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = ( - f"Recent Conversations [{primary_display_key_for_command('OPEN_RECENT_CONVERSATIONS')}]" - ) + 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), diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 4dca55df89..7abc64e3a6 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -53,8 +53,8 @@ PMButton, StarredButton, StreamButton, - TopicButton, TimeMentionedButton, + TopicButton, UserButton, ) from zulipterminal.ui_tools.messages import MessageBox @@ -304,27 +304,36 @@ def read_message(self, index: int = -1) -> None: break 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.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.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)) + 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.body.set_focus_valign("middle") # Fix: Call on self.body (the ListBox) self.log.set_focus(1) # Focus on first conversation row - def _build_body_contents(self, conversations: List[Dict[str, Any]]) -> List[urwid.Widget]: + def _build_body_contents( + self, conversations: List[Dict[str, Any]] + ) -> List[urwid.Widget]: contents = [] header = self._build_header_row() contents.append(header) @@ -350,7 +359,11 @@ def _build_conversation_row(self, conv: Dict[str, Any], idx: int) -> urwid.Widge participants = conv["participants"] time = conv["time"] - participant_text = f"{len(participants)} users" if len(participants) > 3 else ", ".join(participants) + participant_text = ( + f"{len(participants)} users" + if len(participants) > 3 + else ", ".join(participants) + ) columns = [ ("weight", 1, urwid.Text(f"#{stream}")), @@ -380,34 +393,42 @@ def update_conversations(self, search_box: Any, new_text: str) -> None: 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"])) + 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) + self.log.set_focus(1) self.controller.update_screen() def mouse_event( - self, size: tuple[int, int], event: str, button: int, col: int, row: int, focus: bool + self, + size: tuple[int, int], + event: str, + button: int, + col: int, + row: int, + focus: bool, ) -> bool: if event == "mouse press": - if button == 4: + if button == 4: for _ in range(5): self.keypress(size, primary_key_for_command("GO_UP")) return True - elif button == 5: + elif button == 5: for _ in range(5): self.keypress(size, primary_key_for_command("GO_DOWN")) return True @@ -443,10 +464,12 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: if focused_position > 0: focused_widget._emit("click") return None - elif is_command_key("ALL_MESSAGES", key): + elif is_command_key("ALL_MESSAGES", key): self.controller.view.middle_column.set_view("messages") return None return super().keypress(size, key) + + class StreamsViewDivider(urwid.Divider): """ A custom urwid.Divider to visually separate pinned and unpinned streams. @@ -693,6 +716,7 @@ def mouse_event( self.keypress(size, primary_key_for_command("GO_DOWN")) return super().mouse_event(size, event, button, col, row, focus) + class MiddleColumnView(urwid.Frame): def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> None: self.model = model @@ -705,7 +729,7 @@ def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> No 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 + 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: @@ -733,7 +757,10 @@ def check_narrow_and_switch_view(self) -> None: Check if the model's narrow has changed and switch to MessageView if necessary. """ current_narrow = self.model.narrow - if current_narrow != self.last_narrow and self.current_view != self.message_view: + if ( + current_narrow != self.last_narrow + and self.current_view != self.message_view + ): self.set_view("messages") self.last_narrow = current_narrow @@ -743,26 +770,26 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: if self.focus_position in ["footer", "header"]: return super().keypress(size, key) - elif is_command_key("SEARCH_MESSAGES", key): + elif is_command_key("SEARCH_MESSAGES", key): self.controller.enter_editor_mode_with(self.search_box) self.set_focus("header") return None - elif is_command_key("OPEN_RECENT_CONVERSATIONS", key): + elif is_command_key("OPEN_RECENT_CONVERSATIONS", key): self.set_view("recent") return None - elif is_command_key("ALL_MESSAGES", key): + 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): + 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): + elif is_command_key("ALL_STARRED", key): self.controller.narrow_to_all_starred() self.set_view("messages") return None @@ -772,25 +799,29 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: self.set_view("messages") return None - elif is_command_key("PRIVATE_MESSAGE", key): + elif is_command_key("PRIVATE_MESSAGE", key): self.footer.private_box_view() self.set_focus("footer") self.footer.focus_position = 0 return None - elif is_command_key("GO_LEFT", key): + elif is_command_key("GO_LEFT", key): self.view.show_left_panel(visible=True) return None - elif is_command_key("GO_RIGHT", key): + elif is_command_key("GO_RIGHT", key): self.view.show_right_panel(visible=True) return None - elif is_command_key("NEXT_UNREAD_TOPIC", key): + elif is_command_key("NEXT_UNREAD_TOPIC", key): narrow = self.model.narrow 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) + 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 + ) elif narrow[0][0] == "stream" and narrow[1][0] == "topic": stream_topic = self.model.next_unread_topic_from_message_id(None) else: @@ -806,7 +837,7 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: self.set_view("messages") return None - elif is_command_key("NEXT_UNREAD_PM", key): + elif is_command_key("NEXT_UNREAD_PM", key): pm = self.model.get_next_unread_pm() if pm is None: return key @@ -823,7 +854,12 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: 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 ( + 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 self.message_view.log: @@ -834,7 +870,7 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: self.footer.focus_position = 1 return None - elif is_command_key("STREAM_MESSAGE", key): + elif is_command_key("STREAM_MESSAGE", key): if self.current_view != self.message_view: self.set_view("messages") self.current_view.keypress(size, key) @@ -849,8 +885,11 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: self.footer.focus_position = 0 return None - elif is_command_key("STREAM_NARROW", key): - if self.current_view != self.message_view or not self.view.message_view.focus: + 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": @@ -859,8 +898,11 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: 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: + 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": @@ -872,16 +914,22 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: 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: + 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: + 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"]) @@ -889,7 +937,10 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: return None elif is_command_key("ADD_REACTION", key): - if self.current_view != self.message_view or not self.view.message_view.focus: + 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"]) @@ -897,6 +948,7 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: return super().keypress(size, key) + class RightColumnView(urwid.Frame): """ Displays the users list on the right side of the app. @@ -1040,7 +1092,9 @@ 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.time_button = TimeMentionedButton( + controller=self.controller, count=count + ) self.view.mentioned_button = MentionedButton( controller=self.controller, @@ -1059,7 +1113,7 @@ def menu_view(self) -> Any: self.view.mentioned_button, self.view.starred_button, ] - + w = urwid.ListBox(urwid.SimpleFocusListWalker(menu_btn_list)) return w From 9608c3a51da6c8af7334a1843eb778fe0c586918 Mon Sep 17 00:00:00 2001 From: "Sahith.Chinthalapuri" <64692161+sahith-ch@users.noreply.github.com> Date: Mon, 24 Mar 2025 08:48:22 +0530 Subject: [PATCH 03/11] Delete non relevant change --- .python-version | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .python-version diff --git a/.python-version b/.python-version deleted file mode 100644 index 475ba515c0..0000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.7 From aa6353ffb775cc0481cb765bc0651954ec5be935 Mon Sep 17 00:00:00 2001 From: "Sahith.Chinthalapuri" <64692161+sahith-ch@users.noreply.github.com> Date: Mon, 24 Mar 2025 08:49:05 +0530 Subject: [PATCH 04/11] Delete non relevant changes --- docs/hotkeys.md | 141 ------------------------------------------------ 1 file changed, 141 deletions(-) delete mode 100644 docs/hotkeys.md diff --git a/docs/hotkeys.md b/docs/hotkeys.md deleted file mode 100644 index f8c002a2fc..0000000000 --- a/docs/hotkeys.md +++ /dev/null @@ -1,141 +0,0 @@ - - - -# Hot Keys -## General -|Command|Key Combination| -| :--- | :---: | -|Show/hide Help Menu|?| -|Show/hide Markdown Help Menu|Meta + m| -|Show/hide About Menu|Meta + ?| -|Copy information from About Menu to clipboard|c| -|Copy traceback from Exception Popup to clipboard|c| -|Redraw screen|Ctrl + l| -|Quit|Ctrl + c| -|New footer hotkey hint|Tab| - -## Navigation -|Command|Key Combination| -| :--- | :---: | -|Close popup|Esc| -|Go up / Previous message|Up / k| -|Go down / Next message|Down / j| -|Go left|Left / h| -|Go right|Right / l| -|Scroll up|PgUp / K| -|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| -| :--- | :---: | -|View the stream of the current message|s| -|View the topic of the current message|S| -|Zoom in/out the message's conversation context|z| -|Switch message view to the compose box target|Meta + .| -|View all messages|a / Esc| -|View all direct messages|P| -|View all starred messages|f| -|View all messages in which you're mentioned|#| -|Next unread topic|n| -|Next unread direct message|p| - -## Searching -|Command|Key Combination| -| :--- | :---: | -|Search users|w| -|Search messages|/| -|Search streams|q| -|Search topics in a stream|q| -|Search emojis from emoji picker|p| -|Submit search and browse results|Enter| -|Clear search in current panel|Esc| - -## Message actions -|Command|Key Combination| -| :--- | :---: | -|Edit message's content or topic|e| -|Show/hide emoji picker for current message|:| -|Toggle first emoji reaction on selected message|=| -|Toggle thumbs-up reaction to the current message|+| -|Toggle star status of the current message|Ctrl + s / *| -|Show/hide message information|i| -|Show/hide message sender information|u| - -## Stream list actions -|Command|Key Combination| -| :--- | :---: | -|Toggle topics in a stream|t| -|Mute/unmute streams|m| -|Show/hide stream information & modify settings|i| - -## User list actions -|Command|Key Combination| -| :--- | :---: | -|Show/hide user information|i| -|Narrow to direct messages with user|Enter| - -## Begin composing a message -|Command|Key Combination| -| :--- | :---: | -|Open draft message saved in this session|d| -|Reply to the current message|r / Enter| -|Reply mentioning the sender of the current message|@| -|Reply quoting the current message text|>| -|Reply directly to the sender of the current message|R| -|New message to a stream|c| -|New message to a person or group of people|x| - -## Writing a message -|Command|Key Combination| -| :--- | :---: | -|Cycle through recipient and content boxes|Tab| -|Send a message|Ctrl + d / Meta + Enter| -|Save current message as a draft|Meta + s| -|Autocomplete @mentions, #stream_names, :emoji: and topics|Ctrl + f| -|Cycle through autocomplete suggestions in reverse|Ctrl + r| -|Exit message compose box|Esc| -|Insert new line|Enter| -|Open an external editor to edit the message content|Ctrl + o| - -## Editor: Navigation -|Command|Key Combination| -| :--- | :---: | -|Start of line|Ctrl + a / Home| -|End of line|Ctrl + e / End| -|Start of current or previous word|Meta + b / Shift + Left| -|Start of next word|Meta + f / Shift + Right| -|Previous line|Up / Ctrl + p| -|Next line|Down / Ctrl + n| - -## Editor: Text Manipulation -|Command|Key Combination| -| :--- | :---: | -|Undo last action|Ctrl + _| -|Clear text box|Ctrl + l| -|Cut forwards to the end of the line|Ctrl + k| -|Cut backwards to the start of the line|Ctrl + u| -|Cut forwards to the end of the current word|Meta + d| -|Cut backwards to the start of the current word|Ctrl + w / Meta + Backspace| -|Cut the current line|Meta + x| -|Paste last cut section|Ctrl + y| -|Delete previous character|Ctrl + h| -|Swap with previous character|Ctrl + t| - -## Stream information (press i to view info of a stream) -|Command|Key Combination| -| :--- | :---: | -|Show/hide stream members|m| -|Copy stream email to clipboard|c| - -## Message information (press i to view info of a message) -|Command|Key Combination| -| :--- | :---: | -|Show/hide edit history|e| -|View current message in browser|v| -|Show/hide full rendered message|f| -|Show/hide full raw message|r| - From 1f970efc5709c4a33997bbc0bf95408d99449f2e Mon Sep 17 00:00:00 2001 From: "Sahith.Chinthalapuri" <64692161+sahith-ch@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:48:08 +0530 Subject: [PATCH 05/11] Recovered hotkeys.md --- docs/hotkeys.md | 140 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/hotkeys.md diff --git a/docs/hotkeys.md b/docs/hotkeys.md new file mode 100644 index 0000000000..fa380e4843 --- /dev/null +++ b/docs/hotkeys.md @@ -0,0 +1,140 @@ + + + +# Hot Keys +## General +|Command|Key Combination| +| :--- | :---: | +|Show/hide Help Menu|?| +|Show/hide Markdown Help Menu|Meta + m| +|Show/hide About Menu|Meta + ?| +|Copy information from About Menu to clipboard|c| +|Copy traceback from Exception Popup to clipboard|c| +|Redraw screen|Ctrl + l| +|Quit|Ctrl + c| +|New footer hotkey hint|Tab| + +## Navigation +|Command|Key Combination| +| :--- | :---: | +|Close popup|Esc| +|Go up / Previous message|Up / k| +|Go down / Next message|Down / j| +|Go left|Left / h| +|Go right|Right / l| +|Scroll up|PgUp / K| +|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| +| :--- | :---: | +|View the stream of the current message|s| +|View the topic of the current message|S| +|Zoom in/out the message's conversation context|z| +|Switch message view to the compose box target|Meta + .| +|View all messages|a / Esc| +|View all direct messages|P| +|View all starred messages|f| +|View all messages in which you're mentioned|#| +|Next unread topic|n| +|Next unread direct message|p| + +## Searching +|Command|Key Combination| +| :--- | :---: | +|Search users|w| +|Search messages|/| +|Search streams|q| +|Search topics in a stream|q| +|Search emojis from emoji picker|p| +|Submit search and browse results|Enter| +|Clear search in current panel|Esc| + +## Message actions +|Command|Key Combination| +| :--- | :---: | +|Edit message's content or topic|e| +|Show/hide emoji picker for current message|:| +|Toggle first emoji reaction on selected message|=| +|Toggle thumbs-up reaction to the current message|+| +|Toggle star status of the current message|Ctrl + s / *| +|Show/hide message information|i| +|Show/hide message sender information|u| + +## Stream list actions +|Command|Key Combination| +| :--- | :---: | +|Toggle topics in a stream|t| +|Mute/unmute streams|m| +|Show/hide stream information & modify settings|i| + +## User list actions +|Command|Key Combination| +| :--- | :---: | +|Show/hide user information|i| +|Narrow to direct messages with user|Enter| + +## Begin composing a message +|Command|Key Combination| +| :--- | :---: | +|Open draft message saved in this session|d| +|Reply to the current message|r / Enter| +|Reply mentioning the sender of the current message|@| +|Reply quoting the current message text|>| +|Reply directly to the sender of the current message|R| +|New message to a stream|c| +|New message to a person or group of people|x| + +## Writing a message +|Command|Key Combination| +| :--- | :---: | +|Cycle through recipient and content boxes|Tab| +|Send a message|Ctrl + d / Meta + Enter| +|Save current message as a draft|Meta + s| +|Autocomplete @mentions, #stream_names, :emoji: and topics|Ctrl + f| +|Cycle through autocomplete suggestions in reverse|Ctrl + r| +|Exit message compose box|Esc| +|Insert new line|Enter| +|Open an external editor to edit the message content|Ctrl + o| + +## Editor: Navigation +|Command|Key Combination| +| :--- | :---: | +|Start of line|Ctrl + a / Home| +|End of line|Ctrl + e / End| +|Start of current or previous word|Meta + b / Shift + Left| +|Start of next word|Meta + f / Shift + Right| +|Previous line|Up / Ctrl + p| +|Next line|Down / Ctrl + n| + +## Editor: Text Manipulation +|Command|Key Combination| +| :--- | :---: | +|Undo last action|Ctrl + _| +|Clear text box|Ctrl + l| +|Cut forwards to the end of the line|Ctrl + k| +|Cut backwards to the start of the line|Ctrl + u| +|Cut forwards to the end of the current word|Meta + d| +|Cut backwards to the start of the current word|Ctrl + w / Meta + Backspace| +|Cut the current line|Meta + x| +|Paste last cut section|Ctrl + y| +|Delete previous character|Ctrl + h| +|Swap with previous character|Ctrl + t| + +## Stream information (press i to view info of a stream) +|Command|Key Combination| +| :--- | :---: | +|Show/hide stream members|m| +|Copy stream email to clipboard|c| + +## Message information (press i to view info of a message) +|Command|Key Combination| +| :--- | :---: | +|Show/hide edit history|e| +|View current message in browser|v| +|Show/hide full rendered message|f| +|Show/hide full raw message|r| From c8d9e03909e579e424aafacb00e01677e77e82c1 Mon Sep 17 00:00:00 2001 From: sahithch5 Date: Sun, 23 Mar 2025 21:57:40 +0530 Subject: [PATCH 06/11] conversation: Add recent conversations view to zulip-terminal. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a new RecentConversationsView in ui_tools/views.py to display recent conversation data fetched via model.py. Updated core.py and ui.py to integrate this view into the main application flow. Modified cli/run.py to support initialization of conversation data, and adjusted keys.py and ui_sizes.py for navigation and layout consistency. Added button support in ui_tools/buttons.py for interacting with conversations. This enhances zulip-terminal’s interactivity by allowing users to view and navigate recent conversations, addressing requirements from issue #1565. Fixes #1565. --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version 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 From 9b91f5a1673caac727b0a5d736f5cf86c1451e97 Mon Sep 17 00:00:00 2001 From: sahithch5 Date: Sat, 12 Apr 2025 21:21:39 +0530 Subject: [PATCH 07/11] feature: Updated model for recent conversations. fixes: #1565. --- zulipterminal/model.py | 65 ++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 4667bf72d7..de68a84252 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, timezone +from datetime import datetime, timedelta, timezone from typing import ( Any, Callable, @@ -1004,8 +1004,7 @@ def next_unread_topic_from_message_id( if next_topic: if unread_topic == current_topic: return None - if ( - current_topic is not None + if (current_topic is not None and unread_topic[0] != current_topic[0] and stream_start != current_topic ): @@ -1095,10 +1094,35 @@ def get_other_subscribers_in_stream( ] def group_recent_conversations(self) -> List[Dict[str, Any]]: - """Return the 10 most recent stream conversations.""" - # Filter for stream messages + """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 self.index["messages"].values() if m["type"] == "stream" + m for m in messages + if m["type"] == "stream" and m["timestamp"] >= recency_timestamp ] if not stream_msgs: return [] @@ -1108,7 +1132,7 @@ def group_recent_conversations(self) -> List[Dict[str, Any]]: # Group messages by stream and topic convos = defaultdict(list) - for msg in stream_msgs[:50]: # Limit to 50 recent messages + for msg in stream_msgs[:100]: convos[(msg["stream_id"], msg["subject"])].append(msg) # Process conversations into the desired format @@ -1118,27 +1142,30 @@ def group_recent_conversations(self) -> List[Dict[str, Any]]: convos.items(), key=lambda x: max(m["timestamp"] for m in x[1]), reverse=True, - )[:10]: - # Map stream_id to stream name - + )[:30]: stream_name = self.stream_name_from_id(stream_id) topic_name = topic if topic else "(no topic)" - - # Extract participants participants = set() for msg in msg_list: participants.add(msg["sender_full_name"]) - - # Format timestamp (using the most recent message in the conversation) 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 - if delta.days > 0: - time_str = f"{delta.days} days ago" - else: - hours = delta.seconds // 3600 - time_str = f"{hours} hours ago" if hours > 0 else "just now" + + # 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( { From e4e5cb8379d699c0c811f6dd1f795a68e9480881 Mon Sep 17 00:00:00 2001 From: sahithch5 Date: Sat, 12 Apr 2025 21:22:46 +0530 Subject: [PATCH 08/11] feature: Updated view for recentconversation view. fixes: #1565. --- zulipterminal/ui_tools/views.py | 69 +++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 7abc64e3a6..c62e4d65aa 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -305,10 +305,12 @@ 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() @@ -327,9 +329,13 @@ def __init__(self, controller: Any) -> None: list_box = urwid.ListBox(self.log) super().__init__(list_box, header=search_header) + if len(self.log) > 1: - self.body.set_focus_valign("middle") # Fix: Call on self.body (the ListBox) - self.log.set_focus(1) # Focus on first conversation row + 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]] @@ -343,6 +349,10 @@ def _build_body_contents( 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 = [ @@ -372,11 +382,11 @@ def _build_conversation_row(self, conv: Dict[str, Any], idx: int) -> urwid.Widge ("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 = row - button._w = urwid.AttrMap(row, None, "highlight") - + 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: @@ -403,7 +413,6 @@ def update_conversations(self, search_box: Any, new_text: str) -> None: ] self.empty_search = len(filtered_conversations) == 0 - self.log.clear() if not self.empty_search: self.log.extend(self._build_body_contents(filtered_conversations)) @@ -412,11 +421,11 @@ def update_conversations(self, search_box: Any, new_text: str) -> None: if len(self.log) > 1: self.log.set_focus(1) - self.controller.update_screen() + def mouse_event( self, - size: tuple[int, int], + size: Tuple[int, int], event: str, button: int, col: int, @@ -434,7 +443,7 @@ def mouse_event( return True return super().mouse_event(size, event, button, col, row, focus) - def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: + 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(" ") @@ -467,9 +476,20 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: 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. @@ -716,7 +736,6 @@ def mouse_event( self.keypress(size, primary_key_for_command("GO_DOWN")) return super().mouse_event(size, event, button, col, row, focus) - class MiddleColumnView(urwid.Frame): def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> None: self.model = model @@ -733,6 +752,7 @@ def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> No 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 @@ -756,6 +776,7 @@ def check_narrow_and_switch_view(self) -> None: """ Check if the model's narrow has changed and switch to MessageView if necessary. """ + current_narrow = self.model.narrow if ( current_narrow != self.last_narrow @@ -764,7 +785,7 @@ def check_narrow_and_switch_view(self) -> None: self.set_view("messages") self.last_narrow = current_narrow - def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: + def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]: self.check_narrow_and_switch_view() if self.focus_position in ["footer", "header"]: @@ -807,11 +828,9 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: elif is_command_key("GO_LEFT", key): self.view.show_left_panel(visible=True) - return None elif is_command_key("GO_RIGHT", key): self.view.show_right_panel(visible=True) - return None elif is_command_key("NEXT_UNREAD_TOPIC", key): narrow = self.model.narrow @@ -862,7 +881,7 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: ): # 'r', 'enter', '@', '>', 'R' if self.current_view != self.message_view: self.set_view("messages") - if self.message_view.log: + 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: @@ -871,6 +890,8 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: 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) @@ -878,13 +899,21 @@ def keypress(self, size: tuple[int, int], key: str) -> Optional[str]: 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"]) + # 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 None - elif is_command_key("STREAM_NARROW", key): if ( self.current_view != self.message_view From 9c69e0e79f697031183ed10e9656e5ac7ff8b2b8 Mon Sep 17 00:00:00 2001 From: sahithch5 Date: Sat, 12 Apr 2025 21:25:04 +0530 Subject: [PATCH 09/11] feature: Add horizontal navigation for recent conversations view. fixes: #1565. --- zulipterminal/ui.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index 56e5e04ff1..45ae948884 100644 --- a/zulipterminal/ui.py +++ b/zulipterminal/ui.py @@ -160,7 +160,12 @@ def set_typeahead_footer( 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 index == 1: + 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() @@ -185,7 +190,9 @@ 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, @@ -212,9 +219,9 @@ def main_window(self) -> Any: return self.frame def show_left_panel(self, *, visible: bool) -> None: + if not self.controller.autohide: return - if visible: self.frame.body = urwid.Overlay( urwid.Columns( @@ -233,9 +240,9 @@ def show_left_panel(self, *, visible: bool) -> None: self.body.focus_position = 1 def show_right_panel(self, *, visible: bool) -> None: + if not self.controller.autohide: return - if visible: self.frame.body = urwid.Overlay( urwid.Columns( From 78888177c3cbc82647886a7e1666c71b0347b9df Mon Sep 17 00:00:00 2001 From: sahithch5 Date: Sat, 12 Apr 2025 21:44:08 +0530 Subject: [PATCH 10/11] formating: Fix Formating issues. fixes: #1565. --- zulipterminal/model.py | 10 +++++++--- zulipterminal/ui.py | 17 +++++++++-------- zulipterminal/ui_tools/buttons.py | 6 +++--- zulipterminal/ui_tools/views.py | 9 ++++----- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/zulipterminal/model.py b/zulipterminal/model.py index de68a84252..9c3640099e 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -1004,7 +1004,8 @@ def next_unread_topic_from_message_id( if next_topic: if unread_topic == current_topic: return None - if (current_topic is not None + if ( + current_topic is not None and unread_topic[0] != current_topic[0] and stream_start != current_topic ): @@ -1112,7 +1113,9 @@ def group_recent_conversations(self) -> List[Dict[str, Any]]: return [] # Debug: Inspect the fetched messages - messages = [self.modernize_message_response(msg) for msg in response["messages"]] # noqa: E501 + 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) @@ -1121,7 +1124,8 @@ def group_recent_conversations(self) -> List[Dict[str, Any]]: # Filter for stream messages within the last 30 days stream_msgs = [ - m for m in messages + m + for m in messages if m["type"] == "stream" and m["timestamp"] >= recency_timestamp ] if not stream_msgs: diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index 45ae948884..75c2e07c6e 100644 --- a/zulipterminal/ui.py +++ b/zulipterminal/ui.py @@ -160,12 +160,16 @@ def set_typeahead_footer( 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 index == 1: - 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() + if index == 1: + 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() @@ -191,7 +195,6 @@ def main_window(self) -> Any: # 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.on_column_focus_changed) title_text = " {full_name} ({email}) - {server_name} ({url}) ".format( @@ -219,7 +222,6 @@ def main_window(self) -> Any: return self.frame def show_left_panel(self, *, visible: bool) -> None: - if not self.controller.autohide: return if visible: @@ -240,7 +242,6 @@ def show_left_panel(self, *, visible: bool) -> None: self.body.focus_position = 1 def show_right_panel(self, *, visible: bool) -> None: - if not self.controller.autohide: return if visible: diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index b5f6357d06..4fac636359 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -130,7 +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')}]"# noqa: E501 + button_text = f"All messages [{primary_display_key_for_command('ALL_MESSAGES')}]" # noqa: E501 super().__init__( controller=controller, @@ -160,7 +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')}]"# noqa: E501 + button_text = f"Mentions [{primary_display_key_for_command('ALL_MENTIONS')}]" # noqa: E501 super().__init__( controller=controller, @@ -174,7 +174,7 @@ 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 + 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), diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index c62e4d65aa..bd4c100cc4 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -305,7 +305,6 @@ 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 @@ -336,7 +335,6 @@ def __init__(self, controller: Any) -> None: self.set_focus("body") self.body.set_focus_valign("middle") - def _build_body_contents( self, conversations: List[Dict[str, Any]] ) -> List[urwid.Widget]: @@ -349,6 +347,7 @@ def _build_body_contents( contents.append(row) return contents + def focus_restored(self) -> None: if self.focus_position is not None: self.set_focus(self.focus_position) @@ -422,7 +421,6 @@ def update_conversations(self, search_box: Any, new_text: str) -> None: if len(self.log) > 1: self.log.set_focus(1) - def mouse_event( self, size: Tuple[int, int], @@ -490,6 +488,7 @@ def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]: return None return super().keypress(size, key) + class StreamsViewDivider(urwid.Divider): """ A custom urwid.Divider to visually separate pinned and unpinned streams. @@ -736,6 +735,7 @@ def mouse_event( self.keypress(size, primary_key_for_command("GO_DOWN")) return super().mouse_event(size, event, button, col, row, focus) + class MiddleColumnView(urwid.Frame): def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> None: self.model = model @@ -752,7 +752,6 @@ def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> No 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 @@ -881,7 +880,7 @@ def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]: ): # 'r', 'enter', '@', '>', 'R' if self.current_view != self.message_view: self.set_view("messages") - if len(self.message_view.log)>0: + 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: From cd8e323493cec1c2636340b8630ec0fd4a0808c6 Mon Sep 17 00:00:00 2001 From: "Sahith.Chinthalapuri" <64692161+sahith-ch@users.noreply.github.com> Date: Sun, 13 Apr 2025 01:02:02 +0530 Subject: [PATCH 11/11] Update ui.py for fixing a minor bug --- zulipterminal/ui.py | 1 - 1 file changed, 1 deletion(-) diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index 75c2e07c6e..f05dcfa132 100644 --- a/zulipterminal/ui.py +++ b/zulipterminal/ui.py @@ -162,7 +162,6 @@ def footer_view(self) -> Any: return urwid.AttrWrap(urwid.Text(text_header), "footer") def on_column_focus_changed(self, index: int) -> None: - if index == 1: if self.middle_column.current_view == self.message_view: self.message_view.read_message() elif (