Add comprehensive feature updates based on user feedback
- Implement complete list management system with CRUD operations - Add dynamic character count display with instance limits - Include post visibility information in timeline display - Show user interaction status (favorited/boosted/bookmarked) - Extend sound system with social action events - Add list timeline integration and management interface - Update documentation with all new features 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -298,6 +298,65 @@ class ActivityPubClient:
|
||||
params['since_id'] = since_id
|
||||
|
||||
return self._make_request('GET', endpoint, params=params)
|
||||
|
||||
def get_lists(self) -> List[Dict]:
|
||||
"""Get all lists owned by the authenticated user"""
|
||||
return self._make_request('GET', '/api/v1/lists')
|
||||
|
||||
def get_list(self, list_id: str) -> Dict:
|
||||
"""Get a specific list by ID"""
|
||||
return self._make_request('GET', f'/api/v1/lists/{list_id}')
|
||||
|
||||
def create_list(self, title: str, replies_policy: str = 'list') -> Dict:
|
||||
"""Create a new list
|
||||
|
||||
Args:
|
||||
title: The list title
|
||||
replies_policy: One of 'followed', 'list', or 'none'
|
||||
"""
|
||||
data = {
|
||||
'title': title,
|
||||
'replies_policy': replies_policy
|
||||
}
|
||||
return self._make_request('POST', '/api/v1/lists', data=data)
|
||||
|
||||
def update_list(self, list_id: str, title: str, replies_policy: str = 'list') -> Dict:
|
||||
"""Update an existing list"""
|
||||
data = {
|
||||
'title': title,
|
||||
'replies_policy': replies_policy
|
||||
}
|
||||
return self._make_request('PUT', f'/api/v1/lists/{list_id}', data=data)
|
||||
|
||||
def delete_list(self, list_id: str) -> Dict:
|
||||
"""Delete a list"""
|
||||
return self._make_request('DELETE', f'/api/v1/lists/{list_id}')
|
||||
|
||||
def get_list_accounts(self, list_id: str, limit: int = 40) -> List[Dict]:
|
||||
"""Get accounts in a list"""
|
||||
params = {'limit': limit}
|
||||
return self._make_request('GET', f'/api/v1/lists/{list_id}/accounts', params=params)
|
||||
|
||||
def add_accounts_to_list(self, list_id: str, account_ids: List[str]) -> Dict:
|
||||
"""Add accounts to a list"""
|
||||
data = {'account_ids': account_ids}
|
||||
return self._make_request('POST', f'/api/v1/lists/{list_id}/accounts', data=data)
|
||||
|
||||
def remove_accounts_from_list(self, list_id: str, account_ids: List[str]) -> Dict:
|
||||
"""Remove accounts from a list"""
|
||||
data = {'account_ids': account_ids}
|
||||
return self._make_request('DELETE', f'/api/v1/lists/{list_id}/accounts', data=data)
|
||||
|
||||
def get_list_timeline(self, list_id: str, limit: int = 40,
|
||||
max_id: Optional[str] = None, since_id: Optional[str] = None) -> List[Dict]:
|
||||
"""Get timeline posts from a specific list"""
|
||||
params = {'limit': limit}
|
||||
if max_id:
|
||||
params['max_id'] = max_id
|
||||
if since_id:
|
||||
params['since_id'] = since_id
|
||||
|
||||
return self._make_request('GET', f'/api/v1/timelines/list/{list_id}', params=params)
|
||||
|
||||
def get_status(self, status_id: str) -> Dict:
|
||||
"""Get a single status by ID"""
|
||||
|
||||
@@ -95,6 +95,13 @@ class SoundManager:
|
||||
"favorite",
|
||||
"follow",
|
||||
"unfollow",
|
||||
"block",
|
||||
"unblock",
|
||||
"bookmark",
|
||||
"unbookmark",
|
||||
"unfavorite",
|
||||
"unboost",
|
||||
"delete_post",
|
||||
"post_sent",
|
||||
"post",
|
||||
"timeline_update",
|
||||
@@ -171,6 +178,16 @@ class SoundManager:
|
||||
"mention": "mention.ogg",
|
||||
"boost": "boost.ogg",
|
||||
"reply": "reply.ogg",
|
||||
"favorite": "favorite.ogg",
|
||||
"follow": "follow.ogg",
|
||||
"unfollow": "unfollow.ogg",
|
||||
"block": "block.ogg",
|
||||
"unblock": "unblock.ogg",
|
||||
"bookmark": "bookmark.ogg",
|
||||
"unbookmark": "unbookmark.ogg",
|
||||
"unfavorite": "unfavorite.ogg",
|
||||
"unboost": "unboost.ogg",
|
||||
"delete_post": "delete_post.ogg",
|
||||
"post_sent": "post_sent.ogg",
|
||||
"timeline_update": "timeline_update.ogg",
|
||||
"notification": "notification.ogg",
|
||||
@@ -439,6 +456,34 @@ class SoundManager:
|
||||
"""Play unfollow sound"""
|
||||
self.play_event("unfollow")
|
||||
|
||||
def play_block(self):
|
||||
"""Play block user sound"""
|
||||
self.play_event("block")
|
||||
|
||||
def play_unblock(self):
|
||||
"""Play unblock user sound"""
|
||||
self.play_event("unblock")
|
||||
|
||||
def play_bookmark(self):
|
||||
"""Play bookmark post sound"""
|
||||
self.play_event("bookmark")
|
||||
|
||||
def play_unbookmark(self):
|
||||
"""Play remove bookmark sound"""
|
||||
self.play_event("unbookmark")
|
||||
|
||||
def play_unfavorite(self):
|
||||
"""Play unfavorite post sound"""
|
||||
self.play_event("unfavorite")
|
||||
|
||||
def play_unboost(self):
|
||||
"""Play unboost (remove reblog) sound"""
|
||||
self.play_event("unboost")
|
||||
|
||||
def play_delete_post(self):
|
||||
"""Play delete post sound"""
|
||||
self.play_event("delete_post")
|
||||
|
||||
def play_post_sent(self):
|
||||
"""Play post sent sound"""
|
||||
self.play_event("post_sent")
|
||||
|
||||
+114
-2
@@ -31,6 +31,7 @@ from widgets.profile_edit_dialog import ProfileEditDialog
|
||||
from widgets.accessible_text_dialog import AccessibleTextDialog
|
||||
from widgets.search_dialog import SearchDialog
|
||||
from widgets.timeline_filter_dialog import TimelineFilterDialog
|
||||
from widgets.list_manager_dialog import ListManagerDialog
|
||||
from managers.post_manager import PostManager
|
||||
from managers.post_actions_manager import PostActionsManager
|
||||
from managers.sound_coordinator import SoundCoordinator
|
||||
@@ -46,6 +47,10 @@ class MainWindow(QMainWindow):
|
||||
self.account_manager = AccountManager(self.settings)
|
||||
self.logger = logging.getLogger("bifrost.main")
|
||||
|
||||
# List timeline tracking
|
||||
self.current_list_id = None
|
||||
self.current_list_title = None
|
||||
|
||||
# Auto-refresh tracking
|
||||
self.last_activity_time = time.time()
|
||||
self.is_initial_load = True # Flag to skip notifications on first load
|
||||
@@ -298,6 +303,12 @@ class MainWindow(QMainWindow):
|
||||
muted_action.setShortcut(QKeySequence("Ctrl+0"))
|
||||
muted_action.triggered.connect(lambda: self.switch_timeline(9))
|
||||
timeline_menu.addAction(muted_action)
|
||||
|
||||
timeline_menu.addSeparator()
|
||||
|
||||
# Lists submenu
|
||||
self.lists_menu = timeline_menu.addMenu("&Lists")
|
||||
self.lists_menu.aboutToShow.connect(self.refresh_lists_menu)
|
||||
|
||||
# Post menu
|
||||
post_menu = menubar.addMenu("&Post")
|
||||
@@ -379,6 +390,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
social_menu.addSeparator()
|
||||
|
||||
# Lists management action
|
||||
lists_action = QAction("Manage &Lists...", self)
|
||||
lists_action.setShortcut(QKeySequence("Ctrl+L"))
|
||||
lists_action.triggered.connect(self.open_list_manager)
|
||||
social_menu.addAction(lists_action)
|
||||
|
||||
# Manual follow action
|
||||
manual_follow_action = QAction("Follow &Specific User...", self)
|
||||
manual_follow_action.setShortcut(QKeySequence("Ctrl+Shift+M"))
|
||||
@@ -725,11 +742,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def open_search_dialog(self):
|
||||
"""Open the search dialog"""
|
||||
if not self.account_manager.current_account:
|
||||
if not self.account_manager.get_active_account():
|
||||
self.logger.warning("No account available for search")
|
||||
return
|
||||
|
||||
client = self.account_manager.get_client()
|
||||
client = self.account_manager.get_client_for_active_account()
|
||||
if not client:
|
||||
self.logger.warning("No client available for search")
|
||||
return
|
||||
@@ -748,6 +765,101 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self.logger.warning("No timeline available for filtering")
|
||||
|
||||
def open_list_manager(self):
|
||||
"""Open the list manager dialog"""
|
||||
if not self.account_manager.get_active_account():
|
||||
self.logger.warning("No account available for list management")
|
||||
AccessibleTextDialog.show_warning(
|
||||
"No Account",
|
||||
"Please log in to an account before managing lists.",
|
||||
self
|
||||
)
|
||||
return
|
||||
|
||||
self.logger.debug("Opening list manager dialog")
|
||||
dialog = ListManagerDialog(self.account_manager, self)
|
||||
dialog.list_updated.connect(self.on_lists_updated)
|
||||
dialog.exec()
|
||||
|
||||
def on_lists_updated(self):
|
||||
"""Handle when lists are updated"""
|
||||
# Refresh timeline if currently viewing a list
|
||||
if hasattr(self, 'current_list_id') and self.current_list_id:
|
||||
self.timeline.refresh()
|
||||
self.logger.info("Lists updated")
|
||||
|
||||
def refresh_lists_menu(self):
|
||||
"""Refresh the lists submenu with current user lists"""
|
||||
self.lists_menu.clear()
|
||||
|
||||
if not self.account_manager.get_active_account():
|
||||
no_account_action = QAction("(No account logged in)", self)
|
||||
no_account_action.setEnabled(False)
|
||||
self.lists_menu.addAction(no_account_action)
|
||||
return
|
||||
|
||||
try:
|
||||
client = self.account_manager.get_client_for_active_account()
|
||||
if not client:
|
||||
no_client_action = QAction("(No client available)", self)
|
||||
no_client_action.setEnabled(False)
|
||||
self.lists_menu.addAction(no_client_action)
|
||||
return
|
||||
|
||||
# Load user's lists
|
||||
lists = client.get_lists()
|
||||
|
||||
if not lists:
|
||||
no_lists_action = QAction("(No lists created)", self)
|
||||
no_lists_action.setEnabled(False)
|
||||
self.lists_menu.addAction(no_lists_action)
|
||||
else:
|
||||
for list_data in lists:
|
||||
list_title = list_data['title']
|
||||
list_id = list_data['id']
|
||||
|
||||
list_action = QAction(list_title, self)
|
||||
list_action.triggered.connect(
|
||||
lambda checked, lid=list_id, title=list_title: self.switch_to_list_timeline(lid, title)
|
||||
)
|
||||
self.lists_menu.addAction(list_action)
|
||||
|
||||
self.lists_menu.addSeparator()
|
||||
|
||||
# Add management option
|
||||
manage_action = QAction("&Manage Lists...", self)
|
||||
manage_action.triggered.connect(self.open_list_manager)
|
||||
self.lists_menu.addAction(manage_action)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load lists menu: {e}")
|
||||
error_action = QAction("(Error loading lists)", self)
|
||||
error_action.setEnabled(False)
|
||||
self.lists_menu.addAction(error_action)
|
||||
|
||||
def switch_to_list_timeline(self, list_id: str, list_title: str):
|
||||
"""Switch to a specific list timeline"""
|
||||
self.logger.info(f"Switching to list timeline: {list_title} (ID: {list_id})")
|
||||
|
||||
# Store current list info
|
||||
self.current_list_id = list_id
|
||||
self.current_list_title = list_title
|
||||
|
||||
# Update timeline to show list
|
||||
if self.timeline:
|
||||
self.timeline.set_timeline_type("list", list_id)
|
||||
|
||||
# Update window title to show list
|
||||
account = self.account_manager.get_active_account()
|
||||
if account:
|
||||
account_display = account.get_display_text()
|
||||
self.setWindowTitle(f"Bifrost - {account_display} - List: {list_title}")
|
||||
else:
|
||||
self.setWindowTitle(f"Bifrost - List: {list_title}")
|
||||
|
||||
# Update status
|
||||
self.status_bar.showMessage(f"Viewing list: {list_title}", 3000)
|
||||
|
||||
def on_post_sent(self, post_data):
|
||||
"""Handle post data from compose dialog - USING CENTRALIZED POSTMANAGER"""
|
||||
self.status_bar.showMessage("Sending post...", 2000)
|
||||
|
||||
+55
-7
@@ -221,6 +221,42 @@ class Post:
|
||||
|
||||
return ""
|
||||
|
||||
def get_visibility_display(self) -> str:
|
||||
"""Get human-readable visibility status"""
|
||||
# For reblogs/boosts, show the visibility of the original post
|
||||
target_post = self.reblog if self.reblog else self
|
||||
|
||||
visibility_map = {
|
||||
'public': 'Public',
|
||||
'unlisted': 'Unlisted',
|
||||
'private': 'Followers only',
|
||||
'direct': 'Direct message'
|
||||
}
|
||||
|
||||
visibility = target_post.visibility
|
||||
return visibility_map.get(visibility, visibility.title() if visibility else '')
|
||||
|
||||
def get_user_interaction_status(self) -> str:
|
||||
"""Get user's interaction status with this post (favorited, boosted, bookmarked)"""
|
||||
# For reblogs/boosts, check interactions with the original post
|
||||
target_post = self.reblog if self.reblog else self
|
||||
|
||||
interactions = []
|
||||
|
||||
if target_post.favourited:
|
||||
interactions.append("favorited")
|
||||
|
||||
if target_post.reblogged:
|
||||
interactions.append("boosted")
|
||||
|
||||
if target_post.bookmarked:
|
||||
interactions.append("bookmarked")
|
||||
|
||||
if interactions:
|
||||
return f"[{', '.join(interactions)}]"
|
||||
|
||||
return ""
|
||||
|
||||
def get_relative_time(self) -> str:
|
||||
"""Get relative time since post creation (e.g., '5 minutes ago', '2 hours ago')"""
|
||||
# For reblogs/boosts, show the time of the original post
|
||||
@@ -272,14 +308,21 @@ class Post:
|
||||
content = self.get_display_content()
|
||||
relative_time = self.get_relative_time()
|
||||
|
||||
# Include timestamp and client info if available
|
||||
# Get visibility info
|
||||
visibility_info = self.get_visibility_display()
|
||||
|
||||
# Include timestamp, client info, and visibility if available
|
||||
client_info = self.get_client_info()
|
||||
if relative_time and client_info:
|
||||
summary = f"{author}: {content} ({relative_time} {client_info})"
|
||||
elif relative_time:
|
||||
summary = f"{author}: {content} ({relative_time})"
|
||||
elif client_info:
|
||||
summary = f"{author}: {content} ({client_info})"
|
||||
metadata_parts = []
|
||||
if relative_time:
|
||||
metadata_parts.append(relative_time)
|
||||
if client_info:
|
||||
metadata_parts.append(client_info)
|
||||
if visibility_info:
|
||||
metadata_parts.append(visibility_info)
|
||||
|
||||
if metadata_parts:
|
||||
summary = f"{author}: {content} ({', '.join(metadata_parts)})"
|
||||
else:
|
||||
summary = f"{author}: {content}"
|
||||
|
||||
@@ -300,6 +343,11 @@ class Post:
|
||||
if self.has_poll():
|
||||
poll_info = self.get_poll_info()
|
||||
summary += f" {poll_info}"
|
||||
|
||||
# Add user interaction status (favorited/boosted by current user)
|
||||
interaction_status = self.get_user_interaction_status()
|
||||
if interaction_status:
|
||||
summary += f" {interaction_status}"
|
||||
|
||||
# Add notification context if this is from notifications timeline
|
||||
if self.notification_type and self.notification_account:
|
||||
|
||||
@@ -46,6 +46,7 @@ class ComposeDialog(QDialog):
|
||||
self.account_manager = account_manager
|
||||
self.media_upload_widget = None
|
||||
self.logger = logging.getLogger("bifrost.compose")
|
||||
self.character_limit = self.get_instance_character_limit()
|
||||
self.setup_ui()
|
||||
self.setup_shortcuts()
|
||||
self.load_default_settings()
|
||||
@@ -58,8 +59,8 @@ class ComposeDialog(QDialog):
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Character count label
|
||||
self.char_count_label = QLabel("Characters: 0/500")
|
||||
# Character count label with dynamic limit
|
||||
self.char_count_label = QLabel(f"Characters: 0/{self.character_limit}")
|
||||
self.char_count_label.setAccessibleName("Character Count")
|
||||
layout.addWidget(self.char_count_label)
|
||||
|
||||
@@ -233,6 +234,45 @@ class ComposeDialog(QDialog):
|
||||
# Set initial focus
|
||||
self.text_edit.setFocus()
|
||||
|
||||
def get_instance_character_limit(self):
|
||||
"""Get the character limit for the current instance"""
|
||||
try:
|
||||
client = self.account_manager.get_client_for_active_account()
|
||||
if client:
|
||||
instance_info = client.get_instance_info()
|
||||
|
||||
# Different servers store this information differently
|
||||
# Mastodon: configuration.statuses.max_characters
|
||||
# Pleroma: max_toot_chars or configuration.statuses.max_characters
|
||||
# GoToSocial: configuration.statuses.max_characters
|
||||
|
||||
if 'configuration' in instance_info:
|
||||
config = instance_info['configuration']
|
||||
if 'statuses' in config:
|
||||
limit = config['statuses'].get('max_characters')
|
||||
if limit and isinstance(limit, int):
|
||||
self.logger.info(f"Instance character limit from configuration: {limit}")
|
||||
return limit
|
||||
|
||||
# Fallback for Pleroma and older instances
|
||||
limit = instance_info.get('max_toot_chars')
|
||||
if limit and isinstance(limit, int):
|
||||
self.logger.info(f"Instance character limit from max_toot_chars: {limit}")
|
||||
return limit
|
||||
|
||||
# Check for other possible fields
|
||||
limit = instance_info.get('status_character_limit')
|
||||
if limit and isinstance(limit, int):
|
||||
self.logger.info(f"Instance character limit from status_character_limit: {limit}")
|
||||
return limit
|
||||
|
||||
self.logger.info("No character limit found in instance info, using default: 500")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get instance character limit: {e}, using default: 500")
|
||||
|
||||
# Default fallback
|
||||
return 500
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Ctrl+Enter to send post
|
||||
@@ -327,19 +367,19 @@ class ComposeDialog(QDialog):
|
||||
"""Update character count display"""
|
||||
text = self.text_edit.toPlainText()
|
||||
char_count = len(text)
|
||||
self.char_count_label.setText(f"Characters: {char_count}/500")
|
||||
self.char_count_label.setText(f"Characters: {char_count}/{self.character_limit}")
|
||||
|
||||
# Enable/disable post button based on content
|
||||
has_content = bool(text.strip())
|
||||
within_limit = char_count <= 500
|
||||
within_limit = char_count <= self.character_limit
|
||||
self.post_button.setEnabled(has_content and within_limit)
|
||||
|
||||
# Update accessibility
|
||||
if char_count > 500:
|
||||
if char_count > self.character_limit:
|
||||
self.char_count_label.setAccessibleDescription("Character limit exceeded")
|
||||
else:
|
||||
self.char_count_label.setAccessibleDescription(
|
||||
f"{500 - char_count} characters remaining"
|
||||
f"{self.character_limit - char_count} characters remaining"
|
||||
)
|
||||
|
||||
def send_post(self):
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
"""
|
||||
List management dialog for creating and managing lists
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QCheckBox,
|
||||
QDialogButtonBox,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QGroupBox,
|
||||
QTextEdit,
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QThread, QObject
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
import logging
|
||||
|
||||
from audio.sound_manager import SoundManager
|
||||
from config.settings import SettingsManager
|
||||
from widgets.accessible_text_dialog import AccessibleTextDialog
|
||||
|
||||
|
||||
class ListWorker(QObject):
|
||||
"""Worker for list operations in background thread"""
|
||||
|
||||
finished = Signal(object) # Emitted when operation is done
|
||||
error = Signal(str) # Emitted when operation fails
|
||||
|
||||
def __init__(self, client, operation, **kwargs):
|
||||
super().__init__()
|
||||
self.client = client
|
||||
self.operation = operation
|
||||
self.kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
"""Execute the list operation"""
|
||||
try:
|
||||
if self.operation == "get_lists":
|
||||
result = self.client.get_lists()
|
||||
elif self.operation == "create_list":
|
||||
result = self.client.create_list(**self.kwargs)
|
||||
elif self.operation == "update_list":
|
||||
result = self.client.update_list(**self.kwargs)
|
||||
elif self.operation == "delete_list":
|
||||
result = self.client.delete_list(**self.kwargs)
|
||||
elif self.operation == "get_list_accounts":
|
||||
result = self.client.get_list_accounts(**self.kwargs)
|
||||
elif self.operation == "add_accounts_to_list":
|
||||
result = self.client.add_accounts_to_list(**self.kwargs)
|
||||
elif self.operation == "remove_accounts_from_list":
|
||||
result = self.client.remove_accounts_from_list(**self.kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown operation: {self.operation}")
|
||||
|
||||
self.finished.emit(result)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class ListManagerDialog(QDialog):
|
||||
"""Dialog for managing user lists"""
|
||||
|
||||
list_updated = Signal() # Emitted when lists are modified
|
||||
|
||||
def __init__(self, account_manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.account_manager = account_manager
|
||||
self.client = account_manager.get_client_for_active_account()
|
||||
self.settings = SettingsManager()
|
||||
self.sound_manager = SoundManager(self.settings)
|
||||
self.logger = logging.getLogger("bifrost.list_manager")
|
||||
|
||||
self.logger.debug("ListManagerDialog: Starting initialization")
|
||||
|
||||
self.lists = [] # Current lists
|
||||
self.current_list = None # Selected list
|
||||
self.list_members = [] # Members of current list
|
||||
self.following_accounts = [] # Cache of followed accounts
|
||||
|
||||
self.logger.debug("ListManagerDialog: About to call setup_ui")
|
||||
self.setup_ui()
|
||||
self.logger.debug("ListManagerDialog: setup_ui completed")
|
||||
|
||||
self.logger.debug("ListManagerDialog: About to call setup_shortcuts")
|
||||
self.setup_shortcuts()
|
||||
self.logger.debug("ListManagerDialog: setup_shortcuts completed")
|
||||
|
||||
# Skip automatic data loading to avoid network calls during initialization
|
||||
# Data will be loaded on demand when dialog is shown
|
||||
self.logger.debug("ListManagerDialog: Skipping automatic data loading")
|
||||
|
||||
self.logger.debug("ListManagerDialog: Initialization completed")
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the list manager UI"""
|
||||
self.logger.debug("setup_ui: Setting window properties")
|
||||
self.setWindowTitle("Manage Lists")
|
||||
self.setMinimumSize(800, 600)
|
||||
self.setModal(True)
|
||||
|
||||
self.logger.debug("setup_ui: Creating main layout")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.logger.debug("setup_ui: Creating splitter")
|
||||
# Main splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Left panel: List management
|
||||
left_panel = QGroupBox("Your Lists")
|
||||
left_layout = QVBoxLayout(left_panel)
|
||||
|
||||
# List widget
|
||||
self.lists_widget = QListWidget()
|
||||
self.lists_widget.setAccessibleName("Lists")
|
||||
self.lists_widget.setAccessibleDescription("Your created lists. Select a list to view and edit its members.")
|
||||
self.lists_widget.currentItemChanged.connect(self.on_list_selected)
|
||||
left_layout.addWidget(self.lists_widget)
|
||||
|
||||
# List actions
|
||||
list_actions = QHBoxLayout()
|
||||
|
||||
self.create_list_btn = QPushButton("&Create List")
|
||||
self.create_list_btn.setAccessibleName("Create New List")
|
||||
self.create_list_btn.clicked.connect(self.create_list)
|
||||
list_actions.addWidget(self.create_list_btn)
|
||||
|
||||
self.edit_list_btn = QPushButton("&Edit List")
|
||||
self.edit_list_btn.setAccessibleName("Edit Selected List")
|
||||
self.edit_list_btn.clicked.connect(self.edit_list)
|
||||
self.edit_list_btn.setEnabled(False)
|
||||
list_actions.addWidget(self.edit_list_btn)
|
||||
|
||||
self.delete_list_btn = QPushButton("&Delete List")
|
||||
self.delete_list_btn.setAccessibleName("Delete Selected List")
|
||||
self.delete_list_btn.clicked.connect(self.delete_list)
|
||||
self.delete_list_btn.setEnabled(False)
|
||||
list_actions.addWidget(self.delete_list_btn)
|
||||
|
||||
left_layout.addLayout(list_actions)
|
||||
splitter.addWidget(left_panel)
|
||||
|
||||
# Right panel: List member management
|
||||
right_panel = QGroupBox("List Members")
|
||||
right_layout = QVBoxLayout(right_panel)
|
||||
|
||||
# Current list info
|
||||
self.list_info_label = QLabel("Select a list to manage its members")
|
||||
self.list_info_label.setAccessibleName("List Information")
|
||||
right_layout.addWidget(self.list_info_label)
|
||||
|
||||
# Members list
|
||||
self.members_widget = QListWidget()
|
||||
self.members_widget.setAccessibleName("List Members")
|
||||
self.members_widget.setAccessibleDescription("Accounts in the selected list. Select accounts to remove them.")
|
||||
right_layout.addWidget(self.members_widget)
|
||||
|
||||
# Add members section
|
||||
add_section = QGroupBox("Add Members")
|
||||
add_layout = QVBoxLayout(add_section)
|
||||
|
||||
# Search/filter
|
||||
search_layout = QHBoxLayout()
|
||||
search_layout.addWidget(QLabel("Search:"))
|
||||
self.search_edit = QLineEdit()
|
||||
self.search_edit.setAccessibleName("Search Followed Accounts")
|
||||
self.search_edit.setAccessibleDescription("Type to search your followed accounts")
|
||||
self.search_edit.setPlaceholderText("Search your followed accounts...")
|
||||
self.search_edit.textChanged.connect(self.filter_following_accounts)
|
||||
search_layout.addWidget(self.search_edit)
|
||||
add_layout.addLayout(search_layout)
|
||||
|
||||
# Following accounts list
|
||||
self.following_widget = QListWidget()
|
||||
self.following_widget.setAccessibleName("Followed Accounts")
|
||||
self.following_widget.setAccessibleDescription("Your followed accounts. Select accounts to add to the list.")
|
||||
self.following_widget.setSelectionMode(QListWidget.MultiSelection)
|
||||
add_layout.addWidget(self.following_widget)
|
||||
|
||||
# Add/remove buttons
|
||||
member_actions = QHBoxLayout()
|
||||
|
||||
self.add_members_btn = QPushButton("&Add Selected")
|
||||
self.add_members_btn.setAccessibleName("Add Selected Accounts to List")
|
||||
self.add_members_btn.clicked.connect(self.add_selected_members)
|
||||
self.add_members_btn.setEnabled(False)
|
||||
member_actions.addWidget(self.add_members_btn)
|
||||
|
||||
self.remove_members_btn = QPushButton("&Remove Selected")
|
||||
self.remove_members_btn.setAccessibleName("Remove Selected Accounts from List")
|
||||
self.remove_members_btn.clicked.connect(self.remove_selected_members)
|
||||
self.remove_members_btn.setEnabled(False)
|
||||
member_actions.addWidget(self.remove_members_btn)
|
||||
|
||||
add_layout.addLayout(member_actions)
|
||||
right_layout.addWidget(add_section)
|
||||
|
||||
splitter.addWidget(right_panel)
|
||||
splitter.setSizes([300, 500])
|
||||
|
||||
# Dialog buttons
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
button_box.rejected.connect(self.accept)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Escape to close
|
||||
close_shortcut = QShortcut(QKeySequence.Cancel, self)
|
||||
close_shortcut.activated.connect(self.accept)
|
||||
|
||||
# Ctrl+N for new list
|
||||
new_shortcut = QShortcut(QKeySequence.New, self)
|
||||
new_shortcut.activated.connect(self.create_list)
|
||||
|
||||
# Delete key for delete list
|
||||
delete_shortcut = QShortcut(QKeySequence.Delete, self)
|
||||
delete_shortcut.activated.connect(self.delete_list)
|
||||
|
||||
def load_lists(self):
|
||||
"""Load user's lists"""
|
||||
if not self.client:
|
||||
return
|
||||
|
||||
worker = ListWorker(self.client, "get_lists")
|
||||
worker.finished.connect(self.on_lists_loaded)
|
||||
worker.error.connect(self.on_operation_error)
|
||||
|
||||
thread = QThread()
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.error.connect(thread.quit)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def on_lists_loaded(self, lists):
|
||||
"""Handle loaded lists"""
|
||||
self.lists = lists
|
||||
self.lists_widget.clear()
|
||||
|
||||
for list_data in lists:
|
||||
item = QListWidgetItem(list_data['title'])
|
||||
item.setData(Qt.UserRole, list_data)
|
||||
self.lists_widget.addItem(item)
|
||||
|
||||
self.logger.info(f"Loaded {len(lists)} lists")
|
||||
|
||||
def load_following_accounts(self):
|
||||
"""Load accounts the user follows"""
|
||||
if not self.client:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get current user info
|
||||
user_info = self.client.verify_credentials()
|
||||
following = self.client.get_following(user_info['id'], limit=1000) # Get many
|
||||
|
||||
self.following_accounts = following
|
||||
self.update_following_display()
|
||||
|
||||
self.logger.info(f"Loaded {len(following)} followed accounts")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load following accounts: {e}")
|
||||
|
||||
def update_following_display(self):
|
||||
"""Update the following accounts display"""
|
||||
search_text = self.search_edit.text().lower()
|
||||
|
||||
self.following_widget.clear()
|
||||
for account in self.following_accounts:
|
||||
# Filter by search text
|
||||
if search_text:
|
||||
account_text = f"{account.get('display_name', '')} {account.get('username', '')} {account.get('acct', '')}".lower()
|
||||
if search_text not in account_text:
|
||||
continue
|
||||
|
||||
# Don't show accounts already in current list
|
||||
if self.current_list and any(member['id'] == account['id'] for member in self.list_members):
|
||||
continue
|
||||
|
||||
display_name = account.get('display_name') or account.get('username', '')
|
||||
acct = account.get('acct', account.get('username', ''))
|
||||
item_text = f"{display_name} (@{acct})"
|
||||
|
||||
item = QListWidgetItem(item_text)
|
||||
item.setData(Qt.UserRole, account)
|
||||
self.following_widget.addItem(item)
|
||||
|
||||
def filter_following_accounts(self):
|
||||
"""Filter following accounts based on search"""
|
||||
self.update_following_display()
|
||||
|
||||
def on_list_selected(self, current, previous):
|
||||
"""Handle list selection"""
|
||||
if not current:
|
||||
self.current_list = None
|
||||
self.list_members = []
|
||||
self.edit_list_btn.setEnabled(False)
|
||||
self.delete_list_btn.setEnabled(False)
|
||||
self.add_members_btn.setEnabled(False)
|
||||
self.remove_members_btn.setEnabled(False)
|
||||
self.list_info_label.setText("Select a list to manage its members")
|
||||
self.members_widget.clear()
|
||||
return
|
||||
|
||||
self.current_list = current.data(Qt.UserRole)
|
||||
self.edit_list_btn.setEnabled(True)
|
||||
self.delete_list_btn.setEnabled(True)
|
||||
self.add_members_btn.setEnabled(True)
|
||||
self.remove_members_btn.setEnabled(True)
|
||||
|
||||
# Update info label
|
||||
list_title = self.current_list['title']
|
||||
replies_policy = self.current_list.get('replies_policy', 'list')
|
||||
self.list_info_label.setText(f"List: {list_title} (Replies: {replies_policy})")
|
||||
|
||||
# Load list members
|
||||
self.load_list_members()
|
||||
|
||||
def load_list_members(self):
|
||||
"""Load members of current list"""
|
||||
if not self.current_list or not self.client:
|
||||
return
|
||||
|
||||
worker = ListWorker(self.client, "get_list_accounts",
|
||||
list_id=self.current_list['id'], limit=1000)
|
||||
worker.finished.connect(self.on_list_members_loaded)
|
||||
worker.error.connect(self.on_operation_error)
|
||||
|
||||
thread = QThread()
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.error.connect(thread.quit)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def on_list_members_loaded(self, members):
|
||||
"""Handle loaded list members"""
|
||||
self.list_members = members
|
||||
self.members_widget.clear()
|
||||
|
||||
for account in members:
|
||||
display_name = account.get('display_name') or account.get('username', '')
|
||||
acct = account.get('acct', account.get('username', ''))
|
||||
item_text = f"{display_name} (@{acct})"
|
||||
|
||||
item = QListWidgetItem(item_text)
|
||||
item.setData(Qt.UserRole, account)
|
||||
self.members_widget.addItem(item)
|
||||
|
||||
# Update following display to hide members
|
||||
self.update_following_display()
|
||||
|
||||
self.logger.info(f"Loaded {len(members)} members for list '{self.current_list['title']}'")
|
||||
|
||||
def create_list(self):
|
||||
"""Create a new list"""
|
||||
dialog = ListEditDialog(parent=self)
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
title, replies_policy = dialog.get_list_data()
|
||||
|
||||
worker = ListWorker(self.client, "create_list",
|
||||
title=title, replies_policy=replies_policy)
|
||||
worker.finished.connect(self.on_list_created)
|
||||
worker.error.connect(self.on_operation_error)
|
||||
|
||||
thread = QThread()
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.error.connect(thread.quit)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def on_list_created(self, list_data):
|
||||
"""Handle list creation"""
|
||||
self.sound_manager.play_success()
|
||||
self.load_lists() # Reload lists
|
||||
self.list_updated.emit()
|
||||
|
||||
# Select the new list
|
||||
for i in range(self.lists_widget.count()):
|
||||
item = self.lists_widget.item(i)
|
||||
if item.data(Qt.UserRole)['id'] == list_data['id']:
|
||||
self.lists_widget.setCurrentItem(item)
|
||||
break
|
||||
|
||||
def edit_list(self):
|
||||
"""Edit the selected list"""
|
||||
if not self.current_list:
|
||||
return
|
||||
|
||||
dialog = ListEditDialog(
|
||||
title=self.current_list['title'],
|
||||
replies_policy=self.current_list.get('replies_policy', 'list'),
|
||||
parent=self
|
||||
)
|
||||
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
title, replies_policy = dialog.get_list_data()
|
||||
|
||||
worker = ListWorker(self.client, "update_list",
|
||||
list_id=self.current_list['id'],
|
||||
title=title, replies_policy=replies_policy)
|
||||
worker.finished.connect(self.on_list_updated)
|
||||
worker.error.connect(self.on_operation_error)
|
||||
|
||||
thread = QThread()
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.error.connect(thread.quit)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def on_list_updated(self, list_data):
|
||||
"""Handle list update"""
|
||||
self.sound_manager.play_success()
|
||||
self.load_lists() # Reload lists
|
||||
self.list_updated.emit()
|
||||
|
||||
def delete_list(self):
|
||||
"""Delete the selected list"""
|
||||
if not self.current_list:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete List",
|
||||
f"Are you sure you want to delete the list '{self.current_list['title']}'?\n\n"
|
||||
"This action cannot be undone.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
worker = ListWorker(self.client, "delete_list",
|
||||
list_id=self.current_list['id'])
|
||||
worker.finished.connect(self.on_list_deleted)
|
||||
worker.error.connect(self.on_operation_error)
|
||||
|
||||
thread = QThread()
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.error.connect(thread.quit)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def on_list_deleted(self, result):
|
||||
"""Handle list deletion"""
|
||||
self.sound_manager.play_success()
|
||||
self.load_lists() # Reload lists
|
||||
self.list_updated.emit()
|
||||
|
||||
def add_selected_members(self):
|
||||
"""Add selected accounts to current list"""
|
||||
if not self.current_list:
|
||||
return
|
||||
|
||||
selected_items = self.following_widget.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
account_ids = [item.data(Qt.UserRole)['id'] for item in selected_items]
|
||||
|
||||
worker = ListWorker(self.client, "add_accounts_to_list",
|
||||
list_id=self.current_list['id'],
|
||||
account_ids=account_ids)
|
||||
worker.finished.connect(self.on_members_added)
|
||||
worker.error.connect(self.on_operation_error)
|
||||
|
||||
thread = QThread()
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.error.connect(thread.quit)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def on_members_added(self, result):
|
||||
"""Handle members added"""
|
||||
self.sound_manager.play_success()
|
||||
self.load_list_members() # Reload members
|
||||
|
||||
def remove_selected_members(self):
|
||||
"""Remove selected accounts from current list"""
|
||||
if not self.current_list:
|
||||
return
|
||||
|
||||
selected_items = self.members_widget.selectedItems()
|
||||
if not selected_items:
|
||||
return
|
||||
|
||||
account_ids = [item.data(Qt.UserRole)['id'] for item in selected_items]
|
||||
|
||||
worker = ListWorker(self.client, "remove_accounts_from_list",
|
||||
list_id=self.current_list['id'],
|
||||
account_ids=account_ids)
|
||||
worker.finished.connect(self.on_members_removed)
|
||||
worker.error.connect(self.on_operation_error)
|
||||
|
||||
thread = QThread()
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.error.connect(thread.quit)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def on_members_removed(self, result):
|
||||
"""Handle members removed"""
|
||||
self.sound_manager.play_success()
|
||||
self.load_list_members() # Reload members
|
||||
|
||||
def on_operation_error(self, error_msg):
|
||||
"""Handle operation errors"""
|
||||
self.sound_manager.play_error()
|
||||
AccessibleTextDialog.show_error("List Operation Failed", error_msg, self)
|
||||
|
||||
|
||||
class ListEditDialog(QDialog):
|
||||
"""Dialog for creating/editing list details"""
|
||||
|
||||
def __init__(self, title="", replies_policy="list", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setup_ui()
|
||||
|
||||
# Pre-fill if editing
|
||||
if title:
|
||||
self.setWindowTitle("Edit List")
|
||||
self.title_edit.setText(title)
|
||||
else:
|
||||
self.setWindowTitle("Create List")
|
||||
|
||||
# Set replies policy
|
||||
index = self.replies_combo.findData(replies_policy)
|
||||
if index >= 0:
|
||||
self.replies_combo.setCurrentIndex(index)
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the edit dialog UI"""
|
||||
self.setMinimumSize(400, 200)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Title
|
||||
layout.addWidget(QLabel("List &Title:"))
|
||||
self.title_edit = QLineEdit()
|
||||
self.title_edit.setAccessibleName("List Title")
|
||||
self.title_edit.setAccessibleDescription("Enter a name for this list")
|
||||
self.title_edit.setPlaceholderText("e.g., News, Friends, Tech...")
|
||||
layout.addWidget(self.title_edit)
|
||||
|
||||
# Replies policy
|
||||
layout.addWidget(QLabel("&Replies Policy:"))
|
||||
self.replies_combo = QComboBox()
|
||||
self.replies_combo.setAccessibleName("Replies Policy")
|
||||
self.replies_combo.addItem("Show replies to list members only", "list")
|
||||
self.replies_combo.addItem("Show replies to followed accounts", "followed")
|
||||
self.replies_combo.addItem("Show no replies", "none")
|
||||
layout.addWidget(self.replies_combo)
|
||||
|
||||
# Help text
|
||||
help_text = QTextEdit()
|
||||
help_text.setReadOnly(True)
|
||||
help_text.setMaximumHeight(80)
|
||||
help_text.setAccessibleName("Help Information")
|
||||
help_text.setPlainText(
|
||||
"Lists create focused timelines from your followed accounts. "
|
||||
"Replies policy controls whether you see replies in the list timeline."
|
||||
)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Buttons
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
||||
)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Set focus
|
||||
self.title_edit.setFocus()
|
||||
|
||||
def get_list_data(self):
|
||||
"""Get the list data from the form"""
|
||||
title = self.title_edit.text().strip()
|
||||
replies_policy = self.replies_combo.currentData()
|
||||
return title, replies_policy
|
||||
|
||||
def accept(self):
|
||||
"""Validate and accept the dialog"""
|
||||
title = self.title_edit.text().strip()
|
||||
if not title:
|
||||
AccessibleTextDialog.show_warning(
|
||||
"Invalid Title", "Please enter a title for the list.", self
|
||||
)
|
||||
self.title_edit.setFocus()
|
||||
return
|
||||
|
||||
super().accept()
|
||||
@@ -48,6 +48,7 @@ class TimelineView(QTreeWidget):
|
||||
def __init__(self, account_manager: AccountManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.timeline_type = "home"
|
||||
self.list_id = None # For list timelines
|
||||
self.settings = SettingsManager()
|
||||
self.sound_manager = SoundManager(self.settings)
|
||||
self.notification_manager = NotificationManager(self.settings)
|
||||
@@ -132,9 +133,11 @@ class TimelineView(QTreeWidget):
|
||||
self.setAccessibleName("Timeline Tree")
|
||||
self.setAccessibleDescription("Timeline showing posts and conversations")
|
||||
|
||||
def set_timeline_type(self, timeline_type: str):
|
||||
"""Set the timeline type (home, local, federated)"""
|
||||
def set_timeline_type(self, timeline_type: str, list_id: str = None):
|
||||
"""Set the timeline type (home, local, federated, list)"""
|
||||
self.timeline_type = timeline_type
|
||||
self.list_id = list_id if timeline_type == "list" else None
|
||||
|
||||
# Reset post tracking when switching timeline types since content will be different
|
||||
self.newest_post_id = None
|
||||
# Reset notification tracking when switching to notifications timeline
|
||||
@@ -289,6 +292,10 @@ class TimelineView(QTreeWidget):
|
||||
timeline_data = self.activitypub_client.get_muted_accounts(
|
||||
limit=posts_per_page
|
||||
)
|
||||
elif self.timeline_type == "list" and self.list_id:
|
||||
timeline_data = self.activitypub_client.get_list_timeline(
|
||||
self.list_id, limit=posts_per_page
|
||||
)
|
||||
else:
|
||||
timeline_data = self.activitypub_client.get_timeline(
|
||||
self.timeline_type, limit=posts_per_page
|
||||
@@ -820,9 +827,9 @@ class TimelineView(QTreeWidget):
|
||||
|
||||
# Show mentions filter (check if current user is mentioned)
|
||||
if not self.filter_settings['show_mentions']:
|
||||
current_account = self.account_manager.current_account
|
||||
current_account = self.account_manager.get_active_account()
|
||||
if current_account:
|
||||
current_username = current_account.get('username', '')
|
||||
current_username = current_account.username
|
||||
content = post.get_content_text().lower()
|
||||
if f"@{current_username}" in content:
|
||||
return False
|
||||
@@ -1313,6 +1320,10 @@ class TimelineView(QTreeWidget):
|
||||
more_data = self.activitypub_client.get_favorites(
|
||||
limit=posts_per_page, max_id=self.oldest_post_id
|
||||
)
|
||||
elif self.timeline_type == "list" and self.list_id:
|
||||
more_data = self.activitypub_client.get_list_timeline(
|
||||
self.list_id, limit=posts_per_page, max_id=self.oldest_post_id
|
||||
)
|
||||
else:
|
||||
more_data = self.activitypub_client.get_timeline(
|
||||
self.timeline_type, limit=posts_per_page, max_id=self.oldest_post_id
|
||||
|
||||
Reference in New Issue
Block a user