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