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

@ -6,14 +6,18 @@ Bifrost is a fully accessible fediverse client built with PySide6, designed spec
## Core Features
- Full ActivityPub protocol support (Pleroma and GoToSocial primary targets)
- Threaded conversation navigation with collapsible tree view
- Customizable sound pack system for audio notifications
- Screen reader optimized interface
- Comprehensive soundpack management system with secure repository support
- Smart autocomplete for mentions (@user@instance.com) and emojis (5,000+ Unicode)
- Auto-refresh with intelligent activity-based timing
- Screen reader optimized interface with Orca compatibility fixes
- XDG Base Directory specification compliance
## Technology Stack
- **PySide6**: Main GUI framework (proven accessibility with existing doom launcher)
- **requests**: HTTP client for ActivityPub APIs
- **simpleaudio**: Cross-platform audio with subprocess fallback
- **emoji**: Comprehensive Unicode emoji library (5,000+ emojis with keyword search)
- **plyer**: Cross-platform desktop notifications
- **XDG directories**: Configuration and data storage
## Architecture
@ -41,13 +45,16 @@ bifrost/
│ │ └── thread.py # Conversation threading
│ ├── widgets/ # Custom UI components
│ │ ├── __init__.py
│ │ ├── timeline_view.py # Main timeline widget
│ │ ├── compose_dialog.py # Post composition
│ │ ├── timeline_view.py # Main timeline widget with auto-refresh
│ │ ├── compose_dialog.py # Post composition with smart autocomplete
│ │ ├── autocomplete_textedit.py # Mention and emoji autocomplete system
│ │ ├── settings_dialog.py # Application settings
│ │ ├── soundpack_manager_dialog.py # Soundpack repository management
│ │ └── login_dialog.py # Instance login
│ ├── audio/ # Sound system
│ │ ├── __init__.py
│ │ ├── sound_manager.py # Audio notification handler
│ │ └── soundpack_manager.py # Secure soundpack installation system
│ │ └── sound_pack.py # Sound pack management
│ └── config/ # Configuration management
│ ├── __init__.py
@ -357,9 +364,8 @@ verbose_announcements = true
PySide6>=6.0.0
requests>=2.25.0
simpleaudio>=1.0.4
numpy
configparser
pathlib
plyer>=2.1.0
emoji>=2.0.0
```
### Optional Dependencies
@ -402,4 +408,15 @@ python bifrost.py
- **JAWS** (Windows via Wine): Basic compatibility
- **VoiceOver** (macOS): Future consideration
## Critical Accessibility Rules
### Text Truncation Is Forbidden
**NEVER TRUNCATE TEXT**: Bifrost is an accessibility-first client. Text truncation (using "..." or limiting character counts) is strictly forbidden as it prevents screen reader users from accessing complete information. Always display full content, descriptions, usernames, profiles, and any other text in its entirety.
Examples of forbidden practices:
- `content[:100] + "..."`
- Character limits on display text
- Shortened usernames or descriptions
- Abbreviated profile information
This document serves as the comprehensive development guide for Bifrost, ensuring all accessibility, functionality, and architectural decisions are preserved and can be referenced throughout development.

View File

@ -245,6 +245,33 @@ class ActivityPubClient:
"""Get custom emojis for this instance"""
return self._make_request('GET', '/api/v1/custom_emojis')
def edit_status(self, status_id: str, content: str, visibility: str = 'public',
content_warning: Optional[str] = None,
media_ids: Optional[List[str]] = None,
content_type: str = 'text/plain') -> Dict:
"""Edit an existing status"""
data = {
'status': content,
'visibility': visibility
}
if content_type == 'text/markdown':
data['content_type'] = 'text/markdown'
if content_warning:
data['spoiler_text'] = content_warning
if media_ids:
data['media_ids'] = media_ids
endpoint = f'/api/v1/statuses/{status_id}'
return self._make_request('PUT', endpoint, data=data)
def get_relationship(self, account_id: str) -> Dict:
"""Get relationship with an account"""
params = {'id': account_id}
result = self._make_request('GET', '/api/v1/accounts/relationships', params=params)
return result[0] if result else {}
class AuthenticationError(Exception):
"""Raised when authentication fails"""

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

View File

@ -159,9 +159,6 @@ class User:
# Add bio if present
bio = self.get_bio_text()
if bio:
# Truncate long bios
if len(bio) > 150:
bio = bio[:147] + "..."
summary += f" - {bio}"
return summary

View File

@ -26,6 +26,10 @@ class TimelineView(AccessibleTreeWidget):
boost_requested = Signal(object) # Post object
favorite_requested = Signal(object) # Post object
profile_requested = Signal(object) # Post object
delete_requested = Signal(object) # Post object
edit_requested = Signal(object) # Post object
follow_requested = Signal(object) # Post object
unfollow_requested = Signal(object) # Post object
def __init__(self, account_manager: AccountManager, parent=None):
super().__init__(parent)
@ -103,9 +107,17 @@ class TimelineView(AccessibleTreeWidget):
# Get posts per page from settings
posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40)
# Fetch timeline or notifications
# Fetch timeline, notifications, or followers/following
if self.timeline_type == "notifications":
timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page)
elif self.timeline_type == "followers":
# Get current user account info first
user_info = self.activitypub_client.verify_credentials()
timeline_data = self.activitypub_client.get_followers(user_info['id'], limit=posts_per_page)
elif self.timeline_type == "following":
# Get current user account info first
user_info = self.activitypub_client.verify_credentials()
timeline_data = self.activitypub_client.get_following(user_info['id'], limit=posts_per_page)
else:
timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=posts_per_page)
self.load_timeline_data(timeline_data)
@ -124,9 +136,9 @@ class TimelineView(AccessibleTreeWidget):
def load_timeline_data(self, timeline_data):
"""Load real timeline data from ActivityPub API"""
# Check for new content by comparing newest post ID
# Check for new content by comparing newest post ID (only for regular timelines)
has_new_content = False
if timeline_data and self.newest_post_id:
if timeline_data and self.newest_post_id and self.timeline_type not in ["followers", "following"]:
# Check if the first post (newest) is different from what we had
current_newest_id = timeline_data[0]['id']
if current_newest_id != self.newest_post_id:
@ -152,7 +164,7 @@ class TimelineView(AccessibleTreeWidget):
# Show desktop notification (skip if this is initial load)
if not self.skip_notifications:
content_preview = post.get_content_text()[:100] + "..." if len(post.get_content_text()) > 100 else post.get_content_text()
content_preview = post.get_content_text()
if notification_type == 'mention':
self.notification_manager.notify_mention(sender, content_preview)
@ -167,6 +179,42 @@ class TimelineView(AccessibleTreeWidget):
except Exception as e:
print(f"Error parsing notification: {e}")
continue
elif self.timeline_type in ["followers", "following"]:
# Handle followers/following data structure (account list)
for account_data in timeline_data:
try:
# Create a pseudo-post from account data for display
from models.user import User
user = User.from_api_dict(account_data)
# Create a special Post-like object for accounts
class AccountDisplayPost:
def __init__(self, user):
self.id = user.id
self.account = user
self.in_reply_to_id = None
self.content = f"<p>@{user.username} - {user.display_name or user.username}</p>"
if user.note:
self.content += f"<br><small>{user.note}</small>"
def get_content_text(self):
return f"@{self.account.username} - {self.account.display_name or self.account.username}"
def get_summary_for_screen_reader(self):
username = self.account.display_name or self.account.username
if self.account.note:
# Strip HTML tags from note
import re
note = re.sub('<[^<]+?>', '', self.account.note)
return f"{username} (@{self.account.username}): {note}"
return f"{username} (@{self.account.username})"
account_post = AccountDisplayPost(user)
self.posts.append(account_post)
except Exception as e:
print(f"Error parsing account: {e}")
continue
else:
# Handle regular timeline data structure
new_posts = []
@ -190,7 +238,10 @@ class TimelineView(AccessibleTreeWidget):
# Use generic "new content" message instead of counting posts
self.notification_manager.notify_new_content(timeline_name)
# Build thread structure
# Build thread structure (accounts don't need threading)
if self.timeline_type in ["followers", "following"]:
self.build_account_list()
else:
self.build_threaded_timeline()
def build_threaded_timeline(self):
@ -244,6 +295,16 @@ class TimelineView(AccessibleTreeWidget):
if top_item.childCount() > 0:
self.update_child_accessibility(top_item, False)
def build_account_list(self):
"""Build simple list for followers/following accounts"""
for account_post in self.posts:
item = self.create_post_item(account_post)
self.addTopLevelItem(item)
# Add "Load more" item if we have accounts
if self.posts:
self.add_load_more_item()
def find_thread_root(self, post, all_posts):
"""Find the root post ID for a given reply by walking up the chain"""
current_post = post
@ -419,6 +480,20 @@ class TimelineView(AccessibleTreeWidget):
limit=posts_per_page,
max_id=self.oldest_post_id
)
elif self.timeline_type == "followers":
user_info = self.activitypub_client.verify_credentials()
more_data = self.activitypub_client.get_followers(
user_info['id'],
limit=posts_per_page,
max_id=self.oldest_post_id
)
elif self.timeline_type == "following":
user_info = self.activitypub_client.verify_credentials()
more_data = self.activitypub_client.get_following(
user_info['id'],
limit=posts_per_page,
max_id=self.oldest_post_id
)
else:
more_data = self.activitypub_client.get_timeline(
self.timeline_type,
@ -595,6 +670,14 @@ class TimelineView(AccessibleTreeWidget):
menu = QMenu(self)
# Get current user account for ownership checks
active_account = self.account_manager.get_active_account()
is_own_post = False
if active_account and hasattr(post, 'account'):
# Check if this is user's own post
is_own_post = (post.account.username == active_account.username and
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
# Copy to clipboard action
copy_action = QAction("&Copy to Clipboard", self)
copy_action.setShortcut("Ctrl+C")
@ -634,8 +717,38 @@ class TimelineView(AccessibleTreeWidget):
favorite_action.triggered.connect(lambda: self.favorite_requested.emit(post))
menu.addAction(favorite_action)
# Owner-only actions
if is_own_post:
menu.addSeparator()
# Edit action
edit_action = QAction("&Edit Post", self)
edit_action.setShortcut("Ctrl+Shift+E")
edit_action.triggered.connect(lambda: self.edit_requested.emit(post))
menu.addAction(edit_action)
# Delete action
delete_action = QAction("&Delete Post", self)
delete_action.setShortcut("Shift+Delete")
delete_action.triggered.connect(lambda: self.delete_requested.emit(post))
menu.addAction(delete_action)
menu.addSeparator()
# Follow/Unfollow actions for non-own posts
if not is_own_post and hasattr(post, 'account'):
# TODO: Check relationship status to show correct follow/unfollow option
# For now, show both - this will be improved when we add relationship checking
follow_action = QAction("&Follow User", self)
follow_action.setShortcut("Ctrl+Shift+F")
follow_action.triggered.connect(lambda: self.follow_requested.emit(post))
menu.addAction(follow_action)
unfollow_action = QAction("&Unfollow User", self)
unfollow_action.setShortcut("Ctrl+Shift+U")
unfollow_action.triggered.connect(lambda: self.unfollow_requested.emit(post))
menu.addAction(unfollow_action)
# View profile action
profile_action = QAction("View &Profile", self)
profile_action.triggered.connect(lambda: self.profile_requested.emit(post))