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