- Fix priority-based notification sounds to prevent mentions playing timeline update sounds - Fix reply functionality to use full @username@domain.com format for proper federation - Fix missing reply sound notifications by adding reply to priority system - Fix duplicate reply count display to prevent "(1 replies) (2 replies)" accumulation - Fix poll voting to automatically refresh timeline and prevent duplicate vote attempts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1080 lines
44 KiB
Python
1080 lines
44 KiB
Python
"""
|
|
Main application window for Bifrost
|
|
"""
|
|
|
|
from PySide6.QtWidgets import (
|
|
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QMenuBar, QStatusBar, QPushButton, QTabWidget
|
|
)
|
|
from PySide6.QtCore import Qt, Signal, QTimer
|
|
from PySide6.QtGui import QKeySequence, QAction, QTextCursor
|
|
import time
|
|
|
|
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 widgets.soundpack_manager_dialog import SoundpackManagerDialog
|
|
from widgets.profile_dialog import ProfileDialog
|
|
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)
|
|
|
|
# Auto-refresh tracking
|
|
self.last_activity_time = time.time()
|
|
self.is_initial_load = True # Flag to skip notifications on first load
|
|
|
|
self.setup_ui()
|
|
self.setup_menus()
|
|
self.setup_shortcuts()
|
|
self.setup_auto_refresh()
|
|
|
|
# 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()
|
|
|
|
# Mark initial load as complete after startup
|
|
QTimer.singleShot(2000, self.mark_initial_load_complete)
|
|
|
|
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(), "Messages")
|
|
self.timeline_tabs.addTab(QWidget(), "Notifications")
|
|
self.timeline_tabs.addTab(QWidget(), "Local")
|
|
self.timeline_tabs.addTab(QWidget(), "Federated")
|
|
self.timeline_tabs.addTab(QWidget(), "Bookmarks")
|
|
self.timeline_tabs.addTab(QWidget(), "Followers")
|
|
self.timeline_tabs.addTab(QWidget(), "Following")
|
|
self.timeline_tabs.addTab(QWidget(), "Blocked")
|
|
self.timeline_tabs.addTab(QWidget(), "Muted")
|
|
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)
|
|
self.timeline.delete_requested.connect(self.delete_post)
|
|
self.timeline.edit_requested.connect(self.edit_post)
|
|
self.timeline.follow_requested.connect(self.follow_user)
|
|
self.timeline.unfollow_requested.connect(self.unfollow_user)
|
|
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)
|
|
|
|
# Soundpack Manager action
|
|
soundpack_action = QAction("Sound&pack Manager", self)
|
|
soundpack_action.setShortcut(QKeySequence("Ctrl+Shift+P"))
|
|
soundpack_action.triggered.connect(self.show_soundpack_manager)
|
|
file_menu.addAction(soundpack_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)
|
|
|
|
# Messages timeline action
|
|
messages_action = QAction("&Messages", self)
|
|
messages_action.setShortcut(QKeySequence("Ctrl+2"))
|
|
messages_action.triggered.connect(lambda: self.switch_timeline(1))
|
|
timeline_menu.addAction(messages_action)
|
|
|
|
# Notifications timeline action
|
|
notifications_action = QAction("&Notifications", self)
|
|
notifications_action.setShortcut(QKeySequence("Ctrl+3"))
|
|
notifications_action.triggered.connect(lambda: self.switch_timeline(2))
|
|
timeline_menu.addAction(notifications_action)
|
|
|
|
# Local timeline action
|
|
local_action = QAction("&Local", self)
|
|
local_action.setShortcut(QKeySequence("Ctrl+4"))
|
|
local_action.triggered.connect(lambda: self.switch_timeline(3))
|
|
timeline_menu.addAction(local_action)
|
|
|
|
# Federated timeline action
|
|
federated_action = QAction("&Federated", self)
|
|
federated_action.setShortcut(QKeySequence("Ctrl+5"))
|
|
federated_action.triggered.connect(lambda: self.switch_timeline(4))
|
|
timeline_menu.addAction(federated_action)
|
|
|
|
# Bookmarks timeline action
|
|
bookmarks_action = QAction("&Bookmarks", self)
|
|
bookmarks_action.setShortcut(QKeySequence("Ctrl+6"))
|
|
bookmarks_action.triggered.connect(lambda: self.switch_timeline(5))
|
|
timeline_menu.addAction(bookmarks_action)
|
|
|
|
# Followers timeline action
|
|
followers_action = QAction("Follo&wers", self)
|
|
followers_action.setShortcut(QKeySequence("Ctrl+7"))
|
|
followers_action.triggered.connect(lambda: self.switch_timeline(6))
|
|
timeline_menu.addAction(followers_action)
|
|
|
|
# Following timeline action
|
|
following_action = QAction("Follo&wing", self)
|
|
following_action.setShortcut(QKeySequence("Ctrl+8"))
|
|
following_action.triggered.connect(lambda: self.switch_timeline(7))
|
|
timeline_menu.addAction(following_action)
|
|
|
|
# Blocked users timeline action
|
|
blocked_action = QAction("Bloc&ked Users", self)
|
|
blocked_action.setShortcut(QKeySequence("Ctrl+9"))
|
|
blocked_action.triggered.connect(lambda: self.switch_timeline(8))
|
|
timeline_menu.addAction(blocked_action)
|
|
|
|
# Muted users timeline action
|
|
muted_action = QAction("M&uted Users", self)
|
|
muted_action.setShortcut(QKeySequence("Ctrl+0"))
|
|
muted_action.triggered.connect(lambda: self.switch_timeline(9))
|
|
timeline_menu.addAction(muted_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)
|
|
|
|
post_menu.addSeparator()
|
|
|
|
# Edit action (for owned posts)
|
|
edit_action = QAction("&Edit Post", self)
|
|
edit_action.setShortcut(QKeySequence("Ctrl+Shift+E"))
|
|
edit_action.triggered.connect(self.edit_current_post)
|
|
post_menu.addAction(edit_action)
|
|
|
|
# Delete action (for owned posts)
|
|
delete_action = QAction("&Delete Post", self)
|
|
delete_action.setShortcut(QKeySequence("Shift+Delete"))
|
|
delete_action.triggered.connect(self.delete_current_post)
|
|
post_menu.addAction(delete_action)
|
|
|
|
# Social menu
|
|
social_menu = menubar.addMenu("&Social")
|
|
|
|
# Follow action
|
|
follow_action = QAction("&Follow User", self)
|
|
follow_action.setShortcut(QKeySequence("Ctrl+Shift+F"))
|
|
follow_action.triggered.connect(self.follow_current_user)
|
|
social_menu.addAction(follow_action)
|
|
|
|
# Unfollow action
|
|
unfollow_action = QAction("&Unfollow User", self)
|
|
unfollow_action.setShortcut(QKeySequence("Ctrl+Shift+U"))
|
|
unfollow_action.triggered.connect(self.unfollow_current_user)
|
|
social_menu.addAction(unfollow_action)
|
|
|
|
social_menu.addSeparator()
|
|
|
|
# Block action
|
|
block_action = QAction("&Block User", self)
|
|
block_action.setShortcut(QKeySequence("Ctrl+Shift+B"))
|
|
block_action.triggered.connect(self.block_current_user)
|
|
social_menu.addAction(block_action)
|
|
|
|
# Mute action
|
|
mute_action = QAction("&Mute User", self)
|
|
mute_action.setShortcut(QKeySequence("Ctrl+Shift+M"))
|
|
mute_action.triggered.connect(self.mute_current_user)
|
|
social_menu.addAction(mute_action)
|
|
|
|
social_menu.addSeparator()
|
|
|
|
# Manual follow action
|
|
manual_follow_action = QAction("Follow &Specific User...", self)
|
|
manual_follow_action.setShortcut(QKeySequence("Ctrl+Shift+M"))
|
|
manual_follow_action.triggered.connect(self.show_manual_follow_dialog)
|
|
social_menu.addAction(manual_follow_action)
|
|
|
|
def setup_shortcuts(self):
|
|
"""Set up keyboard shortcuts"""
|
|
# Additional shortcuts that don't need menu items
|
|
pass
|
|
|
|
def setup_auto_refresh(self):
|
|
"""Set up auto-refresh timer"""
|
|
# Create auto-refresh timer
|
|
self.auto_refresh_timer = QTimer()
|
|
self.auto_refresh_timer.timeout.connect(self.check_auto_refresh)
|
|
|
|
# Check every 30 seconds if we should refresh
|
|
self.auto_refresh_timer.start(30000) # 30 seconds
|
|
|
|
def mark_initial_load_complete(self):
|
|
"""Mark that initial loading is complete"""
|
|
self.is_initial_load = False
|
|
# Enable notifications on the timeline
|
|
if hasattr(self.timeline, 'enable_notifications'):
|
|
self.timeline.enable_notifications()
|
|
|
|
def keyPressEvent(self, event):
|
|
"""Track keyboard activity for auto-refresh"""
|
|
self.last_activity_time = time.time()
|
|
super().keyPressEvent(event)
|
|
|
|
def check_auto_refresh(self):
|
|
"""Check if we should auto-refresh the timeline"""
|
|
# Skip if auto-refresh is disabled
|
|
if not self.settings.get_bool('general', 'auto_refresh_enabled', True):
|
|
return
|
|
|
|
# Skip if no account is active
|
|
if not self.account_manager.get_active_account():
|
|
return
|
|
|
|
# Get refresh interval from settings
|
|
refresh_interval = self.settings.get_int('general', 'timeline_refresh_interval', 300)
|
|
|
|
# Check if enough time has passed since last activity
|
|
time_since_activity = time.time() - self.last_activity_time
|
|
required_idle_time = refresh_interval + 10 # refresh_rate + 10 seconds
|
|
|
|
if time_since_activity >= required_idle_time:
|
|
self.auto_refresh_timeline()
|
|
|
|
def auto_refresh_timeline(self):
|
|
"""Automatically refresh the timeline"""
|
|
# Store the current scroll position and selected item
|
|
current_item = self.timeline.currentItem()
|
|
|
|
# Store the current newest post ID to detect new content
|
|
old_newest_post_id = self.timeline.newest_post_id
|
|
|
|
# Temporarily disable notifications to prevent double notifications
|
|
old_skip_notifications = self.timeline.skip_notifications
|
|
self.timeline.skip_notifications = True
|
|
|
|
# Refresh the timeline
|
|
self.timeline.refresh()
|
|
|
|
# Restore notification setting
|
|
self.timeline.skip_notifications = old_skip_notifications
|
|
|
|
# Check for new content by comparing newest post ID
|
|
if (self.timeline.newest_post_id and
|
|
old_newest_post_id and
|
|
self.timeline.newest_post_id != old_newest_post_id and
|
|
not self.is_initial_load):
|
|
|
|
timeline_name = {
|
|
'home': 'home timeline',
|
|
'local': 'local timeline',
|
|
'federated': 'federated timeline',
|
|
'notifications': 'notifications'
|
|
}.get(self.timeline.timeline_type, 'timeline')
|
|
|
|
# Show desktop notification for new content
|
|
if hasattr(self.timeline, 'notification_manager'):
|
|
self.timeline.notification_manager.notify_new_content(timeline_name)
|
|
|
|
# Try to restore focus to the previous item
|
|
if current_item:
|
|
self.timeline.setCurrentItem(current_item)
|
|
|
|
# Reset activity timer to prevent immediate re-refresh
|
|
self.last_activity_time = time.time()
|
|
|
|
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'),
|
|
poll=self.post_data.get('poll'),
|
|
media_ids=self.post_data.get('media_ids')
|
|
)
|
|
|
|
# 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 show_soundpack_manager(self):
|
|
"""Show the soundpack manager dialog"""
|
|
dialog = SoundpackManagerDialog(self.settings, self)
|
|
dialog.exec()
|
|
|
|
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, from_tab_change=True)
|
|
|
|
def switch_timeline(self, index, from_tab_change=False):
|
|
"""Switch to timeline by index with loading feedback"""
|
|
timeline_names = ["Home", "Messages", "Notifications", "Local", "Federated", "Bookmarks", "Followers", "Following", "Blocked", "Muted"]
|
|
timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following", "blocked", "muted"]
|
|
|
|
if 0 <= index < len(timeline_names):
|
|
timeline_name = timeline_names[index]
|
|
timeline_type = timeline_types[index]
|
|
|
|
# Prevent duplicate calls for the same timeline
|
|
if hasattr(self, '_current_timeline_switching') and self._current_timeline_switching:
|
|
return
|
|
self._current_timeline_switching = True
|
|
|
|
# Set tab to match if called from keyboard shortcut (but not if already from tab change)
|
|
if not from_tab_change and 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)
|
|
finally:
|
|
# Reset the flag after a brief delay to allow the operation to complete
|
|
from PySide6.QtCore import QTimer
|
|
QTimer.singleShot(100, lambda: setattr(self, '_current_timeline_switching', False))
|
|
|
|
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"""
|
|
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 using full fediverse handle
|
|
dialog.text_edit.setPlainText(f"@{post.account.acct} ")
|
|
# 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)
|
|
# Play boost sound for successful boost
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_boost()
|
|
# 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)
|
|
# Play favorite sound for successful favorite
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_favorite()
|
|
# 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"""
|
|
try:
|
|
# Convert Post.account to User-compatible data and open profile dialog
|
|
from models.user import User
|
|
|
|
# Create User object from Account data
|
|
account = post.account
|
|
account_data = {
|
|
'id': account.id,
|
|
'username': account.username,
|
|
'acct': account.acct,
|
|
'display_name': account.display_name,
|
|
'note': account.note,
|
|
'url': account.url,
|
|
'avatar': account.avatar,
|
|
'avatar_static': account.avatar_static,
|
|
'header': account.header,
|
|
'header_static': account.header_static,
|
|
'locked': account.locked,
|
|
'bot': account.bot,
|
|
'discoverable': account.discoverable,
|
|
'group': account.group,
|
|
'created_at': account.created_at.isoformat() if account.created_at else None,
|
|
'followers_count': account.followers_count,
|
|
'following_count': account.following_count,
|
|
'statuses_count': account.statuses_count,
|
|
'fields': [], # Will be loaded from API
|
|
'emojis': [] # Will be loaded from API
|
|
}
|
|
|
|
user = User.from_api_dict(account_data)
|
|
|
|
dialog = ProfileDialog(
|
|
user_id=user.id,
|
|
account_manager=self.account_manager,
|
|
sound_manager=self.timeline.sound_manager,
|
|
initial_user=user,
|
|
parent=self
|
|
)
|
|
dialog.exec()
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Error opening profile: {str(e)}", 3000)
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_error()
|
|
|
|
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"""
|
|
self._shutdown_sound_played = True # Mark that we're handling the 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 delete_current_post(self):
|
|
"""Delete the currently selected post"""
|
|
post = self.get_selected_post()
|
|
if post:
|
|
self.delete_post(post)
|
|
else:
|
|
self.status_bar.showMessage("No post selected", 2000)
|
|
|
|
def edit_current_post(self):
|
|
"""Edit the currently selected post"""
|
|
post = self.get_selected_post()
|
|
if post:
|
|
self.edit_post(post)
|
|
else:
|
|
self.status_bar.showMessage("No post selected", 2000)
|
|
|
|
def follow_current_user(self):
|
|
"""Follow the user of the currently selected post"""
|
|
post = self.get_selected_post()
|
|
if post:
|
|
self.follow_user(post)
|
|
else:
|
|
self.status_bar.showMessage("No post selected", 2000)
|
|
|
|
def unfollow_current_user(self):
|
|
"""Unfollow the user of the currently selected post"""
|
|
post = self.get_selected_post()
|
|
if post:
|
|
self.unfollow_user(post)
|
|
else:
|
|
self.status_bar.showMessage("No post selected", 2000)
|
|
|
|
def delete_post(self, post):
|
|
"""Delete a post with confirmation dialog"""
|
|
from PySide6.QtWidgets import QMessageBox
|
|
|
|
# Check if this is user's own post
|
|
active_account = self.account_manager.get_active_account()
|
|
if not active_account or not hasattr(post, 'account'):
|
|
self.status_bar.showMessage("Cannot delete: No active account", 2000)
|
|
return
|
|
|
|
is_own_post = (post.account.username == active_account.username and
|
|
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
|
|
|
if not is_own_post:
|
|
self.status_bar.showMessage("Cannot delete: Not your post", 2000)
|
|
return
|
|
|
|
# Show confirmation dialog
|
|
content_preview = post.get_content_text()
|
|
|
|
result = QMessageBox.question(
|
|
self,
|
|
"Delete Post",
|
|
f"Are you sure you want to delete this post?\n\n\"{content_preview}\"",
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No
|
|
)
|
|
|
|
if result == QMessageBox.Yes:
|
|
try:
|
|
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
|
client.delete_status(post.id)
|
|
self.status_bar.showMessage("Post deleted successfully", 2000)
|
|
# Refresh timeline to remove deleted post
|
|
self.timeline.refresh()
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Delete failed: {str(e)}", 3000)
|
|
|
|
def edit_post(self, post):
|
|
"""Edit a post"""
|
|
# Check if this is user's own post
|
|
active_account = self.account_manager.get_active_account()
|
|
if not active_account or not hasattr(post, 'account'):
|
|
self.status_bar.showMessage("Cannot edit: No active account", 2000)
|
|
return
|
|
|
|
is_own_post = (post.account.username == active_account.username and
|
|
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
|
|
|
if not is_own_post:
|
|
self.status_bar.showMessage("Cannot edit: Not your post", 2000)
|
|
return
|
|
|
|
# Open compose dialog with current post content
|
|
dialog = ComposeDialog(self.account_manager, self)
|
|
dialog.text_edit.setPlainText(post.get_content_text())
|
|
# Move cursor to end
|
|
cursor = dialog.text_edit.textCursor()
|
|
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
dialog.text_edit.setTextCursor(cursor)
|
|
|
|
def handle_edit_sent(data):
|
|
try:
|
|
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
|
client.edit_status(
|
|
post.id,
|
|
content=data['content'],
|
|
visibility=data['visibility'],
|
|
content_warning=data['content_warning']
|
|
)
|
|
self.status_bar.showMessage("Post edited successfully", 2000)
|
|
# Refresh timeline to show edited post
|
|
self.timeline.refresh()
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Edit failed: {str(e)}", 3000)
|
|
|
|
dialog.post_sent.connect(handle_edit_sent)
|
|
dialog.exec()
|
|
|
|
def follow_user(self, post):
|
|
"""Follow a user"""
|
|
active_account = self.account_manager.get_active_account()
|
|
if not active_account or not hasattr(post, 'account'):
|
|
self.status_bar.showMessage("Cannot follow: No active account", 2000)
|
|
return
|
|
|
|
try:
|
|
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
|
client.follow_account(post.account.id)
|
|
username = post.account.display_name or post.account.username
|
|
self.status_bar.showMessage(f"Followed {username}", 2000)
|
|
# Play follow sound for successful follow
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_follow()
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
|
|
|
|
def unfollow_user(self, post):
|
|
"""Unfollow a user"""
|
|
active_account = self.account_manager.get_active_account()
|
|
if not active_account or not hasattr(post, 'account'):
|
|
self.status_bar.showMessage("Cannot unfollow: No active account", 2000)
|
|
return
|
|
|
|
try:
|
|
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
|
client.unfollow_account(post.account.id)
|
|
username = post.account.display_name or post.account.username
|
|
self.status_bar.showMessage(f"Unfollowed {username}", 2000)
|
|
# Play unfollow sound for successful unfollow
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_unfollow()
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Unfollow failed: {str(e)}", 3000)
|
|
|
|
def show_manual_follow_dialog(self):
|
|
"""Show dialog to manually follow a user by @username@instance"""
|
|
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QLabel, QDialogButtonBox, QPushButton
|
|
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("Follow User")
|
|
dialog.setMinimumSize(400, 150)
|
|
dialog.setModal(True)
|
|
|
|
layout = QVBoxLayout(dialog)
|
|
|
|
# Label
|
|
label = QLabel("Enter the user to follow (e.g. @user@instance.social):")
|
|
label.setAccessibleName("Follow User Instructions")
|
|
layout.addWidget(label)
|
|
|
|
# Input field
|
|
self.follow_input = QLineEdit()
|
|
self.follow_input.setAccessibleName("Username to Follow")
|
|
self.follow_input.setPlaceholderText("@username@instance.social")
|
|
layout.addWidget(self.follow_input)
|
|
|
|
# Buttons
|
|
button_box = QDialogButtonBox()
|
|
|
|
follow_button = QPushButton("Follow")
|
|
follow_button.setDefault(True)
|
|
cancel_button = QPushButton("Cancel")
|
|
|
|
button_box.addButton(follow_button, QDialogButtonBox.AcceptRole)
|
|
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
|
|
|
|
button_box.accepted.connect(dialog.accept)
|
|
button_box.rejected.connect(dialog.reject)
|
|
layout.addWidget(button_box)
|
|
|
|
# Show dialog
|
|
if dialog.exec() == QDialog.Accepted:
|
|
username = self.follow_input.text().strip()
|
|
if username:
|
|
self.manual_follow_user(username)
|
|
else:
|
|
self.status_bar.showMessage("Please enter a username", 2000)
|
|
|
|
def manual_follow_user(self, username):
|
|
"""Follow a user by username"""
|
|
active_account = self.account_manager.get_active_account()
|
|
if not active_account:
|
|
self.status_bar.showMessage("Cannot follow: No active account", 2000)
|
|
return
|
|
|
|
# Remove @ prefix if present
|
|
if username.startswith('@'):
|
|
username = username[1:]
|
|
|
|
try:
|
|
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
|
|
|
# Search for the account first
|
|
accounts = client.search_accounts(username)
|
|
if not accounts:
|
|
self.status_bar.showMessage(f"User not found: {username}", 3000)
|
|
return
|
|
|
|
# Find exact match
|
|
target_account = None
|
|
for account in accounts:
|
|
if account['acct'] == username or account['username'] == username.split('@')[0]:
|
|
target_account = account
|
|
break
|
|
|
|
if not target_account:
|
|
self.status_bar.showMessage(f"User not found: {username}", 3000)
|
|
return
|
|
|
|
# Follow the account
|
|
client.follow_account(target_account['id'])
|
|
display_name = target_account.get('display_name') or target_account['username']
|
|
self.status_bar.showMessage(f"Followed {display_name}", 2000)
|
|
# Play follow sound for successful follow
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_follow()
|
|
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
|
|
|
|
def block_current_user(self):
|
|
"""Block the user of the currently selected post"""
|
|
post = self.get_selected_post()
|
|
if post:
|
|
self.block_user(post)
|
|
else:
|
|
self.status_bar.showMessage("No post selected", 2000)
|
|
|
|
def mute_current_user(self):
|
|
"""Mute the user of the currently selected post"""
|
|
post = self.get_selected_post()
|
|
if post:
|
|
self.mute_user(post)
|
|
else:
|
|
self.status_bar.showMessage("No post selected", 2000)
|
|
|
|
def block_user(self, post):
|
|
"""Block a user with confirmation dialog"""
|
|
active_account = self.account_manager.get_active_account()
|
|
if not active_account or not hasattr(post, 'account'):
|
|
self.status_bar.showMessage("Cannot block: No active account", 2000)
|
|
return
|
|
|
|
# Don't allow blocking yourself
|
|
is_own_post = (post.account.username == active_account.username and
|
|
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
|
|
|
if is_own_post:
|
|
self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000)
|
|
return
|
|
|
|
# Show confirmation dialog
|
|
username = post.account.display_name or post.account.username
|
|
full_username = f"@{post.account.acct}"
|
|
|
|
result = QMessageBox.question(
|
|
self,
|
|
"Block User",
|
|
f"Are you sure you want to block {username} ({full_username})?\n\n"
|
|
"This will prevent them from following you and seeing your posts.",
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No
|
|
)
|
|
|
|
if result == QMessageBox.Yes:
|
|
try:
|
|
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
|
client.block_account(post.account.id)
|
|
self.status_bar.showMessage(f"Blocked {username}", 2000)
|
|
# Play success sound for successful block
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_success()
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Block failed: {str(e)}", 3000)
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_error()
|
|
|
|
def mute_user(self, post):
|
|
"""Mute a user"""
|
|
active_account = self.account_manager.get_active_account()
|
|
if not active_account or not hasattr(post, 'account'):
|
|
self.status_bar.showMessage("Cannot mute: No active account", 2000)
|
|
return
|
|
|
|
# Don't allow muting yourself
|
|
is_own_post = (post.account.username == active_account.username and
|
|
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
|
|
|
if is_own_post:
|
|
self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000)
|
|
return
|
|
|
|
try:
|
|
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
|
client.mute_account(post.account.id)
|
|
username = post.account.display_name or post.account.username
|
|
self.status_bar.showMessage(f"Muted {username}", 2000)
|
|
# Play success sound for successful mute
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_success()
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Mute failed: {str(e)}", 3000)
|
|
if hasattr(self.timeline, 'sound_manager'):
|
|
self.timeline.sound_manager.play_error()
|
|
|
|
def closeEvent(self, event):
|
|
"""Handle window close event"""
|
|
# Only play shutdown sound if not already played through quit_application
|
|
if not hasattr(self, '_shutdown_sound_played') and 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() |