Add comprehensive social features with accessibility-first design
Implements complete post management and social interaction capabilities: **Post Management:** - Delete posts with confirmation dialog (owned posts only) - Edit posts using existing compose dialog (owned posts only) - Robust ownership validation and error handling **Follow System:** - Follow/unfollow users from context menus and keyboard shortcuts - Manual follow dialog for @user@instance lookups - Account search and validation before following - Smart context menu options based on post ownership **Timeline Extensions:** - Followers tab showing accounts following you - Following tab showing accounts you follow - Seamless integration with existing timeline system - Account list display for social relationship viewing **Keyboard Shortcuts:** - Ctrl+Shift+E: Edit post - Shift+Delete: Delete post (with confirmation) - Ctrl+Shift+F: Follow user - Ctrl+Shift+U: Unfollow user - Ctrl+Shift+M: Manual follow dialog **ActivityPub API Extensions:** - edit_status() method for post editing - get_relationship() method for follow status checking - Enhanced followers/following pagination support **Critical Accessibility Fix:** - Eliminated ALL text truncation throughout the application - Added explicit no-truncation rule to development guidelines - Full content accessibility for screen reader users All features maintain Bifrost's accessibility-first principles with proper error handling, user feedback, and complete information display. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -72,6 +72,8 @@ class MainWindow(QMainWindow):
|
||||
self.timeline_tabs.addTab(QWidget(), "Mentions")
|
||||
self.timeline_tabs.addTab(QWidget(), "Local")
|
||||
self.timeline_tabs.addTab(QWidget(), "Federated")
|
||||
self.timeline_tabs.addTab(QWidget(), "Followers")
|
||||
self.timeline_tabs.addTab(QWidget(), "Following")
|
||||
self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed)
|
||||
main_layout.addWidget(self.timeline_tabs)
|
||||
|
||||
@ -88,6 +90,10 @@ class MainWindow(QMainWindow):
|
||||
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
|
||||
@ -218,6 +224,43 @@ class MainWindow(QMainWindow):
|
||||
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()
|
||||
|
||||
# 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
|
||||
@ -404,8 +447,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def switch_timeline(self, index):
|
||||
"""Switch to timeline by index with loading feedback"""
|
||||
timeline_names = ["Home", "Mentions", "Local", "Federated"]
|
||||
timeline_types = ["home", "notifications", "local", "federated"]
|
||||
timeline_names = ["Home", "Mentions", "Local", "Federated", "Followers", "Following"]
|
||||
timeline_types = ["home", "notifications", "local", "federated", "followers", "following"]
|
||||
|
||||
if 0 <= index < len(timeline_names):
|
||||
timeline_name = timeline_names[index]
|
||||
@ -592,6 +635,230 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close event"""
|
||||
# Play shutdown sound if not already played through quit_application
|
||||
|
Reference in New Issue
Block a user