From 0e5a75048a5846d9ec028958b654474ea8d69ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Pav=C3=B3n=20Abi=C3=A1n?= Date: Tue, 22 Sep 2026 16:29:04 +0200 Subject: [PATCH] Fix duplicated posts in Bluesky threads and bogus list position in DMs Thread buffers rebuilt themselves from scratch on every refresh with no deduplication and no guard against overlapping runs, and the list control was mutated from the update timer's background thread. Threads opened from the same account also shared a single session.db entry, because the buffer name was derived from the author instead of the post. - Name conversation buffers after the post URI, and navigate to an already open thread instead of opening a second buffer for it. - Deduplicate thread items by URI, and refuse a refresh while one is in flight. - Move the clear-and-fill step onto the GUI thread via wx.CallAfter. - Stop start_buffers() from starting a buffer that create_buffer() already started, since needs_init was never cleared. The direct messages list never focused an item after being rebuilt, so its position was announced as "0 of 1", which cannot exist. The conversation list now focuses an item whenever it cannot restore the previous selection, and the buffer navigation announcements no longer report a position while nothing is focused. Verified against the live Bluesky API that app.bsky.feed.getPostThread itself returns no repeated posts, so the duplication was entirely on our side. Co-Authored-By: Claude Opus 5 --- src/controller/blueski/handler.py | 18 +++++-- src/controller/buffers/blueski/chat.py | 19 +++++++- src/controller/buffers/blueski/timeline.py | 56 ++++++++++++++++++++-- src/controller/mainController.py | 52 ++++++++++++-------- 4 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/controller/blueski/handler.py b/src/controller/blueski/handler.py index 44eda881..6620f54f 100644 --- a/src/controller/blueski/handler.py +++ b/src/controller/blueski/handler.py @@ -587,16 +587,28 @@ class Handler: display_name = g(author, "displayName") or g(author, "display_name") label = handle or display_name or _("Unknown") title = _("Conversation with {0}").format(label) - + # The buffer name identifies the buffer and keys its entry in session.db, so + # it has to be derived from the post instead of the author. Two threads from + # the same account would otherwise share a single database entry. + buffer_name = "%s-conversation" % (uri,) + account_name = buffer.session.get_name() if hasattr(buffer.session, "get_name") else None + + # If this thread is already open, go to it rather than opening it twice. + if account_name != None and controller.search_buffer(buffer_name, account_name): + index = controller.view.search(buffer_name, account_name) + if index is not None: + controller.view.change_buffer(index) + return + from pubsub import pub pub.sendMessage( "createBuffer", buffer_type="conversation", session_type="blueski", buffer_title=title, - parent_tab=controller.view.search(buffer.session.get_name(), buffer.session.get_name()) if hasattr(buffer.session, "get_name") else None, + parent_tab=controller.view.search(account_name, account_name) if account_name != None else None, start=True, - kwargs=dict(parent=controller.view.nb, name=title, session=buffer.session, uri=uri, sound="search_updated.ogg") + kwargs=dict(parent=controller.view.nb, name=buffer_name, session=buffer.session, uri=uri, sound="search_updated.ogg") ) def open_timeline(self, controller, buffer, default="posts"): diff --git a/src/controller/buffers/blueski/chat.py b/src/controller/buffers/blueski/chat.py index b51ac948..53280317 100644 --- a/src/controller/buffers/blueski/chat.py +++ b/src/controller/buffers/blueski/chat.py @@ -257,12 +257,25 @@ class ConversationListBuffer(BaseBuffer): self.buffer.list.insert_item(False, *row) # Restore selection if possible + restored = False 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) + restored = True break + # This buffer rebuilds its list on every refresh and does not go through + # put_items_on_list(), so nothing would be focused on the first load or + # whenever the previously selected conversation is gone. Screen readers + # then announce the position as "0 of n", so focus an item ourselves. + if restored == False: + total = self.buffer.list.get_count() + if total > 0: + if self.session.settings["general"].get("reverse_timelines", False): + self.buffer.list.select_item(0) + else: + self.buffer.list.select_item(total-1) # Sound and auto-read only when something actually changed if new_count > 0: @@ -397,7 +410,11 @@ class ConversationListBuffer(BaseBuffer): 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) + selected = buffer_obj.buffer.list.get_selected() + if selected < 0: + selected = count-1 + buffer_obj.buffer.list.select_item(selected) + msg = _("{0}, {1} of {2}").format(title, selected+1, count) else: msg = _("{0}. Empty").format(title) except Exception: diff --git a/src/controller/buffers/blueski/timeline.py b/src/controller/buffers/blueski/timeline.py index 1dc87579..149f5797 100644 --- a/src/controller/buffers/blueski/timeline.py +++ b/src/controller/buffers/blueski/timeline.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import logging +import wx import output from .base import BaseBuffer from wxUI.buffers.blueski import panels as BlueskiPanels @@ -237,6 +238,9 @@ class Conversation(BaseBuffer): self.type = "conversation" self.root_uri = kwargs.get("uri") self.sound = "search_updated.ogg" + # Set while a refresh is in flight. This buffer rebuilds itself from scratch + # on every call, so two overlapping runs would show every post twice. + self.loading = False def create_buffer(self, parent, name): self.buffer = BlueskiPanels.HomePanel(parent, name) @@ -245,11 +249,15 @@ class Conversation(BaseBuffer): def start_stream(self, mandatory=False, play_sound=True): if not self.root_uri: return 0 - api = self.session._ensure_client() + if self.loading == True: + return 0 + self.loading = True try: + api = self.session._ensure_client() res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri, "depth": 100, "parentHeight": 100}) thread = getattr(res, "thread", None) if not thread: + self.loading = False return 0 def g(obj, key, default=None): @@ -277,14 +285,42 @@ class Conversation(BaseBuffer): traverse(r) traverse(thread) + + # A post must only show up once, no matter how the thread is shaped. + seen = set() + unique_items = [] + for item in final_items: + uri = g(item, "uri") + if uri: + if uri in seen: + continue + seen.add(uri) + unique_items.append(item) + final_items = unique_items + except Exception as e: + log.error("Error fetching thread: %s", e) + self.loading = False + return 0 + # The list control may only be touched from the GUI thread, and start_stream + # is also called from the update timer, which runs in a background thread. + if wx.IsMainThread(): + self._rebuild_list(final_items, play_sound) + else: + wx.CallAfter(self._rebuild_list, final_items, play_sound) + return len(final_items) + + def _rebuild_list(self, items, play_sound=True): + """Replace the contents of this buffer with the given thread items.""" + try: 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) + self._add_items_chronological(items, play_sound) except Exception as e: - log.error("Error fetching thread: %s", e) - return 0 + log.error("Error building thread: %s", e) + finally: + self.loading = False def _add_items_chronological(self, items, play_sound=True): """Add items in chronological order (oldest first) without reverse logic.""" @@ -295,7 +331,17 @@ class Conversation(BaseBuffer): relative_times = self.session.settings["general"].get("relative_times", False) show_screen_names = self.session.settings["general"].get("show_screen_names", False) + # Unlike process_items(), this method does not deduplicate on its own, so + # guard against a post reaching the list more than once. + added = 0 + seen = set() for item in items: + uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None) + if uri: + if uri in seen: + continue + seen.add(uri) + added += 1 self.session.db[self.name].append(item) post = self.compose_function(item, self.session.db, self.session.settings, relative_times=relative_times, @@ -311,7 +357,7 @@ class Conversation(BaseBuffer): if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]: self.session.sound.play(self.sound) - return len(items) + return added class LikesBuffer(BaseBuffer): diff --git a/src/controller/mainController.py b/src/controller/mainController.py index 0bca1c15..760a84df 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -448,6 +448,9 @@ class Controller(object): except ValueError: commonMessageDialogs.unauthorized() return + # This buffer has already retrieved its items, so start_buffers() + # must not start it a second time. + buffer.needs_init = False self.buffers.append(buffer) if parent_tab == None: log.debug("Appending buffer {}...".format(buffer,)) @@ -1090,6 +1093,31 @@ class Controller(object): # except: # pass + def announce_buffer_position(self, buffer, with_account=False): + """ Speaks the current buffer name plus the position within its list. + + If the list holds items but none of them is focused yet, this focuses one + before speaking. Otherwise get_selected() returns -1 and we would announce + a position of "0 of n", which does not exist.""" + try: + count = buffer.buffer.list.get_count() + if count == 0: + raise ValueError("Empty buffer") + selected = buffer.buffer.list.get_selected() + if selected < 0: + reverse = False + if getattr(buffer, "session", None) != None: + reverse = buffer.session.settings["general"].get("reverse_timelines", False) + selected = 0 if reverse else count-1 + buffer.buffer.list.select_item(selected) + if with_account == True: + msg = _(u"%s. %s, %s of %s") % (buffer.account, self.view.get_buffer_text(), selected+1, count) + else: + msg = _(u"%s, %s of %s") % (self.view.get_buffer_text(), selected+1, count) + except: + msg = _(u"%s. Empty") % (self.view.get_buffer_text(),) + output.speak(msg, True) + def left(self, *args, **kwargs): buff = self.view.get_current_buffer_pos() buffer = self.get_current_buffer() @@ -1103,11 +1131,7 @@ class Controller(object): while self.get_current_buffer().invisible == False: self.skip_buffer(False) buffer = self.get_current_buffer() if self.showing == True: buffer.buffer.set_focus_in_list() - try: - msg = _(u"%s, %s of %s") % (self.view.get_buffer_text(), buffer.buffer.list.get_selected()+1, buffer.buffer.list.get_count()) - except: - msg = _(u"%s. Empty") % (self.view.get_buffer_text(),) - output.speak(msg, True) + self.announce_buffer_position(buffer) def right(self, *args, **kwargs): buff = self.view.get_current_buffer_pos() @@ -1122,11 +1146,7 @@ class Controller(object): while self.get_current_buffer().invisible == False: self.skip_buffer(True) buffer = self.get_current_buffer() if self.showing == True: buffer.buffer.set_focus_in_list() - try: - msg = _(u"%s, %s of %s") % (self.view.get_buffer_text(), buffer.buffer.list.get_selected()+1, buffer.buffer.list.get_count()) - except: - msg = _(u"%s. Empty") % (self.view.get_buffer_text(),) - output.speak(msg, True) + self.announce_buffer_position(buffer) def next_account(self, *args, **kwargs): if not self.accounts: @@ -1153,11 +1173,7 @@ class Controller(object): self.view.change_buffer(buff) buffer = self.get_current_buffer() if self.showing == True: buffer.buffer.set_focus_in_list() - try: - msg = _(u"%s. %s, %s of %s") % (buffer.account, self.view.get_buffer_text(), buffer.buffer.list.get_selected()+1, buffer.buffer.list.get_count()) - except: - msg = _(u"%s. Empty") % (self.view.get_buffer_text(),) - output.speak(msg, True) + self.announce_buffer_position(buffer, with_account=True) def previous_account(self, *args, **kwargs): if not self.accounts: @@ -1184,11 +1200,7 @@ class Controller(object): self.view.change_buffer(buff) buffer = self.get_current_buffer() if self.showing == True: buffer.buffer.set_focus_in_list() - try: - msg = _(u"%s. %s, %s of %s") % (buffer.account, self.view.get_buffer_text(), buffer.buffer.list.get_selected()+1, buffer.buffer.list.get_count()) - except: - msg = _(u"%s. Empty") % (self.view.get_buffer_text(),) - output.speak(msg, True) + self.announce_buffer_position(buffer, with_account=True) def go_home(self): buffer = self.get_current_buffer()