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:
418
src/main_window.py
Normal file
418
src/main_window.py
Normal 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()
|
Reference in New Issue
Block a user