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
|
### 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
|
- **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
|
- **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
|
### Keyboard Shortcuts
|
||||||
- **Ctrl+N**: New post
|
- **Ctrl+N**: New post
|
||||||
- **Ctrl+R**: Reply to selected post
|
- **Ctrl+R**: Reply to selected post
|
||||||
- **Ctrl+B**: Boost selected post
|
- **Ctrl+B**: Boost selected post
|
||||||
- **Ctrl+F**: Favorite 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
|
- **F5**: Refresh timeline
|
||||||
- **Ctrl+,**: Settings
|
- **Ctrl+,**: Settings
|
||||||
- **Escape**: Close dialogs
|
- **Escape**: Close dialogs
|
||||||
@ -281,10 +286,24 @@ mention_enabled = true
|
|||||||
mention_volume = 1.0
|
mention_volume = 1.0
|
||||||
# ... other sound settings
|
# ... 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]
|
[accessibility]
|
||||||
announce_thread_state = true
|
announce_thread_state = true
|
||||||
auto_expand_mentions = false
|
auto_expand_mentions = false
|
||||||
keyboard_navigation_wrap = true
|
keyboard_navigation_wrap = true
|
||||||
|
page_step_size = 5
|
||||||
|
verbose_announcements = true
|
||||||
```
|
```
|
||||||
|
|
||||||
### Account Storage
|
### Account Storage
|
||||||
|
36
README.md
36
README.md
@ -35,6 +35,36 @@ Bifrost includes a sophisticated sound system with:
|
|||||||
- **Plyer**: Cross-platform desktop notifications
|
- **Plyer**: Cross-platform desktop notifications
|
||||||
- **XDG Base Directory**: Standards-compliant configuration storage
|
- **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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -44,6 +74,12 @@ pip install -r requirements.txt
|
|||||||
python bifrost.py
|
python bifrost.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or on Arch Linux:
|
||||||
|
```bash
|
||||||
|
sudo pacman -S python-pyside6 python-requests python-simpleaudio
|
||||||
|
yay -S python-plyer
|
||||||
|
```
|
||||||
|
|
||||||
## Accessibility Features
|
## Accessibility Features
|
||||||
|
|
||||||
- Complete keyboard navigation
|
- Complete keyboard navigation
|
||||||
|
@ -42,6 +42,27 @@ class AccessibleTreeWidget(QTreeWidget):
|
|||||||
super().keyPressEvent(event)
|
super().keyPressEvent(event)
|
||||||
return
|
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
|
# Check for Shift modifier
|
||||||
has_shift = event.modifiers() & Qt.ShiftModifier
|
has_shift = event.modifiers() & Qt.ShiftModifier
|
||||||
|
|
||||||
@ -204,10 +225,27 @@ class AccessibleTreeWidget(QTreeWidget):
|
|||||||
def on_item_expanded(self, item: QTreeWidgetItem):
|
def on_item_expanded(self, item: QTreeWidgetItem):
|
||||||
"""Handle item expansion"""
|
"""Handle item expansion"""
|
||||||
self.announce_item_state(item, "expanded")
|
self.announce_item_state(item, "expanded")
|
||||||
|
# Make child items accessible
|
||||||
|
self.update_child_accessibility(item, True)
|
||||||
|
|
||||||
def on_item_collapsed(self, item: QTreeWidgetItem):
|
def on_item_collapsed(self, item: QTreeWidgetItem):
|
||||||
"""Handle item collapse"""
|
"""Handle item collapse"""
|
||||||
self.announce_item_state(item, "collapsed")
|
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):
|
def announce_item_state(self, item: QTreeWidgetItem, state: str):
|
||||||
"""Announce item state change for screen readers"""
|
"""Announce item state change for screen readers"""
|
||||||
|
@ -166,6 +166,41 @@ class MainWindow(QMainWindow):
|
|||||||
federated_action.triggered.connect(lambda: self.switch_timeline(3))
|
federated_action.triggered.connect(lambda: self.switch_timeline(3))
|
||||||
timeline_menu.addAction(federated_action)
|
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):
|
def setup_shortcuts(self):
|
||||||
"""Set up keyboard shortcuts"""
|
"""Set up keyboard shortcuts"""
|
||||||
# Additional shortcuts that don't need menu items
|
# Additional shortcuts that don't need menu items
|
||||||
@ -292,6 +327,53 @@ class MainWindow(QMainWindow):
|
|||||||
if hasattr(self.timeline, 'sound_manager'):
|
if hasattr(self.timeline, 'sound_manager'):
|
||||||
self.timeline.sound_manager.play_error()
|
self.timeline.sound_manager.play_error()
|
||||||
self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000)
|
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):
|
def show_first_time_setup(self):
|
||||||
"""Show first-time setup dialog"""
|
"""Show first-time setup dialog"""
|
||||||
|
@ -204,6 +204,18 @@ class SettingsDialog(QDialog):
|
|||||||
sr_layout.addWidget(self.announce_thread_state)
|
sr_layout.addWidget(self.announce_thread_state)
|
||||||
|
|
||||||
layout.addWidget(sr_group)
|
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()
|
layout.addStretch()
|
||||||
|
|
||||||
self.tabs.addTab(accessibility_widget, "A&ccessibility")
|
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.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)))
|
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):
|
def apply_settings(self):
|
||||||
"""Apply the current settings without closing the dialog"""
|
"""Apply the current settings without closing the dialog"""
|
||||||
# Audio settings
|
# Audio settings
|
||||||
@ -296,6 +311,9 @@ class SettingsDialog(QDialog):
|
|||||||
self.settings.set('accessibility', 'verbose_announcements', self.verbose_announcements.isChecked())
|
self.settings.set('accessibility', 'verbose_announcements', self.verbose_announcements.isChecked())
|
||||||
self.settings.set('accessibility', 'announce_thread_state', self.announce_thread_state.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
|
# Save to file
|
||||||
self.settings.save_settings()
|
self.settings.save_settings()
|
||||||
|
|
||||||
|
@ -2,10 +2,12 @@
|
|||||||
Timeline view widget for displaying posts and threads
|
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.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QAction
|
from PySide6.QtGui import QAction, QClipboard
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
import re
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
from accessibility.accessible_tree import AccessibleTreeWidget
|
from accessibility.accessible_tree import AccessibleTreeWidget
|
||||||
from audio.sound_manager import SoundManager
|
from audio.sound_manager import SoundManager
|
||||||
@ -34,6 +36,7 @@ class TimelineView(AccessibleTreeWidget):
|
|||||||
self.account_manager = account_manager
|
self.account_manager = account_manager
|
||||||
self.activitypub_client = None
|
self.activitypub_client = None
|
||||||
self.posts = [] # Store loaded posts
|
self.posts = [] # Store loaded posts
|
||||||
|
self.oldest_post_id = None # Track for pagination
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@ -86,12 +89,19 @@ class TimelineView(AccessibleTreeWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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
|
# Fetch timeline or notifications
|
||||||
if self.timeline_type == "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:
|
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)
|
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:
|
except Exception as e:
|
||||||
print(f"Failed to fetch timeline: {e}")
|
print(f"Failed to fetch timeline: {e}")
|
||||||
# Show error message instead of sample data
|
# Show error message instead of sample data
|
||||||
@ -158,31 +168,80 @@ class TimelineView(AccessibleTreeWidget):
|
|||||||
|
|
||||||
def build_threaded_timeline(self):
|
def build_threaded_timeline(self):
|
||||||
"""Build threaded timeline from posts"""
|
"""Build threaded timeline from posts"""
|
||||||
# Group posts by conversation
|
# Find thread roots and flatten all replies under them
|
||||||
conversations = {}
|
thread_roots = {} # Maps thread root ID to list of all posts in thread
|
||||||
top_level_posts = []
|
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:
|
for post in self.posts:
|
||||||
if post.in_reply_to_id:
|
if post.in_reply_to_id:
|
||||||
# This is a reply
|
# Find the thread root for this reply
|
||||||
if post.in_reply_to_id not in conversations:
|
root_id = self.find_thread_root(post, self.posts)
|
||||||
conversations[post.in_reply_to_id] = []
|
if root_id and root_id in thread_roots:
|
||||||
conversations[post.in_reply_to_id].append(post)
|
thread_roots[root_id].append(post)
|
||||||
else:
|
else:
|
||||||
# This is a top-level post
|
# Can't find thread root, treat as orphaned
|
||||||
top_level_posts.append(post)
|
orphaned_posts.append(post)
|
||||||
|
|
||||||
# Create tree items
|
# Create tree items - one root with all replies as direct children
|
||||||
for post in top_level_posts:
|
for root_id, thread_posts in thread_roots.items():
|
||||||
post_item = self.create_post_item(post)
|
root_post = thread_posts[0] # First post is always the root
|
||||||
self.addTopLevelItem(post_item)
|
root_item = self.create_post_item(root_post)
|
||||||
|
self.addTopLevelItem(root_item)
|
||||||
|
|
||||||
# Add replies if any
|
# Add all other posts in thread as direct children (flattened)
|
||||||
if post.id in conversations:
|
for post in thread_posts[1:]:
|
||||||
self.add_replies(post_item, conversations[post.id], conversations)
|
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()
|
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:
|
def create_post_item(self, post: Post) -> QTreeWidgetItem:
|
||||||
"""Create a tree item for a post"""
|
"""Create a tree item for a post"""
|
||||||
@ -196,15 +255,269 @@ class TimelineView(AccessibleTreeWidget):
|
|||||||
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
def add_replies(self, parent_item: QTreeWidgetItem, replies, all_conversations):
|
def copy_post_to_clipboard(self, post: Optional[Post] = None):
|
||||||
"""Recursively add replies to a post"""
|
"""Copy the selected post's content to clipboard"""
|
||||||
for reply in replies:
|
if not post:
|
||||||
reply_item = self.create_post_item(reply)
|
post = self.get_selected_post()
|
||||||
parent_item.addChild(reply_item)
|
|
||||||
|
if not post:
|
||||||
|
return
|
||||||
|
|
||||||
# Add nested replies
|
# Get the full post content
|
||||||
if reply.id in all_conversations:
|
content = post.get_content_text()
|
||||||
self.add_replies(reply_item, all_conversations[reply.id], all_conversations)
|
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):
|
def show_empty_message(self, message: str):
|
||||||
"""Show an empty timeline with a message"""
|
"""Show an empty timeline with a message"""
|
||||||
@ -249,48 +562,6 @@ class TimelineView(AccessibleTreeWidget):
|
|||||||
if posts:
|
if posts:
|
||||||
self.sound_manager.play_timeline_update()
|
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):
|
def announce_current_item(self):
|
||||||
"""Announce the current item for screen readers"""
|
"""Announce the current item for screen readers"""
|
||||||
|
Reference in New Issue
Block a user