mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-10-16 02:06:52 +02:00
Merge branch 'next-gen' of github.com:mcv-software/twblue into next-gen
This commit is contained in:
@@ -10,7 +10,7 @@ from . import base
|
||||
log = logging.getLogger("controller.buffers.base.account")
|
||||
|
||||
class AccountBuffer(base.Buffer):
|
||||
def __init__(self, parent, name, account, account_id):
|
||||
def __init__(self, parent, name, account, account_id, session=None):
|
||||
super(AccountBuffer, self).__init__(parent, None, name)
|
||||
log.debug("Initializing buffer %s, account %s" % (name, account,))
|
||||
self.buffer = buffers.accountPanel(parent, name)
|
||||
@@ -53,4 +53,4 @@ class AccountBuffer(base.Buffer):
|
||||
else:
|
||||
self.buffer.change_autostart(False)
|
||||
config.app["sessions"]["ignored_sessions"].append(self.account_id)
|
||||
config.app.write()
|
||||
config.app.write()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .timeline import (
|
||||
HomeTimeline,
|
||||
FollowingTimeline,
|
||||
NotificationBuffer,
|
||||
Conversation,
|
||||
LikesBuffer,
|
||||
MentionsBuffer,
|
||||
SentBuffer,
|
||||
UserTimeline,
|
||||
SearchBuffer,
|
||||
)
|
||||
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer, PostUserListBuffer
|
||||
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import wx
|
||||
import widgetUtils
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from controller.blueski import messages as blueski_messages
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
from sessions.blueski import compose
|
||||
from mysc.thread_utils import call_threaded
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.chat")
|
||||
|
||||
class ConversationListBuffer(BaseBuffer):
|
||||
"""Buffer for listing conversations, similar to Mastodon's ConversationListBuffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_convo"
|
||||
super(ConversationListBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "chat"
|
||||
self.sound = "dm_received.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def bind_events(self):
|
||||
"""Bind events like Mastodon's ConversationListBuffer."""
|
||||
self.buffer.set_focus_function(self.onFocus)
|
||||
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key)
|
||||
# Buttons
|
||||
if hasattr(self.buffer, "post"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_post, self.buffer.post)
|
||||
if hasattr(self.buffer, "reply"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.reply, self.buffer.reply)
|
||||
if hasattr(self.buffer, "new_chat"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_new_chat, self.buffer.new_chat)
|
||||
|
||||
def get_item(self):
|
||||
"""Get the last message from the selected conversation (like Mastodon)."""
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||
convo = self.session.db[self.name][index]
|
||||
# Return lastMessage for compatibility with item-based operations
|
||||
last_msg = getattr(convo, "lastMessage", None) or (convo.get("lastMessage") if isinstance(convo, dict) else None)
|
||||
return last_msg
|
||||
return None
|
||||
|
||||
def get_conversation(self):
|
||||
"""Get the full conversation object."""
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||
return self.session.db[self.name][index]
|
||||
return None
|
||||
|
||||
def get_convo_id(self, conversation):
|
||||
"""Extract conversation ID from a conversation object, handling different field names."""
|
||||
if not conversation:
|
||||
return None
|
||||
# Try different possible field names for the conversation ID
|
||||
for attr in ("id", "convo_id", "convoId"):
|
||||
val = getattr(conversation, attr, None)
|
||||
if val:
|
||||
return val
|
||||
if isinstance(conversation, dict) and attr in conversation:
|
||||
return conversation[attr]
|
||||
return None
|
||||
|
||||
def _get_members_key(self, conversation):
|
||||
"""Fallback key when convo id is missing: stable member DID list."""
|
||||
members = getattr(conversation, "members", None) or (conversation.get("members") if isinstance(conversation, dict) else None) or []
|
||||
dids = []
|
||||
for m in members:
|
||||
did = getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)
|
||||
if did:
|
||||
dids.append(did)
|
||||
if not dids:
|
||||
return None
|
||||
dids.sort()
|
||||
return tuple(dids)
|
||||
|
||||
def _get_last_message_key(self, conversation):
|
||||
"""Key for detecting conversation updates based on last message."""
|
||||
last_msg = getattr(conversation, "lastMessage", None) or (conversation.get("lastMessage") if isinstance(conversation, dict) else None)
|
||||
if not last_msg:
|
||||
return None
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||
val = g(last_msg, attr)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
nested = g(last_msg, "message") or g(last_msg, "record")
|
||||
if nested:
|
||||
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||
val = g(nested, attr)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
sent_at = g(last_msg, "sentAt") or g(last_msg, "sent_at") or g(last_msg, "createdAt") or g(last_msg, "created_at")
|
||||
sender = g(last_msg, "sender") or (g(nested, "sender") if nested else {}) or {}
|
||||
sender_did = g(sender, "did")
|
||||
text = g(last_msg, "text") or (g(nested, "text") if nested else None)
|
||||
if sent_at or sender_did or text:
|
||||
return (str(sent_at) if sent_at else None, str(sender_did) if sender_did else None, str(text) if text else None)
|
||||
return None
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Return last message text for current conversation."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
return None
|
||||
return self.compose_function(
|
||||
conversation,
|
||||
self.session.db,
|
||||
self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)[1]
|
||||
|
||||
def get_message(self):
|
||||
"""Return a readable summary for the selected conversation."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
conversation,
|
||||
self.session.db,
|
||||
self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return " ".join(composed)
|
||||
|
||||
def on_new_chat(self, *args, **kwargs):
|
||||
"""Start a new conversation by entering a handle."""
|
||||
dlg = wx.TextEntryDialog(None, _("Enter the handle of the user (e.g., user.bsky.social):"), _("New Chat"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
handle = dlg.GetValue().strip()
|
||||
if handle:
|
||||
if handle.startswith("@"):
|
||||
handle = handle[1:]
|
||||
def do_create():
|
||||
try:
|
||||
# Resolve handle to DID
|
||||
profile = self.session.get_profile(handle)
|
||||
if not profile:
|
||||
wx.CallAfter(output.speak, _("User not found."), True)
|
||||
return
|
||||
did = getattr(profile, "did", None) or (profile.get("did") if isinstance(profile, dict) else None)
|
||||
if not did:
|
||||
wx.CallAfter(output.speak, _("Could not get user ID."), True)
|
||||
return
|
||||
# Get or create conversation
|
||||
convo = self.session.get_or_create_convo([did])
|
||||
if not convo:
|
||||
wx.CallAfter(output.speak, _("Could not create conversation."), True)
|
||||
return
|
||||
convo_id = self.get_convo_id(convo)
|
||||
user_handle = getattr(profile, "handle", None) or (profile.get("handle") if isinstance(profile, dict) else None) or handle
|
||||
title = _("Chat: {0}").format(user_handle)
|
||||
# Create the buffer under direct_messages node
|
||||
wx.CallAfter(self._create_chat_buffer, self.controller, title, convo_id)
|
||||
# Refresh conversation list
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error creating new conversation")
|
||||
wx.CallAfter(output.speak, _("Error creating conversation."), True)
|
||||
call_threaded(do_create)
|
||||
dlg.Destroy()
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = self.session.list_convos(limit=count)
|
||||
items = res.get("items", [])
|
||||
self._build_member_maps(items)
|
||||
return self._merge_conversations(items, play_sound, avoid_autoreading=avoid_autoreading)
|
||||
except Exception:
|
||||
log.exception("Error fetching conversations")
|
||||
output.speak(_("Error loading conversations."), True)
|
||||
return 0
|
||||
|
||||
def _build_member_maps(self, convos):
|
||||
"""Build DID→name maps from conversation members and store in db for chat buffers."""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
for convo in convos:
|
||||
convo_id = self.get_convo_id(convo)
|
||||
if not convo_id:
|
||||
continue
|
||||
members = g(convo, "members", []) or []
|
||||
member_map = {}
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if did:
|
||||
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
member_map[did] = name
|
||||
if member_map:
|
||||
self.session.db["convo_" + str(convo_id) + "_members"] = member_map
|
||||
|
||||
def _merge_conversations(self, items, play_sound=True, avoid_autoreading=False):
|
||||
"""Merge conversation list, updating items without duplicating or re-alerting."""
|
||||
if self.session.db.get(self.name) is None:
|
||||
self.session.db[self.name] = []
|
||||
|
||||
# Track current selection to restore after refresh
|
||||
selected_key = None
|
||||
current_convo = self.get_conversation()
|
||||
if current_convo:
|
||||
selected_key = self.get_convo_id(current_convo) or self._get_members_key(current_convo)
|
||||
|
||||
existing = {}
|
||||
existing_last = {}
|
||||
for convo in self.session.db[self.name]:
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
if key is None:
|
||||
continue
|
||||
existing[key] = convo
|
||||
existing_last[key] = self._get_last_message_key(convo)
|
||||
|
||||
new_db = []
|
||||
new_count = 0
|
||||
first_load = len(self.session.db[self.name]) == 0
|
||||
for convo in items:
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
new_db.append(convo)
|
||||
if key is None:
|
||||
if first_load:
|
||||
new_count += 1
|
||||
continue
|
||||
if key not in existing:
|
||||
new_count += 1
|
||||
continue
|
||||
if self._get_last_message_key(convo) != existing_last.get(key):
|
||||
new_count += 1
|
||||
|
||||
# Replace DB with latest ordered list from API
|
||||
self.session.db[self.name] = new_db
|
||||
|
||||
# Rebuild list UI to keep ordering consistent with API
|
||||
self.buffer.list.clear()
|
||||
safe = True
|
||||
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||
for convo in new_db:
|
||||
row = self.compose_function(convo, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
|
||||
self.buffer.list.insert_item(False, *row)
|
||||
|
||||
# Restore selection if possible
|
||||
if selected_key is not None:
|
||||
for idx, convo in enumerate(new_db):
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
if key == selected_key:
|
||||
self.buffer.list.select_item(idx)
|
||||
break
|
||||
|
||||
# Sound and auto-read only when something actually changed
|
||||
if new_count > 0:
|
||||
if play_sound and self.sound and not self.session.settings["sound"].get("session_mute", False):
|
||||
self.session.sound.play(self.sound)
|
||||
if not avoid_autoreading:
|
||||
self.auto_read(new_count)
|
||||
|
||||
return new_count
|
||||
|
||||
def fav(self):
|
||||
pass
|
||||
|
||||
def unfav(self):
|
||||
pass
|
||||
|
||||
def can_share(self):
|
||||
return False
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
"""Enter key opens the chat conversation buffer."""
|
||||
self.view_chat()
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
"""Global DM shortcut - reply to conversation."""
|
||||
return self.reply()
|
||||
|
||||
def reply(self, *args, **kwargs):
|
||||
"""Reply to the selected conversation (like Mastodon)."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
output.speak(_("No conversation selected."), True)
|
||||
return
|
||||
|
||||
convo_id = self.get_convo_id(conversation)
|
||||
if not convo_id:
|
||||
log.error("Could not get conversation ID from conversation object")
|
||||
output.speak(_("Could not identify conversation."), True)
|
||||
return
|
||||
|
||||
# Get participants for title
|
||||
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||
user_did = self.session.db.get("user_id")
|
||||
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||
if not others:
|
||||
others = members
|
||||
|
||||
if others:
|
||||
first_user = others[0]
|
||||
username = getattr(first_user, "handle", None) or (first_user.get("handle") if isinstance(first_user, dict) else None) or "unknown"
|
||||
else:
|
||||
username = "unknown"
|
||||
|
||||
title = _("Conversation with {0}").format(username)
|
||||
caption = _("Write your message here")
|
||||
initial_text = "@{} ".format(username)
|
||||
|
||||
post = blueski_messages.post(session=self.session, title=title, caption=caption, text=initial_text)
|
||||
if post.message.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = post.get_data()
|
||||
if text:
|
||||
def do_send():
|
||||
try:
|
||||
self.session.send_chat_message(convo_id, text)
|
||||
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||
wx.CallAfter(output.speak, _("Message sent."))
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error sending message")
|
||||
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||
call_threaded(do_send)
|
||||
if hasattr(post.message, "Destroy"):
|
||||
post.message.Destroy()
|
||||
|
||||
def view_chat(self):
|
||||
"""Open the conversation in a separate buffer (nested under Chats node)."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
output.speak(_("No conversation selected."), True)
|
||||
return
|
||||
|
||||
convo_id = self.get_convo_id(conversation)
|
||||
if not convo_id:
|
||||
log.error("Could not get conversation ID from conversation object: %r", conversation)
|
||||
output.speak(_("Could not identify conversation."), True)
|
||||
return
|
||||
|
||||
# Determine participants names for title
|
||||
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||
user_did = self.session.db.get("user_id")
|
||||
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||
if not others:
|
||||
others = members
|
||||
names = ", ".join([getattr(m, "handle", None) or (m.get("handle") if isinstance(m, dict) else None) or "unknown" for m in others])
|
||||
|
||||
title = _("Chat: {0}").format(names)
|
||||
|
||||
self._create_chat_buffer(self.controller, title, convo_id)
|
||||
|
||||
def _create_chat_buffer(self, controller, title, convo_id):
|
||||
"""Create a chat buffer under the direct_messages node, avoiding duplicates."""
|
||||
account_name = self.session.get_name()
|
||||
|
||||
# Avoid duplicates: if buffer already exists, navigate to it
|
||||
existing = controller.search_buffer(title, account_name)
|
||||
if existing:
|
||||
index = controller.view.search(title, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
return
|
||||
|
||||
# Insert under direct_messages node (like Mastodon's ConversationBuffer)
|
||||
chats_position = controller.view.search("direct_messages", account_name)
|
||||
if chats_position is None:
|
||||
chats_position = controller.view.search(account_name, account_name)
|
||||
|
||||
controller.create_buffer(
|
||||
buffer_type="chat_messages",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=chats_position,
|
||||
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
|
||||
start=True
|
||||
)
|
||||
|
||||
# Navigate to the newly created buffer and announce it
|
||||
new_index = controller.view.search(title, account_name)
|
||||
if new_index is not None:
|
||||
controller.view.change_buffer(new_index)
|
||||
buffer_obj = controller.search_buffer(title, account_name)
|
||||
if buffer_obj and hasattr(buffer_obj.buffer, "list"):
|
||||
try:
|
||||
count = buffer_obj.buffer.list.get_count()
|
||||
if count > 0:
|
||||
msg = _("{0}, {1} of {2}").format(title, buffer_obj.buffer.list.get_selected()+1, count)
|
||||
else:
|
||||
msg = _("{0}. Empty").format(title)
|
||||
except Exception:
|
||||
msg = _("{0}. Empty").format(title)
|
||||
output.speak(msg, True)
|
||||
|
||||
def destroy_status(self):
|
||||
pass
|
||||
|
||||
class ChatBuffer(BaseBuffer):
|
||||
"""Buffer for displaying messages in a conversation, similar to Mastodon's ConversationBuffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_chat_message"
|
||||
super(ChatBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "chat_messages"
|
||||
self.convo_id = kwargs.get("convo_id")
|
||||
self.sound = "dm_received.ogg"
|
||||
self._member_map_loaded = False
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def _update_member_map(self):
|
||||
"""Fetch conversation members to build a DID-to-name map for sender resolution."""
|
||||
try:
|
||||
convo = self.session.get_convo(self.convo_id)
|
||||
if not convo:
|
||||
return
|
||||
member_map = {}
|
||||
for m in getattr(convo, "members", []) or []:
|
||||
did = getattr(m, "did", None)
|
||||
if did:
|
||||
name = getattr(m, "display_name", None) or getattr(m, "handle", None) or "unknown"
|
||||
member_map[did] = name
|
||||
self.session.db[self.name + "_members"] = member_map
|
||||
except Exception:
|
||||
log.exception("Error fetching conversation members for DID resolution")
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.convo_id:
|
||||
return 0
|
||||
if not self._member_map_loaded:
|
||||
self._update_member_map()
|
||||
self._member_map_loaded = True
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = self.session.get_convo_messages(self.convo_id, limit=count)
|
||||
items = res.get("items", [])
|
||||
items = list(reversed(items))
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception:
|
||||
log.exception("Error fetching chat messages")
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
output.speak(_("This action is not supported for this buffer"), True)
|
||||
|
||||
def fav(self):
|
||||
pass
|
||||
|
||||
def unfav(self):
|
||||
pass
|
||||
|
||||
def can_share(self):
|
||||
return False
|
||||
|
||||
def destroy_status(self):
|
||||
pass
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
"""Enter key opens reply dialog in chat."""
|
||||
self.on_reply(None)
|
||||
|
||||
def on_reply(self, evt):
|
||||
"""Open dialog to send a message in this conversation."""
|
||||
if not self.convo_id:
|
||||
output.speak(_("Cannot send message: no conversation selected."), True)
|
||||
return
|
||||
|
||||
# Get conversation title from buffer name or use generic
|
||||
title = _("Send Message")
|
||||
if self.name and self.name.startswith(_("Chat: ")):
|
||||
title = self.name
|
||||
caption = _("Write your message here")
|
||||
|
||||
post = blueski_messages.post(session=self.session, title=title, caption=caption, text="")
|
||||
if post.message.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = post.get_data()
|
||||
if text:
|
||||
def do_send():
|
||||
try:
|
||||
self.session.send_chat_message(self.convo_id, text)
|
||||
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||
wx.CallAfter(output.speak, _("Message sent."))
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error sending chat message")
|
||||
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||
call_threaded(do_send)
|
||||
if hasattr(post.message, "Destroy"):
|
||||
post.message.Destroy()
|
||||
|
||||
def reply(self, *args, **kwargs):
|
||||
"""Handle reply action (from menu or keyboard shortcut)."""
|
||||
self.on_reply(None)
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
"""Global shortcut for DM."""
|
||||
self.on_reply(None)
|
||||
|
||||
def get_message(self):
|
||||
"""Return a readable summary for the selected chat message."""
|
||||
item = self.get_item()
|
||||
if item is None:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
item, self.session.db, self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return " ".join(composed)
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Return the text content of the selected chat message."""
|
||||
item = self.get_item()
|
||||
if item is None:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
item, self.session.db, self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return composed[1]
|
||||
|
||||
def view_item(self, item=None):
|
||||
"""View the selected chat message in a dialog."""
|
||||
msg = self.get_formatted_message()
|
||||
if not msg:
|
||||
output.speak(_("No message selected."), True)
|
||||
return
|
||||
viewer = blueski_messages.text(title=_("Chat message"), text=msg)
|
||||
viewer.message.ShowModal()
|
||||
viewer.message.Destroy()
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
"""Allow removing this buffer."""
|
||||
from wxUI import commonMessageDialogs
|
||||
if force == False:
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
else:
|
||||
dlg = widgetUtils.YES
|
||||
if dlg == widgetUtils.YES:
|
||||
if self.name in self.session.db:
|
||||
self.session.db.pop(self.name)
|
||||
return True
|
||||
elif dlg == widgetUtils.NO:
|
||||
return False
|
||||
@@ -0,0 +1,602 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.timeline")
|
||||
|
||||
|
||||
class HomeTimeline(BaseBuffer):
|
||||
"""Discover feed buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HomeTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "home_timeline"
|
||||
self.feed_uri = None
|
||||
self.next_cursor = None
|
||||
self.sound = "tweet_received.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not self.feed_uri:
|
||||
self.feed_uri = self._resolve_discover_feed(api)
|
||||
try:
|
||||
if self.feed_uri:
|
||||
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count})
|
||||
else:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching home timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
if self.feed_uri:
|
||||
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count, "cursor": self.next_cursor})
|
||||
else:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more home timeline: %s", e)
|
||||
|
||||
def _resolve_discover_feed(self, api):
|
||||
cached = self.session.db.get("discover_feed_uri")
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
res = api.app.bsky.feed.get_suggested_feeds({"limit": 50})
|
||||
for feed in getattr(res, "feeds", []):
|
||||
dn = getattr(feed, "displayName", "") or getattr(feed, "display_name", "")
|
||||
if "discover" in dn.lower():
|
||||
uri = getattr(feed, "uri", "")
|
||||
self.session.db["discover_feed_uri"] = uri
|
||||
return uri
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class FollowingTimeline(BaseBuffer):
|
||||
"""Following-only timeline (reverse-chronological)."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FollowingTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "following_timeline"
|
||||
self.next_cursor = None
|
||||
self.sound = "tweet_received.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching following timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological", "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more following timeline: %s", e)
|
||||
|
||||
|
||||
class NotificationBuffer(BaseBuffer):
|
||||
"""Notifications buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_notification"
|
||||
super(NotificationBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "notifications"
|
||||
self.sound = "notification_received.ogg"
|
||||
self.next_cursor = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def _hydrate_notifications(self, notifications):
|
||||
"""Fetch subject post text for like/repost notifications."""
|
||||
if not notifications:
|
||||
return notifications
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Collect URIs for likes/reposts that need subject post text
|
||||
uris_to_fetch = []
|
||||
for notif in notifications:
|
||||
reason = g(notif, "reason", "")
|
||||
if reason in ("like", "repost"):
|
||||
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||
if reason_subject and isinstance(reason_subject, str):
|
||||
uris_to_fetch.append(reason_subject)
|
||||
|
||||
if not uris_to_fetch:
|
||||
return notifications
|
||||
|
||||
# Fetch posts in batch
|
||||
posts_map = {}
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
if api and uris_to_fetch:
|
||||
# getPosts accepts up to 25 URIs at a time
|
||||
for i in range(0, len(uris_to_fetch), 25):
|
||||
batch = uris_to_fetch[i:i+25]
|
||||
res = api.app.bsky.feed.get_posts({"uris": batch})
|
||||
for post in getattr(res, "posts", []):
|
||||
uri = g(post, "uri")
|
||||
if uri:
|
||||
record = g(post, "record", {})
|
||||
text = g(record, "text", "")
|
||||
posts_map[uri] = text
|
||||
except Exception as e:
|
||||
log.error("Error fetching subject posts for notifications: %s", e)
|
||||
|
||||
# Attach subject post text to notifications
|
||||
enriched = []
|
||||
for notif in notifications:
|
||||
reason = g(notif, "reason", "")
|
||||
if reason in ("like", "repost"):
|
||||
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||
if reason_subject and reason_subject in posts_map:
|
||||
# Create a modified notification with subject post text
|
||||
if isinstance(notif, dict):
|
||||
notif = dict(notif)
|
||||
notif["_subject_text"] = posts_map[reason_subject]
|
||||
else:
|
||||
# For ATProto model objects, add as attribute
|
||||
try:
|
||||
notif._subject_text = posts_map[reason_subject]
|
||||
except AttributeError:
|
||||
pass
|
||||
enriched.append(notif)
|
||||
|
||||
return enriched
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||
notifications = list(getattr(res, "notifications", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not notifications:
|
||||
return 0
|
||||
notifications = self._hydrate_notifications(notifications)
|
||||
return self.process_items(notifications, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching notifications: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||
notifications = list(getattr(res, "notifications", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
notifications = self._hydrate_notifications(notifications)
|
||||
added = self.process_items(notifications, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more notifications: %s", e)
|
||||
|
||||
def add_new_item(self, notification):
|
||||
notifications = self._hydrate_notifications([notification])
|
||||
return self.process_items(notifications, play_sound=True)
|
||||
|
||||
|
||||
class Conversation(BaseBuffer):
|
||||
"""Thread/conversation view."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Conversation, self).__init__(*args, **kwargs)
|
||||
self.type = "conversation"
|
||||
self.root_uri = kwargs.get("uri")
|
||||
self.sound = "search_updated.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.root_uri:
|
||||
return 0
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri, "depth": 100, "parentHeight": 100})
|
||||
thread = getattr(res, "thread", None)
|
||||
if not thread:
|
||||
return 0
|
||||
|
||||
def g(obj, key, default=None):
|
||||
return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
|
||||
|
||||
final_items = []
|
||||
# Add ancestors
|
||||
ancestors = []
|
||||
parent = g(thread, "parent")
|
||||
while parent:
|
||||
ppost = g(parent, "post")
|
||||
if ppost:
|
||||
ancestors.insert(0, ppost)
|
||||
parent = g(parent, "parent")
|
||||
final_items.extend(ancestors)
|
||||
|
||||
# Traverse thread
|
||||
def traverse(node):
|
||||
if not node:
|
||||
return
|
||||
post = g(node, "post")
|
||||
if post:
|
||||
final_items.append(post)
|
||||
for r in (g(node, "replies") or []):
|
||||
traverse(r)
|
||||
|
||||
traverse(thread)
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
# Don't use process_items() because it applies reverse logic.
|
||||
# Conversations should always be chronological (oldest first).
|
||||
return self._add_items_chronological(final_items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching thread: %s", e)
|
||||
return 0
|
||||
|
||||
def _add_items_chronological(self, items, play_sound=True):
|
||||
"""Add items in chronological order (oldest first) without reverse logic."""
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
safe = True
|
||||
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||
|
||||
for item in items:
|
||||
self.session.db[self.name].append(item)
|
||||
post = self.compose_function(item, self.session.db, self.session.settings,
|
||||
relative_times=relative_times,
|
||||
show_screen_names=show_screen_names,
|
||||
safe=safe)
|
||||
self.buffer.list.insert_item(False, *post)
|
||||
|
||||
# Select the root post (first item after ancestors, or just the first)
|
||||
total = self.buffer.list.get_count()
|
||||
if total > 0:
|
||||
self.buffer.list.select_item(0)
|
||||
|
||||
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
|
||||
self.session.sound.play(self.sound)
|
||||
|
||||
return len(items)
|
||||
|
||||
|
||||
class LikesBuffer(BaseBuffer):
|
||||
"""User's liked posts."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LikesBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "likes"
|
||||
self.next_cursor = None
|
||||
self.sound = "favourite.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count})
|
||||
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching likes: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more likes: %s", e)
|
||||
|
||||
|
||||
class MentionsBuffer(BaseBuffer):
|
||||
"""Mentions, replies and quotes."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_notification"
|
||||
super(MentionsBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "mentions"
|
||||
self.sound = "mention_received.ogg"
|
||||
self.next_cursor = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
|
||||
if not mentions:
|
||||
return 0
|
||||
return self.process_items(mentions, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching mentions: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
|
||||
if mentions:
|
||||
added = self.process_items(mentions, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more mentions: %s", e)
|
||||
|
||||
def add_new_item(self, notification):
|
||||
if getattr(notification, "reason", "") in ("mention", "reply", "quote"):
|
||||
return self.process_items([notification], play_sound=True)
|
||||
return 0
|
||||
|
||||
|
||||
class SentBuffer(BaseBuffer):
|
||||
"""User's sent posts."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SentBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "sent"
|
||||
self.next_cursor = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api or not api.me:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies"})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not items:
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching sent posts: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api or not api.me:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies", "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more sent posts: %s", e)
|
||||
|
||||
|
||||
class UserTimeline(BaseBuffer):
|
||||
"""Timeline for a specific user."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.actor = kwargs.get("actor")
|
||||
self.handle = kwargs.get("handle")
|
||||
super(UserTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "user_timeline"
|
||||
self.next_cursor = None
|
||||
self._resolved_actor = None
|
||||
self.sound = "tweet_timeline.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.actor:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
actor = self.actor.strip().lstrip("@") if isinstance(self.actor, str) else self.actor
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
if isinstance(actor, str) and not actor.startswith("did:"):
|
||||
profile = self.session.get_profile(actor)
|
||||
if profile:
|
||||
did = profile.get("did") if isinstance(profile, dict) else getattr(profile, "did", None)
|
||||
if did:
|
||||
actor = did
|
||||
self._resolved_actor = actor
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": actor, "limit": count})
|
||||
items = list(getattr(res, "feed", []) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching user timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor or not self._resolved_actor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": self._resolved_actor, "limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more user timeline: %s", e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||
return False
|
||||
self.session.db.pop(self.name, None)
|
||||
timelines = self.session.settings["other_buffers"].get("timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
for key in (self.actor or "", self.handle or ""):
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
return True
|
||||
|
||||
|
||||
class SearchBuffer(BaseBuffer):
|
||||
"""Search results buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.search_query = kwargs.pop("query", "")
|
||||
super(SearchBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "search"
|
||||
self.next_cursor = None
|
||||
self.sound = "search_updated.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.search_query:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count})
|
||||
posts = list(getattr(res, "posts", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not posts:
|
||||
return 0
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
return self.process_items(posts, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error searching posts: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor or not self.search_query:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count, "cursor": self.next_cursor})
|
||||
posts = list(getattr(res, "posts", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(posts, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more search results: %s", e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||
return False
|
||||
self.session.db.pop(self.name, None)
|
||||
searches = self.session.settings["other_buffers"].get("searches") or []
|
||||
if isinstance(searches, str):
|
||||
searches = [s for s in searches.split(",") if s]
|
||||
if self.search_query in searches:
|
||||
searches.remove(self.search_query)
|
||||
self.session.settings["other_buffers"]["searches"] = searches
|
||||
self.session.settings.write()
|
||||
return True
|
||||
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
from sessions.blueski import compose
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.user")
|
||||
|
||||
class UserBuffer(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# We need compose_user for this buffer
|
||||
kwargs["compose_func"] = "compose_user"
|
||||
super(UserBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "user"
|
||||
self.next_cursor = None
|
||||
self.sound = "new_event.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.UserPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
api_method = self.kwargs.get("api_method")
|
||||
if not api_method: return 0
|
||||
|
||||
count = self.get_max_items()
|
||||
actor = (
|
||||
self.kwargs.get("actor")
|
||||
or self.kwargs.get("did")
|
||||
or self.kwargs.get("handle")
|
||||
or self.kwargs.get("id")
|
||||
)
|
||||
|
||||
try:
|
||||
if api_method in ("get_followers", "get_follows"):
|
||||
res = getattr(self.session, api_method)(actor=actor, limit=count)
|
||||
else:
|
||||
res = getattr(self.session, api_method)(limit=count)
|
||||
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching user list for %s: %s", self.name, e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
api_method = self.kwargs.get("api_method")
|
||||
if not api_method or not self.next_cursor:
|
||||
return
|
||||
|
||||
count = self.get_max_items()
|
||||
actor = (
|
||||
self.kwargs.get("actor")
|
||||
or self.kwargs.get("did")
|
||||
or self.kwargs.get("handle")
|
||||
or self.kwargs.get("id")
|
||||
)
|
||||
try:
|
||||
if api_method in ("get_followers", "get_follows"):
|
||||
res = getattr(self.session, api_method)(actor=actor, limit=count, cursor=self.next_cursor)
|
||||
else:
|
||||
res = getattr(self.session, api_method)(limit=count, cursor=self.next_cursor)
|
||||
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more user list items for %s: %s", self.name, e)
|
||||
|
||||
def _hydrate_profiles(self, items):
|
||||
if not items:
|
||||
return []
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def resolve_profile(obj):
|
||||
if g(obj, "handle") or g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = g(obj, key)
|
||||
if nested and (g(nested, "handle") or g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
actors = []
|
||||
for item in items:
|
||||
profile = resolve_profile(item)
|
||||
did = g(profile, "did")
|
||||
handle = g(profile, "handle")
|
||||
if did:
|
||||
actors.append(did)
|
||||
elif handle:
|
||||
actors.append(handle)
|
||||
|
||||
if not actors:
|
||||
return items
|
||||
|
||||
profiles = []
|
||||
if actors and hasattr(self.session, "get_profiles"):
|
||||
try:
|
||||
res = self.session.get_profiles(actors)
|
||||
profiles = res.get("items", []) or []
|
||||
except Exception:
|
||||
profiles = []
|
||||
# If batch profiles lack counts, hydrate with detailed profiles.
|
||||
if hasattr(self.session, "get_profile"):
|
||||
def counts_missing(profile_obj):
|
||||
p1 = g(profile_obj, "followersCount") or g(profile_obj, "followers_count")
|
||||
p2 = g(profile_obj, "followsCount") or g(profile_obj, "follows_count")
|
||||
p3 = g(profile_obj, "postsCount") or g(profile_obj, "posts_count")
|
||||
if p1 is None and p2 is None and p3 is None:
|
||||
return True
|
||||
return (p1 or 0) == 0 and (p2 or 0) == 0 and (p3 or 0) == 0
|
||||
|
||||
if not profiles:
|
||||
for actor in actors:
|
||||
try:
|
||||
p = self.session.get_profile(actor)
|
||||
if p:
|
||||
profiles.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
for idx, p in enumerate(profiles):
|
||||
if counts_missing(p):
|
||||
did = g(p, "did") or g(p, "handle")
|
||||
if not did:
|
||||
continue
|
||||
try:
|
||||
detailed = self.session.get_profile(did)
|
||||
if detailed:
|
||||
profiles[idx] = detailed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
profile_map = {}
|
||||
for p in profiles:
|
||||
did = g(p, "did")
|
||||
handle = g(p, "handle")
|
||||
if did:
|
||||
profile_map[did] = p
|
||||
if handle and handle not in profile_map:
|
||||
profile_map[handle] = p
|
||||
|
||||
def needs_replace(item, profile):
|
||||
if profile is None:
|
||||
return False
|
||||
base = resolve_profile(item)
|
||||
f1 = g(base, "followersCount") or g(base, "followers_count")
|
||||
f2 = g(base, "followsCount") or g(base, "follows_count")
|
||||
f3 = g(base, "postsCount") or g(base, "posts_count")
|
||||
p1 = g(profile, "followersCount") or g(profile, "followers_count")
|
||||
p2 = g(profile, "followsCount") or g(profile, "follows_count")
|
||||
p3 = g(profile, "postsCount") or g(profile, "posts_count")
|
||||
if f1 is None and f2 is None and f3 is None:
|
||||
return True
|
||||
if (f1 or 0) == 0 and (f2 or 0) == 0 and (f3 or 0) == 0:
|
||||
return (p1 or 0) != 0 or (p2 or 0) != 0 or (p3 or 0) != 0
|
||||
return False
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
base = resolve_profile(item)
|
||||
did = g(base, "did")
|
||||
handle = g(base, "handle")
|
||||
profile = profile_map.get(did) or profile_map.get(handle)
|
||||
if needs_replace(item, profile):
|
||||
enriched.append(profile)
|
||||
else:
|
||||
enriched.append(item)
|
||||
return enriched
|
||||
|
||||
class FollowersBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_followers"
|
||||
super(FollowersBuffer, self).__init__(*args, **kwargs)
|
||||
self.sound = "update_followers.ogg"
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||
timelines = self.session.settings["other_buffers"].get("followers_timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["followers_timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
except Exception as e:
|
||||
log.error("Error updating Bluesky followers timelines settings: %s", e)
|
||||
return True
|
||||
|
||||
class FollowingBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_follows"
|
||||
super(FollowingBuffer, self).__init__(*args, **kwargs)
|
||||
self.sound = "update_followers.ogg"
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||
timelines = self.session.settings["other_buffers"].get("following_timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["following_timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
except Exception as e:
|
||||
log.error("Error updating Bluesky following timelines settings: %s", e)
|
||||
return True
|
||||
|
||||
class BlocksBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_blocks"
|
||||
super(BlocksBuffer, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class PostUserListBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.post_uri = kwargs.get("post_uri")
|
||||
self.api_method = kwargs.get("api_method")
|
||||
super(PostUserListBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "post_user_list"
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.api_method or not self.post_uri:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = getattr(self.session, self.api_method)(self.post_uri, limit=count)
|
||||
items = res.get("items", [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching post user list for %s: %s", self.name, e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.api_method or not self.post_uri or not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = getattr(self.session, self.api_method)(self.post_uri, limit=count, cursor=self.next_cursor)
|
||||
items = res.get("items", [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more post user list items for %s: %s", self.name, e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
Reference in New Issue
Block a user