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:
Storm Dragon
2025-07-20 19:46:31 -04:00
parent b936e4994d
commit 037fcbf7e0
5 changed files with 439 additions and 18 deletions

View File

@ -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