Some cleanup, a couple new features added.
This commit is contained in:
23
CLAUDE.md
23
CLAUDE.md
@ -197,16 +197,21 @@ Timeline Item: "Alice posted: Hello world (3 replies, collapsed)"
|
||||
```
|
||||
|
||||
### Key UI Components
|
||||
- **Timeline View**: AccessibleTreeWidget showing posts and threads
|
||||
- **Timeline View**: AccessibleTreeWidget showing posts and threads with pagination
|
||||
- **Timeline Tabs**: Home, Mentions, Local, Federated timeline switching
|
||||
- **Compose Dialog**: Modal for creating posts with accessibility
|
||||
- **Settings Dialog**: Sound pack selection, accessibility options
|
||||
- **Settings Dialog**: Sound pack, desktop notifications, accessibility options
|
||||
- **Login Dialog**: Instance selection and authentication
|
||||
- **URL Selection Dialog**: Choose from multiple URLs in posts
|
||||
- **Context Menu**: Copy, URL opening, reply, boost, favorite actions
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- **Ctrl+N**: New post
|
||||
- **Ctrl+R**: Reply to selected post
|
||||
- **Ctrl+B**: Boost selected post
|
||||
- **Ctrl+F**: Favorite selected post
|
||||
- **Ctrl+C**: Copy selected post to clipboard
|
||||
- **Ctrl+U**: Open URLs from selected post in browser
|
||||
- **F5**: Refresh timeline
|
||||
- **Ctrl+,**: Settings
|
||||
- **Escape**: Close dialogs
|
||||
@ -281,10 +286,24 @@ mention_enabled = true
|
||||
mention_volume = 1.0
|
||||
# ... other sound settings
|
||||
|
||||
[notifications]
|
||||
enabled = true
|
||||
direct_messages = true
|
||||
mentions = true
|
||||
boosts = false
|
||||
favorites = false
|
||||
follows = true
|
||||
timeline_updates = false
|
||||
|
||||
[timeline]
|
||||
posts_per_page = 40
|
||||
|
||||
[accessibility]
|
||||
announce_thread_state = true
|
||||
auto_expand_mentions = false
|
||||
keyboard_navigation_wrap = true
|
||||
page_step_size = 5
|
||||
verbose_announcements = true
|
||||
```
|
||||
|
||||
### Account Storage
|
||||
|
36
README.md
36
README.md
@ -35,6 +35,36 @@ Bifrost includes a sophisticated sound system with:
|
||||
- **Plyer**: Cross-platform desktop notifications
|
||||
- **XDG Base Directory**: Standards-compliant configuration storage
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Timeline Navigation
|
||||
- **Ctrl+1**: Switch to Home timeline
|
||||
- **Ctrl+2**: Switch to Mentions/Notifications timeline
|
||||
- **Ctrl+3**: Switch to Local timeline
|
||||
- **Ctrl+4**: Switch to Federated timeline
|
||||
- **Ctrl+Tab**: Switch between timeline tabs
|
||||
- **F5**: Refresh current timeline
|
||||
|
||||
### Post Actions
|
||||
- **Ctrl+N**: Compose new post
|
||||
- **Ctrl+R**: Reply to selected post
|
||||
- **Ctrl+B**: Boost/reblog selected post
|
||||
- **Ctrl+F**: Favorite selected post
|
||||
- **Ctrl+C**: Copy selected post to clipboard
|
||||
- **Ctrl+U**: Open URLs from selected post in browser
|
||||
|
||||
### Navigation
|
||||
- **Arrow Keys**: Navigate through posts
|
||||
- **Page Up/Down**: Jump multiple posts
|
||||
- **Home/End**: Go to first/last post
|
||||
- **Enter**: Expand/collapse threads
|
||||
- **Tab**: Move between interface elements
|
||||
|
||||
### Application
|
||||
- **Ctrl+,**: Open Settings
|
||||
- **Ctrl+Shift+A**: Add new account
|
||||
- **Ctrl+Q**: Quit application
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@ -44,6 +74,12 @@ pip install -r requirements.txt
|
||||
python bifrost.py
|
||||
```
|
||||
|
||||
Or on Arch Linux:
|
||||
```bash
|
||||
sudo pacman -S python-pyside6 python-requests python-simpleaudio
|
||||
yay -S python-plyer
|
||||
```
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
- Complete keyboard navigation
|
||||
|
@ -42,6 +42,27 @@ class AccessibleTreeWidget(QTreeWidget):
|
||||
super().keyPressEvent(event)
|
||||
return
|
||||
|
||||
# Handle Enter key for special items (like "Load more")
|
||||
if key == Qt.Key_Return or key == Qt.Key_Enter:
|
||||
special_data = current.data(0, Qt.UserRole)
|
||||
if special_data == "load_more":
|
||||
# Emit signal for load more action
|
||||
if hasattr(self.parent(), 'load_more_posts'):
|
||||
self.parent().load_more_posts()
|
||||
return
|
||||
|
||||
# Handle copy to clipboard shortcut
|
||||
if key == Qt.Key_C and event.modifiers() & Qt.ControlModifier:
|
||||
if hasattr(self.parent(), 'copy_post_to_clipboard'):
|
||||
self.parent().copy_post_to_clipboard()
|
||||
return
|
||||
|
||||
# Handle open URLs shortcut
|
||||
if key == Qt.Key_U and event.modifiers() & Qt.ControlModifier:
|
||||
if hasattr(self.parent(), 'open_urls_in_browser'):
|
||||
self.parent().open_urls_in_browser()
|
||||
return
|
||||
|
||||
# Check for Shift modifier
|
||||
has_shift = event.modifiers() & Qt.ShiftModifier
|
||||
|
||||
@ -204,10 +225,27 @@ class AccessibleTreeWidget(QTreeWidget):
|
||||
def on_item_expanded(self, item: QTreeWidgetItem):
|
||||
"""Handle item expansion"""
|
||||
self.announce_item_state(item, "expanded")
|
||||
# Make child items accessible
|
||||
self.update_child_accessibility(item, True)
|
||||
|
||||
def on_item_collapsed(self, item: QTreeWidgetItem):
|
||||
"""Handle item collapse"""
|
||||
self.announce_item_state(item, "collapsed")
|
||||
# Hide child items from screen readers
|
||||
self.update_child_accessibility(item, False)
|
||||
|
||||
def update_child_accessibility(self, item: QTreeWidgetItem, visible: bool):
|
||||
"""Update accessibility properties of child items"""
|
||||
for i in range(item.childCount()):
|
||||
child = item.child(i)
|
||||
if visible:
|
||||
# Make child accessible
|
||||
child.setFlags(child.flags() | Qt.ItemIsEnabled)
|
||||
child.setData(0, Qt.AccessibleDescriptionRole, "") # Clear hidden marker
|
||||
else:
|
||||
# Hide from screen readers but keep in tree
|
||||
child.setData(0, Qt.AccessibleDescriptionRole, "hidden")
|
||||
# Don't disable completely as it affects navigation
|
||||
|
||||
def announce_item_state(self, item: QTreeWidgetItem, state: str):
|
||||
"""Announce item state change for screen readers"""
|
||||
|
@ -166,6 +166,41 @@ class MainWindow(QMainWindow):
|
||||
federated_action.triggered.connect(lambda: self.switch_timeline(3))
|
||||
timeline_menu.addAction(federated_action)
|
||||
|
||||
# Post menu
|
||||
post_menu = menubar.addMenu("&Post")
|
||||
|
||||
# Reply action
|
||||
reply_action = QAction("&Reply", self)
|
||||
reply_action.setShortcut(QKeySequence("Ctrl+R"))
|
||||
reply_action.triggered.connect(self.reply_to_current_post)
|
||||
post_menu.addAction(reply_action)
|
||||
|
||||
# Boost action
|
||||
boost_action = QAction("&Boost", self)
|
||||
boost_action.setShortcut(QKeySequence("Ctrl+B"))
|
||||
boost_action.triggered.connect(self.boost_current_post)
|
||||
post_menu.addAction(boost_action)
|
||||
|
||||
# Favorite action
|
||||
favorite_action = QAction("&Favorite", self)
|
||||
favorite_action.setShortcut(QKeySequence("Ctrl+F"))
|
||||
favorite_action.triggered.connect(self.favorite_current_post)
|
||||
post_menu.addAction(favorite_action)
|
||||
|
||||
post_menu.addSeparator()
|
||||
|
||||
# Copy action
|
||||
copy_action = QAction("&Copy to Clipboard", self)
|
||||
copy_action.setShortcut(QKeySequence("Ctrl+C"))
|
||||
copy_action.triggered.connect(self.copy_current_post)
|
||||
post_menu.addAction(copy_action)
|
||||
|
||||
# Open URLs action
|
||||
urls_action = QAction("Open &URLs in Browser", self)
|
||||
urls_action.setShortcut(QKeySequence("Ctrl+U"))
|
||||
urls_action.triggered.connect(self.open_current_post_urls)
|
||||
post_menu.addAction(urls_action)
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Additional shortcuts that don't need menu items
|
||||
@ -292,6 +327,53 @@ class MainWindow(QMainWindow):
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_error()
|
||||
self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000)
|
||||
|
||||
def get_selected_post(self):
|
||||
"""Get the currently selected post from timeline"""
|
||||
current_item = self.timeline.currentItem()
|
||||
if current_item:
|
||||
return current_item.data(0, Qt.UserRole)
|
||||
return None
|
||||
|
||||
def reply_to_current_post(self):
|
||||
"""Reply to the currently selected post"""
|
||||
post = self.get_selected_post()
|
||||
if post:
|
||||
self.reply_to_post(post)
|
||||
else:
|
||||
self.status_bar.showMessage("No post selected", 2000)
|
||||
|
||||
def boost_current_post(self):
|
||||
"""Boost the currently selected post"""
|
||||
post = self.get_selected_post()
|
||||
if post:
|
||||
self.boost_post(post)
|
||||
else:
|
||||
self.status_bar.showMessage("No post selected", 2000)
|
||||
|
||||
def favorite_current_post(self):
|
||||
"""Favorite the currently selected post"""
|
||||
post = self.get_selected_post()
|
||||
if post:
|
||||
self.favorite_post(post)
|
||||
else:
|
||||
self.status_bar.showMessage("No post selected", 2000)
|
||||
|
||||
def copy_current_post(self):
|
||||
"""Copy the currently selected post to clipboard"""
|
||||
post = self.get_selected_post()
|
||||
if post:
|
||||
self.timeline.copy_post_to_clipboard(post)
|
||||
else:
|
||||
self.status_bar.showMessage("No post selected", 2000)
|
||||
|
||||
def open_current_post_urls(self):
|
||||
"""Open URLs from the currently selected post"""
|
||||
post = self.get_selected_post()
|
||||
if post:
|
||||
self.timeline.open_urls_in_browser(post)
|
||||
else:
|
||||
self.status_bar.showMessage("No post selected", 2000)
|
||||
|
||||
def show_first_time_setup(self):
|
||||
"""Show first-time setup dialog"""
|
||||
|
@ -204,6 +204,18 @@ class SettingsDialog(QDialog):
|
||||
sr_layout.addWidget(self.announce_thread_state)
|
||||
|
||||
layout.addWidget(sr_group)
|
||||
|
||||
# Timeline settings
|
||||
timeline_group = QGroupBox("Timeline Settings")
|
||||
timeline_layout = QFormLayout(timeline_group)
|
||||
|
||||
self.posts_per_page = QSpinBox()
|
||||
self.posts_per_page.setRange(10, 200)
|
||||
self.posts_per_page.setAccessibleName("Posts Per Page")
|
||||
self.posts_per_page.setAccessibleDescription("Number of posts to load at once in timeline")
|
||||
timeline_layout.addRow("Posts per page:", self.posts_per_page)
|
||||
|
||||
layout.addWidget(timeline_group)
|
||||
layout.addStretch()
|
||||
|
||||
self.tabs.addTab(accessibility_widget, "A&ccessibility")
|
||||
@ -270,6 +282,9 @@ class SettingsDialog(QDialog):
|
||||
self.verbose_announcements.setChecked(bool(self.settings.get('accessibility', 'verbose_announcements', True)))
|
||||
self.announce_thread_state.setChecked(bool(self.settings.get('accessibility', 'announce_thread_state', True)))
|
||||
|
||||
# Timeline settings
|
||||
self.posts_per_page.setValue(int(self.settings.get('timeline', 'posts_per_page', 40) or 40))
|
||||
|
||||
def apply_settings(self):
|
||||
"""Apply the current settings without closing the dialog"""
|
||||
# Audio settings
|
||||
@ -296,6 +311,9 @@ class SettingsDialog(QDialog):
|
||||
self.settings.set('accessibility', 'verbose_announcements', self.verbose_announcements.isChecked())
|
||||
self.settings.set('accessibility', 'announce_thread_state', self.announce_thread_state.isChecked())
|
||||
|
||||
# Timeline settings
|
||||
self.settings.set('timeline', 'posts_per_page', self.posts_per_page.value())
|
||||
|
||||
# Save to file
|
||||
self.settings.save_settings()
|
||||
|
||||
|
@ -2,10 +2,12 @@
|
||||
Timeline view widget for displaying posts and threads
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu, QDialog, QVBoxLayout, QListWidget, QDialogButtonBox, QLabel
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QAction
|
||||
from typing import Optional
|
||||
from PySide6.QtGui import QAction, QClipboard
|
||||
from typing import Optional, List
|
||||
import re
|
||||
import webbrowser
|
||||
|
||||
from accessibility.accessible_tree import AccessibleTreeWidget
|
||||
from audio.sound_manager import SoundManager
|
||||
@ -34,6 +36,7 @@ class TimelineView(AccessibleTreeWidget):
|
||||
self.account_manager = account_manager
|
||||
self.activitypub_client = None
|
||||
self.posts = [] # Store loaded posts
|
||||
self.oldest_post_id = None # Track for pagination
|
||||
self.setup_ui()
|
||||
self.refresh()
|
||||
|
||||
@ -86,12 +89,19 @@ class TimelineView(AccessibleTreeWidget):
|
||||
)
|
||||
|
||||
try:
|
||||
# Get posts per page from settings
|
||||
posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40)
|
||||
|
||||
# Fetch timeline or notifications
|
||||
if self.timeline_type == "notifications":
|
||||
timeline_data = self.activitypub_client.get_notifications(limit=20)
|
||||
timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page)
|
||||
else:
|
||||
timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=20)
|
||||
timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=posts_per_page)
|
||||
self.load_timeline_data(timeline_data)
|
||||
|
||||
# Track oldest post for pagination
|
||||
if timeline_data:
|
||||
self.oldest_post_id = timeline_data[-1]['id']
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch timeline: {e}")
|
||||
# Show error message instead of sample data
|
||||
@ -158,31 +168,80 @@ class TimelineView(AccessibleTreeWidget):
|
||||
|
||||
def build_threaded_timeline(self):
|
||||
"""Build threaded timeline from posts"""
|
||||
# Group posts by conversation
|
||||
conversations = {}
|
||||
top_level_posts = []
|
||||
# Find thread roots and flatten all replies under them
|
||||
thread_roots = {} # Maps thread root ID to list of all posts in thread
|
||||
orphaned_posts = [] # Posts that couldn't find their thread root
|
||||
|
||||
# First pass: identify thread roots (posts with no in_reply_to_id)
|
||||
for post in self.posts:
|
||||
if not post.in_reply_to_id:
|
||||
thread_roots[post.id] = [post]
|
||||
|
||||
# Second pass: assign all replies to their thread root
|
||||
for post in self.posts:
|
||||
if post.in_reply_to_id:
|
||||
# This is a reply
|
||||
if post.in_reply_to_id not in conversations:
|
||||
conversations[post.in_reply_to_id] = []
|
||||
conversations[post.in_reply_to_id].append(post)
|
||||
else:
|
||||
# This is a top-level post
|
||||
top_level_posts.append(post)
|
||||
|
||||
# Create tree items
|
||||
for post in top_level_posts:
|
||||
post_item = self.create_post_item(post)
|
||||
self.addTopLevelItem(post_item)
|
||||
# Find the thread root for this reply
|
||||
root_id = self.find_thread_root(post, self.posts)
|
||||
if root_id and root_id in thread_roots:
|
||||
thread_roots[root_id].append(post)
|
||||
else:
|
||||
# Can't find thread root, treat as orphaned
|
||||
orphaned_posts.append(post)
|
||||
|
||||
# Create tree items - one root with all replies as direct children
|
||||
for root_id, thread_posts in thread_roots.items():
|
||||
root_post = thread_posts[0] # First post is always the root
|
||||
root_item = self.create_post_item(root_post)
|
||||
self.addTopLevelItem(root_item)
|
||||
|
||||
# Add replies if any
|
||||
if post.id in conversations:
|
||||
self.add_replies(post_item, conversations[post.id], conversations)
|
||||
# Add all other posts in thread as direct children (flattened)
|
||||
for post in thread_posts[1:]:
|
||||
reply_item = self.create_post_item(post)
|
||||
reply_item.setData(0, Qt.UserRole + 1, post.in_reply_to_id) # Store what this replies to
|
||||
root_item.addChild(reply_item)
|
||||
|
||||
# Add orphaned posts as top-level items
|
||||
for post in orphaned_posts:
|
||||
orphaned_item = self.create_post_item(post)
|
||||
self.addTopLevelItem(orphaned_item)
|
||||
|
||||
# Collapse all initially
|
||||
# Add "Load more posts" item if we have posts
|
||||
if self.posts:
|
||||
self.add_load_more_item()
|
||||
|
||||
# Collapse all initially and update accessibility
|
||||
self.collapseAll()
|
||||
# Ensure collapsed items are properly marked for screen readers
|
||||
for i in range(self.topLevelItemCount()):
|
||||
top_item = self.topLevelItem(i)
|
||||
if top_item.childCount() > 0:
|
||||
self.update_child_accessibility(top_item, False)
|
||||
|
||||
def find_thread_root(self, post, all_posts):
|
||||
"""Find the root post ID for a given reply by walking up the chain"""
|
||||
current_post = post
|
||||
visited = set() # Prevent infinite loops
|
||||
|
||||
while current_post and current_post.in_reply_to_id and current_post.id not in visited:
|
||||
visited.add(current_post.id)
|
||||
# Find the parent post
|
||||
parent_post = None
|
||||
for p in all_posts:
|
||||
if p.id == current_post.in_reply_to_id:
|
||||
parent_post = p
|
||||
break
|
||||
|
||||
if parent_post:
|
||||
if not parent_post.in_reply_to_id:
|
||||
# Found the root
|
||||
return parent_post.id
|
||||
current_post = parent_post
|
||||
else:
|
||||
# Parent not found, current post becomes root
|
||||
break
|
||||
|
||||
# If we couldn't find a proper root, use the post's direct parent
|
||||
return post.in_reply_to_id
|
||||
|
||||
def create_post_item(self, post: Post) -> QTreeWidgetItem:
|
||||
"""Create a tree item for a post"""
|
||||
@ -196,15 +255,269 @@ class TimelineView(AccessibleTreeWidget):
|
||||
|
||||
return item
|
||||
|
||||
def add_replies(self, parent_item: QTreeWidgetItem, replies, all_conversations):
|
||||
"""Recursively add replies to a post"""
|
||||
for reply in replies:
|
||||
reply_item = self.create_post_item(reply)
|
||||
parent_item.addChild(reply_item)
|
||||
def copy_post_to_clipboard(self, post: Optional[Post] = None):
|
||||
"""Copy the selected post's content to clipboard"""
|
||||
if not post:
|
||||
post = self.get_selected_post()
|
||||
|
||||
if not post:
|
||||
return
|
||||
|
||||
# Add nested replies
|
||||
if reply.id in all_conversations:
|
||||
self.add_replies(reply_item, all_conversations[reply.id], all_conversations)
|
||||
# Get the full post content
|
||||
content = post.get_content_text()
|
||||
author = post.get_display_name()
|
||||
|
||||
# Format for clipboard
|
||||
clipboard_text = f"{author}:\n{content}"
|
||||
|
||||
# Copy to clipboard
|
||||
from PySide6.QtWidgets import QApplication
|
||||
clipboard = QApplication.clipboard()
|
||||
clipboard.setText(clipboard_text)
|
||||
|
||||
# Show feedback
|
||||
if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'):
|
||||
self.parent().status_bar.showMessage("Post copied to clipboard", 2000)
|
||||
|
||||
def extract_urls_from_post(self, post: Optional[Post] = None) -> List[str]:
|
||||
"""Extract URLs from post content"""
|
||||
if not post:
|
||||
post = self.get_selected_post()
|
||||
|
||||
if not post:
|
||||
return []
|
||||
|
||||
content = post.get_content_text()
|
||||
|
||||
# URL regex pattern - matches http/https URLs, more comprehensive
|
||||
url_pattern = r'https?://[^\s<>"\'`\)\]\}]+'
|
||||
urls = re.findall(url_pattern, content)
|
||||
|
||||
# Also check the original HTML content for href attributes
|
||||
html_content = post.content if hasattr(post, 'content') else ""
|
||||
href_pattern = r'href=["\']([^"\']+)["\']'
|
||||
href_urls = re.findall(href_pattern, html_content)
|
||||
|
||||
# Combine and filter URLs
|
||||
all_urls = urls + href_urls
|
||||
filtered_urls = []
|
||||
for url in all_urls:
|
||||
if url.startswith(('http://', 'https://')):
|
||||
filtered_urls.append(url)
|
||||
|
||||
return list(set(filtered_urls)) # Remove duplicates
|
||||
|
||||
def open_urls_in_browser(self, post: Optional[Post] = None):
|
||||
"""Open URLs from post in browser"""
|
||||
urls = self.extract_urls_from_post(post)
|
||||
|
||||
if not urls:
|
||||
if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'):
|
||||
self.parent().status_bar.showMessage("No URLs found in post", 2000)
|
||||
return
|
||||
|
||||
if len(urls) == 1:
|
||||
# Single URL - open directly
|
||||
try:
|
||||
webbrowser.open(urls[0])
|
||||
if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'):
|
||||
self.parent().status_bar.showMessage(f"Opened URL in browser", 2000)
|
||||
except Exception as e:
|
||||
if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'):
|
||||
self.parent().status_bar.showMessage(f"Failed to open URL: {str(e)}", 3000)
|
||||
else:
|
||||
# Multiple URLs - show selection dialog
|
||||
self.show_url_selection_dialog(urls)
|
||||
|
||||
def show_url_selection_dialog(self, urls: List[str]):
|
||||
"""Show dialog to select which URL to open"""
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Select URL to Open")
|
||||
dialog.setMinimumSize(500, 300)
|
||||
dialog.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(dialog)
|
||||
|
||||
# Label
|
||||
label = QLabel(f"Found {len(urls)} URLs in this post. Select one to open:")
|
||||
label.setAccessibleName("URL Selection")
|
||||
layout.addWidget(label)
|
||||
|
||||
# URL list
|
||||
url_list = QListWidget()
|
||||
url_list.setAccessibleName("URL List")
|
||||
for url in urls:
|
||||
url_list.addItem(url)
|
||||
url_list.setCurrentRow(0) # Select first item
|
||||
layout.addWidget(url_list)
|
||||
|
||||
# Buttons
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(dialog.accept)
|
||||
button_box.rejected.connect(dialog.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
current_item = url_list.currentItem()
|
||||
if current_item:
|
||||
selected_url = current_item.text()
|
||||
try:
|
||||
webbrowser.open(selected_url)
|
||||
if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'):
|
||||
self.parent().status_bar.showMessage(f"Opened URL in browser", 2000)
|
||||
except Exception as e:
|
||||
if hasattr(self, 'parent') and hasattr(self.parent(), 'status_bar'):
|
||||
self.parent().status_bar.showMessage(f"Failed to open URL: {str(e)}", 3000)
|
||||
|
||||
def add_load_more_item(self):
|
||||
"""Add a 'Load more posts' item at the bottom of the timeline"""
|
||||
load_more_item = QTreeWidgetItem(["Load more posts (Press Enter)"])
|
||||
load_more_item.setData(0, Qt.UserRole, "load_more") # Special marker
|
||||
load_more_item.setData(0, Qt.AccessibleTextRole, "Load more posts from timeline")
|
||||
self.addTopLevelItem(load_more_item)
|
||||
|
||||
def load_more_posts(self):
|
||||
"""Load more posts from the current timeline"""
|
||||
if not self.activitypub_client or not self.oldest_post_id:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get posts per page from settings
|
||||
posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40)
|
||||
|
||||
# Fetch more posts using max_id for pagination
|
||||
if self.timeline_type == "notifications":
|
||||
more_data = self.activitypub_client.get_notifications(
|
||||
limit=posts_per_page,
|
||||
max_id=self.oldest_post_id
|
||||
)
|
||||
else:
|
||||
more_data = self.activitypub_client.get_timeline(
|
||||
self.timeline_type,
|
||||
limit=posts_per_page,
|
||||
max_id=self.oldest_post_id
|
||||
)
|
||||
|
||||
if more_data:
|
||||
# Remove current "Load more" item
|
||||
self.remove_load_more_item()
|
||||
|
||||
# Add new posts to existing list
|
||||
self.load_additional_timeline_data(more_data)
|
||||
|
||||
# Update oldest post ID
|
||||
self.oldest_post_id = more_data[-1]['id']
|
||||
|
||||
# Rebuild timeline with all posts
|
||||
self.build_threaded_timeline()
|
||||
|
||||
self.status_bar.showMessage(f"Loaded {len(more_data)} more posts", 2000)
|
||||
else:
|
||||
self.status_bar.showMessage("No more posts to load", 2000)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to load more posts: {e}")
|
||||
self.status_bar.showMessage(f"Failed to load more posts: {str(e)}", 3000)
|
||||
|
||||
def remove_load_more_item(self):
|
||||
"""Remove the 'Load more posts' item"""
|
||||
for i in range(self.topLevelItemCount()):
|
||||
item = self.topLevelItem(i)
|
||||
if item.data(0, Qt.UserRole) == "load_more":
|
||||
self.takeTopLevelItem(i)
|
||||
break
|
||||
|
||||
def load_additional_timeline_data(self, timeline_data):
|
||||
"""Load additional timeline data and append to existing posts"""
|
||||
if self.timeline_type == "notifications":
|
||||
# Handle notifications data structure
|
||||
for notification_data in timeline_data:
|
||||
try:
|
||||
notification_type = notification_data['type']
|
||||
sender = notification_data['account']['display_name'] or notification_data['account']['username']
|
||||
|
||||
# Notifications with status (mentions, boosts, favorites)
|
||||
if 'status' in notification_data:
|
||||
post = Post.from_api_dict(notification_data['status'])
|
||||
post.notification_type = notification_type
|
||||
post.notification_account = notification_data['account']['acct']
|
||||
self.posts.append(post)
|
||||
elif notification_type == 'follow':
|
||||
# Handle follow notifications without status
|
||||
pass # Could create a special post type for follows
|
||||
except Exception as e:
|
||||
print(f"Error parsing notification: {e}")
|
||||
continue
|
||||
else:
|
||||
# Handle regular timeline data structure
|
||||
for status_data in timeline_data:
|
||||
try:
|
||||
post = Post.from_api_dict(status_data)
|
||||
self.posts.append(post)
|
||||
except Exception as e:
|
||||
print(f"Error parsing post: {e}")
|
||||
continue
|
||||
|
||||
def show_context_menu(self, position):
|
||||
"""Show context menu for the selected post"""
|
||||
item = self.itemAt(position)
|
||||
if not item:
|
||||
return
|
||||
|
||||
post = item.data(0, Qt.UserRole)
|
||||
if not post or post == "load_more":
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# Copy to clipboard action
|
||||
copy_action = QAction("&Copy to Clipboard", self)
|
||||
copy_action.setShortcut("Ctrl+C")
|
||||
copy_action.triggered.connect(lambda: self.copy_post_to_clipboard(post))
|
||||
menu.addAction(copy_action)
|
||||
|
||||
# Open URLs action
|
||||
urls = self.extract_urls_from_post(post)
|
||||
if urls:
|
||||
if len(urls) == 1:
|
||||
url_action = QAction("&Open URL in Browser", self)
|
||||
else:
|
||||
url_action = QAction(f"Open &URLs in Browser ({len(urls)} found)", self)
|
||||
url_action.setShortcut("Ctrl+U")
|
||||
url_action.triggered.connect(lambda: self.open_urls_in_browser(post))
|
||||
menu.addAction(url_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# Reply action
|
||||
reply_action = QAction("&Reply", self)
|
||||
reply_action.setShortcut("Ctrl+R")
|
||||
reply_action.triggered.connect(lambda: self.reply_requested.emit(post))
|
||||
menu.addAction(reply_action)
|
||||
|
||||
# Boost action
|
||||
boost_text = "Un&boost" if getattr(post, 'reblogged', False) else "&Boost"
|
||||
boost_action = QAction(boost_text, self)
|
||||
boost_action.setShortcut("Ctrl+B")
|
||||
boost_action.triggered.connect(lambda: self.boost_requested.emit(post))
|
||||
menu.addAction(boost_action)
|
||||
|
||||
# Favorite action
|
||||
fav_text = "Un&favorite" if getattr(post, 'favourited', False) else "&Favorite"
|
||||
favorite_action = QAction(fav_text, self)
|
||||
favorite_action.setShortcut("Ctrl+F")
|
||||
favorite_action.triggered.connect(lambda: self.favorite_requested.emit(post))
|
||||
menu.addAction(favorite_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# View profile action
|
||||
profile_action = QAction("View &Profile", self)
|
||||
profile_action.triggered.connect(lambda: self.profile_requested.emit(post))
|
||||
menu.addAction(profile_action)
|
||||
|
||||
menu.exec(self.mapToGlobal(position))
|
||||
|
||||
def show_empty_message(self, message: str):
|
||||
"""Show an empty timeline with a message"""
|
||||
@ -249,48 +562,6 @@ class TimelineView(AccessibleTreeWidget):
|
||||
if posts:
|
||||
self.sound_manager.play_timeline_update()
|
||||
|
||||
def show_context_menu(self, position):
|
||||
"""Show context menu for post actions"""
|
||||
item = self.itemAt(position)
|
||||
if not item:
|
||||
return
|
||||
|
||||
post = item.data(0, Qt.UserRole)
|
||||
if not post:
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# Reply action
|
||||
reply_action = QAction("&Reply", self)
|
||||
reply_action.setStatusTip("Reply to this post")
|
||||
reply_action.triggered.connect(lambda: self.reply_requested.emit(post))
|
||||
menu.addAction(reply_action)
|
||||
|
||||
# Boost action
|
||||
boost_text = "Un&boost" if post.reblogged else "&Boost"
|
||||
boost_action = QAction(boost_text, self)
|
||||
boost_action.setStatusTip(f"{boost_text.replace('&', '')} this post")
|
||||
boost_action.triggered.connect(lambda: self.boost_requested.emit(post))
|
||||
menu.addAction(boost_action)
|
||||
|
||||
# Favorite action
|
||||
fav_text = "&Unfavorite" if post.favourited else "&Favorite"
|
||||
fav_action = QAction(fav_text, self)
|
||||
fav_action.setStatusTip(f"{fav_text.replace('&', '')} this post")
|
||||
fav_action.triggered.connect(lambda: self.favorite_requested.emit(post))
|
||||
menu.addAction(fav_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# View profile action
|
||||
profile_action = QAction("View &Profile", self)
|
||||
profile_action.setStatusTip(f"View profile of {post.account.display_name}")
|
||||
profile_action.triggered.connect(lambda: self.profile_requested.emit(post))
|
||||
menu.addAction(profile_action)
|
||||
|
||||
# Show menu
|
||||
menu.exec(self.mapToGlobal(position))
|
||||
|
||||
def announce_current_item(self):
|
||||
"""Announce the current item for screen readers"""
|
||||
|
Reference in New Issue
Block a user