Add comprehensive profile editing functionality with accessibility features
- Added update_credentials method to ActivityPub client for profile API calls - Created ProfileEditDialog with tabbed interface for editing profile information - Implemented profile fields management with accessible list navigation - Added HTML stripping for clean display of profile field content - Integrated profile editing into File menu with Ctrl+Alt+E shortcut - Fixed Tab key accessibility in biography text field - Updated documentation with new feature and keyboard shortcut 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -433,6 +433,7 @@ Due to Qt's visual display synchronization, thread collapse may require double-o
|
|||||||
- **Ctrl+S**: Open Search dialog for users, hashtags, and posts
|
- **Ctrl+S**: Open Search dialog for users, hashtags, and posts
|
||||||
- **Ctrl+Shift+F**: Open Timeline Filters dialog
|
- **Ctrl+Shift+F**: Open Timeline Filters dialog
|
||||||
- **Ctrl+Shift+E**: Edit selected post (your own posts only)
|
- **Ctrl+Shift+E**: Edit selected post (your own posts only)
|
||||||
|
- **Ctrl+Alt+E**: Edit your profile (display name, bio, fields, privacy settings)
|
||||||
- **Shift+Delete**: Delete selected post (your own posts only)
|
- **Shift+Delete**: Delete selected post (your own posts only)
|
||||||
- **F5**: Refresh timeline
|
- **F5**: Refresh timeline
|
||||||
- **Ctrl+,**: Settings
|
- **Ctrl+,**: Settings
|
||||||
@@ -560,6 +561,7 @@ verbose_announcements = true
|
|||||||
- **Timeline Filtering**: ✅ Filter home timeline by content type (replies, boosts, mentions) and media
|
- **Timeline Filtering**: ✅ Filter home timeline by content type (replies, boosts, mentions) and media
|
||||||
- **Search Dialog**: ✅ Dedicated search interface with tabbed results and background processing
|
- **Search Dialog**: ✅ Dedicated search interface with tabbed results and background processing
|
||||||
- **Filter Dialog**: ✅ Timeline filter configuration with real-time application
|
- **Filter Dialog**: ✅ Timeline filter configuration with real-time application
|
||||||
|
- **Profile Editing**: ✅ Edit your profile information including display name, bio, profile fields, and privacy settings
|
||||||
|
|
||||||
### Remaining High Priority Features
|
### Remaining High Priority Features
|
||||||
- **User Blocking Management**: Block/unblock users with dedicated management interface
|
- **User Blocking Management**: Block/unblock users with dedicated management interface
|
||||||
|
@@ -33,6 +33,7 @@ This project was created through "vibe coding" - a collaborative development app
|
|||||||
- **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users
|
- **Blocked/Muted Management**: Dedicated tabs for managing blocked and muted users
|
||||||
- **Custom Emoji Support**: Instance-specific emoji support with caching
|
- **Custom Emoji Support**: Instance-specific emoji support with caching
|
||||||
- **Post Editing**: Edit your own posts and direct messages with full content preservation
|
- **Post Editing**: Edit your own posts and direct messages with full content preservation
|
||||||
|
- **Profile Editing**: Edit your profile information including display name, bio, profile fields, and privacy settings
|
||||||
- **Search Functionality**: Search for users, hashtags, and posts across the fediverse with dedicated search interface
|
- **Search Functionality**: Search for users, hashtags, and posts across the fediverse with dedicated search interface
|
||||||
- **Timeline Filtering**: Customize your timeline view by hiding/showing replies, boosts, mentions, and media content
|
- **Timeline Filtering**: Customize your timeline view by hiding/showing replies, boosts, mentions, and media content
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ Bifrost includes a sophisticated sound system with intelligent notification hand
|
|||||||
- **Ctrl+Shift+F**: Open Timeline Filters dialog
|
- **Ctrl+Shift+F**: Open Timeline Filters dialog
|
||||||
- **Ctrl+,**: Open Settings
|
- **Ctrl+,**: Open Settings
|
||||||
- **Ctrl+Shift+A**: Add new account
|
- **Ctrl+Shift+A**: Add new account
|
||||||
|
- **Ctrl+Alt+E**: Edit your profile
|
||||||
- **Ctrl+Alt+S**: Open Soundpack Manager
|
- **Ctrl+Alt+S**: Open Soundpack Manager
|
||||||
- **Ctrl+Q**: Quit application
|
- **Ctrl+Q**: Quit application
|
||||||
|
|
||||||
|
@@ -245,6 +245,38 @@ class ActivityPubClient:
|
|||||||
"""Verify account credentials"""
|
"""Verify account credentials"""
|
||||||
return self._make_request('GET', '/api/v1/accounts/verify_credentials')
|
return self._make_request('GET', '/api/v1/accounts/verify_credentials')
|
||||||
|
|
||||||
|
def update_credentials(self, display_name: Optional[str] = None, note: Optional[str] = None,
|
||||||
|
avatar: Optional[str] = None, header: Optional[str] = None,
|
||||||
|
locked: Optional[bool] = None, bot: Optional[bool] = None,
|
||||||
|
discoverable: Optional[bool] = None, fields_attributes: Optional[List[Dict]] = None) -> Dict:
|
||||||
|
"""Update account credentials and profile information"""
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if display_name is not None:
|
||||||
|
data['display_name'] = display_name
|
||||||
|
if note is not None:
|
||||||
|
data['note'] = note
|
||||||
|
if avatar is not None:
|
||||||
|
data['avatar'] = avatar
|
||||||
|
if header is not None:
|
||||||
|
data['header'] = header
|
||||||
|
if locked is not None:
|
||||||
|
data['locked'] = str(locked).lower()
|
||||||
|
if bot is not None:
|
||||||
|
data['bot'] = str(bot).lower()
|
||||||
|
if discoverable is not None:
|
||||||
|
data['discoverable'] = str(discoverable).lower()
|
||||||
|
|
||||||
|
# Handle profile fields
|
||||||
|
if fields_attributes is not None:
|
||||||
|
for i, field in enumerate(fields_attributes):
|
||||||
|
if 'name' in field:
|
||||||
|
data[f'fields_attributes[{i}][name]'] = field['name']
|
||||||
|
if 'value' in field:
|
||||||
|
data[f'fields_attributes[{i}][value]'] = field['value']
|
||||||
|
|
||||||
|
return self._make_request_form('PATCH', '/api/v1/accounts/update_credentials', data=data)
|
||||||
|
|
||||||
def get_timeline(self, timeline_type: str = 'home', limit: int = 40,
|
def get_timeline(self, timeline_type: str = 'home', limit: int = 40,
|
||||||
max_id: Optional[str] = None, since_id: Optional[str] = None) -> List[Dict]:
|
max_id: Optional[str] = None, since_id: Optional[str] = None) -> List[Dict]:
|
||||||
"""Get timeline posts"""
|
"""Get timeline posts"""
|
||||||
|
@@ -27,6 +27,7 @@ from widgets.account_selector import AccountSelector
|
|||||||
from widgets.settings_dialog import SettingsDialog
|
from widgets.settings_dialog import SettingsDialog
|
||||||
from widgets.soundpack_manager_dialog import SoundpackManagerDialog
|
from widgets.soundpack_manager_dialog import SoundpackManagerDialog
|
||||||
from widgets.profile_dialog import ProfileDialog
|
from widgets.profile_dialog import ProfileDialog
|
||||||
|
from widgets.profile_edit_dialog import ProfileEditDialog
|
||||||
from widgets.accessible_text_dialog import AccessibleTextDialog
|
from widgets.accessible_text_dialog import AccessibleTextDialog
|
||||||
from widgets.search_dialog import SearchDialog
|
from widgets.search_dialog import SearchDialog
|
||||||
from widgets.timeline_filter_dialog import TimelineFilterDialog
|
from widgets.timeline_filter_dialog import TimelineFilterDialog
|
||||||
@@ -182,6 +183,12 @@ class MainWindow(QMainWindow):
|
|||||||
add_account_action.triggered.connect(self.show_login_dialog)
|
add_account_action.triggered.connect(self.show_login_dialog)
|
||||||
file_menu.addAction(add_account_action)
|
file_menu.addAction(add_account_action)
|
||||||
|
|
||||||
|
# Edit Profile action
|
||||||
|
edit_profile_action = QAction("&Edit Profile", self)
|
||||||
|
edit_profile_action.setShortcut(QKeySequence("Ctrl+Alt+E"))
|
||||||
|
edit_profile_action.triggered.connect(self.show_edit_profile)
|
||||||
|
file_menu.addAction(edit_profile_action)
|
||||||
|
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
|
|
||||||
# Settings action
|
# Settings action
|
||||||
@@ -809,6 +816,58 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.status_bar.showMessage("Settings saved successfully", 2000)
|
self.status_bar.showMessage("Settings saved successfully", 2000)
|
||||||
|
|
||||||
|
def show_edit_profile(self):
|
||||||
|
"""Show the profile editing dialog"""
|
||||||
|
try:
|
||||||
|
# Check if we have an active account
|
||||||
|
if not self.account_manager.has_accounts():
|
||||||
|
AccessibleTextDialog.show_error(
|
||||||
|
"No Account",
|
||||||
|
"You need to be logged in to edit your profile.",
|
||||||
|
"",
|
||||||
|
self
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
active_account = self.account_manager.get_active_account()
|
||||||
|
if not active_account:
|
||||||
|
AccessibleTextDialog.show_error(
|
||||||
|
"No Active Account",
|
||||||
|
"Please select an active account to edit your profile.",
|
||||||
|
"",
|
||||||
|
self
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create and show dialog
|
||||||
|
dialog = ProfileEditDialog(
|
||||||
|
self.account_manager,
|
||||||
|
self.timeline.sound_manager if hasattr(self.timeline, 'sound_manager') else None,
|
||||||
|
self
|
||||||
|
)
|
||||||
|
dialog.profile_updated.connect(self.on_profile_updated)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to show profile edit dialog: {e}")
|
||||||
|
AccessibleTextDialog.show_error(
|
||||||
|
"Profile Edit Error",
|
||||||
|
"Failed to open profile editor.",
|
||||||
|
str(e),
|
||||||
|
self
|
||||||
|
)
|
||||||
|
if hasattr(self.timeline, 'sound_manager'):
|
||||||
|
self.timeline.sound_manager.play_error()
|
||||||
|
|
||||||
|
def on_profile_updated(self, updated_user):
|
||||||
|
"""Handle successful profile update"""
|
||||||
|
self.logger.info(f"Profile updated for user: {updated_user.username}")
|
||||||
|
self.status_bar.showMessage("Profile updated successfully", 3000)
|
||||||
|
|
||||||
|
# Refresh timeline to show any changes (display name, etc.)
|
||||||
|
if hasattr(self.timeline, 'refresh_timeline'):
|
||||||
|
self.timeline.refresh_timeline()
|
||||||
|
|
||||||
def show_soundpack_manager(self):
|
def show_soundpack_manager(self):
|
||||||
"""Show the soundpack manager dialog"""
|
"""Show the soundpack manager dialog"""
|
||||||
dialog = SoundpackManagerDialog(self.settings, self)
|
dialog = SoundpackManagerDialog(self.settings, self)
|
||||||
|
574
src/widgets/profile_edit_dialog.py
Normal file
574
src/widgets/profile_edit_dialog.py
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
"""
|
||||||
|
Profile editing dialog for Bifrost
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||||
|
QLabel, QPushButton, QDialogButtonBox, QTextEdit,
|
||||||
|
QLineEdit, QCheckBox, QGroupBox, QTabWidget, QWidget,
|
||||||
|
QScrollArea, QMessageBox, QListWidget, QListWidgetItem
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, Signal, QThread
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from models.user import User, Field
|
||||||
|
from activitypub.client import ActivityPubClient
|
||||||
|
from config.accounts import AccountManager
|
||||||
|
from audio.sound_manager import SoundManager
|
||||||
|
from widgets.accessible_text_dialog import AccessibleTextDialog
|
||||||
|
|
||||||
|
|
||||||
|
def strip_html_tags(text: str) -> str:
|
||||||
|
"""Strip HTML tags and decode entities for clean text display"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Remove HTML tags
|
||||||
|
clean_text = re.sub(r'<[^>]+>', '', text)
|
||||||
|
# Decode common HTML entities
|
||||||
|
clean_text = clean_text.replace('<', '<').replace('>', '>').replace('&', '&')
|
||||||
|
clean_text = clean_text.replace('"', '"').replace(''', "'")
|
||||||
|
return clean_text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateThread(QThread):
|
||||||
|
"""Thread for updating profile data without blocking UI"""
|
||||||
|
|
||||||
|
profile_updated = Signal(object) # Updated User object
|
||||||
|
error_occurred = Signal(str) # Error message
|
||||||
|
|
||||||
|
def __init__(self, client: ActivityPubClient, profile_data: Dict[str, Any]):
|
||||||
|
super().__init__()
|
||||||
|
self.client = client
|
||||||
|
self.profile_data = profile_data
|
||||||
|
self.logger = logging.getLogger('bifrost.profile_edit')
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
self.logger.info(f"Updating profile with data: {list(self.profile_data.keys())}")
|
||||||
|
|
||||||
|
# Update profile via API
|
||||||
|
result = self.client.update_credentials(**self.profile_data)
|
||||||
|
|
||||||
|
# Convert response to User object
|
||||||
|
updated_user = User.from_api_dict(result)
|
||||||
|
self.profile_updated.emit(updated_user)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Profile update failed: {e}")
|
||||||
|
self.error_occurred.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileFieldsWidget(QWidget):
|
||||||
|
"""Widget for managing profile fields"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.fields = [] # List of field dictionaries
|
||||||
|
self.setup_ui()
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
"""Setup the profile fields UI"""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
instructions = QLabel("Profile fields are name-value pairs displayed on your profile. You can have up to 4 fields.")
|
||||||
|
instructions.setAccessibleName("Profile Fields Instructions")
|
||||||
|
instructions.setWordWrap(True)
|
||||||
|
layout.addWidget(instructions)
|
||||||
|
|
||||||
|
# Fields list
|
||||||
|
self.fields_list = QListWidget()
|
||||||
|
self.fields_list.setAccessibleName("Profile Fields List")
|
||||||
|
layout.addWidget(self.fields_list)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
buttons_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.add_button = QPushButton("Add Field")
|
||||||
|
self.add_button.setAccessibleName("Add Profile Field")
|
||||||
|
self.add_button.clicked.connect(self.add_field)
|
||||||
|
buttons_layout.addWidget(self.add_button)
|
||||||
|
|
||||||
|
self.edit_button = QPushButton("Edit Field")
|
||||||
|
self.edit_button.setAccessibleName("Edit Selected Profile Field")
|
||||||
|
self.edit_button.clicked.connect(self.edit_field)
|
||||||
|
self.edit_button.setEnabled(False)
|
||||||
|
buttons_layout.addWidget(self.edit_button)
|
||||||
|
|
||||||
|
self.remove_button = QPushButton("Remove Field")
|
||||||
|
self.remove_button.setAccessibleName("Remove Selected Profile Field")
|
||||||
|
self.remove_button.clicked.connect(self.remove_field)
|
||||||
|
self.remove_button.setEnabled(False)
|
||||||
|
buttons_layout.addWidget(self.remove_button)
|
||||||
|
|
||||||
|
buttons_layout.addStretch()
|
||||||
|
layout.addLayout(buttons_layout)
|
||||||
|
|
||||||
|
# Connect list selection
|
||||||
|
self.fields_list.itemSelectionChanged.connect(self.on_selection_changed)
|
||||||
|
|
||||||
|
def set_fields(self, fields: List[Field]):
|
||||||
|
"""Set the profile fields"""
|
||||||
|
self.fields = []
|
||||||
|
for field in fields:
|
||||||
|
# Store the original HTML values (they'll be cleaned for display)
|
||||||
|
self.fields.append({
|
||||||
|
'name': field.name,
|
||||||
|
'value': field.value
|
||||||
|
})
|
||||||
|
self.refresh_list()
|
||||||
|
|
||||||
|
def get_fields(self) -> List[Dict[str, str]]:
|
||||||
|
"""Get the current profile fields as API format"""
|
||||||
|
return self.fields.copy()
|
||||||
|
|
||||||
|
def refresh_list(self):
|
||||||
|
"""Refresh the fields list display"""
|
||||||
|
self.fields_list.clear()
|
||||||
|
|
||||||
|
# Add fake header for single-item navigation (accessibility requirement)
|
||||||
|
field_count = len(self.fields)
|
||||||
|
header_text = f"Profile fields ({field_count} configured):"
|
||||||
|
fake_header = QListWidgetItem(header_text)
|
||||||
|
fake_header.setFlags(Qt.ItemIsEnabled) # Not selectable
|
||||||
|
fake_header.setData(Qt.AccessibleDescriptionRole, "Profile fields list header")
|
||||||
|
self.fields_list.addItem(fake_header)
|
||||||
|
|
||||||
|
if not self.fields:
|
||||||
|
item = QListWidgetItem("No profile fields configured")
|
||||||
|
item.setData(Qt.UserRole, None)
|
||||||
|
item.setData(Qt.AccessibleTextRole, "No profile fields have been configured")
|
||||||
|
self.fields_list.addItem(item)
|
||||||
|
return
|
||||||
|
|
||||||
|
for i, field in enumerate(self.fields):
|
||||||
|
# Strip HTML for clean display in list
|
||||||
|
clean_name = strip_html_tags(field['name'])
|
||||||
|
clean_value = strip_html_tags(field['value'])
|
||||||
|
|
||||||
|
display_text = f"{clean_name}: {clean_value}"
|
||||||
|
item = QListWidgetItem(display_text)
|
||||||
|
item.setData(Qt.UserRole, i)
|
||||||
|
item.setData(Qt.AccessibleTextRole, f"Profile field {i+1}: {clean_name} equals {clean_value}")
|
||||||
|
self.fields_list.addItem(item)
|
||||||
|
|
||||||
|
def on_selection_changed(self):
|
||||||
|
"""Handle field selection changes"""
|
||||||
|
current_item = self.fields_list.currentItem()
|
||||||
|
# Enable buttons only if it's not the header item and has field data
|
||||||
|
has_field_data = current_item and current_item.data(Qt.UserRole) is not None
|
||||||
|
self.edit_button.setEnabled(has_field_data)
|
||||||
|
self.remove_button.setEnabled(has_field_data)
|
||||||
|
|
||||||
|
def add_field(self):
|
||||||
|
"""Add a new profile field"""
|
||||||
|
if len(self.fields) >= 4:
|
||||||
|
QMessageBox.warning(self, "Maximum Fields", "You can only have up to 4 profile fields.")
|
||||||
|
return
|
||||||
|
|
||||||
|
field_data = self.show_field_dialog()
|
||||||
|
if field_data:
|
||||||
|
self.fields.append(field_data)
|
||||||
|
self.refresh_list()
|
||||||
|
|
||||||
|
def edit_field(self):
|
||||||
|
"""Edit the selected profile field"""
|
||||||
|
current_item = self.fields_list.currentItem()
|
||||||
|
if not current_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
field_index = current_item.data(Qt.UserRole)
|
||||||
|
if field_index is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_field = self.fields[field_index]
|
||||||
|
field_data = self.show_field_dialog(current_field)
|
||||||
|
if field_data:
|
||||||
|
self.fields[field_index] = field_data
|
||||||
|
self.refresh_list()
|
||||||
|
|
||||||
|
def remove_field(self):
|
||||||
|
"""Remove the selected profile field"""
|
||||||
|
current_item = self.fields_list.currentItem()
|
||||||
|
if not current_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
field_index = current_item.data(Qt.UserRole)
|
||||||
|
if field_index is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Remove Field",
|
||||||
|
f"Are you sure you want to remove the field '{self.fields[field_index]['name']}'?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if result == QMessageBox.Yes:
|
||||||
|
del self.fields[field_index]
|
||||||
|
self.refresh_list()
|
||||||
|
|
||||||
|
def show_field_dialog(self, existing_field: Optional[Dict[str, str]] = None) -> Optional[Dict[str, str]]:
|
||||||
|
"""Show dialog for editing a field"""
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("Edit Profile Field" if existing_field else "Add Profile Field")
|
||||||
|
dialog.setModal(True)
|
||||||
|
dialog.setMinimumSize(400, 200)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
form_layout = QFormLayout()
|
||||||
|
|
||||||
|
# Field name
|
||||||
|
name_edit = QLineEdit()
|
||||||
|
name_edit.setAccessibleName("Field Name")
|
||||||
|
name_edit.setPlaceholderText("e.g., Website, Location, Pronouns")
|
||||||
|
if existing_field:
|
||||||
|
# Strip HTML for clean editing
|
||||||
|
clean_name = strip_html_tags(existing_field['name'])
|
||||||
|
name_edit.setText(clean_name)
|
||||||
|
form_layout.addRow("Field Name:", name_edit)
|
||||||
|
|
||||||
|
# Field value
|
||||||
|
value_edit = QLineEdit()
|
||||||
|
value_edit.setAccessibleName("Field Value")
|
||||||
|
value_edit.setPlaceholderText("e.g., https://example.com, New York, they/them")
|
||||||
|
if existing_field:
|
||||||
|
# Strip HTML for clean editing
|
||||||
|
clean_value = strip_html_tags(existing_field['value'])
|
||||||
|
value_edit.setText(clean_value)
|
||||||
|
form_layout.addRow("Field Value:", value_edit)
|
||||||
|
|
||||||
|
layout.addLayout(form_layout)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
button_box.accepted.connect(dialog.accept)
|
||||||
|
button_box.rejected.connect(dialog.reject)
|
||||||
|
layout.addWidget(button_box)
|
||||||
|
|
||||||
|
# Focus on name field
|
||||||
|
name_edit.setFocus()
|
||||||
|
|
||||||
|
if dialog.exec() == QDialog.Accepted:
|
||||||
|
name = name_edit.text().strip()
|
||||||
|
value = value_edit.text().strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
QMessageBox.warning(dialog, "Invalid Field", "Field name cannot be empty.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {'name': name, 'value': value}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileEditDialog(QDialog):
|
||||||
|
"""Dialog for editing user profile information"""
|
||||||
|
|
||||||
|
profile_updated = Signal(object) # Emitted when profile is successfully updated
|
||||||
|
|
||||||
|
def __init__(self, account_manager: AccountManager, sound_manager: SoundManager, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.account_manager = account_manager
|
||||||
|
self.sound_manager = sound_manager
|
||||||
|
self.current_user = None
|
||||||
|
self.logger = logging.getLogger('bifrost.profile_edit')
|
||||||
|
|
||||||
|
# Get API client
|
||||||
|
active_account = self.account_manager.get_active_account()
|
||||||
|
if not active_account:
|
||||||
|
raise ValueError("No active account for profile editing")
|
||||||
|
|
||||||
|
self.client = ActivityPubClient(
|
||||||
|
active_account.instance_url,
|
||||||
|
active_account.access_token
|
||||||
|
)
|
||||||
|
|
||||||
|
self.setup_ui()
|
||||||
|
self.load_current_profile()
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
"""Initialize the user interface"""
|
||||||
|
self.setWindowTitle("Edit Profile")
|
||||||
|
self.setMinimumSize(600, 500)
|
||||||
|
self.setModal(True)
|
||||||
|
|
||||||
|
# Main layout
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Loading label
|
||||||
|
self.loading_label = QLabel("Loading your profile...")
|
||||||
|
self.loading_label.setAccessibleName("Profile Loading Status")
|
||||||
|
self.loading_label.setAlignment(Qt.AlignCenter)
|
||||||
|
main_layout.addWidget(self.loading_label)
|
||||||
|
|
||||||
|
# Content widget (initially hidden)
|
||||||
|
self.content_widget = QWidget()
|
||||||
|
self.content_widget.hide()
|
||||||
|
self.setup_content_ui()
|
||||||
|
main_layout.addWidget(self.content_widget)
|
||||||
|
|
||||||
|
# Button box
|
||||||
|
self.button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setText("Save Profile")
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setAccessibleName("Save Profile Changes")
|
||||||
|
self.button_box.accepted.connect(self.save_profile)
|
||||||
|
self.button_box.rejected.connect(self.reject)
|
||||||
|
main_layout.addWidget(self.button_box)
|
||||||
|
|
||||||
|
def setup_content_ui(self):
|
||||||
|
"""Setup the main content UI (shown after loading)"""
|
||||||
|
content_layout = QVBoxLayout(self.content_widget)
|
||||||
|
|
||||||
|
# Tab widget for different sections
|
||||||
|
self.tab_widget = QTabWidget()
|
||||||
|
self.tab_widget.setAccessibleName("Profile Edit Sections")
|
||||||
|
|
||||||
|
# Basic info tab
|
||||||
|
self.setup_basic_tab()
|
||||||
|
self.tab_widget.addTab(self.basic_tab, "Basic Info")
|
||||||
|
|
||||||
|
# Profile fields tab
|
||||||
|
self.setup_fields_tab()
|
||||||
|
self.tab_widget.addTab(self.fields_tab, "Profile Fields")
|
||||||
|
|
||||||
|
# Privacy settings tab
|
||||||
|
self.setup_privacy_tab()
|
||||||
|
self.tab_widget.addTab(self.privacy_tab, "Privacy")
|
||||||
|
|
||||||
|
content_layout.addWidget(self.tab_widget)
|
||||||
|
|
||||||
|
def setup_basic_tab(self):
|
||||||
|
"""Setup the basic profile information tab"""
|
||||||
|
self.basic_tab = QScrollArea()
|
||||||
|
self.basic_tab.setAccessibleName("Basic Profile Information")
|
||||||
|
self.basic_tab.setWidgetResizable(True)
|
||||||
|
|
||||||
|
basic_content = QWidget()
|
||||||
|
basic_layout = QVBoxLayout(basic_content)
|
||||||
|
|
||||||
|
# Display name
|
||||||
|
name_group = QGroupBox("Display Name")
|
||||||
|
name_group.setAccessibleName("Display Name Section")
|
||||||
|
name_layout = QVBoxLayout(name_group)
|
||||||
|
|
||||||
|
self.display_name_edit = QLineEdit()
|
||||||
|
self.display_name_edit.setAccessibleName("Display Name")
|
||||||
|
self.display_name_edit.setPlaceholderText("Your display name (shown on posts)")
|
||||||
|
name_layout.addWidget(self.display_name_edit)
|
||||||
|
|
||||||
|
basic_layout.addWidget(name_group)
|
||||||
|
|
||||||
|
# Biography
|
||||||
|
bio_group = QGroupBox("Biography")
|
||||||
|
bio_group.setAccessibleName("Biography Section")
|
||||||
|
bio_layout = QVBoxLayout(bio_group)
|
||||||
|
|
||||||
|
bio_instructions = QLabel("Describe yourself in a few sentences. HTML and links are supported.")
|
||||||
|
bio_instructions.setAccessibleName("Biography Instructions")
|
||||||
|
bio_instructions.setWordWrap(True)
|
||||||
|
bio_layout.addWidget(bio_instructions)
|
||||||
|
|
||||||
|
self.bio_edit = QTextEdit()
|
||||||
|
self.bio_edit.setAccessibleName("Biography")
|
||||||
|
self.bio_edit.setPlaceholderText("Tell people about yourself...")
|
||||||
|
self.bio_edit.setMaximumHeight(150)
|
||||||
|
# Fix accessibility: Tab should move focus, not insert tab character
|
||||||
|
self.bio_edit.setTabChangesFocus(True)
|
||||||
|
bio_layout.addWidget(self.bio_edit)
|
||||||
|
|
||||||
|
basic_layout.addWidget(bio_group)
|
||||||
|
|
||||||
|
basic_layout.addStretch()
|
||||||
|
self.basic_tab.setWidget(basic_content)
|
||||||
|
|
||||||
|
def setup_fields_tab(self):
|
||||||
|
"""Setup the profile fields tab"""
|
||||||
|
self.fields_tab = QWidget()
|
||||||
|
fields_layout = QVBoxLayout(self.fields_tab)
|
||||||
|
|
||||||
|
self.fields_widget = ProfileFieldsWidget()
|
||||||
|
fields_layout.addWidget(self.fields_widget)
|
||||||
|
|
||||||
|
def setup_privacy_tab(self):
|
||||||
|
"""Setup the privacy settings tab"""
|
||||||
|
self.privacy_tab = QScrollArea()
|
||||||
|
self.privacy_tab.setAccessibleName("Privacy Settings")
|
||||||
|
self.privacy_tab.setWidgetResizable(True)
|
||||||
|
|
||||||
|
privacy_content = QWidget()
|
||||||
|
privacy_layout = QVBoxLayout(privacy_content)
|
||||||
|
|
||||||
|
# Account privacy
|
||||||
|
privacy_group = QGroupBox("Account Privacy")
|
||||||
|
privacy_group.setAccessibleName("Account Privacy Section")
|
||||||
|
privacy_group_layout = QVBoxLayout(privacy_group)
|
||||||
|
|
||||||
|
self.locked_checkbox = QCheckBox("Private account (require approval for followers)")
|
||||||
|
self.locked_checkbox.setAccessibleName("Private Account Setting")
|
||||||
|
privacy_group_layout.addWidget(self.locked_checkbox)
|
||||||
|
|
||||||
|
privacy_layout.addWidget(privacy_group)
|
||||||
|
|
||||||
|
# Account type
|
||||||
|
type_group = QGroupBox("Account Type")
|
||||||
|
type_group.setAccessibleName("Account Type Section")
|
||||||
|
type_group_layout = QVBoxLayout(type_group)
|
||||||
|
|
||||||
|
self.bot_checkbox = QCheckBox("This is a bot account")
|
||||||
|
self.bot_checkbox.setAccessibleName("Bot Account Setting")
|
||||||
|
type_group_layout.addWidget(self.bot_checkbox)
|
||||||
|
|
||||||
|
privacy_layout.addWidget(type_group)
|
||||||
|
|
||||||
|
# Discoverability
|
||||||
|
discovery_group = QGroupBox("Discoverability")
|
||||||
|
discovery_group.setAccessibleName("Discoverability Section")
|
||||||
|
discovery_group_layout = QVBoxLayout(discovery_group)
|
||||||
|
|
||||||
|
self.discoverable_checkbox = QCheckBox("Include in directory and search results")
|
||||||
|
self.discoverable_checkbox.setAccessibleName("Discoverable Account Setting")
|
||||||
|
discovery_group_layout.addWidget(self.discoverable_checkbox)
|
||||||
|
|
||||||
|
privacy_layout.addWidget(discovery_group)
|
||||||
|
|
||||||
|
privacy_layout.addStretch()
|
||||||
|
self.privacy_tab.setWidget(privacy_content)
|
||||||
|
|
||||||
|
def load_current_profile(self):
|
||||||
|
"""Load the current user's profile information"""
|
||||||
|
try:
|
||||||
|
# Get current user info
|
||||||
|
user_data = self.client.verify_credentials()
|
||||||
|
self.current_user = User.from_api_dict(user_data)
|
||||||
|
self.populate_fields()
|
||||||
|
|
||||||
|
# Hide loading and show content
|
||||||
|
self.loading_label.hide()
|
||||||
|
self.content_widget.show()
|
||||||
|
|
||||||
|
self.logger.info("Profile loaded successfully for editing")
|
||||||
|
self.sound_manager.play_success()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to load profile for editing: {e}")
|
||||||
|
self.loading_label.setText(f"Error loading profile: {str(e)}")
|
||||||
|
self.sound_manager.play_error()
|
||||||
|
|
||||||
|
def populate_fields(self):
|
||||||
|
"""Populate the form fields with current profile data"""
|
||||||
|
if not self.current_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Basic info
|
||||||
|
self.display_name_edit.setText(self.current_user.display_name or "")
|
||||||
|
self.bio_edit.setPlainText(self.current_user.note or "")
|
||||||
|
|
||||||
|
# Profile fields
|
||||||
|
self.fields_widget.set_fields(self.current_user.fields or [])
|
||||||
|
|
||||||
|
# Privacy settings
|
||||||
|
self.locked_checkbox.setChecked(self.current_user.locked or False)
|
||||||
|
self.bot_checkbox.setChecked(self.current_user.bot or False)
|
||||||
|
self.discoverable_checkbox.setChecked(getattr(self.current_user, 'discoverable', True))
|
||||||
|
|
||||||
|
def save_profile(self):
|
||||||
|
"""Save the profile changes"""
|
||||||
|
try:
|
||||||
|
# Collect changes
|
||||||
|
profile_data = {}
|
||||||
|
|
||||||
|
# Check what changed
|
||||||
|
new_display_name = self.display_name_edit.text().strip()
|
||||||
|
if new_display_name != (self.current_user.display_name or ""):
|
||||||
|
profile_data['display_name'] = new_display_name
|
||||||
|
|
||||||
|
new_bio = self.bio_edit.toPlainText().strip()
|
||||||
|
if new_bio != (self.current_user.note or ""):
|
||||||
|
profile_data['note'] = new_bio
|
||||||
|
|
||||||
|
new_locked = self.locked_checkbox.isChecked()
|
||||||
|
if new_locked != (self.current_user.locked or False):
|
||||||
|
profile_data['locked'] = new_locked
|
||||||
|
|
||||||
|
new_bot = self.bot_checkbox.isChecked()
|
||||||
|
if new_bot != (self.current_user.bot or False):
|
||||||
|
profile_data['bot'] = new_bot
|
||||||
|
|
||||||
|
new_discoverable = self.discoverable_checkbox.isChecked()
|
||||||
|
current_discoverable = getattr(self.current_user, 'discoverable', True)
|
||||||
|
if new_discoverable != current_discoverable:
|
||||||
|
profile_data['discoverable'] = new_discoverable
|
||||||
|
|
||||||
|
# Check fields changes
|
||||||
|
new_fields = self.fields_widget.get_fields()
|
||||||
|
current_fields = [{'name': f.name, 'value': f.value} for f in (self.current_user.fields or [])]
|
||||||
|
if new_fields != current_fields:
|
||||||
|
profile_data['fields_attributes'] = new_fields
|
||||||
|
|
||||||
|
if not profile_data:
|
||||||
|
QMessageBox.information(self, "No Changes", "No changes were made to your profile.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Disable save button during update
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setEnabled(False)
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setText("Saving...")
|
||||||
|
|
||||||
|
# Start update thread
|
||||||
|
self.update_thread = ProfileUpdateThread(self.client, profile_data)
|
||||||
|
self.update_thread.profile_updated.connect(self.on_profile_updated)
|
||||||
|
self.update_thread.error_occurred.connect(self.on_update_error)
|
||||||
|
self.update_thread.start()
|
||||||
|
|
||||||
|
self.logger.info(f"Starting profile update with {len(profile_data)} changes")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error preparing profile update: {e}")
|
||||||
|
AccessibleTextDialog.show_error("Profile Update Error",
|
||||||
|
"Failed to prepare profile update", str(e), self)
|
||||||
|
self.sound_manager.play_error()
|
||||||
|
|
||||||
|
def on_profile_updated(self, updated_user: User):
|
||||||
|
"""Handle successful profile update"""
|
||||||
|
self.current_user = updated_user
|
||||||
|
|
||||||
|
# Re-enable save button
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setEnabled(True)
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setText("Save Profile")
|
||||||
|
|
||||||
|
# Emit signal for other components to update
|
||||||
|
self.profile_updated.emit(updated_user)
|
||||||
|
|
||||||
|
# Show success message
|
||||||
|
QMessageBox.information(self, "Profile Updated", "Your profile has been updated successfully!")
|
||||||
|
|
||||||
|
self.logger.info("Profile update completed successfully")
|
||||||
|
self.sound_manager.play_success()
|
||||||
|
|
||||||
|
# Close dialog
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def on_update_error(self, error_message: str):
|
||||||
|
"""Handle profile update error"""
|
||||||
|
# Re-enable save button
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setEnabled(True)
|
||||||
|
self.button_box.button(QDialogButtonBox.Save).setText("Save Profile")
|
||||||
|
|
||||||
|
# Show error
|
||||||
|
if "422" in error_message:
|
||||||
|
error_msg = "Invalid profile data. Please check your entries and try again."
|
||||||
|
elif "403" in error_message:
|
||||||
|
error_msg = "You don't have permission to update your profile."
|
||||||
|
elif "timeout" in error_message.lower() or "connection" in error_message.lower():
|
||||||
|
error_msg = "Network error while updating profile. Please check your connection and try again."
|
||||||
|
else:
|
||||||
|
error_msg = f"Failed to update profile: {error_message}"
|
||||||
|
|
||||||
|
AccessibleTextDialog.show_error("Profile Update Error", error_msg, "", self)
|
||||||
|
|
||||||
|
self.logger.error(f"Profile update failed: {error_message}")
|
||||||
|
self.sound_manager.play_error()
|
Reference in New Issue
Block a user