Files
bifrost/src/main_window.py
Storm Dragon b80ae3b0e2 Fix multiple notification and UI bugs to improve user experience
- 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>
2025-07-21 17:45:56 -04:00

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()