Initial commit: Bifrost accessible fediverse client

- Full ActivityPub support for Pleroma, GoToSocial, and Mastodon
- Screen reader optimized interface with PySide6
- Timeline switching with tabs and keyboard shortcuts (Ctrl+1-4)
- Threaded conversation navigation with expand/collapse
- Cross-platform desktop notifications via plyer
- Customizable sound pack system with audio feedback
- Complete keyboard navigation and accessibility features
- XDG Base Directory compliant configuration
- Multiple account support with OAuth authentication

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-20 03:39:47 -04:00
commit 460dfc52a5
31 changed files with 5320 additions and 0 deletions

418
src/main_window.py Normal file
View File

@ -0,0 +1,418 @@
"""
Main application window for Bifrost
"""
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QMenuBar, QStatusBar, QPushButton, QTabWidget
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QKeySequence, QAction, QTextCursor
from config.settings import SettingsManager
from config.accounts import AccountManager
from widgets.timeline_view import TimelineView
from widgets.compose_dialog import ComposeDialog
from widgets.login_dialog import LoginDialog
from widgets.account_selector import AccountSelector
from widgets.settings_dialog import SettingsDialog
from activitypub.client import ActivityPubClient
class MainWindow(QMainWindow):
"""Main Bifrost application window"""
def __init__(self):
super().__init__()
self.settings = SettingsManager()
self.account_manager = AccountManager(self.settings)
self.setup_ui()
self.setup_menus()
self.setup_shortcuts()
# Check if we need to show login dialog
if not self.account_manager.has_accounts():
self.show_first_time_setup()
# Play startup sound
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.play_startup()
def setup_ui(self):
"""Initialize the user interface"""
self.setWindowTitle("Bifrost - Fediverse Client")
self.setMinimumSize(800, 600)
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
# Account selector
self.account_selector = AccountSelector(self.account_manager)
self.account_selector.account_changed.connect(self.on_account_changed)
self.account_selector.add_account_requested.connect(self.show_login_dialog)
main_layout.addWidget(self.account_selector)
# Timeline tabs
self.timeline_tabs = QTabWidget()
self.timeline_tabs.setAccessibleName("Timeline Selection")
self.timeline_tabs.addTab(QWidget(), "Home")
self.timeline_tabs.addTab(QWidget(), "Mentions")
self.timeline_tabs.addTab(QWidget(), "Local")
self.timeline_tabs.addTab(QWidget(), "Federated")
self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed)
main_layout.addWidget(self.timeline_tabs)
# Status label for connection info
self.status_label = QLabel()
self.status_label.setAccessibleName("Connection Status")
main_layout.addWidget(self.status_label)
self.update_status_label()
# Timeline view (main content area)
self.timeline = TimelineView(self.account_manager)
self.timeline.setAccessibleName("Timeline")
self.timeline.reply_requested.connect(self.reply_to_post)
self.timeline.boost_requested.connect(self.boost_post)
self.timeline.favorite_requested.connect(self.favorite_post)
self.timeline.profile_requested.connect(self.view_profile)
main_layout.addWidget(self.timeline)
# Compose button
compose_layout = QHBoxLayout()
self.compose_button = QPushButton("&Compose Post")
self.compose_button.setAccessibleName("Compose New Post")
self.compose_button.clicked.connect(self.show_compose_dialog)
compose_layout.addWidget(self.compose_button)
compose_layout.addStretch()
main_layout.addLayout(compose_layout)
# Status bar
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Ready")
def setup_menus(self):
"""Create application menus"""
menubar = self.menuBar()
# File menu
file_menu = menubar.addMenu("&File")
# New post action
new_post_action = QAction("&New Post", self)
new_post_action.setShortcut(QKeySequence.New)
new_post_action.triggered.connect(self.show_compose_dialog)
file_menu.addAction(new_post_action)
file_menu.addSeparator()
# Account management
add_account_action = QAction("&Add Account", self)
add_account_action.setShortcut(QKeySequence("Ctrl+Shift+A"))
add_account_action.triggered.connect(self.show_login_dialog)
file_menu.addAction(add_account_action)
file_menu.addSeparator()
# Settings action
settings_action = QAction("&Settings", self)
settings_action.setShortcut(QKeySequence.Preferences)
settings_action.triggered.connect(self.show_settings)
file_menu.addAction(settings_action)
file_menu.addSeparator()
# Quit action
quit_action = QAction("&Quit", self)
quit_action.setShortcut(QKeySequence.Quit)
quit_action.triggered.connect(self.quit_application)
file_menu.addAction(quit_action)
# View menu
view_menu = menubar.addMenu("&View")
# Refresh timeline action
refresh_action = QAction("&Refresh Timeline", self)
refresh_action.setShortcut(QKeySequence.Refresh)
refresh_action.triggered.connect(self.refresh_timeline)
view_menu.addAction(refresh_action)
# Timeline menu
timeline_menu = menubar.addMenu("&Timeline")
# Home timeline action
home_action = QAction("&Home", self)
home_action.setShortcut(QKeySequence("Ctrl+1"))
home_action.triggered.connect(lambda: self.switch_timeline(0))
timeline_menu.addAction(home_action)
# Mentions timeline action
mentions_action = QAction("&Mentions", self)
mentions_action.setShortcut(QKeySequence("Ctrl+2"))
mentions_action.triggered.connect(lambda: self.switch_timeline(1))
timeline_menu.addAction(mentions_action)
# Local timeline action
local_action = QAction("&Local", self)
local_action.setShortcut(QKeySequence("Ctrl+3"))
local_action.triggered.connect(lambda: self.switch_timeline(2))
timeline_menu.addAction(local_action)
# Federated timeline action
federated_action = QAction("&Federated", self)
federated_action.setShortcut(QKeySequence("Ctrl+4"))
federated_action.triggered.connect(lambda: self.switch_timeline(3))
timeline_menu.addAction(federated_action)
def setup_shortcuts(self):
"""Set up keyboard shortcuts"""
# Additional shortcuts that don't need menu items
pass
def show_compose_dialog(self):
"""Show the compose post dialog"""
dialog = ComposeDialog(self.account_manager, self)
dialog.post_sent.connect(self.on_post_sent)
dialog.exec()
def on_post_sent(self, post_data):
"""Handle post data from compose dialog"""
self.status_bar.showMessage("Sending post...", 2000)
# Start background posting
self.start_background_post(post_data)
def start_background_post(self, post_data):
"""Start posting in background thread"""
from PySide6.QtCore import QThread
class PostThread(QThread):
post_success = Signal()
post_failed = Signal(str)
def __init__(self, post_data, parent):
super().__init__()
self.post_data = post_data
self.parent_window = parent
def run(self):
try:
account = self.post_data['account']
client = ActivityPubClient(account.instance_url, account.access_token)
result = client.post_status(
content=self.post_data['content'],
visibility=self.post_data['visibility'],
content_warning=self.post_data['content_warning'],
in_reply_to_id=self.post_data.get('in_reply_to_id')
)
# Success
self.post_success.emit()
except Exception as e:
# Error
self.post_failed.emit(str(e))
self.post_thread = PostThread(post_data, self)
self.post_thread.post_success.connect(self.on_post_success)
self.post_thread.post_failed.connect(self.on_post_failed)
self.post_thread.start()
def on_post_success(self):
"""Handle successful post submission"""
# Play success sound
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.play_success()
self.status_bar.showMessage("Post sent successfully!", 3000)
# Refresh timeline to show the new post
self.timeline.refresh()
def on_post_failed(self, error_message: str):
"""Handle failed post submission"""
# Play error sound
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.play_error()
self.status_bar.showMessage(f"Post failed: {error_message}", 5000)
def show_settings(self):
"""Show the settings dialog"""
dialog = SettingsDialog(self)
dialog.settings_changed.connect(self.on_settings_changed)
dialog.exec()
def on_settings_changed(self):
"""Handle settings changes"""
# Reload sound manager with new settings
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.reload_settings()
self.status_bar.showMessage("Settings saved successfully", 2000)
def refresh_timeline(self):
"""Refresh the current timeline"""
self.timeline.refresh()
self.status_bar.showMessage("Timeline refreshed", 2000)
def on_timeline_tab_changed(self, index):
"""Handle timeline tab change"""
self.switch_timeline(index)
def switch_timeline(self, index):
"""Switch to timeline by index with loading feedback"""
timeline_names = ["Home", "Mentions", "Local", "Federated"]
timeline_types = ["home", "notifications", "local", "federated"]
if 0 <= index < len(timeline_names):
timeline_name = timeline_names[index]
timeline_type = timeline_types[index]
# Set tab to match if called from keyboard shortcut
if self.timeline_tabs.currentIndex() != index:
self.timeline_tabs.setCurrentIndex(index)
# Announce loading
self.status_bar.showMessage(f"Loading {timeline_name} timeline...")
# Switch timeline type
try:
self.timeline.set_timeline_type(timeline_type)
# Success feedback
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.play_success()
self.status_bar.showMessage(f"Loaded {timeline_name} timeline", 2000)
except Exception as e:
# Error feedback
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 show_first_time_setup(self):
"""Show first-time setup dialog"""
from PySide6.QtWidgets import QMessageBox
result = QMessageBox.question(
self,
"Welcome to Bifrost",
"Welcome to Bifrost! You need to add a fediverse account to get started.\n\n"
"Would you like to add an account now?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if result == QMessageBox.Yes:
self.show_login_dialog()
def show_login_dialog(self):
"""Show the login dialog"""
dialog = LoginDialog(self)
dialog.account_added.connect(self.on_account_added)
dialog.exec()
def on_account_added(self, account_data):
"""Handle new account being added"""
self.account_selector.add_account(account_data)
self.update_status_label()
self.status_bar.showMessage(f"Added account: {account_data['username']}", 3000)
# Refresh timeline with new account
self.timeline.refresh()
def on_account_changed(self, account_id):
"""Handle account switching"""
account = self.account_manager.get_account_by_id(account_id)
if account:
self.update_status_label()
self.status_bar.showMessage(f"Switched to {account.get_display_text()}", 2000)
# Refresh timeline with new account
self.timeline.refresh()
def reply_to_post(self, post):
"""Reply to a specific post"""
dialog = ComposeDialog(self.account_manager, self)
# Pre-fill with reply mention
dialog.text_edit.setPlainText(f"@{post.account.username} ")
# Move cursor to end
cursor = dialog.text_edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
dialog.text_edit.setTextCursor(cursor)
dialog.post_sent.connect(lambda data: self.on_post_sent({**data, 'in_reply_to_id': post.id}))
dialog.exec()
def boost_post(self, post):
"""Boost/unboost a post"""
active_account = self.account_manager.get_active_account()
if not active_account:
return
try:
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
if post.reblogged:
client.unreblog_status(post.id)
self.status_bar.showMessage("Post unboosted", 2000)
else:
client.reblog_status(post.id)
self.status_bar.showMessage("Post boosted", 2000)
# Refresh timeline to show updated state
self.timeline.refresh()
except Exception as e:
self.status_bar.showMessage(f"Boost failed: {str(e)}", 3000)
def favorite_post(self, post):
"""Favorite/unfavorite a post"""
active_account = self.account_manager.get_active_account()
if not active_account:
return
try:
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
if post.favourited:
client.unfavourite_status(post.id)
self.status_bar.showMessage("Post unfavorited", 2000)
else:
client.favourite_status(post.id)
self.status_bar.showMessage("Post favorited", 2000)
# Refresh timeline to show updated state
self.timeline.refresh()
except Exception as e:
self.status_bar.showMessage(f"Favorite failed: {str(e)}", 3000)
def view_profile(self, post):
"""View user profile"""
# TODO: Implement profile viewing dialog
self.status_bar.showMessage(f"Profile viewing not implemented yet: {post.account.display_name}", 3000)
def update_status_label(self):
"""Update the status label with current account info"""
active_account = self.account_manager.get_active_account()
if active_account:
self.status_label.setText(f"Connected as {active_account.get_display_text()}")
else:
self.status_label.setText("No account connected")
def quit_application(self):
"""Quit the application with shutdown sound"""
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.play_shutdown()
# Wait briefly for sound to start playing
from PySide6.QtCore import QTimer
QTimer.singleShot(500, self.close)
else:
self.close()
def closeEvent(self, event):
"""Handle window close event"""
# Play shutdown sound if not already played through quit_application
if hasattr(self.timeline, 'sound_manager'):
self.timeline.sound_manager.play_shutdown()
# Wait briefly for sound to complete
from PySide6.QtCore import QTimer, QEventLoop
loop = QEventLoop()
QTimer.singleShot(500, loop.quit)
loop.exec()
event.accept()